fix(FN-667): fix model dropdown layering in quick entry

- Render the custom model dropdown menu in a portal so it escapes clipped stacking contexts
- Update dashboard styles to give the floating model menu reliable overlay positioning above quick entry surfaces
- Refresh dropdown-focused tests to cover portal layering behavior in quick entry and model selector flows
- Document the layering behavior in the dashboard README for future UI maintenance
This commit is contained in:
gsxdsm
2026-04-01 13:32:29 -07:00
parent 826a495679
commit 26df995f36
6 changed files with 267 additions and 395 deletions

View File

@@ -44,6 +44,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Task Detail Editing**: Edit task title and description directly in the task detail modal. Click the pencil icon in the modal header (available for Triage and Todo tasks) to enter edit mode.
- **List View**: Alternative tabular view for tasks with sorting and filtering. The "Hide Done" toggle hides both Done and Archived tasks for an active-work-only view.
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
import { ProviderIcon } from "./ProviderIcon";
@@ -13,6 +14,12 @@ export interface CustomModelDropdownProps {
label: string;
}
interface DropdownPosition {
top: number;
left: number;
width: number;
}
/**
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
*
@@ -21,6 +28,10 @@ export interface CustomModelDropdownProps {
* - Open: Dropdown with search input at top, scrollable list of models grouped by provider with icons
* - Filtering: Real-time filtering using filterModels() utility
* - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus
*
* The dropdown listbox is rendered in a portal so it can escape clipping/stacking
* contexts created by scrollable modal or board containers while still anchoring to
* the trigger button.
*/
export function CustomModelDropdown({
models,
@@ -34,8 +45,12 @@ export function CustomModelDropdown({
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -94,10 +109,25 @@ export function CustomModelDropdown({
return optionsList.findIndex((opt) => opt.value === value);
}, [optionsList, value]);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}, []);
useEffect(() => {
setPortalRoot(document.body);
}, []);
// Reset highlighted index when opening
useEffect(() => {
if (isOpen) {
// Start with current value highlighted, or first selectable option
const selectableIndex = optionsList.findIndex(
(opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
);
@@ -105,26 +135,48 @@ export function CustomModelDropdown({
}
}, [isOpen, optionsList, currentValueIndex]);
// Focus search input when opening
useEffect(() => {
if (isOpen) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Click outside to close
// Focus search input and position dropdown when opening
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
updateDropdownPosition();
const rafId = requestAnimationFrame(() => searchInputRef.current?.focus());
return () => cancelAnimationFrame(rafId);
}, [isOpen, updateDropdownPosition]);
// Keep portaled dropdown anchored during viewport and container scrolling.
useEffect(() => {
if (!isOpen) return;
const handleReposition = () => updateDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen, updateDropdownPosition]);
// Click outside to close, treating both trigger container and portaled menu as inside.
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (e: MouseEvent) => {
const target = e.target as Node;
const clickedInsideTrigger = containerRef.current?.contains(target);
const clickedInsideDropdown = dropdownRef.current?.contains(target);
if (!clickedInsideTrigger && !clickedInsideDropdown) {
setIsOpen(false);
setLocalFilter("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [isOpen]);
// Keyboard navigation
@@ -136,7 +188,6 @@ export function CustomModelDropdown({
if (!isOpen) {
setIsOpen(true);
} else {
// Find next selectable option (skip provider headers)
let nextIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex + i) % optionsList.length;
@@ -152,7 +203,6 @@ export function CustomModelDropdown({
case "ArrowUp":
e.preventDefault();
if (isOpen) {
// Find previous selectable option (skip provider headers)
let prevIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
@@ -186,7 +236,6 @@ export function CustomModelDropdown({
break;
case "Tab":
// Close dropdown on tab (focus moves to next field)
if (isOpen) {
setIsOpen(false);
setLocalFilter("");
@@ -229,115 +278,121 @@ export function CustomModelDropdown({
const hasFilter = localFilter.length > 0;
return (
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
{/* Trigger Button */}
<button
type="button"
id={id}
className="model-combobox-trigger"
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
{currentProvider && (
<span className="model-combobox-trigger-icon">
<ProviderIcon provider={currentProvider} size="sm" />
</span>
const dropdownContent = isOpen && dropdownPosition ? (
<div
ref={dropdownRef}
className="model-combobox-dropdown model-combobox-dropdown--portal"
role="listbox"
data-testid="model-combobox-portal"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
}}
>
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{hasFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
>
×
</button>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
</div>
{/* Dropdown Panel */}
{isOpen && (
<div className="model-combobox-dropdown" role="listbox">
{/* Search Input */}
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{hasFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
>
×
</button>
)}
</div>
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
{/* Results Count */}
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
{/* Options List */}
<div ref={listRef} className="model-combobox-list">
{/* Use default option */}
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{/* Provider groups */}
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
return (
<div key={provider} className="model-combobox-group">
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
</div>
);
})}
</div>
);
})}
{/* No results message */}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">No models match &apos;{localFilter}&apos;</div>
)}
</div>
<div ref={listRef} className="model-combobox-list">
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
)}
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
return (
<div key={provider} className="model-combobox-group">
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
</div>
);
})}
</div>
);
})}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">No models match &apos;{localFilter}&apos;</div>
)}
</div>
</div>
) : null;
return (
<>
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
id={id}
className="model-combobox-trigger"
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
{currentProvider && (
<span className="model-combobox-trigger-icon">
<ProviderIcon provider={currentProvider} size="sm" />
</span>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText || placeholder}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
</div>
{portalRoot && dropdownContent ? createPortal(dropdownContent, portalRoot) : null}
</>
);
}

View File

@@ -2,309 +2,98 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomModelDropdown } from "../CustomModelDropdown";
import type { ModelInfo } from "../../api";
// Mock ProviderIcon to avoid rendering actual icons in most tests
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
const MOCK_MODELS: ModelInfo[] = [
const MOCK_MODELS = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "ollama", id: "llama3", name: "Llama 3", reasoning: false, contextWindow: 4096 },
];
const defaultProps = {
models: MOCK_MODELS,
value: "",
onChange: vi.fn(),
label: "Test Model",
id: "test-model",
};
describe("CustomModelDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
document.body.innerHTML = "";
});
it("renders trigger button with placeholder text", () => {
render(<CustomModelDropdown {...defaultProps} />);
expect(screen.getByLabelText("Test Model")).toBeInTheDocument();
expect(screen.getByText("Use default")).toBeInTheDocument();
});
it("renders trigger button with selected model name", () => {
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
});
it("shows provider icon in trigger when model is selected", () => {
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
});
it("does not show provider icon in trigger when using default", () => {
render(<CustomModelDropdown {...defaultProps} value="" />);
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
});
it("opens dropdown when trigger is clicked", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("groups models by provider in dropdown", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
// Provider groups should be visible with icons
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-ollama")).toBeInTheDocument();
});
it("displays provider names in group headers", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("anthropic")).toBeInTheDocument();
expect(screen.getByText("openai")).toBeInTheDocument();
expect(screen.getByText("ollama")).toBeInTheDocument();
});
it("displays model names in dropdown", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.getByText("Llama 3")).toBeInTheDocument();
});
it("displays model IDs next to names", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
});
it("calls onChange when a model is selected", async () => {
it("renders the open dropdown in a portal attached to document.body", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
await user.click(screen.getByLabelText("Test Model"));
await user.click(screen.getByText("GPT-4o"));
render(
<div data-testid="host-surface">
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
/>
</div>,
);
expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
await user.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal).toBeInTheDocument();
expect(portal).toHaveClass("model-combobox-dropdown--portal");
expect(document.body).toContainElement(portal);
const hostSurface = screen.getByTestId("host-surface");
expect(hostSurface).not.toContainElement(portal);
});
it("calls onChange with empty string when 'Use default' is selected", async () => {
it("keeps the portaled list interactive for selecting a model and clearing back to default", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" onChange={onChange} />);
await user.click(screen.getByLabelText("Test Model"));
render(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
/>,
);
// Find and click the "Use default" option (it's always first)
const defaultOptions = screen.getAllByText("Use default");
// Click the one in the dropdown list (not the trigger)
const dropdownDefault = defaultOptions.find((el) => el.classList.contains("model-combobox-option-text--default"));
if (dropdownDefault) {
await user.click(dropdownDefault);
}
await user.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal");
await user.click(within(portal).getByText("Claude Sonnet 4.5"));
expect(onChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5");
onChange.mockClear();
await user.click(screen.getByRole("button", { name: "Executor Model" }));
const reopenedPortal = await screen.findByTestId("model-combobox-portal");
await user.click(within(reopenedPortal).getByText("Use default"));
expect(onChange).toHaveBeenCalledWith("");
});
it("filters models when typing in search input", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
});
it("filters models by model name", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "opus");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
});
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
await user.click(screen.getByLabelText("Clear filter"));
expect(searchInput).toHaveValue("");
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("shows empty state when filter matches nothing", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "xyz123");
expect(screen.getByText("0 models")).toBeInTheDocument();
expect(screen.getByText(/No models match/)).toBeInTheDocument();
});
it("closes dropdown when clicking outside", async () => {
const user = userEvent.setup();
render(
<div>
<CustomModelDropdown {...defaultProps} />
<div data-testid="outside">Outside</div>
</div>
);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("closes dropdown on Escape key", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("opens dropdown with arrow down key", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
});
it("navigates with arrow keys and selects with Enter", async () => {
it("closes the portaled dropdown when clicking outside the trigger and menu", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
render(
<div>
<button type="button">Outside surface</button>
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
/>
</div>,
);
await user.click(screen.getByRole("button", { name: "Executor Model" }));
expect(await screen.findByTestId("model-combobox-portal")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Outside surface" }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.queryByTestId("model-combobox-portal")).not.toBeInTheDocument();
});
// Navigate down and press Enter
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(onChange).toHaveBeenCalled();
});
it("is disabled when disabled prop is true", () => {
render(<CustomModelDropdown {...defaultProps} disabled />);
expect(screen.getByLabelText("Test Model")).toBeDisabled();
});
it("does not open when disabled", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} disabled />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("has correct ARIA attributes", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
const trigger = screen.getByLabelText("Test Model");
// Native button element - role is implicit
expect(trigger.tagName.toLowerCase()).toBe("button");
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
expect(trigger).toHaveAttribute("aria-expanded", "false");
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});
it("marks selected option with aria-selected", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} value="openai/gpt-4o" />);
await user.click(screen.getByLabelText("Test Model"));
// Find the selected option by looking for the selected class and aria-selected
const options = screen.getAllByRole("option");
const selectedOption = options.find((opt) => opt.getAttribute("aria-selected") === "true");
expect(selectedOption).toHaveTextContent("GPT-4o");
});
it("shows 'Use default' option at the top", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
// The "Use default" option should be visible
const defaultOptions = screen.getAllByText("Use default");
expect(defaultOptions.length).toBeGreaterThan(0);
});
it("shows fallback value text when model is not found", () => {
render(<CustomModelDropdown {...defaultProps} value="unknown/unknown-model" />);
expect(screen.getByText("unknown/unknown-model")).toBeInTheDocument();
});
});

