From 3fdc2c1fb7ae3392abf19093f2c9b80451a767ad Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 00:48:36 -0700 Subject: [PATCH] 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) --- .../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(-) create mode 100644 .changeset/fn-8193-collapsible-provider-lists.md diff --git a/.changeset/fn-8193-collapsible-provider-lists.md b/.changeset/fn-8193-collapsible-provider-lists.md new file mode 100644 index 0000000000..8699f1797d --- /dev/null +++ b/.changeset/fn-8193-collapsible-provider-lists.md @@ -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). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2fa4ec8961..4e7511da30 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index 975efd2c8d..de26991908 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -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)); } } diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index 5f08e60427..d6d4618746 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -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 { + 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(null); const [portalRoot, setPortalRoot] = useState(null); + const [collapsedProviders, setCollapsedProviders] = useState>(loadCollapsedProviders); const generatedThinkingId = useId(); const containerRef = useRef(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 ? (
)} - {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 (
@@ -685,9 +734,24 @@ export function CustomModelDropdown({ ★ )} +
- {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} > - {m.name} - {m.id} + {model.name} + {model.id} {onToggleModelFavorite && ( diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index 218d3a6410..9a84e34b04 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -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(); + 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( + , + ); + 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(); + + 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(); + 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(); + 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(); + 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( + , + ); + 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(); + + 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"),