diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index f6052b452..b645e3739 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -49,6 +49,7 @@ import { useAdvertisedGoalActions } from "@/hooks/use-goal-actions" import { ConversationShell } from "@/components/chat/conversation-shell" import { SessionConfigStaleBanner } from "@/components/chat/session-config-stale-banner" import { PiProjectTrustBanner } from "@/components/chat/pi-project-trust-banner" +import { useContextMenuPointerGuard } from "@/hooks/use-context-menu-pointer-guard" import { FeedbackNotesDisplay } from "@/components/chat/feedback-notes-display" import { FeedbackDialog } from "@/components/chat/feedback-dialog" import { AgentDiagnosticsDialog } from "@/components/settings/agent-diagnostics-dialog" @@ -2368,6 +2369,7 @@ export function ConversationDetailPanel() { const [detailsOpen, setDetailsOpen] = useState(false) const exportLabels = useExportLabels() + const contextMenuPointerGuard = useContextMenuPointerGuard() // Release the old connection as soon as a preview tab is replaced (the next // single-click in the sidebar takes its slot) instead of waiting for a sweep. @@ -2855,6 +2857,7 @@ export function ConversationDetailPanel() {
{/* Flat sibling shells keyed by stable group id + divider overlays — stable across every split/tile flip, otherwise diff --git a/src/hooks/use-context-menu-pointer-guard.test.tsx b/src/hooks/use-context-menu-pointer-guard.test.tsx new file mode 100644 index 000000000..a383c6a5a --- /dev/null +++ b/src/hooks/use-context-menu-pointer-guard.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, renderHook, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { useContextMenuPointerGuard } from "./use-context-menu-pointer-guard" + +/** + * jsdom's `fireEvent.pointerDown` drops `pointerType` (it builds a plain + * MouseEvent), so the property is pinned by hand — the guard reads exactly + * that field to distinguish a native text-selection long-press from a mouse + * right-click. + */ +function pointerDown(element: Element, pointerType: string) { + const event = new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, "pointerType", { value: pointerType }) + fireEvent(element, event) +} + +function renderGuarded(onContextMenu: (event: Event) => void) { + const { result } = renderHook(() => useContextMenuPointerGuard()) + + return render( +
+
+ message text +
+
+ ) +} + +describe("useContextMenuPointerGuard", () => { + it("lets a touch long-press reach the native menu but not the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "touch") + const event = fireEvent.contextMenu(text) + + expect(onContextMenu).not.toHaveBeenCalled() + // Stopping Radix's propagation must not cancel the browser's native + // selection/image menu, which is the whole point on touch. + expect(event).toBe(true) + }) + + it("lets a pen long-press reach the native menu but not the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "pen") + fireEvent.contextMenu(text) + + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("keeps a mouse right-click on the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "mouse") + fireEvent.contextMenu(text) + + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("keeps an app-menu shortcut usable when no pointer press preceded it", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + + fireEvent.contextMenu(screen.getByTestId("text")) + + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) +}) + +describe("context menu integration", () => { + function renderRadixGuarded() { + const { result } = renderHook(() => useContextMenuPointerGuard()) + + return render( + + +
+ message text +
+
+ + App action + +
+ ) + } + + it("keeps a touch long-press from opening the Radix menu", () => { + renderRadixGuarded() + const trigger = screen.getByTestId("trigger") + + pointerDown(trigger, "touch") + const event = fireEvent.contextMenu(trigger) + + expect(screen.queryByRole("menu")).not.toBeInTheDocument() + expect(event).toBe(true) + }) + + it("opens the Radix menu for a mouse right-click", () => { + renderRadixGuarded() + const trigger = screen.getByTestId("trigger") + + pointerDown(trigger, "mouse") + fireEvent.contextMenu(trigger) + + expect(screen.getByRole("menu")).toBeInTheDocument() + }) +}) diff --git a/src/hooks/use-context-menu-pointer-guard.ts b/src/hooks/use-context-menu-pointer-guard.ts new file mode 100644 index 000000000..c9c581d5d --- /dev/null +++ b/src/hooks/use-context-menu-pointer-guard.ts @@ -0,0 +1,41 @@ +"use client" + +import { + useCallback, + useRef, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react" + +interface ContextMenuTriggerGuardProps { + /** Record the gesture before the browser translates a long-press into `contextmenu`. */ + onPointerDownCapture: (event: ReactPointerEvent) => void + /** Suppress the app menu for touch/pen while leaving the native menu intact. */ + onContextMenuCapture: (event: ReactMouseEvent) => void +} + +/** + * Text long-presses should belong to the platform's selection UI, not Radix's + * app menu. The pointer type is captured on the way in because `contextmenu` + * itself no longer says whether the gesture came from touch or mouse. + */ +export function useContextMenuPointerGuard() { + const pointerTypeRef = useRef(null) + + const trackPointerType = useCallback((event: ReactPointerEvent) => { + pointerTypeRef.current = event.pointerType + }, []) + + const suppressAppMenu = useCallback((event: ReactMouseEvent) => { + if (pointerTypeRef.current && pointerTypeRef.current !== "mouse") { + event.stopPropagation() + } + }, []) + + const triggerProps: ContextMenuTriggerGuardProps = { + onPointerDownCapture: trackPointerType, + onContextMenuCapture: suppressAppMenu, + } + + return { triggerProps } +}