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(
+
+ )
+}
+
+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 }
+}