diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx
index 8472df2a8f..b063295cc3 100644
--- a/packages/dashboard/app/components/QuickEntryBox.tsx
+++ b/packages/dashboard/app/components/QuickEntryBox.tsx
@@ -23,6 +23,7 @@ import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition";
+import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([
@@ -584,7 +585,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const clickedInsideTrigger = modelTriggerRef.current?.contains(target);
const clickedInsidePortal = modelMenuPortalRef.current?.contains(target);
// Also check for clicks inside CustomModelDropdown's portaled dropdown
- const clickedInsideCombobox = (target instanceof Element) && (target.closest?.(".model-combobox-dropdown--portal") != null);
+ const clickedInsideCombobox = isInsidePortaledModelMenu(target);
if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) {
setIsModelMenuOpen(false);
@@ -2410,7 +2411,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
focus for its filter input. Plain menu chrome still preserves the quick-entry focus.
*/
const target = e.target as Element;
- if (target.closest("input, textarea, select, [contenteditable], .model-combobox-dropdown--portal")) {
+ if (target.closest("input, textarea, select, [contenteditable]") || isInsidePortaledModelMenu(target)) {
return;
}
e.preventDefault();
diff --git a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx
index 23b9a3a9aa..9230a6e15c 100644
--- a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.portal.test.tsx
@@ -40,6 +40,15 @@ describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", ()
await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument());
});
+ it("keeps the brain popup open for touchstart inside the portaled model menu", async () => {
+ const portal = await openModelPortalWithRender({ onChangeModel: vi.fn() });
+
+ fireEvent.touchStart(portal);
+
+ expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
+ expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
+ });
+
it("still closes the brain popup for a genuine outside pointerdown", async () => {
await openModelPortalWithRender({ onChangeModel: vi.fn() });
diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
index 8751084a51..8816f3b60a 100644
--- a/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
+++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.test.tsx
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useEffect, useRef, useState } from "react";
-import { render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { loadAllAppCss } from "../../test/cssFixture";
import { CustomModelDropdown } from "../CustomModelDropdown";
@@ -60,6 +60,28 @@ describe("CustomModelDropdown", () => {
} as MediaQueryList));
});
+ it("stops portal touch events without stopping model option clicks", async () => {
+ const onChange = vi.fn();
+ const documentTouchStart = vi.fn();
+ const documentTouchEnd = vi.fn();
+ document.addEventListener("touchstart", documentTouchStart);
+ document.addEventListener("touchend", documentTouchEnd);
+ render(
);
+
+ fireEvent.click(screen.getByRole("button", { name: "Model" }));
+ const portal = await screen.findByTestId("model-combobox-portal");
+ expect(portal).toHaveAttribute("data-portal-surface", "model-menu");
+ fireEvent.touchStart(portal);
+ fireEvent.touchEnd(portal);
+ expect(documentTouchStart).not.toHaveBeenCalled();
+ expect(documentTouchEnd).not.toHaveBeenCalled();
+
+ fireEvent.click(within(portal).getByText("GPT-4o"));
+ expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
+ document.removeEventListener("touchstart", documentTouchStart);
+ document.removeEventListener("touchend", documentTouchEnd);
+ });
+
it("keeps the search wrapper background opaque to prevent list bleed-through", () => {
const css = loadAllAppCss();
const wrapperRuleMatch = css.match(/\.model-combobox-search-wrapper\s*\{[^}]*\}/);
diff --git a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx
index ac8d0d33f9..28b3f169e6 100644
--- a/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ModelSelectionModal.test.tsx
@@ -215,7 +215,8 @@ describe("ModelSelectionModal", () => {
renderModelSelectionModal({ onClose });
const overlay = screen.getByTestId("model-selection-modal");
- fireEvent.click(overlay);
+ fireEvent.mouseDown(overlay);
+ fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalled();
});
diff --git a/packages/dashboard/app/components/__tests__/NewAgentDialog.portal-dismissal.test.tsx b/packages/dashboard/app/components/__tests__/NewAgentDialog.portal-dismissal.test.tsx
new file mode 100644
index 0000000000..3143353c89
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/NewAgentDialog.portal-dismissal.test.tsx
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NewAgentDialog } from "../NewAgentDialog";
+import * as api from "../../api";
+
+vi.mock("../../api", async (importOriginal) => ({
+ ...(await importOriginal
()),
+ fetchModels: vi.fn().mockResolvedValue({ models: [
+ { id: "gpt-4o", provider: "openai", name: "GPT-4o" },
+ { id: "claude-sonnet", provider: "anthropic", name: "Claude Sonnet" },
+ ] }),
+ fetchAgents: vi.fn().mockResolvedValue([]),
+ fetchPluginRuntimes: vi.fn().mockResolvedValue({ runtimes: [] }),
+}));
+vi.mock("../SkillMultiselect", () => ({ SkillMultiselect: () => }));
+vi.mock("../AgentGenerationModal", () => ({ AgentGenerationModal: () => null }));
+vi.mock("../ExperimentalAgentOnboardingModal", () => ({ ExperimentalAgentOnboardingModal: () => null }));
+
+function setViewport(mobile: boolean) {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: mobile ? 375 : 1280 });
+ Object.defineProperty(window, "matchMedia", { configurable: true, value: vi.fn((query: string) => ({ matches: mobile && query.includes("max-width: 768px"), media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn() })) });
+}
+
+/**
+ * FNXC:ModelDropdown 2026-08-15-13:11:
+ * New Agent owns a real model picker, so its overlay must survive a filter gesture whose mobile
+ * keyboard re-anchor sends the release or synthesized click to the backdrop.
+ */
+describe("NewAgentDialog portaled model menu", () => {
+ beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = ""; });
+
+ it.each([{ mobile: false }, { mobile: true }])("does not close after a $mobile filter-origin gesture ends on the overlay", async ({ mobile }) => {
+ setViewport(mobile);
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId("agent-dialog-tab-custom"));
+ fireEvent.click(await screen.findByLabelText("Model"));
+ const filter = await screen.findByPlaceholderText("Filter models…");
+ const overlay = document.querySelector(".agent-dialog-overlay") as HTMLElement;
+
+ if (mobile) fireEvent.touchStart(filter);
+ fireEvent.pointerDown(filter); fireEvent.mouseDown(filter);
+ fireEvent.change(filter, { target: { value: "no-match" } });
+ if (mobile) fireEvent.touchEnd(overlay);
+ fireEvent.mouseUp(overlay); fireEvent.click(overlay);
+
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeInTheDocument();
+ expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
+ expect(filter).toHaveValue("no-match");
+ expect(screen.getByText(/No models match/)).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText("Close"));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([{ mobile: false }, { mobile: true }])("keeps the empty $mobile model menu attached to New Agent after a portal-origin gesture", async ({ mobile }) => {
+ setViewport(mobile);
+ vi.mocked(api.fetchModels).mockResolvedValueOnce({ models: [] } as Awaited>);
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId("agent-dialog-tab-custom"));
+ fireEvent.click(await screen.findByLabelText("Model"));
+ const filter = await screen.findByPlaceholderText("Filter models…");
+ const overlay = document.querySelector(".agent-dialog-overlay") as HTMLElement;
+
+ if (mobile) fireEvent.touchStart(filter);
+ fireEvent.pointerDown(filter); fireEvent.mouseDown(filter);
+ fireEvent.change(filter, { target: { value: "no-match" } });
+ if (mobile) fireEvent.touchEnd(overlay);
+ fireEvent.mouseUp(overlay); fireEvent.click(overlay);
+
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
+ expect(screen.getByText(/No models match/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/NewAgentDialog.test.tsx b/packages/dashboard/app/components/__tests__/NewAgentDialog.test.tsx
index f7a88707c6..2ecd3ae543 100644
--- a/packages/dashboard/app/components/__tests__/NewAgentDialog.test.tsx
+++ b/packages/dashboard/app/components/__tests__/NewAgentDialog.test.tsx
@@ -61,6 +61,17 @@ describe("NewAgentDialog thinking level", () => {
vi.clearAllMocks();
});
+ it("closes for a genuine touch that begins and ends on its overlay", () => {
+ const onClose = vi.fn();
+ render();
+
+ const overlay = document.querySelector(".agent-dialog-overlay") as HTMLElement;
+ fireEvent.touchStart(overlay);
+ fireEvent.touchEnd(overlay);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
it("uses CustomModelDropdown thinking control with concrete-only agent semantics", async () => {
render();
diff --git a/packages/dashboard/app/components/__tests__/model-menu-filter-host-dismissal.test.tsx b/packages/dashboard/app/components/__tests__/model-menu-filter-host-dismissal.test.tsx
new file mode 100644
index 0000000000..fb27268792
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/model-menu-filter-host-dismissal.test.tsx
@@ -0,0 +1,163 @@
+// @vitest-environment jsdom
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ChatView } from "../ChatView";
+import { ChatThinkingLevelControl } from "../ChatThinkingLevelControl";
+import { ModelSelectionModal } from "../ModelSelectionModal";
+import * as api from "../../api";
+import * as useChatModule from "../../hooks/useChat";
+import * as useChatRoomsModule from "../../hooks/useChatRooms";
+import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
+import type { UseChatReturn } from "../../hooks/useChat";
+import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
+
+Element.prototype.scrollIntoView = vi.fn();
+
+vi.mock("../SessionTerminal", () => ({ SessionTerminal: () => }));
+vi.mock("../../hooks/useChat");
+vi.mock("../../hooks/useChatRooms");
+vi.mock("../../hooks/useNavigationHistory", () => ({
+ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
+}));
+vi.mock("../../hooks/useModelsCache", () => ({
+ useModelsCache: () => ({
+ models: [
+ { id: "gpt-4o", provider: "openai", name: "GPT-4o" },
+ { id: "claude-sonnet", provider: "anthropic", name: "Claude Sonnet" },
+ ],
+ favoriteProviders: [], favoriteModels: [], defaultProvider: "openai", defaultModelId: "gpt-4o",
+ loading: false, refresh: vi.fn(async () => undefined),
+ }),
+}));
+vi.mock("../../hooks/useAgentsMapCache", () => ({
+ useAgentsMapCache: () => ({ loading: false, agents: [], agentsMap: new Map(), refresh: vi.fn(async () => undefined) }),
+}));
+vi.mock("../../api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), fetchTasks: vi.fn().mockResolvedValue([]),
+ fetchSettings: vi.fn().mockResolvedValue({}), searchFiles: vi.fn().mockResolvedValue({ files: [] }),
+ fetchModels: vi.fn().mockResolvedValue({ models: [
+ { id: "gpt-4o", provider: "openai", name: "GPT-4o" },
+ { id: "claude-sonnet", provider: "anthropic", name: "Claude Sonnet" },
+ ] }),
+ fetchAgents: vi.fn().mockResolvedValue([]), fetchPluginRuntimes: vi.fn().mockResolvedValue({ runtimes: [] }),
+ createAgent: vi.fn().mockResolvedValue({ id: "agent-1" }),
+}));
+
+const mockUseChat = vi.mocked(useChatModule.useChat);
+const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
+const mockFetchSettings = vi.mocked(api.fetchSettings);
+
+function chatState(): UseChatReturn {
+ return {
+ sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false,
+ isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [],
+ selectSession: vi.fn(), createSession: vi.fn(), archiveSession: vi.fn(), renameSession: vi.fn(),
+ setSessionThinkingLevel: vi.fn(), deleteSession: vi.fn(), sendMessage: vi.fn(), editMessageAndResend: vi.fn(),
+ stopStreaming: vi.fn(), pendingMessages: [], clearPendingMessage: vi.fn(), loadMoreMessages: vi.fn(),
+ hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(), filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(),
+ };
+}
+
+function roomsState(): UseChatRoomsResult {
+ return { rooms: [], roomsLoading: false, roomsError: null, activeRoom: null, activeRoomMembers: [], messages: [], messagesLoading: false, selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), refreshRooms: vi.fn() };
+}
+
+function setViewport(mobile: boolean) {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: mobile ? 375 : 1280 });
+ Object.defineProperty(window, "matchMedia", { configurable: true, value: vi.fn((query: string) => ({ matches: mobile && query.includes("max-width: 768px"), media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn() })) });
+}
+
+/**
+ * FNXC:ModelDropdown 2026-08-15-12:27:
+ * A portaled model menu is a logical child of its host dialog. These cases reproduce a keyboard-driven menu re-anchor where release/click lands on the backdrop after a filter press.
+ */
+describe("model-menu filter host dismissal", () => {
+ beforeEach(() => {
+ _resetInitialViewportHeight();
+ vi.clearAllMocks(); localStorage.clear();
+ mockUseChat.mockReturnValue(chatState());
+ mockUseChatRooms.mockReturnValue(roomsState());
+ mockFetchSettings.mockResolvedValue({ chatNewSessionMode: "prompt", chatDefaultKind: "model", chatDefaultModelProvider: "openai", chatDefaultModelId: "gpt-4o" } as Awaited>);
+ });
+
+ it.each([{ mobile: false }, { mobile: true }])("keeps New Chat open after a $mobile portal-origin filter gesture", async ({ mobile }) => {
+ setViewport(mobile);
+ await act(async () => { render(); });
+ await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
+ fireEvent.click(screen.getAllByTestId("chat-new-btn")[0]);
+ fireEvent.click(screen.getByLabelText("Model"));
+ const filter = await screen.findByPlaceholderText("Filter models…");
+ const backdrop = screen.getByRole("dialog");
+
+ if (mobile) fireEvent.touchStart(filter);
+ fireEvent.pointerDown(filter);
+ fireEvent.mouseDown(filter);
+ fireEvent.change(filter, { target: { value: "no-match" } });
+ if (mobile) fireEvent.touchEnd(backdrop);
+ fireEvent.mouseUp(backdrop);
+ fireEvent.click(backdrop);
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("model-combobox-portal")).toBeInTheDocument();
+ expect(filter).toHaveValue("no-match");
+ expect(screen.getByText(/No models match/)).toBeInTheDocument();
+ });
+
+ it.each([{ mobile: false }, { mobile: true }])("keeps ModelSelectionModal open after a $mobile portal-origin filter gesture and still closes for a genuine backdrop touch", async ({ mobile }) => {
+ setViewport(mobile);
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByLabelText("Executor Model"));
+ const filter = await screen.findByPlaceholderText("Filter models…");
+ const overlay = screen.getByTestId("model-selection-modal");
+ if (mobile) fireEvent.touchStart(filter);
+ fireEvent.pointerDown(filter); fireEvent.mouseDown(filter);
+ fireEvent.change(filter, { target: { value: "nothing" } });
+ if (mobile) fireEvent.touchEnd(overlay);
+ fireEvent.mouseUp(overlay); fireEvent.click(overlay);
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByTestId("model-selection-modal")).toBeInTheDocument();
+ expect(filter).toHaveValue("nothing");
+ expect(screen.getByText(/No models match/)).toBeInTheDocument();
+
+ // The clear affordance and search chrome are also portal-origin surfaces.
+ for (const origin of [screen.getByLabelText("Clear filter"), filter.parentElement!]) {
+ if (mobile) fireEvent.touchStart(origin);
+ fireEvent.pointerDown(origin); fireEvent.mouseDown(origin);
+ if (mobile) fireEvent.touchEnd(overlay);
+ fireEvent.mouseUp(overlay); fireEvent.click(overlay);
+ expect(onClose).not.toHaveBeenCalled();
+ }
+
+ if (mobile) {
+ fireEvent.touchStart(overlay); fireEvent.touchEnd(overlay);
+ } else {
+ fireEvent.mouseDown(overlay); fireEvent.mouseUp(overlay);
+ }
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([{ mobile: false }, { mobile: true }])("keeps the thinking popup open after a $mobile portal-origin filter gesture but closes for an outside press", async ({ mobile }) => {
+ setViewport(mobile);
+ render();
+ fireEvent.click(screen.getByTestId("chat-thinking-btn"));
+ fireEvent.click(screen.getByLabelText("Model"));
+ const filter = await screen.findByPlaceholderText("Filter models…");
+ if (mobile) fireEvent.touchStart(filter);
+ fireEvent.pointerDown(filter); fireEvent.mouseDown(filter);
+ expect(screen.getByTestId("chat-thinking-popover")).toBeInTheDocument();
+ fireEvent.pointerDown(document.body);
+ await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument());
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx b/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx
index 7f8e1abfa4..e3489f11f5 100644
--- a/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx
+++ b/packages/dashboard/app/hooks/__tests__/useOverlayDismiss.test.tsx
@@ -34,17 +34,18 @@ describe("useOverlayDismiss", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("ignores compatibility mouse sequence immediately after touch", () => {
+ it("closes on touch while ignoring its compatibility mouse sequence", () => {
const onClose = vi.fn();
const { getByTestId } = render();
const overlay = getByTestId("overlay");
fireEvent.touchStart(overlay);
fireEvent.touchEnd(overlay);
+ expect(onClose).toHaveBeenCalledTimes(1);
+
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
-
- expect(onClose).toHaveBeenCalledTimes(0);
+ expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not close when mouse starts inside modal and ends on overlay", () => {
diff --git a/packages/dashboard/app/hooks/useOverlayDismiss.ts b/packages/dashboard/app/hooks/useOverlayDismiss.ts
index 3982df88e9..9980de4b1d 100644
--- a/packages/dashboard/app/hooks/useOverlayDismiss.ts
+++ b/packages/dashboard/app/hooks/useOverlayDismiss.ts
@@ -18,8 +18,8 @@ export function useModalDismissPreference(): boolean {
/**
* Returns props for a modal-overlay element that dismisses only when a real
- * overlay click happens — i.e. both mousedown AND mouseup land on the overlay
- * itself.
+ * overlay press happens — i.e. both mouse or touch start AND end land on the
+ * overlay itself.
*
* This avoids a subtle dismiss-during-resize bug: when a user drags the
* native CSS `resize: both` grip from inside a modal and releases the mouse
@@ -34,8 +34,8 @@ export function useModalDismissPreference(): boolean {
export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boolean }): {
onMouseDown: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void;
- onTouchStart: () => void;
- onTouchEnd: () => void;
+ onTouchStart: (e: React.TouchEvent) => void;
+ onTouchEnd: (e: React.TouchEvent) => void;
} {
const contextEnabled = useModalDismissPreference();
/*
@@ -46,9 +46,21 @@ export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boo
const startedOnOverlayRef = useRef(false);
const lastTouchAtRef = useRef(0);
- const markTouch = useCallback(() => {
+ /*
+ FNXC:ModalDismissal 2026-08-15-12:43:
+ A portaled model menu can re-anchor while the mobile keyboard opens, leaving touchend on the backdrop. Touch dismissal must pair its own origin and release, then suppress compatibility mouse events without disabling genuine backdrop taps.
+ */
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
lastTouchAtRef.current = Date.now();
- }, []);
+ startedOnOverlayRef.current = dismissEnabled && e.target === e.currentTarget;
+ }, [dismissEnabled]);
+
+ const onTouchEnd = useCallback((e: React.TouchEvent) => {
+ lastTouchAtRef.current = Date.now();
+ const shouldClose = dismissEnabled && startedOnOverlayRef.current && e.target === e.currentTarget;
+ startedOnOverlayRef.current = false;
+ if (shouldClose) onClose();
+ }, [dismissEnabled, onClose]);
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (!dismissEnabled) {
@@ -89,5 +101,5 @@ export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boo
[dismissEnabled, onClose],
);
- return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
+ return { onMouseDown, onMouseUp, onTouchStart, onTouchEnd };
}
diff --git a/packages/dashboard/app/utils/__tests__/portalSurfaces.test.ts b/packages/dashboard/app/utils/__tests__/portalSurfaces.test.ts
new file mode 100644
index 0000000000..85a0936050
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/portalSurfaces.test.ts
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { isInsidePortaledModelMenu, isInsidePortalSafeSurface } from "../portalSurfaces";
+
+describe("portal surfaces", () => {
+ it("recognizes model menus including text-node event targets", () => {
+ const menu = document.createElement("div");
+ menu.className = "model-combobox-dropdown--portal";
+ const text = document.createTextNode("Filter models");
+ menu.append(text);
+ document.body.append(menu);
+ expect(isInsidePortaledModelMenu(menu)).toBe(true);
+ expect(isInsidePortaledModelMenu(text)).toBe(true);
+ });
+
+ it("separates generic safe surfaces from model-menu surfaces", () => {
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ const child = document.createElement("button");
+ dialog.append(child);
+ document.body.append(dialog);
+ expect(isInsidePortalSafeSurface(child)).toBe(true);
+ expect(isInsidePortaledModelMenu(child)).toBe(false);
+ });
+
+ it("rejects outside, null, and non-node targets", () => {
+ expect(isInsidePortalSafeSurface(document.createElement("div"))).toBe(false);
+ expect(isInsidePortaledModelMenu(null)).toBe(false);
+ expect(isInsidePortalSafeSurface({})).toBe(false);
+ });
+});
diff --git a/packages/dashboard/app/utils/portalSurfaces.ts b/packages/dashboard/app/utils/portalSurfaces.ts
new file mode 100644
index 0000000000..b14a1375a1
--- /dev/null
+++ b/packages/dashboard/app/utils/portalSurfaces.ts
@@ -0,0 +1,31 @@
+/*
+FNXC:ModelDropdown 2026-08-15-12:27:
+Body-portaled menus are logical children of their launching controls. Dismissal hosts must share this contract instead of copying class selectors, so a menu re-anchor during mobile keyboard use cannot turn an inside gesture into an outside dismissal.
+*/
+export const PORTAL_SAFE_SURFACE_SELECTOR = [
+ ".floating-window",
+ ".modal-overlay",
+ "[role=\"dialog\"]",
+ ".model-combobox-dropdown--portal",
+ ".model-nested-menu--portal",
+ ".dep-dropdown--portal",
+ ".node-picker-dropdown--portal",
+ ".agent-picker-dropdown--portal",
+ ".priority-picker-dropdown--portal",
+ ".activity-view-menu",
+].join(", ");
+
+export const PORTALED_MODEL_MENU_SELECTOR = ".model-combobox-dropdown--portal, .model-nested-menu--portal, [data-portal-surface=\"model-menu\"]";
+
+function targetElement(target: unknown): Element | null {
+ if (typeof Node === "undefined" || !(target instanceof Node)) return null;
+ return target.nodeType === Node.ELEMENT_NODE ? target as Element : target.parentElement;
+}
+
+export function isInsidePortalSafeSurface(target: unknown, selector = PORTAL_SAFE_SURFACE_SELECTOR): boolean {
+ return targetElement(target)?.closest(selector) != null;
+}
+
+export function isInsidePortaledModelMenu(target: unknown): boolean {
+ return isInsidePortalSafeSurface(target, PORTALED_MODEL_MENU_SELECTOR);
+}