From 284feeaf11dcf3add83ad2056eadb50b06b40f8e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 06:42:39 -0700 Subject: [PATCH] FN-9092: prevent model menus from dismissing host dialogs Keep portaled model-menu interaction from propagating to host dialog dismissal. - Mark model-menu portal surfaces and stop their pointer events from reaching dialog backdrops. - Apply the dismissal guard across agent, chat, floating-window, and model-selection dialogs. - Close enabled overlays on valid touch taps while suppressing duplicate compatibility mouse closes. - Add desktop and mobile regression coverage for portal-host dismissal behavior. Files changed: .../fn-9092-model-filter-dialog-dismissal.md | 7 + .../ui-bugs/portaled-model-menu-host-dismissal.md | 19 +++ packages/dashboard/app/components/AgentsView.tsx | 6 + .../app/components/ChatThinkingLevelControl.tsx | 17 ++- packages/dashboard/app/components/ChatView.tsx | 38 +++-- .../app/components/CustomModelDropdown.tsx | 13 +- .../dashboard/app/components/FloatingWindow.tsx | 17 +-- .../app/components/ModelSelectionModal.tsx | 17 +-- .../dashboard/app/components/NewAgentDialog.tsx | 12 +- .../dashboard/app/components/QuickEntryBox.tsx | 5 +- .../ChatThinkingLevelControl.portal.test.tsx | 9 ++ .../__tests__/CustomModelDropdown.test.tsx | 24 ++- .../__tests__/ModelSelectionModal.test.tsx | 3 +- .../NewAgentDialog.portal-dismissal.test.tsx | 77 ++++++++++ .../components/__tests__/NewAgentDialog.test.tsx | 11 ++ .../model-menu-filter-host-dismissal.test.tsx | 163 +++++++++++++++++++++ .../app/hooks/__tests__/useOverlayDismiss.test.tsx | 7 +- packages/dashboard/app/hooks/useOverlayDismiss.ts | 26 +++- .../app/utils/__tests__/portalSurfaces.test.ts | 31 ++++ packages/dashboard/app/utils/portalSurfaces.ts | 31 ++++ 20 files changed, 469 insertions(+), 64 deletions(-) Fusion-Task-Id: FN-9092 Fusion-Task-Lineage: a0d0b84d-2efe-4a67-9485-77c08f7cc57e Co-authored-by: Fusion (runfusion.ai) --- .../fn-9092-model-filter-dialog-dismissal.md | 7 + .../portaled-model-menu-host-dismissal.md | 19 ++ .../dashboard/app/components/AgentsView.tsx | 6 + .../components/ChatThinkingLevelControl.tsx | 17 +- .../dashboard/app/components/ChatView.tsx | 38 ++-- .../app/components/CustomModelDropdown.tsx | 13 +- .../app/components/FloatingWindow.tsx | 17 +- .../app/components/ModelSelectionModal.tsx | 17 +- .../app/components/NewAgentDialog.tsx | 12 +- .../app/components/QuickEntryBox.tsx | 5 +- .../ChatThinkingLevelControl.portal.test.tsx | 9 + .../__tests__/CustomModelDropdown.test.tsx | 24 ++- .../__tests__/ModelSelectionModal.test.tsx | 3 +- .../NewAgentDialog.portal-dismissal.test.tsx | 77 +++++++++ .../__tests__/NewAgentDialog.test.tsx | 11 ++ .../model-menu-filter-host-dismissal.test.tsx | 163 ++++++++++++++++++ .../__tests__/useOverlayDismiss.test.tsx | 7 +- .../dashboard/app/hooks/useOverlayDismiss.ts | 26 ++- .../utils/__tests__/portalSurfaces.test.ts | 31 ++++ .../dashboard/app/utils/portalSurfaces.ts | 31 ++++ 20 files changed, 469 insertions(+), 64 deletions(-) create mode 100644 .changeset/fn-9092-model-filter-dialog-dismissal.md create mode 100644 docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md create mode 100644 packages/dashboard/app/components/__tests__/NewAgentDialog.portal-dismissal.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/model-menu-filter-host-dismissal.test.tsx create mode 100644 packages/dashboard/app/utils/__tests__/portalSurfaces.test.ts create mode 100644 packages/dashboard/app/utils/portalSurfaces.ts diff --git a/.changeset/fn-9092-model-filter-dialog-dismissal.md b/.changeset/fn-9092-model-filter-dialog-dismissal.md new file mode 100644 index 0000000000..04f369a87f --- /dev/null +++ b/.changeset/fn-9092-model-filter-dialog-dismissal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Filtering models no longer closes model-picker dialogs, including on mobile. +category: fix +dev: Uses shared portal-safe-surface checks and press-origin backdrop dismissal guards. diff --git a/docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md b/docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md new file mode 100644 index 0000000000..e9dcfc2915 --- /dev/null +++ b/docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md @@ -0,0 +1,19 @@ +--- +category: ui-bugs +module: dashboard model selection +tags: + - portals + - dialogs + - mobile + - model-picker +problem_type: interaction-dismissal +applies_when: A document.body portal is logically owned by a dialog, popover, or panel with outside-dismiss behavior. +--- + +# Keep portaled model menus inside their host interaction boundary + +`CustomModelDropdown` renders its listbox in `document.body` to escape clipping. DOM containment alone therefore misclassifies menu gestures as outside a host dialog or popup. On mobile, virtual-keyboard re-anchoring can make a gesture that starts in the filter input release or synthesize a click on the backdrop. + +Use `isInsidePortaledModelMenu()` or `isInsidePortalSafeSurface()` from `app/utils/portalSurfaces.ts` for document-level dismissal handlers. Do not duplicate portal class-name selectors in host components. Backdrops that dismiss must use `useOverlayDismiss`, which pairs the press origin with the release before closing. + +The dropdown boundary stops pointer, mouse, and touch start/end events, but deliberately does not stop `click`; React option and favorite handlers require clicks dispatched through the portal. Regression coverage must exercise desktop and mobile press-origin sequences, including a release on the host backdrop. diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 9fb73157fd..5adc6bce15 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -28,6 +28,7 @@ import { } from "../utils/heartbeatIntervals"; import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; +import { isInsidePortaledModelMenu } from "../utils/portalSurfaces"; import { ORG_CHART_LAYOUT_STORAGE_KEY, isOrgChartLayoutPreference, @@ -833,6 +834,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin if (!target) return; if (controlsPanelRef.current?.contains(target)) return; if (controlsTriggerRef.current?.contains(target)) return; + /* + FNXC:ModelDropdown 2026-08-15-12:27: + Body-portaled model controls are logical children of their host; shared pointer and touch dismissal must not close a panel from their gesture origin. + */ + if (isInsidePortaledModelMenu(target)) return; setIsControlsPanelOpen(false); }; diff --git a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx index 4fc945271f..366c843db6 100644 --- a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx +++ b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx @@ -5,6 +5,7 @@ import { THINKING_LEVELS } from "@fusion/core"; import { CustomModelDropdown } from "./CustomModelDropdown"; import type { ModelInfo } from "../api"; import { FN_AGENT_ID } from "../hooks/useChat"; +import { isInsidePortaledModelMenu } from "../utils/portalSurfaces"; /* FNXC:Chat-ThinkingLevel 2026-07-12-19:30: @@ -84,21 +85,27 @@ export function ChatThinkingLevelControl({ useEffect(() => { if (!open) return; - const handlePointerDown = (event: PointerEvent) => { + const handleOutsidePress = (event: PointerEvent | TouchEvent) => { const target = event.target; if (!(target instanceof Node)) return; /* FNXC:Chat-ModelSwitch 2026-07-12-22:35: FN-7916: CustomModelDropdown renders its option list in a document.body portal outside rootRef. Treat that portaled menu as inside this popup so tablet/touch pointerdown does not dismiss the brain popup before the option onClick can persist the model selection. + + FNXC:ModelDropdown 2026-08-15-12:27: + Use the shared portal predicate for pointer and touch origins. Mobile outside-close handlers can receive touchstart before a re-anchored menu's synthesized click lands on the popup backdrop. */ const clickedInsideRoot = rootRef.current?.contains(target); - const clickedInsidePortaledModelMenu = target instanceof Element && Boolean(target.closest(".model-combobox-dropdown--portal")); - if (!clickedInsideRoot && !clickedInsidePortaledModelMenu) { + if (!clickedInsideRoot && !isInsidePortaledModelMenu(target)) { setOpen(false); } }; - document.addEventListener("pointerdown", handlePointerDown); - return () => document.removeEventListener("pointerdown", handlePointerDown); + document.addEventListener("pointerdown", handleOutsidePress); + document.addEventListener("touchstart", handleOutsidePress); + return () => { + document.removeEventListener("pointerdown", handleOutsidePress); + document.removeEventListener("touchstart", handleOutsidePress); + }; }, [open]); // Close the popup whenever the underlying level or target changes out from under us diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 7ddf696e9a..b2c6a3a901 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -46,6 +46,7 @@ import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; @@ -438,9 +439,14 @@ function NewChatDialog({ projectId, defaultModel, defaultKind, defaultAgentId, d const isSubmitDisabled = chatMode === "agent" ? !selectedAgentId : !resolvedModel; + /* + FNXC:ModalDismissal 2026-08-15-12:27: + The model listbox is portaled to document.body and can re-anchor when a mobile keyboard opens. Pair press origin with release so a filter gesture that ends on this backdrop never closes New Chat. + */ + const overlayDismiss = useOverlayDismiss(onClose, { enabled: true }); return ( -
+
e.stopPropagation()}>

