diff --git a/.changeset/fn-8355-mobile-model-dropdown-scroll.md b/.changeset/fn-8355-mobile-model-dropdown-scroll.md
new file mode 100644
index 0000000000..a9bf93b3bd
--- /dev/null
+++ b/.changeset/fn-8355-mobile-model-dropdown-scroll.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix mobile model dropdown so the list stays scrollable after searching.
+category: fix
+dev: CustomModelDropdown portaled list retains touch-scroll ownership after filter + visualViewport reposition.
diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css
index a6bfcf5525..b3159589dc 100644
--- a/packages/dashboard/app/components/CustomModelDropdown.css
+++ b/packages/dashboard/app/components/CustomModelDropdown.css
@@ -231,9 +231,13 @@ FN-7760 requires the portaled model list to remain the touch-scroll owner on mob
FNXC:ModelDropdown 2026-07-15-00:00:
FN-8212 refines FN-8193 by removing the scroller's top padding. A sticky provider header anchors at the list top, so top padding exposed scrolling model rows in a seam between the fixed header stack and that header. Bottom token spacing preserves the list's trailing rhythm.
+
+FNXC:ModelDropdown 2026-07-16-00:00:
+FN-8355 requires the portaled list to remain the touch-scroll owner after filtering and a virtual-keyboard visualViewport reposition. Permit this flex child to shrink below its content height; otherwise the fixed search/header stack can leave its overflow clipped by the dropdown instead of scrollable on iOS and Android.
*/
.model-combobox-list {
flex: 1;
+ min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
index 824a3531d2..4ed9c022c5 100644
--- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
+++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
@@ -342,6 +342,7 @@ describe("CustomModelDropdown", () => {
});
it.each([
+ { width: 1024, height: 800, query: "desktop", expectedMaxHeight: "320px" },
{ width: 390, height: 844, query: "(max-width: 640px)", expectedMaxHeight: "360px" },
{ width: 700, height: 900, query: "(max-width: 768px)", expectedMaxHeight: "420px" },
])("keeps the portaled model list touch-scrollable at $query", async ({ width, height, query, expectedMaxHeight }) => {
@@ -403,6 +404,7 @@ describe("CustomModelDropdown", () => {
expect(portal.style.maxHeight).toBe(expectedMaxHeight);
expect(list).toBeInstanceOf(HTMLElement);
expect(within(portal).getAllByRole("option").length).toBeGreaterThan(20);
+ expect(listRule).toContain("min-height: 0;");
expect(listRule).toContain("overflow-y: auto;");
expect(listRule).toContain("overflow-x: hidden;");
expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
@@ -1270,6 +1272,83 @@ describe("CustomModelDropdown", () => {
};
};
+ it("keeps the filtered mobile list scrollable after visualViewport keyboard repositioning", async () => {
+ const user = userEvent.setup();
+ const css = readFileSync(resolve(__dirname, "../CustomModelDropdown.css"), "utf-8");
+ const listRule = css.match(/\.model-combobox-list\s*\{[^}]*\}/)?.[0] ?? "";
+ const overflowingModels = Array.from({ length: 30 }, (_, index) => ({
+ provider: index % 2 === 0 ? "anthropic" : "openai",
+ id: `mobile-model-${index}`,
+ name: `Mobile Model ${index}`,
+ reasoning: false,
+ contextWindow: 128000,
+ }));
+ const { simulateChange, cleanup: vvCleanup } = setupVisualViewportMock({
+ width: 375,
+ height: 667,
+ offsetTop: 0,
+ offsetLeft: 0,
+ });
+
+ vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(667);
+ vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
+ matches: query === "(max-width: 640px)" || query === "(max-width: 768px)",
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ } as MediaQueryList));
+ const restore = setupBoundingRectMock({
+ top: 200,
+ left: 20,
+ bottom: 236,
+ width: 335,
+ height: 36,
+ right: 355,
+ x: 20,
+ y: 200,
+ } as DOMRect);
+
+ try {
+ render();
+ await user.click(screen.getByRole("button", { name: "Mobile model" }));
+
+ const portal = await screen.findByTestId("model-combobox-portal");
+ const list = portal.querySelector(".model-combobox-list");
+ expect(list).not.toBeNull();
+ expect(within(list!).getAllByRole("option")).toHaveLength(31);
+
+ await user.type(screen.getByPlaceholderText("Filter models…"), "mobile");
+ expect(within(list!).getAllByRole("option")).toHaveLength(31);
+
+ // Simulate the virtual keyboard shrinking the visual viewport after search.
+ simulateChange({ height: 160, offsetTop: 507 });
+ await waitFor(() => expect(parseFloat(portal.style.maxHeight)).toBe(160));
+
+ // JSDOM does not lay out overflow, so assert the CSS scroll contract and its runtime owner directly.
+ expect(listRule).toContain("min-height: 0;");
+ expect(listRule).toContain("overflow-y: auto;");
+ expect(listRule).toContain("-webkit-overflow-scrolling: touch;");
+ expect(listRule).toContain("touch-action: pan-y;");
+ expect(listRule).not.toContain("min-height: 160");
+ list!.scrollTop = 96;
+ expect(list!.scrollTop).toBe(96);
+
+ // The empty/no-match state retains the same list scroll owner rather than replacing it.
+ await user.clear(screen.getByPlaceholderText("Filter models…"));
+ await user.type(screen.getByPlaceholderText("Filter models…"), "no-matching-mobile-model");
+ expect(portal.querySelector(".model-combobox-no-results")).not.toBeNull();
+ expect(portal.querySelector(".model-combobox-list")).toBe(list);
+ } finally {
+ restore();
+ vvCleanup();
+ }
+ });
+
it("uses visualViewport dimensions for positioning when available", async () => {
const user = userEvent.setup();
const onChange = vi.fn();