дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(agents): group model picker by provider and improve search (#486)
Agents can report model ids with nested provider paths, which made long rows that lost their provider context. The model picker now shows the short name under a provider heading with the remaining path as detail. Searchable Select matches several tokens across label, detail and hidden keywords, with fuzzy matching, and keeps a heading while one of its items matches. Select now has an explicit section option, separate from disabled, so a heading can never be selected and a disabled option is not rendered as a heading. Existing section rows in provider options and Find in Files scopes use it. Keyboard navigation skips headings in filtered lists. Co-authored-by: dimassfeb-09 <[email protected]>
Цей коміт міститься в:
1 джерело
7aad01b035
коміт
986b0ade58
7 files changed
+282
-28
No files matched your search
@@ -1,7 +1,52 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
|
||||
|
||||
const config = (values: AgentConfigOption["values"], category = "model"): AgentConfigOption => ({
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category,
|
||||
description: null,
|
||||
current: null,
|
||||
via: "option",
|
||||
values,
|
||||
});
|
||||
const value = (id: string, name = id, description: string | null = null) => ({ value: id, name, description });
|
||||
|
||||
describe("configPickerOptions", () => {
|
||||
it("groups nested paths by their first provider and preserves the full value and description", () => {
|
||||
expect(configPickerOptions(config([value("9router/cmc/Beam", "Beam display", "Fast")]))).toEqual([
|
||||
{ value: "9router", label: "9router", section: true },
|
||||
{ value: "9router/cmc/Beam", label: "Beam", detail: "cmc/Beam · Fast", keywords: "9router" },
|
||||
]);
|
||||
});
|
||||
it("keeps plain ids ungrouped even when the display name contains a slash", () => {
|
||||
expect(configPickerOptions(config([value("plain", "Local / Beam", "Offline")]))).toEqual([
|
||||
{ value: "plain", label: "Local / Beam", detail: "Offline", keywords: "" },
|
||||
]);
|
||||
});
|
||||
it("collects several providers in encounter order and preserves order inside each group", () => {
|
||||
const options = configPickerOptions(
|
||||
config([value("a/One"), value("b/Two"), value("a/Three"), value("plain"), value("__section__:a")]),
|
||||
);
|
||||
expect(options.map((o) => [o.value, !!o.section])).toEqual([
|
||||
["plain", false],
|
||||
["__section__:a", false],
|
||||
["a", true],
|
||||
["a/One", false],
|
||||
["a/Three", false],
|
||||
["b", true],
|
||||
["b/Two", false],
|
||||
]);
|
||||
expect(options[3]?.detail).toBe("One");
|
||||
});
|
||||
it("leaves non-model option labels and descriptions intact", () => {
|
||||
expect(configPickerOptions(config([value("a/One", "Custom", "Description")], "mode"))).toEqual([
|
||||
{ value: "a/One", label: "Custom", detail: "Description" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Cursor approvals separately from its execution mode and sends the choice", () => {
|
||||
const option = (id: string, category: string, values: string[]): AgentConfigOption => ({
|
||||
@@ -32,3 +77,11 @@ it("shows Cursor approvals separately from its execution mode and sends the choi
|
||||
fireEvent.click(screen.getByRole("option", { name: "Bypass approvals" }));
|
||||
expect(onChange).toHaveBeenCalledWith("openidea.cursor.approval", "bypass");
|
||||
});
|
||||
|
||||
it("sends the complete nested model id through ConfigPickers", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ConfigPickers config={[config([value("9router/cmc/Beam")])]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
|
||||
});
|
||||
@@ -5,9 +5,33 @@
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Select } from "@/ui";
|
||||
import type { SelectOption } from "@/ui/Select";
|
||||
import { mainOptions, modeIcon } from "./composerModel";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
export function configPickerOptions(option: AgentConfigOption): SelectOption[] {
|
||||
if (option.category !== "model") {
|
||||
return option.values.map((v) => ({ value: v.value, label: v.name, detail: v.description ?? undefined }));
|
||||
}
|
||||
const groups = new Map<string, SelectOption[]>();
|
||||
for (const v of option.values) {
|
||||
const slash = v.value.indexOf("/");
|
||||
const provider = slash >= 0 ? v.value.slice(0, slash) : "";
|
||||
const path = slash >= 0 ? v.value.slice(slash + 1) : "";
|
||||
const label = path ? path.slice(path.lastIndexOf("/") + 1) : v.name || v.value;
|
||||
const detail = path ? [path, v.description].filter(Boolean).join(" · ") : (v.description ?? undefined);
|
||||
const bucket = groups.get(provider) ?? [];
|
||||
bucket.push({ value: v.value, label, detail, keywords: provider });
|
||||
groups.set(provider, bucket);
|
||||
}
|
||||
const plain = groups.get("") ?? [];
|
||||
groups.delete("");
|
||||
return [
|
||||
...plain,
|
||||
...[...groups].flatMap(([provider, items]) => [{ value: provider, label: provider, section: true }, ...items]),
|
||||
];
|
||||
}
|
||||
|
||||
export function ConfigPickers({
|
||||
config,
|
||||
values,
|
||||
@@ -24,6 +48,8 @@ export function ConfigPickers({
|
||||
{mainOptions(config).map((o) => {
|
||||
const isMode = o.category === "mode";
|
||||
const value = values?.[o.id] ?? o.current ?? null;
|
||||
const options = configPickerOptions(o);
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={o.id}
|
||||
@@ -31,18 +57,16 @@ export function ConfigPickers({
|
||||
aria-label={o.name}
|
||||
value={value}
|
||||
placeholder={tx(o.name)}
|
||||
options={o.values.map((v) => ({
|
||||
value: v.value,
|
||||
label: v.name,
|
||||
options={options.map((opt) => ({
|
||||
...opt,
|
||||
icon:
|
||||
o.category === "approval"
|
||||
? v.value === "bypass"
|
||||
? opt.value === "bypass"
|
||||
? "unlock"
|
||||
: "lock"
|
||||
: isMode
|
||||
? modeIcon(v.value)
|
||||
? modeIcon(opt.value)
|
||||
: undefined,
|
||||
detail: v.description ?? undefined,
|
||||
}))}
|
||||
searchable={o.category === "model"}
|
||||
onChange={(v) => onChange(o.id, v)}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function providerOptions(
|
||||
const missing = providers.filter((p) => !p.installed);
|
||||
return [
|
||||
...installed.map((p) => ({ value: p.id, label: p.name, icon: icon(p.id), detail: version(p) })),
|
||||
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), disabled: true }] : []),
|
||||
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), section: true }] : []),
|
||||
...missing.map((p) => ({ value: p.id, label: p.name, icon: icon(p.id), detail: p.command })),
|
||||
{ value: ADD_AGENT, label: tx("More agents…") },
|
||||
];
|
||||
|
||||
@@ -640,7 +640,7 @@ function FindInFilesDialog() {
|
||||
...NAMED_SCOPES,
|
||||
...(customScopes.length
|
||||
? [
|
||||
{ value: "custom:" as NamedScope, label: "Custom Scopes", disabled: true },
|
||||
{ value: "custom:" as NamedScope, label: "Custom Scopes", section: true },
|
||||
...customScopes.map((c) => ({
|
||||
value: customScopeValue(c.name),
|
||||
label: c.name,
|
||||
|
||||
@@ -136,3 +136,7 @@
|
||||
box-shadow: inset 0 0 0 1px var(--border-button);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { Select, type SelectOption } from "./Select";
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ value: "9router", label: "9router", section: true },
|
||||
{ value: "9router/cmc/Beam", label: "Beam", detail: "cmc/Beam · Fast", keywords: "9router" },
|
||||
{ value: "9router/blocked", label: "Blocked", disabled: true, keywords: "9router" },
|
||||
{ value: "other", label: "Other", section: true },
|
||||
{ value: "other/BlueMoon", label: "Blue Moon", keywords: "other" },
|
||||
{ value: "__section__:real", label: "Plain" },
|
||||
];
|
||||
function setup(searchable = true, rows = options) {
|
||||
const onChange = vi.fn();
|
||||
render(<Select aria-label="Model" options={rows} value={null} searchable={searchable} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Model" }));
|
||||
return onChange;
|
||||
}
|
||||
const list = () => screen.getByRole("listbox");
|
||||
const labels = () =>
|
||||
within(list())
|
||||
.queryAllByRole("option")
|
||||
.map((o) => o.textContent);
|
||||
const query = (value: string) => fireEvent.change(screen.getByRole("textbox"), { target: { value } });
|
||||
const key = (key: string, target = list()) => fireEvent.keyDown(target, { key });
|
||||
|
||||
describe("Select", () => {
|
||||
it("focuses the search input when opened", async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
|
||||
});
|
||||
it("never selects sections by click, Enter or type-ahead, and preserves full model values", () => {
|
||||
const changed = setup(false);
|
||||
fireEvent.click(within(list()).getByText("9router"));
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
key("9");
|
||||
key("Enter");
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("9router/cmc/Beam");
|
||||
});
|
||||
it("does not select a list containing only headings", () => {
|
||||
const changed = setup(false, [{ value: "a", label: "A", section: true }]);
|
||||
key("a");
|
||||
key("Enter");
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
it("skips sections and disabled options with arrows, Home and End", () => {
|
||||
const changed = setup(false);
|
||||
key("ArrowDown");
|
||||
expect(within(list()).getByRole("option", { name: "Blue Moon" })).toHaveAttribute("aria-selected", "true");
|
||||
key("End");
|
||||
key("Home");
|
||||
key("ArrowUp");
|
||||
key("Enter");
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("__section__:real");
|
||||
});
|
||||
it("renders disabled models as options, retains them in search, and rejects selection", () => {
|
||||
const changed = setup();
|
||||
query("blocked");
|
||||
const blocked = within(list()).getByRole("option", { name: "Blocked" });
|
||||
expect(within(list()).getByText("9router")).toBeInTheDocument();
|
||||
fireEvent.click(blocked);
|
||||
key("Enter");
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
});
|
||||
it("matches provider, model, nested path, description, and multiple case-insensitive tokens", () => {
|
||||
setup();
|
||||
for (const search of ["9router Beam", "CMC fast", "9ROUTER bm"]) {
|
||||
query(search);
|
||||
expect(labels()).toEqual(["Beamcmc/Beam · Fast"]);
|
||||
expect(within(list()).getByText("9router")).toBeInTheDocument();
|
||||
expect(within(list()).queryByText("Other")).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
it("navigates filtered results and lets search input handle spaces and caret movement", () => {
|
||||
const changed = setup();
|
||||
query("other");
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(fireEvent.keyDown(input, { key: " " })).toBe(true);
|
||||
expect(fireEvent.keyDown(input, { key: "Home" })).toBe(true);
|
||||
key("ArrowDown", input);
|
||||
key("Enter", input);
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("other/BlueMoon");
|
||||
});
|
||||
it("keeps exact substrings alongside fuzzy matches in original order", () => {
|
||||
setup(true, [
|
||||
{ value: "exact", label: "Beam" },
|
||||
{ value: "fuzzy", label: "Beta amber" },
|
||||
]);
|
||||
query("beam");
|
||||
expect(labels()).toEqual(["Beam", "Beta amber"]);
|
||||
});
|
||||
it("keeps non-searchable type-ahead prefix-only and shows every option", () => {
|
||||
const changed = setup(false);
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
key("b");
|
||||
key("m");
|
||||
key("Enter");
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("other/BlueMoon");
|
||||
});
|
||||
it("does not confuse a heading value with a real option value", () => {
|
||||
const changed = setup(false, [
|
||||
{ value: "a", label: "Provider", section: true },
|
||||
{ value: "a", label: "Real" },
|
||||
]);
|
||||
key("Enter");
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith("a");
|
||||
});
|
||||
});
|
||||
+83
-18
@@ -12,7 +12,11 @@ export interface SelectOption<V extends string = string> {
|
||||
label: string;
|
||||
icon?: IconName | (string & {});
|
||||
detail?: string;
|
||||
/** Extra text (not shown) that the search should also match. */
|
||||
keywords?: string;
|
||||
disabled?: boolean;
|
||||
/** A non-selectable group heading, independent of disabled options. */
|
||||
section?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps<V extends string = string> {
|
||||
@@ -33,6 +37,17 @@ export interface SelectProps<V extends string = string> {
|
||||
|
||||
const TYPE_AHEAD_MS = 800;
|
||||
|
||||
/** Substring or subsequence (fuzzy) match. */
|
||||
function containsOrFuzzy(haystack: string, needle: string): boolean {
|
||||
if (haystack.includes(needle)) return true;
|
||||
let i = 0;
|
||||
for (const ch of haystack) {
|
||||
if (ch === needle[i]) i++;
|
||||
if (i >= needle.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */
|
||||
export function Select<V extends string = string>({
|
||||
value,
|
||||
@@ -49,40 +64,80 @@ export function Select<V extends string = string>({
|
||||
const tx = useTx();
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const current = options.find((o) => o.value === value);
|
||||
const current = options.find((o) => !o.section && 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 isSection = (o: SelectOption<V>) => !!o.section;
|
||||
const unavailable = (o: SelectOption<V>) => !!(o.section || o.disabled);
|
||||
const filtered =
|
||||
searchable && query.trim()
|
||||
? options.filter((o) => !o.disabled && tx(o.label).toLowerCase().includes(query.trim().toLowerCase()))
|
||||
? (() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const matches = (o: SelectOption<V>) => {
|
||||
const haystack = `${tx(o.label)} ${o.detail != null ? tx(o.detail) : ""} ${o.keywords ?? ""}`.toLowerCase();
|
||||
return tokens.every((tok) => containsOrFuzzy(haystack, tok));
|
||||
};
|
||||
// Keep section headers visible as long as at least one of their
|
||||
// items matches, so the provider grouping survives filtering.
|
||||
const out: SelectOption<V>[] = [];
|
||||
let section: SelectOption<V> | null = null;
|
||||
let bucket: SelectOption<V>[] = [];
|
||||
let sectionMatched = false;
|
||||
const flush = () => {
|
||||
if (section && sectionMatched) {
|
||||
out.push(section, ...bucket);
|
||||
} else if (!section && bucket.length) {
|
||||
out.push(...bucket);
|
||||
}
|
||||
section = null;
|
||||
bucket = [];
|
||||
sectionMatched = false;
|
||||
};
|
||||
for (const o of options) {
|
||||
if (o.section) {
|
||||
flush();
|
||||
section = o;
|
||||
} else if (matches(o)) {
|
||||
bucket.push(o);
|
||||
sectionMatched = true;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
})()
|
||||
: options;
|
||||
|
||||
const activeIndex =
|
||||
filtered[active] && !unavailable(filtered[active]!) ? active : filtered.findIndex((o) => !unavailable(o));
|
||||
|
||||
const openPopup = () => {
|
||||
setQuery("");
|
||||
setActive(
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value),
|
||||
options.findIndex((o) => !unavailable(o) && o.value === value),
|
||||
),
|
||||
);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const choose = (o: SelectOption<V> | undefined) => {
|
||||
if (!o || o.disabled) return;
|
||||
if (!o || unavailable(o)) return;
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onPopupKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
const searching = searchable && e.target instanceof HTMLInputElement;
|
||||
if (e.key === "Enter" || (e.key === " " && !searching)) {
|
||||
e.preventDefault();
|
||||
choose(filtered[active]);
|
||||
choose(filtered[activeIndex]);
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: options, active, isSection, wrap: true });
|
||||
if (searching && !["ArrowDown", "ArrowUp"].includes(e.key)) return;
|
||||
const next = listNavigate(e.key, { items: filtered, active: activeIndex, isSection: unavailable, wrap: true });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
@@ -97,8 +152,8 @@ export function Select<V extends string = string>({
|
||||
const prefix = repeat ? ahead.text[0]! : ahead.text;
|
||||
const n = filtered.length;
|
||||
for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) {
|
||||
const i = (active + s) % n;
|
||||
if (!filtered[i]!.disabled && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) {
|
||||
const i = (activeIndex + s) % n;
|
||||
if (!unavailable(filtered[i]!) && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) {
|
||||
setActive(i);
|
||||
break;
|
||||
}
|
||||
@@ -144,7 +199,12 @@ export function Select<V extends string = string>({
|
||||
role="presentation"
|
||||
className={styles.popup}
|
||||
>
|
||||
<div tabIndex={-1} data-autofocus onKeyDown={onPopupKey} className={styles.popupInner}>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
data-autofocus={searchable ? undefined : true}
|
||||
onKeyDown={onPopupKey}
|
||||
className={styles.popupInner}
|
||||
>
|
||||
{searchable && (
|
||||
<div className={styles.searchWrap}>
|
||||
<SearchField
|
||||
@@ -153,7 +213,7 @@ export function Select<V extends string = string>({
|
||||
placeholder={tx("Search…")}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setActive(0);
|
||||
setActive(-1);
|
||||
}}
|
||||
size="normal"
|
||||
variant="framed"
|
||||
@@ -164,7 +224,7 @@ export function Select<V extends string = string>({
|
||||
{/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
|
||||
<div aria-hidden className={styles.sizer}>
|
||||
{options.map((o) => (
|
||||
<div key={o.value} className={styles.sizerRow}>
|
||||
<div key={`${o.section ? "section" : "option"}:${o.value}`} className={styles.sizerRow}>
|
||||
<span className={styles.check} />
|
||||
{o.icon && <Icon name={o.icon} size={14} />}
|
||||
<span>{tx(o.label)}</span>
|
||||
@@ -174,11 +234,13 @@ export function Select<V extends string = string>({
|
||||
</div>
|
||||
<List
|
||||
items={filtered}
|
||||
getKey={(o) => o.value}
|
||||
getKey={(o) => `${o.section ? "section" : "option"}:${o.value}`}
|
||||
isSection={isSection}
|
||||
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}
|
||||
activeIndex={active}
|
||||
onActiveChange={setActive}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={(i) => {
|
||||
if (!unavailable(filtered[i]!)) setActive(i);
|
||||
}}
|
||||
onActivate={(o) => choose(o)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
@@ -186,7 +248,10 @@ export function Select<V extends string = string>({
|
||||
aria-label={tx(aria["aria-label"])}
|
||||
className={styles.list}
|
||||
renderItem={(o) => (
|
||||
<>
|
||||
<span
|
||||
className={clsx(styles.option, o.disabled && styles.disabled)}
|
||||
aria-disabled={o.disabled || undefined}
|
||||
>
|
||||
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
|
||||
{o.icon && <Icon name={o.icon} size={14} />}
|
||||
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{tx(o.label)}</span>
|
||||
@@ -195,7 +260,7 @@ export function Select<V extends string = string>({
|
||||
{tx(o.detail)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Заблокувати користувача