{t("chat.newChatTitle", "New Chat")}

@@ -536,6 +542,16 @@ function NewChatDialog({ projectId, defaultModel, defaultKind, defaultAgentId, d } +/** + * FNXC:ModalDismissal 2026-08-15-13:11: + * Chat dialogs share this press-paired backdrop because a portaled model menu can re-anchor under + * the mobile keyboard and deliver its release or synthesized click to the backdrop. Only a gesture + * that starts and ends on the backdrop may dismiss its host dialog. + */ +function ChatDialogBackdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const overlayDismiss = useOverlayDismiss(onClose, { enabled: true }); + return
{children}
; +} type CopyFeedbackState = "success" | "error" | null; @@ -3805,7 +3821,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {/* Rename Dialog */} {renameDialog && ( -
setRenameDialog(null)}> + setRenameDialog(null)}>
-
+ )} {renameTagDialog && ( -
setRenameTagDialog(null)}> + setRenameTagDialog(null)}>
event.stopPropagation()}>

{t("chat.renameTagTitle", "Rename tag")}

@@ -3863,11 +3879,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
-
+ )} {confirmDeleteTag && ( -
setConfirmDeleteTag(null)}> + setConfirmDeleteTag(null)}>
event.stopPropagation()}>

{t("chat.deleteTagTitle", "Delete tag?")}

