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

@@ -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();