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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 06:42:39 -07:00
parent b26894e0dd
commit 284feeaf11
20 changed files with 469 additions and 64 deletions

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ import {
} from "../utils/heartbeatIntervals"; } from "../utils/heartbeatIntervals";
import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core"; import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core";
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
import { import {
ORG_CHART_LAYOUT_STORAGE_KEY, ORG_CHART_LAYOUT_STORAGE_KEY,
isOrgChartLayoutPreference, isOrgChartLayoutPreference,
@@ -833,6 +834,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
if (!target) return; if (!target) return;
if (controlsPanelRef.current?.contains(target)) return; if (controlsPanelRef.current?.contains(target)) return;
if (controlsTriggerRef.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); setIsControlsPanelOpen(false);
}; };

View File

@@ -5,6 +5,7 @@ import { THINKING_LEVELS } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import type { ModelInfo } from "../api"; import type { ModelInfo } from "../api";
import { FN_AGENT_ID } from "../hooks/useChat"; import { FN_AGENT_ID } from "../hooks/useChat";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
/* /*
FNXC:Chat-ThinkingLevel 2026-07-12-19:30: FNXC:Chat-ThinkingLevel 2026-07-12-19:30:
@@ -84,21 +85,27 @@ export function ChatThinkingLevelControl({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handlePointerDown = (event: PointerEvent) => { const handleOutsidePress = (event: PointerEvent | TouchEvent) => {
const target = event.target; const target = event.target;
if (!(target instanceof Node)) return; if (!(target instanceof Node)) return;
/* /*
FNXC:Chat-ModelSwitch 2026-07-12-22:35: 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. 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 clickedInsideRoot = rootRef.current?.contains(target);
const clickedInsidePortaledModelMenu = target instanceof Element && Boolean(target.closest(".model-combobox-dropdown--portal")); if (!clickedInsideRoot && !isInsidePortaledModelMenu(target)) {
if (!clickedInsideRoot && !clickedInsidePortaledModelMenu) {
setOpen(false); setOpen(false);
} }
}; };
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handleOutsidePress);
return () => document.removeEventListener("pointerdown", handlePointerDown); document.addEventListener("touchstart", handleOutsidePress);
return () => {
document.removeEventListener("pointerdown", handleOutsidePress);
document.removeEventListener("touchstart", handleOutsidePress);
};
}, [open]); }, [open]);
// Close the popup whenever the underlying level or target changes out from under us // Close the popup whenever the underlying level or target changes out from under us

View File

@@ -46,6 +46,7 @@ import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock"; import { useMobileKeyboardViewportLock, isIOS } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { matchesAgentMentionFilter } from "./mentionMatching"; import { matchesAgentMentionFilter } from "./mentionMatching";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
@@ -438,9 +439,14 @@ function NewChatDialog({ projectId, defaultModel, defaultKind, defaultAgentId, d
const isSubmitDisabled = const isSubmitDisabled =
chatMode === "agent" ? !selectedAgentId : !resolvedModel; 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 ( return (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={onClose} role="dialog" aria-modal="true"> <div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" {...overlayDismiss} role="dialog" aria-modal="true">
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}> <div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.newChatTitle", "New Chat")}</h3> <h3>{t("chat.newChatTitle", "New Chat")}</h3>
<div className="chat-new-dialog-mode-toggle" data-testid="chat-new-dialog-mode-toggle"> <div className="chat-new-dialog-mode-toggle" data-testid="chat-new-dialog-mode-toggle">
@@ -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 <div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" {...overlayDismiss}>{children}</div>;
}
type CopyFeedbackState = "success" | "error" | null; type CopyFeedbackState = "success" | "error" | null;
@@ -3805,7 +3821,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{/* Rename Dialog */} {/* Rename Dialog */}
{renameDialog && ( {renameDialog && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setRenameDialog(null)}> <ChatDialogBackdrop onClose={() => setRenameDialog(null)}>
<div <div
className="chat-new-dialog chat-view-dialog" className="chat-new-dialog chat-view-dialog"
role="dialog" role="dialog"
@@ -3849,11 +3865,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button> </button>
</div> </div>
</div> </div>
</div> </ChatDialogBackdrop>
)} )}
{renameTagDialog && ( {renameTagDialog && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setRenameTagDialog(null)}> <ChatDialogBackdrop onClose={() => setRenameTagDialog(null)}>
<div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-rename-tag-dialog-title" onClick={(event) => event.stopPropagation()}> <div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-rename-tag-dialog-title" onClick={(event) => event.stopPropagation()}>
<h3 id="chat-rename-tag-dialog-title">{t("chat.renameTagTitle", "Rename tag")}</h3> <h3 id="chat-rename-tag-dialog-title">{t("chat.renameTagTitle", "Rename tag")}</h3>
<label className="chat-rename-label" htmlFor="chat-rename-tag-input">{t("chat.tagName", "Tag name")}</label> <label className="chat-rename-label" htmlFor="chat-rename-tag-input">{t("chat.tagName", "Tag name")}</label>
@@ -3863,11 +3879,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<button className="btn btn-sm btn-primary" data-testid="chat-rename-tag-save" onClick={() => void renameTag(renameTagDialog.id, renameTagName).then(() => setRenameTagDialog(null)).catch(() => addToast(t("chat.failedToRenameTag", "Failed to rename tag"), "error"))}>{t("chat.save", "Save")}</button> <button className="btn btn-sm btn-primary" data-testid="chat-rename-tag-save" onClick={() => void renameTag(renameTagDialog.id, renameTagName).then(() => setRenameTagDialog(null)).catch(() => addToast(t("chat.failedToRenameTag", "Failed to rename tag"), "error"))}>{t("chat.save", "Save")}</button>
</div> </div>
</div> </div>
</div> </ChatDialogBackdrop>
)} )}
{confirmDeleteTag && ( {confirmDeleteTag && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteTag(null)}> <ChatDialogBackdrop onClose={() => setConfirmDeleteTag(null)}>
<div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-delete-tag-dialog-title" onClick={(event) => event.stopPropagation()}> <div className="chat-new-dialog chat-view-dialog" role="dialog" aria-modal="true" aria-labelledby="chat-delete-tag-dialog-title" onClick={(event) => event.stopPropagation()}>
<h3 id="chat-delete-tag-dialog-title">{t("chat.deleteTagTitle", "Delete tag?")}</h3> <h3 id="chat-delete-tag-dialog-title">{t("chat.deleteTagTitle", "Delete tag?")}</h3>
<p className="chat-view-delete-dialog-copy">{t("chat.deleteTagBody", "This removes the tag from all conversations, but does not delete conversations.")}</p> <p className="chat-view-delete-dialog-copy">{t("chat.deleteTagBody", "This removes the tag from all conversations, but does not delete conversations.")}</p>
@@ -3876,12 +3892,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<button className="btn btn-sm btn-danger" data-testid="chat-delete-tag-confirm" onClick={() => void deleteTag(confirmDeleteTag.id).then(() => setConfirmDeleteTag(null)).catch(() => addToast(t("chat.failedToDeleteTag", "Failed to delete tag"), "error"))}>{t("chat.delete", "Delete")}</button> <button className="btn btn-sm btn-danger" data-testid="chat-delete-tag-confirm" onClick={() => void deleteTag(confirmDeleteTag.id).then(() => setConfirmDeleteTag(null)).catch(() => addToast(t("chat.failedToDeleteTag", "Failed to delete tag"), "error"))}>{t("chat.delete", "Delete")}</button>
</div> </div>
</div> </div>
</div> </ChatDialogBackdrop>
)} )}
{/* Confirm Delete Dialog */} {/* Confirm Delete Dialog */}
{confirmDelete && ( {confirmDelete && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}> <ChatDialogBackdrop onClose={() => setConfirmDelete(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}> <div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.deleteConversationTitle", "Delete Conversation?")}</h3> <h3>{t("chat.deleteConversationTitle", "Delete Conversation?")}</h3>
<p className="chat-view-delete-dialog-copy"> <p className="chat-view-delete-dialog-copy">
@@ -3899,11 +3915,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button> </button>
</div> </div>
</div> </div>
</div> </ChatDialogBackdrop>
)} )}
{chatRoomsEnabled && confirmDeleteRoomId && ( {chatRoomsEnabled && confirmDeleteRoomId && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}> <ChatDialogBackdrop onClose={() => setConfirmDeleteRoomId(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}> <div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.deleteRoomTitle", "Delete Room?")}</h3> <h3>{t("chat.deleteRoomTitle", "Delete Room?")}</h3>
<p className="chat-view-delete-dialog-copy"> <p className="chat-view-delete-dialog-copy">
@@ -3930,7 +3946,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button> </button>
</div> </div>
</div> </div>
</div> </ChatDialogBackdrop>
)} )}
{/* Thread */} {/* Thread */}
{chatRoomsEnabled && chatScope === "rooms" ? ( {chatRoomsEnabled && chatScope === "rooms" ? (

View File

@@ -521,18 +521,18 @@ export function CustomModelDropdown({
/* /*
FNXC:ModelDropdown 2026-08-12-21:56: 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. 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(() => { useEffect(() => {
const dropdown = dropdownRef.current; const dropdown = dropdownRef.current;
if (!dropdown) return; if (!dropdown) return;
const stopPortalOutsideClose = (event: Event) => event.stopPropagation(); const stopPortalOutsideClose = (event: Event) => event.stopPropagation();
dropdown.addEventListener("pointerdown", stopPortalOutsideClose); const events = ["pointerdown", "mousedown", "touchstart", "touchend"] as const;
dropdown.addEventListener("mousedown", stopPortalOutsideClose); events.forEach((eventName) => dropdown.addEventListener(eventName, stopPortalOutsideClose));
return () => { return () => events.forEach((eventName) => dropdown.removeEventListener(eventName, stopPortalOutsideClose));
dropdown.removeEventListener("pointerdown", stopPortalOutsideClose);
dropdown.removeEventListener("mousedown", stopPortalOutsideClose);
};
}, [dropdownPosition, isOpen]); }, [dropdownPosition, isOpen]);
// Keyboard navigation // Keyboard navigation
@@ -660,6 +660,7 @@ export function CustomModelDropdown({
className="model-combobox-dropdown model-combobox-dropdown--portal" className="model-combobox-dropdown model-combobox-dropdown--portal"
role="listbox" role="listbox"
data-testid="model-combobox-portal" data-testid="model-combobox-portal"
data-portal-surface="model-menu"
data-menu-width={menuWidth} data-menu-width={menuWidth}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ style={{

View File

@@ -13,6 +13,7 @@ import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { isFullScreenSheetViewport, isShortViewport, isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode"; import { isFullScreenSheetViewport, isShortViewport, isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack"; import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack";
import { isInsidePortalSafeSurface } from "../utils/portalSurfaces";
import "./FloatingWindow.css"; 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"]; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
export const FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT = "fusion:floating-window-geometry-change"; 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: FNXC:ModalTouchGeometry 2026-07-27-12:00:
FN-8619: Task Detail's body-portaled activity-view menu is a logical child of its modal. 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; return;
} }
const targetElement = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : null; if (isInsidePortalSafeSurface(target)) return;
if (targetElement?.closest(FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR)) return;
onClose(); onClose();
}; };

View File

@@ -7,6 +7,7 @@ import { applyPresetToSelection } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { Brain, X } from "lucide-react"; import { Brain, X } from "lucide-react";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
const PRESET_OPTION_SEPARATOR = "──────────"; const PRESET_OPTION_SEPARATOR = "──────────";
@@ -130,15 +131,11 @@ export function ModelSelectionModal({
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Handle overlay click /*
const handleOverlayClick = useCallback( FNXC:ModalDismissal 2026-08-15-12:27:
(event: React.MouseEvent) => { 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.
if (event.target === event.currentTarget) { */
onClose(); const overlayDismiss = useOverlayDismiss(onClose, { enabled: true });
}
},
[onClose],
);
const showPresets = !!(presets && presets.length > 0 && onPresetChange); const showPresets = !!(presets && presets.length > 0 && onPresetChange);
const selectedPreset = presets?.find((p) => p.id === selectedPresetId); const selectedPreset = presets?.find((p) => p.id === selectedPresetId);
@@ -209,7 +206,7 @@ export function ModelSelectionModal({
const hasMergerOverride = Boolean(mergerValue); const hasMergerOverride = Boolean(mergerValue);
return ( return (
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true" data-testid="model-selection-modal"> <div className="modal-overlay open" {...overlayDismiss} role="dialog" aria-modal="true" data-testid="model-selection-modal">
<div className="modal modal-lg"> <div className="modal modal-lg">
<div className="modal-header"> <div className="modal-header">
<div className="detail-title-row"> <div className="detail-title-row">

View File

@@ -22,6 +22,7 @@ import { SkillMultiselect } from "./SkillMultiselect";
import { AgentAvatar } from "./AgentAvatar"; import { AgentAvatar } from "./AgentAvatar";
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
import { useFavorites } from "../hooks/useFavorites"; import { useFavorites } from "../hooks/useFavorites";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
export interface NewAgentDialogProps { export interface NewAgentDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -267,8 +268,6 @@ export function NewAgentDialog({
applyDraftToForm(prefillDraft); applyDraftToForm(prefillDraft);
}, [isOpen, prefillDraft, applyDraftToForm]); }, [isOpen, prefillDraft, applyDraftToForm]);
if (!isOpen) return null;
const handleClose = () => { const handleClose = () => {
setStep(0); setStep(0);
setStepZeroTab("presets"); setStepZeroTab("presets");
@@ -293,6 +292,13 @@ export function NewAgentDialog({
setIsInterviewOpen(false); setIsInterviewOpen(false);
onClose(); 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 () => { const handleCreate = async () => {
if (!name.trim()) return; 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 // mobile (the header isn't taller than the dialog top — it's just stacked
// above it because the dialog couldn't escape its container). // above it because the dialog couldn't escape its container).
return createPortal( return createPortal(
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}> <div className="agent-dialog-overlay" {...overlayDismiss}>
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label={t("agents.dialogAriaLabel", "Create new agent")}> <div className="agent-dialog" role="dialog" aria-modal="true" aria-label={t("agents.dialogAriaLabel", "Create new agent")}>
{/* Header */} {/* Header */}
<div className="agent-dialog-header"> <div className="agent-dialog-header">

View File

@@ -23,6 +23,7 @@ import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator"; import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart"; import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition"; import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
const STORAGE_KEY = "kb-quick-entry-text"; const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([ 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 clickedInsideTrigger = modelTriggerRef.current?.contains(target);
const clickedInsidePortal = modelMenuPortalRef.current?.contains(target); const clickedInsidePortal = modelMenuPortalRef.current?.contains(target);
// Also check for clicks inside CustomModelDropdown's portaled dropdown // 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) { if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) {
setIsModelMenuOpen(false); 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. focus for its filter input. Plain menu chrome still preserves the quick-entry focus.
*/ */
const target = e.target as Element; 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; return;
} }
e.preventDefault(); e.preventDefault();

View File

@@ -40,6 +40,15 @@ describe("ChatThinkingLevelControl with the real CustomModelDropdown portal", ()
await waitFor(() => expect(screen.queryByTestId("chat-thinking-popover")).not.toBeInTheDocument()); 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 () => { it("still closes the brain popup for a genuine outside pointerdown", async () => {
await openModelPortalWithRender({ onChangeModel: vi.fn() }); await openModelPortalWithRender({ onChangeModel: vi.fn() });

View File

@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { useEffect, useRef, useState } from "react"; 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 userEvent from "@testing-library/user-event";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss } from "../../test/cssFixture";
import { CustomModelDropdown } from "../CustomModelDropdown"; import { CustomModelDropdown } from "../CustomModelDropdown";
@@ -60,6 +60,28 @@ describe("CustomModelDropdown", () => {
} as MediaQueryList)); } 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(<CustomModelDropdown label="Model" value="" onChange={onChange} models={MOCK_MODELS} />);
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", () => { it("keeps the search wrapper background opaque to prevent list bleed-through", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();
const wrapperRuleMatch = css.match(/\.model-combobox-search-wrapper\s*\{[^}]*\}/); const wrapperRuleMatch = css.match(/\.model-combobox-search-wrapper\s*\{[^}]*\}/);

View File

@@ -215,7 +215,8 @@ describe("ModelSelectionModal", () => {
renderModelSelectionModal({ onClose }); renderModelSelectionModal({ onClose });
const overlay = screen.getByTestId("model-selection-modal"); const overlay = screen.getByTestId("model-selection-modal");
fireEvent.click(overlay); fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });

View File

@@ -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<typeof import("../../api")>()),
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: () => <div /> }));
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(<NewAgentDialog isOpen onClose={onClose} onCreated={vi.fn()} />);
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<ReturnType<typeof api.fetchModels>>);
const onClose = vi.fn();
render(<NewAgentDialog isOpen onClose={onClose} onCreated={vi.fn()} />);
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();
});
});

View File

@@ -61,6 +61,17 @@ describe("NewAgentDialog thinking level", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("closes for a genuine touch that begins and ends on its overlay", () => {
const onClose = vi.fn();
render(<NewAgentDialog isOpen onClose={onClose} onCreated={vi.fn()} />);
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 () => { it("uses CustomModelDropdown thinking control with concrete-only agent semantics", async () => {
render(<NewAgentDialog isOpen onClose={vi.fn()} onCreated={vi.fn()} />); render(<NewAgentDialog isOpen onClose={vi.fn()} onCreated={vi.fn()} />);

View File

@@ -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: () => <div /> }));
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<typeof import("../../api")>()),
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<ReturnType<typeof api.fetchSettings>>);
});
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(<ChatView projectId="project-a" addToast={vi.fn()} />); });
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(<ModelSelectionModal
isOpen onClose={onClose}
models={[
{ id: "gpt-4o", provider: "openai", name: "GPT-4o" },
{ id: "claude-sonnet", provider: "anthropic", name: "Claude Sonnet" },
]}
executorValue="" validatorValue="" onExecutorChange={vi.fn()} onValidatorChange={vi.fn()}
modelsLoading={false} modelsError={null} onRetry={vi.fn()}
/>);
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(<ChatThinkingLevelControl level={null} onChange={vi.fn()} onChangeModel={vi.fn()} models={[
{ id: "gpt-4o", provider: "openai", name: "GPT-4o" },
]} />);
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());
});
});

