diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css
index 08a7d505f..716a3f609 100644
--- a/src/features/agents/Composer.module.css
+++ b/src/features/agents/Composer.module.css
@@ -180,15 +180,21 @@
color: var(--fg-muted);
}
.modelEffortPopup {
- width: min(300px, calc(100vw - 24px));
- padding: 4px 12px 12px;
+ width: min(340px, calc(100vw - 24px));
+ padding: 12px 0 6px;
+}
+.effortBlock {
+ padding: 0 12px 8px;
+}
+/* The list takes the height of its models; with many it scrolls inside the popup. */
+.modelEffortPopup .modelList {
+ max-height: 216px;
}
.effortHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
- margin-top: 12px;
}
.effortValue {
color: var(--fg-strong);
diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx
index d2a347924..66745aa5f 100644
--- a/src/features/agents/ConfigPickers.test.tsx
+++ b/src/features/agents/ConfigPickers.test.tsx
@@ -234,8 +234,24 @@ describe("model and effort button", () => {
expect(slider).toHaveAttribute("aria-valuetext", "high");
fireEvent.change(slider, { target: { value: "3" } });
expect(onChange).toHaveBeenLastCalledWith("reasoning_effort", "xhigh");
- fireEvent.click(within(dialog).getByRole("button", { name: "Model" }));
- fireEvent.click(screen.getByRole("option", { name: /GPT-6 Sol/ }));
+ // The model list is in the popup itself, not behind a second dropdown.
+ expect(within(dialog).queryByRole("button", { name: "Model" })).not.toBeInTheDocument();
+ fireEvent.click(within(dialog).getByRole("option", { name: /GPT-6 Sol/ }));
+ expect(onChange).toHaveBeenLastCalledWith("model", "gpt-6-sol");
+ expect(screen.getByRole("dialog", { name: "Model & effort settings" })).toBeInTheDocument();
+ });
+
+ it("filters the model list and picks the highlighted model with the keyboard", () => {
+ const onChange = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Model" }));
+ const dialog = screen.getByRole("dialog", { name: "Model & effort settings" });
+ const search = within(dialog).getByPlaceholderText("Search…");
+ expect(search).toHaveFocus();
+ fireEvent.change(search, { target: { value: "sol" } });
+ expect(within(dialog).getAllByRole("option")).toHaveLength(1);
+ fireEvent.keyDown(search, { key: "ArrowDown" });
+ fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenLastCalledWith("model", "gpt-6-sol");
});
diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx
index 9118b2202..4223cfe85 100644
--- a/src/features/agents/ConfigPickers.tsx
+++ b/src/features/agents/ConfigPickers.tsx
@@ -9,7 +9,7 @@ import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Icon, IconButton, Popup, Select, T, Toggle } from "@/ui";
-import type { SelectOption } from "@/ui/Select";
+import { SelectListbox, type SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -241,15 +241,21 @@ function ModelEffortPicker({
aria-label={tx("Model & effort settings")}
className={styles.modelEffortPopup}
>
-
-
{tx(model.name)}
-
+
+
+ {tx(effort.name)}
+ {effortName}
+
+
onChange(effort.id, v)} />
-
- {tx(effort.name)}
- {effortName}
-
-
onChange(effort.id, v)} />
+ onChange(model.id, v)}
+ />
>
);
diff --git a/src/features/agents/HomeEditor.test.tsx b/src/features/agents/HomeEditor.test.tsx
index 57970f1cd..48a3b5b14 100644
--- a/src/features/agents/HomeEditor.test.tsx
+++ b/src/features/agents/HomeEditor.test.tsx
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from "vitest";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentConfigOption, AgentProvider, NewAgentTask } from "@/bindings";
import { HomeEditor } from "./HomeEditor";
import { HOME, savedDraft, updateComposer, useAgents } from "./store";
@@ -64,11 +64,7 @@ beforeEach(() => {
});
async function chooseModel(model: string) {
- const button = screen.getByRole("button", { name: "Model" });
- if (button.getAttribute("aria-haspopup") === "dialog") {
- fireEvent.click(button);
- fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Model" }));
- } else fireEvent.click(button);
+ fireEvent.click(screen.getByRole("button", { name: "Model" }));
fireEvent.click(screen.getByRole("option", { name: model }));
fireEvent.pointerDown(document.body);
}
diff --git a/src/ui/Select.module.css b/src/ui/Select.module.css
index c3e155739..91374ff79 100644
--- a/src/ui/Select.module.css
+++ b/src/ui/Select.module.css
@@ -99,6 +99,7 @@
}
/* The label keeps its width (up to most of the row); a long detail (a path) is cut instead. */
.withDetail {
+ flex-shrink: 0;
max-width: 70%;
}
.detail {
diff --git a/src/ui/Select.test.tsx b/src/ui/Select.test.tsx
index 2e14d453c..1865b68af 100644
--- a/src/ui/Select.test.tsx
+++ b/src/ui/Select.test.tsx
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
-import { Select, type SelectOption } from "./Select";
+import { StrictMode, useState } from "react";
+import { Select, SelectListbox, type SelectOption } from "./Select";
+import { Dialog } from "./Dialog";
const options: SelectOption[] = [
{ value: "9router", label: "9router", section: true },
@@ -106,3 +108,62 @@ describe("Select", () => {
expect(changed).toHaveBeenCalledExactlyOnceWith("a");
});
});
+
+describe("Select preview lifecycle", () => {
+ const rows = [
+ { value: "a", label: "A" },
+ { value: "b", label: "B" },
+ ];
+
+ it("restores using the newly selected value when closing", () => {
+ const preview = vi.fn();
+ function Picker() {
+ const [value, setValue] = useState("a");
+ return (
+