{t("chat.deleteTagBody", "This removes the tag from all conversations, but does not delete conversations.")}

@@ -3876,12 +3892,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
-
+ )} {/* Confirm Delete Dialog */} {confirmDelete && ( -
setConfirmDelete(null)}> + setConfirmDelete(null)}>
e.stopPropagation()}>

{t("chat.deleteConversationTitle", "Delete Conversation?")}

@@ -3899,11 +3915,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout

-
+ )} {chatRoomsEnabled && confirmDeleteRoomId && ( -
setConfirmDeleteRoomId(null)}> + setConfirmDeleteRoomId(null)}>
e.stopPropagation()}>

{t("chat.deleteRoomTitle", "Delete Room?")}

@@ -3930,7 +3946,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout

- + )} {/* Thread */} {chatRoomsEnabled && chatScope === "rooms" ? ( diff --git a/packages/dashboard/app/components/CustomModelDropdown.tsx b/packages/dashboard/app/components/CustomModelDropdown.tsx index cfcb1a6601..94b25b57e1 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.tsx +++ b/packages/dashboard/app/components/CustomModelDropdown.tsx @@ -521,18 +521,18 @@ export function CustomModelDropdown({ /* FNXC:ModelDropdown 2026-08-12-21:56: Every dashboard host must treat the document.body model-menu portal as inside its model control. Stop native pointer and mouse events at the portal boundary before document-level outside-close listeners run, so provider collapse/expand remains interactive on desktop and mobile without requiring each consumer to duplicate the portal exemption. + + FNXC:ModelDropdown 2026-08-15-12:27: + Mobile hosts may close from touchstart before a virtual-keyboard re-anchor sends the synthesized click to a backdrop. Stop touchstart and touchend too, but never click: React option and favorite handlers dispatch through this body portal. */ useEffect(() => { const dropdown = dropdownRef.current; if (!dropdown) return; const stopPortalOutsideClose = (event: Event) => event.stopPropagation(); - dropdown.addEventListener("pointerdown", stopPortalOutsideClose); - dropdown.addEventListener("mousedown", stopPortalOutsideClose); - return () => { - dropdown.removeEventListener("pointerdown", stopPortalOutsideClose); - dropdown.removeEventListener("mousedown", stopPortalOutsideClose); - }; + const events = ["pointerdown", "mousedown", "touchstart", "touchend"] as const; + events.forEach((eventName) => dropdown.addEventListener(eventName, stopPortalOutsideClose)); + return () => events.forEach((eventName) => dropdown.removeEventListener(eventName, stopPortalOutsideClose)); }, [dropdownPosition, isOpen]); // Keyboard navigation @@ -660,6 +660,7 @@ export function CustomModelDropdown({ className="model-combobox-dropdown model-combobox-dropdown--portal" role="listbox" data-testid="model-combobox-portal" + data-portal-surface="model-menu" data-menu-width={menuWidth} onKeyDown={handleKeyDown} style={{ diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 034921bf33..68dddf1096 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -13,6 +13,7 @@ import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { isFullScreenSheetViewport, isShortViewport, isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode"; import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack"; +import { isInsidePortalSafeSurface } from "../utils/portalSurfaces"; import "./FloatingWindow.css"; /* @@ -106,19 +107,6 @@ type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; export const FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT = "fusion:floating-window-geometry-change"; -const FLOATING_WINDOW_OUTSIDE_POINTER_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(", "); - /* FNXC:ModalTouchGeometry 2026-07-27-12:00: FN-8619: Task Detail's body-portaled activity-view menu is a logical child of its modal. @@ -562,8 +550,7 @@ export function FloatingWindow({ return; } - const targetElement = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : null; - if (targetElement?.closest(FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR)) return; + if (isInsidePortalSafeSurface(target)) return; onClose(); }; diff --git a/packages/dashboard/app/components/ModelSelectionModal.tsx b/packages/dashboard/app/components/ModelSelectionModal.tsx index 11373db127..765e48d9cd 100644 --- a/packages/dashboard/app/components/ModelSelectionModal.tsx +++ b/packages/dashboard/app/components/ModelSelectionModal.tsx @@ -7,6 +7,7 @@ import { applyPresetToSelection } from "../utils/modelPresets"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { Brain, X } from "lucide-react"; import { LoadingSpinner } from "./LoadingSpinner"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; const PRESET_OPTION_SEPARATOR = "──────────"; @@ -130,15 +131,11 @@ export function ModelSelectionModal({ return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); - // Handle overlay click - const handleOverlayClick = useCallback( - (event: React.MouseEvent) => { - if (event.target === event.currentTarget) { - onClose(); - } - }, - [onClose], - ); + /* + FNXC:ModalDismissal 2026-08-15-12:27: + Model controls render their listbox through document.body. Pair overlay press and release so a re-anchored portal gesture cannot be mistaken for a backdrop click. + */ + const overlayDismiss = useOverlayDismiss(onClose, { enabled: true }); const showPresets = !!(presets && presets.length > 0 && onPresetChange); const selectedPreset = presets?.find((p) => p.id === selectedPresetId); @@ -209,7 +206,7 @@ export function ModelSelectionModal({ const hasMergerOverride = Boolean(mergerValue); return ( -
+
diff --git a/packages/dashboard/app/components/NewAgentDialog.tsx b/packages/dashboard/app/components/NewAgentDialog.tsx index d149b84d52..5a4ec6ba50 100644 --- a/packages/dashboard/app/components/NewAgentDialog.tsx +++ b/packages/dashboard/app/components/NewAgentDialog.tsx @@ -22,6 +22,7 @@ import { SkillMultiselect } from "./SkillMultiselect"; import { AgentAvatar } from "./AgentAvatar"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; import { useFavorites } from "../hooks/useFavorites"; +import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; export interface NewAgentDialogProps { isOpen: boolean; @@ -267,8 +268,6 @@ export function NewAgentDialog({ applyDraftToForm(prefillDraft); }, [isOpen, prefillDraft, applyDraftToForm]); - if (!isOpen) return null; - const handleClose = () => { setStep(0); setStepZeroTab("presets"); @@ -293,6 +292,13 @@ export function NewAgentDialog({ setIsInterviewOpen(false); onClose(); }; + /* + FNXC:ModalDismissal 2026-08-15-12:27: + A model-menu gesture can end on the overlay after mobile re-anchoring. Preserve outside dismissal only when the press itself started on this overlay. + */ + const overlayDismiss = useOverlayDismiss(handleClose, { enabled: true }); + + if (!isOpen) return null; const handleCreate = async () => { if (!name.trim()) return; @@ -418,7 +424,7 @@ export function NewAgentDialog({ // mobile (the header isn't taller than the dialog top — it's just stacked // above it because the dialog couldn't escape its container). return createPortal( -
{ if (e.target === e.currentTarget) handleClose(); }}> +
{/* Header */}
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); +}