View File

@@ -34,17 +34,18 @@ describe("useOverlayDismiss", () => {
expect(onClose).toHaveBeenCalledTimes(1); 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 onClose = vi.fn();
const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />); const { getByTestId } = render(<OverlayHarness onClose={onClose} enabled />);
const overlay = getByTestId("overlay"); const overlay = getByTestId("overlay");
fireEvent.touchStart(overlay); fireEvent.touchStart(overlay);
fireEvent.touchEnd(overlay); fireEvent.touchEnd(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.mouseDown(overlay); fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay); fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(0);
}); });
it("does not close when mouse starts inside modal and ends on overlay", () => { it("does not close when mouse starts inside modal and ends on overlay", () => {

View File

@@ -18,8 +18,8 @@ export function useModalDismissPreference(): boolean {
/** /**
* Returns props for a modal-overlay element that dismisses only when a real * 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 * overlay press happens — i.e. both mouse or touch start AND end land on the
* itself. * overlay itself.
* *
* This avoids a subtle dismiss-during-resize bug: when a user drags the * 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 * 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 }): { export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boolean }): {
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void; onMouseUp: (e: React.MouseEvent) => void;
onTouchStart: () => void; onTouchStart: (e: React.TouchEvent) => void;
onTouchEnd: () => void; onTouchEnd: (e: React.TouchEvent) => void;
} { } {
const contextEnabled = useModalDismissPreference(); const contextEnabled = useModalDismissPreference();
/* /*
@@ -46,9 +46,21 @@ export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boo
const startedOnOverlayRef = useRef(false); const startedOnOverlayRef = useRef(false);
const lastTouchAtRef = useRef(0); 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(); 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) => { const onMouseDown = useCallback((e: React.MouseEvent) => {
if (!dismissEnabled) { if (!dismissEnabled) {
@@ -89,5 +101,5 @@ export function useOverlayDismiss(onClose: () => void, options?: { enabled?: boo
[dismissEnabled, onClose], [dismissEnabled, onClose],
); );
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch }; return { onMouseDown, onMouseUp, onTouchStart, onTouchEnd };
} }

View File

@@ -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);
});
});

View File

@@ -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);
}