FN-7399: widen Project Models model menus

Make Project Models dropdown menus easier to scan without changing shared selector defaults.

- Add an opt-in readable menu width mode to the shared custom model dropdown.
- Apply the wider menu only to Project Models lane, workflow, and preset model selectors.
- Cover readable sizing, viewport clamping, and default trigger-width behavior with dashboard tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/wide-project-model-dropdowns.md         |   7 +
 .../app/__tests__/settings-sections.test.tsx       |  66 ++++++-
 .../app/components/CustomModelDropdown.tsx         |  17 +-
 .../__tests__/CustomModelDropdown.test.tsx         | 210 +++++++++++++++++++++
 .../settings/sections/ProjectModelsSection.tsx     |   8 +-
 5 files changed, 301 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7399

Fusion-Task-Lineage: 40aae990-37bc-4ed8-8e2a-7e78a543053b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 13:48:44 -07:00
parent 519d8dfbc8
commit 07aa1a0085
5 changed files with 301 additions and 7 deletions

View File

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

View File

@@ -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: () => <div data-testid="agent-prompts-manager" />,
@@ -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" }) => (
<button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} data-menu-width={menuWidth}>
{label}
</button>
),
}));
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(
<ProjectModelsSection
scopeBanner={null}
form={{} as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "summarization", label: "Summarization", helperText: "Summarization", fallbackOrder: "global" },
] as never,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
presetDraft: { id: "preset", name: "Preset", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined },
}}
addToast={vi.fn()}
/>,
);
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(
<ProjectModelsSection
scopeBanner={null}
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
/>,
);
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<SettingsFormState>({

View File

@@ -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`,

View File

@@ -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(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
/>,
);
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(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
menuWidth="readable"
/>,
);
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(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
menuWidth="readable"
/>,
);
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(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={MOCK_MODELS}
menuWidth="readable"
/>,
);
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(
<CustomModelDropdown
label="Executor Model"
value=""
onChange={onChange}
models={longModels}
favoriteModels={["anthropic-enterprise-cloud/claude-sonnet-4-5-20260701-extremely-long-production-model-id"]}
onToggleModelFavorite={vi.fn()}
menuWidth="readable"
/>,
);
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 () => {

View File

@@ -286,7 +286,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
<CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
</div>
{isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => resetLaneValue(lane)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
@@ -318,7 +318,7 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => 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"/>
</div>
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
@@ -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"/>
</div>
<div className="form-group">
<label htmlFor="preset-validator-model">{t("settings.projectModels.reviewerModel", "Reviewer model")}</label>
@@ -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"/>
</div>
</>)}
</div>