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:
7
.changeset/fn-9092-model-filter-dialog-dismissal.md
Normal file
7
.changeset/fn-9092-model-filter-dialog-dismissal.md
Normal 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.
|
||||||
19
docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md
Normal file
19
docs/solutions/ui-bugs/portaled-model-menu-host-dismissal.md
Normal 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.
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" ? (
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|
||||||
|
|||||||
@@ -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*\{[^}]*\}/);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
packages/dashboard/app/utils/portalSurfaces.ts
Normal file
31
packages/dashboard/app/utils/portalSurfaces.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user