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:
gsxdsm
2026-07-17 00:48:36 -07:00
parent c3e98d193f
commit 3fdc2c1fb7
5 changed files with 266 additions and 29 deletions

View 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).

View File

@@ -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.
- 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**.
- 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.
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.

View File

@@ -310,15 +310,22 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
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 {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 10px;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg);
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);
}
.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-combobox-option-favorite {
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;
}
.model-combobox-option {
min-height: 36px;
.model-combobox-option,
.model-combobox-optgroup-toggle {
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
}

View File

@@ -48,6 +48,25 @@ interface DropdownPosition {
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.
*
@@ -91,6 +110,7 @@ export function CustomModelDropdown({
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(loadCollapsedProviders);
const generatedThinkingId = useId();
const containerRef = useRef<HTMLDivElement>(null);
@@ -101,6 +121,7 @@ export function CustomModelDropdown({
// Filter models based on local filter text
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
const hasFilter = localFilter.length > 0;
// Group filtered models by provider and sort by favorites
const modelsByProvider = useMemo(() => {
@@ -150,6 +171,21 @@ export function CustomModelDropdown({
});
}, [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 shouldShowThinking = showThinkingLevel ?? Boolean(onThinkingLevelChange);
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 });
providerModels.forEach((m) => {
if (isCollapsed) return;
providerModels.forEach((model) => {
options.push({
type: "model",
value: `${m.provider}/${m.id}`,
label: m.name,
provider: m.provider,
value: `${model.provider}/${model.id}`,
label: model.name,
provider: model.provider,
});
});
});
return options;
}, [favoritedModelEntries, sortedProviderEntries, specialOptions]);
}, [favoritedModelEntries, specialOptions, visibleProviderEntries]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
@@ -509,6 +546,28 @@ export function CustomModelDropdown({
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(() => {
if (!disabled) {
setIsOpen((prev) => !prev);
@@ -525,8 +584,6 @@ export function CustomModelDropdown({
}
}, [highlightedIndex, isOpen]);
const hasFilter = localFilter.length > 0;
const dropdownContent = isOpen && dropdownPosition ? (
<div
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 isFavorite = favoriteProviders.includes(provider);
// 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;
const isExpanded = !isCollapsed;
return (
<div key={provider} className="model-combobox-group">
@@ -685,9 +734,24 @@ export function CustomModelDropdown({
★
</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>
{nonFavoritedModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
{!isCollapsed && providerModels.map((model) => {
const optionValue = `${model.provider}/${model.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
@@ -703,8 +767,8 @@ export function CustomModelDropdown({
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
<span className="model-combobox-option-text">{model.name}</span>
<span className="model-combobox-option-id">{model.id}</span>
{onToggleModelFavorite && (
<button
type="button"
@@ -714,7 +778,7 @@ export function CustomModelDropdown({
onToggleModelFavorite(optionValue);
}}
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 ? "★" : "☆"}
</button>

View File

@@ -15,9 +15,17 @@ const MOCK_MODELS = [
{ 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", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
document.body.innerHTML = "";
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
@@ -38,6 +46,127 @@ describe("CustomModelDropdown", () => {
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", () => {
const css = readFileSync(
resolve(__dirname, "../CustomModelDropdown.css"),