View File

@@ -279,6 +279,19 @@ describe("InlineCreateCard model selector", () => {
expect(screen.queryByText("Executor Model")).toBeNull();
});
it("renders the shared model dropdown in the portal layer from the inline create surface", async () => {
renderCard();
expandInlineCreate();
openModelPanel();
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal).toBeTruthy();
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
expect(document.body.contains(portal)).toBe(true);
});
it("updates executor selection and shows the selected model badge", () => {
renderCard();
expandInlineCreate();

View File

@@ -157,7 +157,7 @@ describe("ModelSelectorTab", () => {
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
});
it("opens combobox when trigger is clicked", async () => {
it("opens combobox in the shared portal layer when trigger is clicked", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
@@ -165,6 +165,11 @@ describe("ModelSelectorTab", () => {
await user.click(getSelector("Executor Model"));
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal).toBeInTheDocument();
expect(portal).toHaveClass("model-combobox-dropdown--portal");
expect(document.body).toContainElement(portal);
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.getByText("3 models")).toBeInTheDocument();
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();

View File

@@ -6131,13 +6131,22 @@ body {
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
z-index: 100;
z-index: 500;
max-height: 320px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.model-combobox-dropdown--portal {
position: fixed;
top: 0;
left: 0;
right: auto;
margin-top: 0;
z-index: 1000;
}
.model-combobox-search-wrapper {
position: relative;
flex-shrink: 0;