Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/conversations/conversation-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2855,6 +2857,7 @@ export function ConversationDetailPanel() {
<div
ref={groupContainerRef}
className="relative min-h-0 flex-1 overflow-hidden"
{...contextMenuPointerGuard.triggerProps}
>
{/* Flat sibling shells keyed by stable group id + divider
overlays — stable across every split/tile flip, otherwise
Expand Down
124 changes: 124 additions & 0 deletions src/hooks/use-context-menu-pointer-guard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div onContextMenu={onContextMenu}>
<div data-testid="trigger" {...result.current.triggerProps}>
<span data-testid="text">message text</span>
</div>
</div>
)
}

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(
<ContextMenu>
<ContextMenuTrigger asChild>
<div data-testid="trigger" {...result.current.triggerProps}>
<span data-testid="text">message text</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>App action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

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()
})
})
41 changes: 41 additions & 0 deletions src/hooks/use-context-menu-pointer-guard.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 }
}
Loading