feat(KB-080): add combined model dropdown with combobox component

- Create new combobox component for model/provider selection
- Refactor ModelSelectorTab to use combined dropdown UI
- Add scrollIntoView null check for test environment compatibility
- Add changeset for combined model dropdown feature
- Clean up obsolete changeset and subtask-related code
This commit is contained in:
gsxdsm
2026-03-29 22:26:10 -07:00
parent f99ae366f7
commit bcbb22a6ca
4 changed files with 859 additions and 288 deletions

View File

@@ -94,20 +94,41 @@ describe("ModelSelectorTab", () => {
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
});
it("groups models by provider in select options", async () => {
it("opens combobox when trigger is clicked", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
expect(executorSelect).toBeInTheDocument();
// Click the executor combobox trigger
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Check options exist with model names
expect(screen.getAllByText("Claude Sonnet 4.5").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Claude Opus 4").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("GPT-4o").length).toBeGreaterThanOrEqual(1);
// Dropdown should be visible with models
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.getByText("3 models")).toBeInTheDocument();
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
});
it("groups models by provider in dropdown", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Open the combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Check provider headers are present
expect(screen.getByText("anthropic")).toBeInTheDocument();
expect(screen.getByText("openai")).toBeInTheDocument();
});
it("enables Save button when selections change", async () => {
@@ -121,9 +142,13 @@ describe("ModelSelectorTab", () => {
const saveButton = screen.getByText("Save");
expect(saveButton).toBeDisabled();
// Select a model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Open combobox and select a model
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Click on a model option
const modelOption = screen.getByText("Claude Sonnet 4.5");
await user.click(modelOption);
expect(saveButton).toBeEnabled();
});
@@ -139,12 +164,14 @@ describe("ModelSelectorTab", () => {
});
// Select executor model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await user.click(screen.getByText("Claude Sonnet 4.5"));
// Select validator model
const validatorSelect = screen.getByLabelText("Validator Model");
await user.selectOptions(validatorSelect, "openai/gpt-4o");
const validatorTrigger = screen.getByLabelText("Validator Model");
await user.click(validatorTrigger);
await user.click(screen.getByText("GPT-4o"));
// Click save
await user.click(screen.getByText("Save"));
@@ -176,9 +203,15 @@ describe("ModelSelectorTab", () => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Select "Use default" (empty value)
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "");
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Select "Use default"
const defaultOption = screen.getAllByText("Use default").find(
el => el.classList.contains("model-combobox-option-text--default")
) || screen.getAllByText("Use default")[0];
await user.click(defaultOption);
// Click save
await user.click(screen.getByText("Save"));
@@ -202,15 +235,19 @@ describe("ModelSelectorTab", () => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Change selection
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
// Open and select a model
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await user.click(screen.getByText("Claude Sonnet 4.5"));
// Verify Save is enabled
expect(screen.getByText("Save")).toBeEnabled();
// Reset
await user.click(screen.getByText("Reset"));
// Selection should be back to empty (Use default)
expect(executorSelect).toHaveValue("");
// Save should be disabled again (no changes)
expect(screen.getByText("Save")).toBeDisabled();
});
it("shows error state when fetchModels fails", async () => {
@@ -246,15 +283,16 @@ describe("ModelSelectorTab", () => {
});
// Select a model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await user.click(screen.getByText("Claude Sonnet 4.5"));
// Start save
await user.click(screen.getByText("Save"));
// Should show saving state
expect(screen.getByText("Saving…")).toBeInTheDocument();
expect(executorSelect).toBeDisabled();
expect(executorTrigger).toBeDisabled();
});
it("shows error toast when save fails", async () => {
@@ -268,8 +306,9 @@ describe("ModelSelectorTab", () => {
});
// Select a model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await user.click(screen.getByText("Claude Sonnet 4.5"));
// Click save
await user.click(screen.getByText("Save"));
@@ -279,203 +318,295 @@ describe("ModelSelectorTab", () => {
});
});
// Filter functionality tests
it("renders filter input for executor and validator selectors", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Combobox-specific tests
describe("Combobox behavior", () => {
it("filters models when typing in search input", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Type filter text
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
// Should show filtered results
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
expect(screen.queryByText("Claude Opus 4")).not.toBeInTheDocument();
});
// Both filter inputs should be present (placeholder text)
const filterInputs = screen.getAllByPlaceholderText("Filter models…");
expect(filterInputs.length).toBe(2);
});
it("filters models by model ID", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
it("filters executor models by provider name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Type model ID
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "gpt-4o");
// Should show only GPT-4o
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
});
// Type "openai" in the executor filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
it("filters models by display name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Should show result count
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// The select should only show openai models
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Use default");
expect(options).toContain("GPT-4o");
expect(options).not.toContain("Claude Sonnet 4.5");
expect(options).not.toContain("Claude Opus 4");
});
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
it("filters models by model ID", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Type display name
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "opus");
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Should show only Opus
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
});
// Type "gpt-4o" in the executor filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "gpt-4o");
it("supports multi-word filter (AND logic)", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Should show only GPT-4o
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("GPT-4o");
expect(options).not.toContain("Claude Sonnet 4.5");
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
it("filters models by display name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Type multi-word filter
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "anthropic claude");
// Should show only anthropic models
expect(screen.getByText("2 models")).toBeInTheDocument();
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument();
});
// Type "opus" (case insensitive)
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "opus");
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Should show only Claude Opus 4
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Opus 4");
expect(options).not.toContain("Claude Sonnet 4.5");
expect(options).not.toContain("GPT-4o");
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
it("supports multi-word filter (AND logic)", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Type a filter
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
// Verify filter is applied
expect(screen.getByText("1 model")).toBeInTheDocument();
// Click clear button
const clearButton = screen.getByLabelText("Clear filter");
await user.click(clearButton);
// Filter should be cleared
expect(searchInput).toHaveValue("");
expect(screen.getByText("3 models")).toBeInTheDocument();
});
// Type "anthropic claude" - both terms must match
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "anthropic claude");
it("shows empty state message when filter matches nothing", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Should show only anthropic claude models
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Sonnet 4.5");
expect(options).toContain("Claude Opus 4");
expect(options).not.toContain("GPT-4o");
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
it("Use default option remains visible regardless of filter", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Type a filter that matches nothing
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "xyz123");
// Should show no results message
expect(screen.getByText("0 models")).toBeInTheDocument();
expect(screen.getByText(/No models match/)).toBeInTheDocument();
});
// Type a filter that matches nothing
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "nonexistent");
it("closes dropdown when clicking outside", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Use default should still be visible
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Use default");
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Dropdown should be visible
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
// Click outside (on the intro text)
await user.click(screen.getByText(/Override the AI models/));
// Dropdown should be closed
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
// Type a filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
it("closes dropdown on Escape key", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Verify filter is applied
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Click clear button
const clearButton = within(executorSection!).getByLabelText("Clear filter");
await user.click(clearButton);
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Filter should be cleared and all models should be visible
expect(filterInput).toHaveValue("");
expect(within(executorSection!).getByText("3 models")).toBeInTheDocument();
// Dropdown should be visible
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Sonnet 4.5");
expect(options).toContain("Claude Opus 4");
expect(options).toContain("GPT-4o");
});
// Press Escape
await user.keyboard("{Escape}");
it("shows empty state message when filter matches nothing", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Dropdown should be closed
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
// Type a filter that matches nothing
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "xyz123");
it("navigates with arrow keys and selects with Enter", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Should show no results message
expect(within(executorSection!).getByText("No models match 'xyz123'")).toBeInTheDocument();
expect(within(executorSection!).getByText("0 models")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
it("selecting a model from filtered list works correctly", async () => {
const user = userEvent.setup();
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
// Focus and open combobox with arrow down
const executorTrigger = screen.getByLabelText("Executor Model");
executorTrigger.focus();
await user.keyboard("{ArrowDown}");
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Dropdown should be visible
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
// Navigate down and press Enter to select
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
// Dropdown should be closed and Save button should be enabled
await waitFor(() => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(screen.getByText("Save")).toBeEnabled();
});
// Filter to show only openai
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
it("Use default option is always visible", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
// Select the filtered model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "openai/gpt-4o");
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Save
await user.click(screen.getByText("Save"));
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Verify correct model was saved
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: undefined,
validatorModelId: undefined,
// Use default should be visible
const defaultOptions = screen.getAllByText("Use default");
expect(defaultOptions.length).toBeGreaterThan(0);
// Type a filter that matches nothing
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "nonexistent123");
// Use default should still be visible
expect(screen.getAllByText("Use default").length).toBeGreaterThan(0);
});
it("shows model ID next to model name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Model IDs should be visible next to names
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
expect(screen.getByText("claude-opus-4")).toBeInTheDocument();
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
});
it("selecting a model from filtered list works correctly", async () => {
const user = userEvent.setup();
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Open combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Filter to show only openai
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
// Select the filtered model
const modelOption = screen.getByText("GPT-4o");
await user.click(modelOption);
// Save
await user.click(screen.getByText("Save"));
// Verify correct model was saved
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
});
});