FN-8193: add collapsible sticky provider lists
Keep model-provider headers visible and let users collapse provider model rows with persisted local preferences. - Add sticky provider headers and accessible collapse toggles to CustomModelDropdown - Persist collapsed provider groups locally while keeping filtered matches visible - Cover collapse, keyboard navigation, storage recovery, and empty-state behavior - Document the dashboard behavior and add a minor changeset Files changed: .changeset/fn-8193-collapsible-provider-lists.md | 7 ++ docs/dashboard-guide.md | 1 + .../app/components/CustomModelDropdown.css | 48 +++++++- .../app/components/CustomModelDropdown.tsx | 110 ++++++++++++++---- .../__tests__/CustomModelDropdown.test.tsx | 129 +++++++++++++++++++++ 5 files changed, 266 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-8193 Fusion-Task-Lineage: 4fbc43a8-ef81-463c-b4d0-5f00a57ab27b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8193-collapsible-provider-lists.md
Normal file
7
.changeset/fn-8193-collapsible-provider-lists.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Model dropdowns keep the provider header pinned while scrolling and let you collapse each provider list.
|
||||||
|
category: feature
|
||||||
|
dev: CustomModelDropdown gains CSS sticky provider headers and a per-provider collapse chevron persisted to localStorage (key fusion-dashboard-model-dropdown-collapsed-providers).
|
||||||
@@ -532,6 +532,7 @@ Rules:
|
|||||||
- `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode.
|
- `Merge target / base branch` stays optional for all modes and uses the same branch-dropdown + `Custom…` fallback behavior as Planning Mode.
|
||||||
- In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**.
|
- In **More options → Model Configuration**, **Auto-merge** is a per-task override with three states: **Default** (follow project setting), **Enabled**, or **Disabled**.
|
||||||
- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation, Agent Onboarding review, and Agent Detail built-in-model settings are concrete-only and start/fall back to **Off**.
|
- In **More options → Model Configuration**, task and agent model pickers expose **Thinking Level** inside the same model dropdown panel instead of as a separate adjacent selector. Task pickers offer **Default (project setting)** plus **Off**, **Minimal**, **Low**, **Medium**, **High**, and **Very High**; agent creation, Agent Onboarding review, and Agent Detail built-in-model settings are concrete-only and start/fall back to **Off**.
|
||||||
|
- Shared model dropdowns keep the active provider header visible while scrolling. Use the provider chevron to collapse a provider's model rows; this dashboard-local preference persists across sessions, while filtering temporarily shows matching rows from collapsed providers.
|
||||||
- In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting.
|
- In **More options → Model Configuration**, **Planner oversight** is a per-task override of the workflow-native `plannerOversightLevel` setting (FN-7508): **Inherit from workflow** (default) plus **Off**, **Observe**, **Steer**, and **Autonomous recovery**. This selector appears in both the New Task dialog and the Task Detail edit form (same shared control). Selecting **Inherit from workflow** clears the per-task override (sent as `null` on edit, omitted on create) so the task falls back to the effective `plannerOversightLevel` configured on its workflow — set project/global defaults for this in the **Workflow Editor → Values** tab, not in Project Settings; it is workflow-native, not a project setting.
|
||||||
|
|
||||||
The dialog also exposes AI handoffs that quick-add no longer shows: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior for Subtask. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface.
|
The dialog also exposes AI handoffs that quick-add no longer shows: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior for Subtask. **Execution mode** and optional workflow-step selection are available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution and opt into workflow-specific creation-time steps before creating a task from either surface.
|
||||||
|
|||||||
@@ -310,15 +310,22 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ModelDropdown 2026-07-15-00:00:
|
||||||
|
Provider headers must remain visible while their long model groups scroll in the shared list. Sticky positioning is scoped to the list scroller, so it cannot overlap the search, result-count, or thinking controls above it.
|
||||||
|
*/
|
||||||
.model-combobox-optgroup {
|
.model-combobox-optgroup {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--space-sm);
|
||||||
padding: 6px 12px;
|
padding: var(--space-sm) var(--space-md);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -350,6 +357,34 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
|
|||||||
color: var(--star-active);
|
color: var(--star-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-combobox-optgroup-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--transition-fast), transform var(--transition-fast), background var(--transition-fast);
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-combobox-optgroup-toggle:hover,
|
||||||
|
.model-combobox-optgroup-toggle:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-combobox-optgroup-toggle--expanded {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Model favorite star (inside option row) */
|
/* Model favorite star (inside option row) */
|
||||||
.model-combobox-option-favorite {
|
.model-combobox-option-favorite {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
@@ -401,8 +436,9 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-combobox-option {
|
.model-combobox-option,
|
||||||
min-height: 36px;
|
.model-combobox-optgroup-toggle {
|
||||||
|
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,25 @@ interface DropdownPosition {
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_PROVIDERS_STORAGE_KEY = "fusion-dashboard-model-dropdown-collapsed-providers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:ModelDropdown 2026-07-15-00:00:
|
||||||
|
* Provider-group collapse is dashboard-local preference state, not a server setting. Read defensively so SSR, unavailable storage, and malformed legacy data keep every provider expanded instead of breaking a model picker.
|
||||||
|
*/
|
||||||
|
function loadCollapsedProviders(): Set<string> {
|
||||||
|
if (typeof window === "undefined") return new Set();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(COLLAPSED_PROVIDERS_STORAGE_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? new Set(parsed.filter((provider): provider is string => typeof provider === "string")) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
|
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
|
||||||
*
|
*
|
||||||
@@ -91,6 +110,7 @@ export function CustomModelDropdown({
|
|||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||||
|
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(loadCollapsedProviders);
|
||||||
const generatedThinkingId = useId();
|
const generatedThinkingId = useId();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -101,6 +121,7 @@ export function CustomModelDropdown({
|
|||||||
|
|
||||||
// Filter models based on local filter text
|
// Filter models based on local filter text
|
||||||
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
|
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
|
||||||
|
const hasFilter = localFilter.length > 0;
|
||||||
|
|
||||||
// Group filtered models by provider and sort by favorites
|
// Group filtered models by provider and sort by favorites
|
||||||
const modelsByProvider = useMemo(() => {
|
const modelsByProvider = useMemo(() => {
|
||||||
@@ -150,6 +171,21 @@ export function CustomModelDropdown({
|
|||||||
});
|
});
|
||||||
}, [modelsByProvider, favoriteProviders]);
|
}, [modelsByProvider, favoriteProviders]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ModelDropdown 2026-07-15-00:00:
|
||||||
|
Collapsed provider rows are omitted from both the DOM and keyboard option list. An active filter temporarily expands every matching group so search never hides a matching model; the saved collapsed preference resumes after clearing the filter.
|
||||||
|
*/
|
||||||
|
const visibleProviderEntries = useMemo(() => sortedProviderEntries.flatMap(([provider, providerModels]) => {
|
||||||
|
const nonFavoritedModels = providerModels.filter((model) => !favoriteModels.includes(`${model.provider}/${model.id}`));
|
||||||
|
if (nonFavoritedModels.length === 0) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
provider,
|
||||||
|
models: nonFavoritedModels,
|
||||||
|
isCollapsed: !hasFilter && collapsedProviders.has(provider),
|
||||||
|
}];
|
||||||
|
}), [collapsedProviders, favoriteModels, hasFilter, sortedProviderEntries]);
|
||||||
|
|
||||||
const hasNoChangeOption = typeof noChangeValue === "string" && noChangeValue.length > 0;
|
const hasNoChangeOption = typeof noChangeValue === "string" && noChangeValue.length > 0;
|
||||||
const shouldShowThinking = showThinkingLevel ?? Boolean(onThinkingLevelChange);
|
const shouldShowThinking = showThinkingLevel ?? Boolean(onThinkingLevelChange);
|
||||||
const normalizedThinkingLevel = thinkingLevel ?? "";
|
const normalizedThinkingLevel = thinkingLevel ?? "";
|
||||||
@@ -208,20 +244,21 @@ export function CustomModelDropdown({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedProviderEntries.forEach(([provider, providerModels]) => {
|
visibleProviderEntries.forEach(({ provider, models: providerModels, isCollapsed }) => {
|
||||||
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
|
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
|
||||||
providerModels.forEach((m) => {
|
if (isCollapsed) return;
|
||||||
|
providerModels.forEach((model) => {
|
||||||
options.push({
|
options.push({
|
||||||
type: "model",
|
type: "model",
|
||||||
value: `${m.provider}/${m.id}`,
|
value: `${model.provider}/${model.id}`,
|
||||||
label: m.name,
|
label: model.name,
|
||||||
provider: m.provider,
|
provider: model.provider,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}, [favoritedModelEntries, sortedProviderEntries, specialOptions]);
|
}, [favoritedModelEntries, specialOptions, visibleProviderEntries]);
|
||||||
|
|
||||||
// Get current selection display text
|
// Get current selection display text
|
||||||
const selectedDisplayText = useMemo(() => {
|
const selectedDisplayText = useMemo(() => {
|
||||||
@@ -509,6 +546,28 @@ export function CustomModelDropdown({
|
|||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleCollapsedProvider = useCallback((provider: string) => {
|
||||||
|
setCollapsedProviders((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(provider)) {
|
||||||
|
next.delete(provider);
|
||||||
|
} else {
|
||||||
|
next.add(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(COLLAPSED_PROVIDERS_STORAGE_KEY, JSON.stringify([...next].sort()));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage failures must not prevent the in-memory affordance from working.
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTriggerClick = useCallback(() => {
|
const handleTriggerClick = useCallback(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
setIsOpen((prev) => !prev);
|
setIsOpen((prev) => !prev);
|
||||||
@@ -525,8 +584,6 @@ export function CustomModelDropdown({
|
|||||||
}
|
}
|
||||||
}, [highlightedIndex, isOpen]);
|
}, [highlightedIndex, isOpen]);
|
||||||
|
|
||||||
const hasFilter = localFilter.length > 0;
|
|
||||||
|
|
||||||
const dropdownContent = isOpen && dropdownPosition ? (
|
const dropdownContent = isOpen && dropdownPosition ? (
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
@@ -653,18 +710,10 @@ export function CustomModelDropdown({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedProviderEntries.map(([provider, providerModels]) => {
|
{visibleProviderEntries.map(({ provider, models: providerModels, isCollapsed }) => {
|
||||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||||
const isFavorite = favoriteProviders.includes(provider);
|
const isFavorite = favoriteProviders.includes(provider);
|
||||||
|
const isExpanded = !isCollapsed;
|
||||||
// Filter out favorited models - they already appear in the favorites section
|
|
||||||
const nonFavoritedModels = providerModels.filter((m) => {
|
|
||||||
const optionValue = `${m.provider}/${m.id}`;
|
|
||||||
return !favoriteModels.includes(optionValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Skip provider group if all models are favorited
|
|
||||||
if (nonFavoritedModels.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={provider} className="model-combobox-group">
|
<div key={provider} className="model-combobox-group">
|
||||||
@@ -685,9 +734,24 @@ export function CustomModelDropdown({
|
|||||||
★
|
★
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`model-combobox-optgroup-toggle ${isExpanded ? "model-combobox-optgroup-toggle--expanded" : ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleToggleCollapsedProvider(provider);
|
||||||
|
}}
|
||||||
|
aria-label={isExpanded
|
||||||
|
? t("models.collapseProvider", "Collapse {{provider}}", { provider })
|
||||||
|
: t("models.expandProvider", "Expand {{provider}}", { provider })}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`model-combobox-provider-toggle-${provider}`}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{nonFavoritedModels.map((m) => {
|
{!isCollapsed && providerModels.map((model) => {
|
||||||
const optionValue = `${m.provider}/${m.id}`;
|
const optionValue = `${model.provider}/${model.id}`;
|
||||||
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
||||||
const isHighlighted = highlightedIndex === optionIndex;
|
const isHighlighted = highlightedIndex === optionIndex;
|
||||||
const isSelected = value === optionValue;
|
const isSelected = value === optionValue;
|
||||||
@@ -703,8 +767,8 @@ export function CustomModelDropdown({
|
|||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
<span className="model-combobox-option-text">{m.name}</span>
|
<span className="model-combobox-option-text">{model.name}</span>
|
||||||
<span className="model-combobox-option-id">{m.id}</span>
|
<span className="model-combobox-option-id">{model.id}</span>
|
||||||
{onToggleModelFavorite && (
|
{onToggleModelFavorite && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -714,7 +778,7 @@ export function CustomModelDropdown({
|
|||||||
onToggleModelFavorite(optionValue);
|
onToggleModelFavorite(optionValue);
|
||||||
}}
|
}}
|
||||||
title={isFavorited ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
|
title={isFavorited ? t("models.removeFromFavorites", "Remove from favorites") : t("models.addToFavorites", "Add to favorites")}
|
||||||
aria-label={isFavorited ? t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: m.name }) : t("models.addToFavoritesAriaLabel", "Add {{name}} to favorites", { name: m.name })}
|
aria-label={isFavorited ? t("models.removeFromFavoritesAriaLabel", "Remove {{name}} from favorites", { name: model.name }) : t("models.addToFavoritesAriaLabel", "Add {{name}} to favorites", { name: model.name })}
|
||||||
>
|
>
|
||||||
{isFavorited ? "★" : "☆"}
|
{isFavorited ? "★" : "☆"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,9 +15,17 @@ const MOCK_MODELS = [
|
|||||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const COLLAPSIBLE_MODELS = [
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 },
|
||||||
|
{ provider: "anthropic", id: "claude-haiku", name: "Claude Haiku", reasoning: false, contextWindow: 200000 },
|
||||||
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||||
|
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini", reasoning: false, contextWindow: 128000 },
|
||||||
|
];
|
||||||
|
|
||||||
describe("CustomModelDropdown", () => {
|
describe("CustomModelDropdown", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
@@ -38,6 +46,127 @@ describe("CustomModelDropdown", () => {
|
|||||||
expect(wrapperRuleMatch![0]).toContain("background: var(--surface);");
|
expect(wrapperRuleMatch![0]).toContain("background: var(--surface);");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders provider headers inside the list with sticky positioning", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const css = loadAllAppCss();
|
||||||
|
const optgroupRule = css.match(/\.model-combobox-optgroup\s*\{[^}]*\}/)?.[0] ?? "";
|
||||||
|
|
||||||
|
expect(optgroupRule).toContain("position: sticky;");
|
||||||
|
expect(optgroupRule).toContain("top: 0;");
|
||||||
|
expect(optgroupRule).toContain("z-index: 1;");
|
||||||
|
expect(optgroupRule).toContain("background: var(--bg);");
|
||||||
|
|
||||||
|
render(<CustomModelDropdown label="Model" value="" onChange={vi.fn()} models={MOCK_MODELS} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
|
||||||
|
const list = screen.getByTestId("model-combobox-portal").querySelector(".model-combobox-list");
|
||||||
|
expect(list).not.toBeNull();
|
||||||
|
expect(within(list!).getByText("anthropic").closest(".model-combobox-optgroup")).not.toBeNull();
|
||||||
|
expect(within(list!).getByText("openai").closest(".model-combobox-optgroup")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses provider rows, preserves special rows, and persists the preference", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomModelDropdown
|
||||||
|
label="Model"
|
||||||
|
value=""
|
||||||
|
onChange={vi.fn()}
|
||||||
|
models={COLLAPSIBLE_MODELS}
|
||||||
|
favoriteModels={["anthropic/claude-haiku"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Collapse anthropic" })).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Claude Haiku")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("Use default")).toHaveLength(2);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Collapse anthropic" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Expand anthropic" })).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByText("Claude Sonnet")).toBeNull();
|
||||||
|
expect(screen.getByText("Claude Haiku")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("Use default")).toHaveLength(2);
|
||||||
|
expect(window.localStorage.getItem("fusion-dashboard-model-dropdown-collapsed-providers")).toBe('["anthropic"]');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Expand anthropic" }));
|
||||||
|
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores collapsed providers, tolerates malformed storage, and surfaces matches while filtering", async () => {
|
||||||
|
window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", '["anthropic"]');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const view = render(<CustomModelDropdown label="Model" value="" onChange={vi.fn()} models={COLLAPSIBLE_MODELS} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
expect(screen.queryByText("Claude Sonnet")).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Expand anthropic" })).toBeTruthy();
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Filter models…"), "sonnet");
|
||||||
|
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Collapse anthropic" })).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
window.localStorage.setItem("fusion-dashboard-model-dropdown-collapsed-providers", "not-json");
|
||||||
|
render(<CustomModelDropdown label="Other model" value="" onChange={vi.fn()} models={MOCK_MODELS} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Other model" }));
|
||||||
|
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits collapsed rows from keyboard navigation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(<CustomModelDropdown label="Model" value="" onChange={onChange} models={COLLAPSIBLE_MODELS} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Collapse anthropic" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("Claude Sonnet")).toBeNull();
|
||||||
|
await user.keyboard("{ArrowDown}{Enter}");
|
||||||
|
expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips collapsed provider rows when navigating upward", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(<CustomModelDropdown label="Model" value="" onChange={onChange} models={COLLAPSIBLE_MODELS} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Collapse anthropic" }));
|
||||||
|
|
||||||
|
await user.keyboard("{ArrowDown}{ArrowUp}{Enter}");
|
||||||
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides toggles for fully favorited provider groups", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<CustomModelDropdown
|
||||||
|
label="Model"
|
||||||
|
value=""
|
||||||
|
onChange={vi.fn()}
|
||||||
|
models={COLLAPSIBLE_MODELS}
|
||||||
|
favoriteModels={["anthropic/claude-sonnet", "anthropic/claude-haiku"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("model-combobox-provider-toggle-anthropic")).toBeNull();
|
||||||
|
expect(screen.getByText("Claude Sonnet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps empty model lists free of provider toggles", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CustomModelDropdown label="Model" value="" onChange={vi.fn()} models={[]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Model" }));
|
||||||
|
expect(screen.queryByTestId(/model-combobox-provider-toggle-/)).toBeNull();
|
||||||
|
expect(screen.getAllByText("Use default")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps CustomModelDropdown.css scoped to .model-combobox selectors", () => {
|
it("keeps CustomModelDropdown.css scoped to .model-combobox selectors", () => {
|
||||||
const css = readFileSync(
|
const css = readFileSync(
|
||||||
resolve(__dirname, "../CustomModelDropdown.css"),
|
resolve(__dirname, "../CustomModelDropdown.css"),
|
||||||
|
|||||||
Reference in New Issue
Block a user