diff --git a/.changeset/wide-project-model-dropdowns.md b/.changeset/wide-project-model-dropdowns.md new file mode 100644 index 0000000000..6d3b345d5e --- /dev/null +++ b/.changeset/wide-project-model-dropdowns.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Widen Project Models dropdown menus so long provider and model names are easier to read. +category: fix +dev: Adds an opt-in readable menu width to the shared dashboard model dropdown and applies it only in Project Models. diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 9409029b26..6bc0f14891 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -23,6 +23,7 @@ import { PromptsSection } from "../components/settings/sections/PromptsSection"; import { SecretsSection } from "../components/settings/sections/SecretsSection"; import { WorktreesSection } from "../components/settings/sections/WorktreesSection"; import type { SettingsFormState } from "../components/settings/sections/context"; +import { fetchWorkflow, fetchWorkflowSettingValues } from "../api"; vi.mock("../components/AgentPromptsManager", () => ({ AgentPromptsManager: () =>
, @@ -35,12 +36,20 @@ vi.mock("../api", async (importOriginal) => { return { ...actual, fetchWorkflows: vi.fn(async () => []), - fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding" })), + fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding", ir: {} })), + fetchWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })), fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })), setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })), fetchGlobalSettings: vi.fn(async () => ({})), }; }); +vi.mock("../components/CustomModelDropdown", () => ({ + CustomModelDropdown: ({ id, label, menuWidth = "trigger" }: { id?: string; label: string; menuWidth?: "trigger" | "readable" }) => ( + + ), +})); expect.extend(jestDomMatchers); afterEach(() => cleanup()); @@ -281,6 +290,61 @@ describe("ProjectModelsSection", () => { confirmDelete: vi.fn(), }; + it("opts Project Models lane and preset dropdowns into readable menu width", () => { + render( + , + ); + + expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-menu-width", "readable"); + expect(screen.getByTestId("mock-model-dropdown-summarizationModel")).toHaveAttribute("data-menu-width", "readable"); + expect(screen.getByTestId("mock-model-dropdown-preset-executor-model")).toHaveAttribute("data-menu-width", "readable"); + expect(screen.getByTestId("mock-model-dropdown-preset-validator-model")).toHaveAttribute("data-menu-width", "readable"); + }); + + it("opts default workflow model lane dropdowns into readable menu width", async () => { + vi.mocked(fetchWorkflow).mockResolvedValueOnce({ + id: "builtin:coding", + name: "Coding", + ir: { + settings: [ + { id: "planningProvider", name: "Planning Provider", type: "string" }, + { id: "planningModelId", name: "Planning Model", type: "string" }, + ], + }, + } as never); + vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] }); + + render( + , + ); + + expect(await screen.findByTestId("mock-model-dropdown-workflow-planning-model")).toHaveAttribute("data-menu-width", "readable"); + }); + it("renders PR prompt guidance textareas and emits edits through setForm", () => { function ProjectModelsHost() { const [form, setFormState] = useState({ diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index 6210529e34..3b9c317174 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -28,6 +28,8 @@ export interface CustomModelDropdownProps { favoriteModels?: string[]; /** Called when user toggles a model's favorite status */ onToggleModelFavorite?: (modelId: string) => void; + /** Request a wider menu for dense settings surfaces while default callers keep trigger-width sizing. */ + menuWidth?: "trigger" | "readable"; } interface DropdownPosition { @@ -65,6 +67,7 @@ export function CustomModelDropdown({ noChangeValue, noChangeLabel: noChangeLabelProp, defaultOptionLabel: defaultOptionLabelProp, + menuWidth = "trigger", }: CustomModelDropdownProps) { const { t } = useTranslation("app"); const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…"); @@ -270,7 +273,16 @@ export function CustomModelDropdown({ 160, ); - const dropdownWidth = Math.min(rect.width, viewportWidth - horizontalPadding * 2); + const maxDropdownWidth = viewportWidth - horizontalPadding * 2; + /* + FNXC:ModelDropdown 2026-07-01-00:00: + Project Models lanes need a readable portaled menu for long provider/model names, but shared model selectors elsewhere must retain trigger-width behavior unless they opt in. + Clamp the widened target to the effective viewport, including visualViewport offsets, so desktop and mobile keyboards never create offscreen click targets. + */ + const preferredDropdownWidth = menuWidth === "readable" + ? Math.max(rect.width, Math.min(rect.width * 1.6, viewportWidth * 0.72)) + : rect.width; + const dropdownWidth = Math.min(preferredDropdownWidth, maxDropdownWidth); const left = Math.min( Math.max(triggerLeft, horizontalPadding), viewportWidth - horizontalPadding - dropdownWidth, @@ -285,7 +297,7 @@ export function CustomModelDropdown({ width: dropdownWidth, maxHeight, }); - }, [getEffectiveViewport, getPreferredDropdownHeight]); + }, [getEffectiveViewport, getPreferredDropdownHeight, menuWidth]); useEffect(() => { setPortalRoot(document.body); @@ -483,6 +495,7 @@ export function CustomModelDropdown({ className="model-combobox-dropdown model-combobox-dropdown--portal" role="listbox" data-testid="model-combobox-portal" + data-menu-width={menuWidth} onKeyDown={handleKeyDown} style={{ top: `${dropdownPosition.top}px`, diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx index 527ea89a48..6c76733c19 100644 --- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx @@ -239,6 +239,216 @@ describe("CustomModelDropdown", () => { expect(portal.style.maxHeight).toBe("360px"); }); + describe("Readable menu sizing", () => { + const setupBoundingRectMock = (rectValues: DOMRect) => { + const originalGetBCR = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = vi.fn(() => rectValues as DOMRect); + return () => { + Element.prototype.getBoundingClientRect = originalGetBCR; + }; + }; + + const setupVisualViewportMock = (vv: { width: number; height: number; offsetTop: number; offsetLeft: number }) => { + const originalVV = window.visualViewport; + const mockVV = { + width: vv.width, + height: vv.height, + offsetTop: vv.offsetTop, + offsetLeft: vv.offsetLeft, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + Object.defineProperty(window, "visualViewport", { + writable: true, + configurable: true, + value: mockVV, + }); + return () => { + Object.defineProperty(window, "visualViewport", { + writable: true, + configurable: true, + value: originalVV, + }); + }; + }; + + it("keeps trigger-width sizing unless readable width is requested", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + vi.spyOn(window, "innerWidth", "get").mockReturnValue(1000); + const restore = setupBoundingRectMock({ + top: 100, + left: 50, + bottom: 136, + width: 300, + height: 36, + right: 350, + x: 50, + y: 100, + } as DOMRect); + + try { + const { unmount } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + expect((await screen.findByTestId("model-combobox-portal")).style.width).toBe("300px"); + unmount(); + document.body.innerHTML = ""; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + const portal = await screen.findByTestId("model-combobox-portal"); + expect(portal.style.width).toBe("480px"); + expect(portal).toHaveAttribute("data-menu-width", "readable"); + } finally { + restore(); + } + }); + + it("clamps readable sizing inside the desktop viewport", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + vi.spyOn(window, "innerWidth", "get").mockReturnValue(320); + const restore = setupBoundingRectMock({ + top: 100, + left: 40, + bottom: 136, + width: 300, + height: 36, + right: 340, + x: 40, + y: 100, + } as DOMRect); + + try { + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + const portal = await screen.findByTestId("model-combobox-portal"); + const left = parseFloat(portal.style.left); + const width = parseFloat(portal.style.width); + + expect(width).toBe(288); + expect(left).toBe(16); + expect(left + width).toBeLessThanOrEqual(320 - 16); + } finally { + restore(); + } + }); + + it("clamps readable sizing with visualViewport horizontal offsets", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + vi.spyOn(window, "innerWidth", "get").mockReturnValue(800); + const vvCleanup = setupVisualViewportMock({ + width: 500, + height: 600, + offsetTop: 0, + offsetLeft: 100, + }); + const restore = setupBoundingRectMock({ + top: 100, + left: 550, + bottom: 136, + width: 200, + height: 36, + right: 750, + x: 550, + y: 100, + } as DOMRect); + + try { + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + const portal = await screen.findByTestId("model-combobox-portal"); + const left = parseFloat(portal.style.left); + const width = parseFloat(portal.style.width); + + expect(width).toBe(320); + expect(left).toBe(264); + expect(left - 100).toBeGreaterThanOrEqual(16); + expect(left - 100 + width).toBeLessThanOrEqual(500 - 16); + } finally { + restore(); + vvCleanup(); + } + }); + + it("keeps long readable rows searchable, selectable, and favorite-aware", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const longModels = [ + ...MOCK_MODELS, + { + provider: "anthropic-enterprise-cloud", + id: "claude-sonnet-4-5-20260701-extremely-long-production-model-id", + name: "Claude Sonnet 4.5 Enterprise Production with a Very Long Readable Name", + reasoning: true, + contextWindow: 200000, + }, + ]; + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Executor Model" })); + const portal = await screen.findByTestId("model-combobox-portal"); + await user.type(within(portal).getByPlaceholderText("Filter models…"), "enterprise production"); + + expect(within(portal).getByText("Claude Sonnet 4.5 Enterprise Production with a Very Long Readable Name")).toBeTruthy(); + expect(within(portal).queryByText("No models found")).toBeNull(); + expect(within(portal).getByLabelText("Remove Claude Sonnet 4.5 Enterprise Production with a Very Long Readable Name from favorites")).toBeTruthy(); + + await user.click(within(portal).getByText("Claude Sonnet 4.5 Enterprise Production with a Very Long Readable Name")); + expect(onChange).toHaveBeenCalledWith("anthropic-enterprise-cloud/claude-sonnet-4-5-20260701-extremely-long-production-model-id"); + }); + }); + describe("Model Favorites", () => { it("shows favorited models as pinned rows at the top before provider groups", async () => { diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index f95ebda76f..bec4af0ebc 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -286,7 +286,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
- updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
{isOverridden && ()}
@@ -318,7 +318,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
- setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
{customized && ()}
@@ -406,7 +406,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje executorProvider: val.slice(0, slashIdx), executorModelId: val.slice(slashIdx + 1), } : current); - }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
@@ -421,7 +421,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje validatorProvider: val.slice(0, slashIdx), validatorModelId: val.slice(slashIdx + 1), } : current); - }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/> + }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
)}