` cannot tell a working
+ * button from one whose trigger never made it into the DOM.
+ */
+function renderRow(
+ row: (actions: ReactNode) => ReactNode,
+ options: { keyboardNavigation?: boolean } = {}
+) {
+ // `onSelect` is what a click on the row itself fires (open the preview /
+ // select the folder) — the handler the button must not leak into.
+ const onRowSelect = vi.fn()
+ render(
+
+
+
+ {row( )}
+
+
+ rename
+
+
+
+ )
+ return { button: screen.getByLabelText("tr:moreActions"), onRowSelect }
+}
+
+const fileRow = (actions: ReactNode) => (
+
+)
+
+const folderRow = (actions: ReactNode) => (
+
+)
+
+function openMenuTexts(): string[] {
+ return [...document.querySelectorAll("[data-slot=context-menu-content]")].map(
+ (node) => node.textContent ?? ""
+ )
+}
+
+describe("RowMoreButton", () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("renders a labelled menu button with the icon hidden from AT", () => {
+ const { button } = renderRow(fileRow)
+ expect(button.tagName).toBe("BUTTON")
+ expect(button).toHaveAttribute("aria-label", "tr:moreActions")
+ expect(button).toHaveAttribute("aria-haspopup", "menu")
+ expect(button.querySelector("svg")).toHaveAttribute("aria-hidden")
+ })
+
+ it.each([
+ ["file", fileRow],
+ ["folder", folderRow],
+ ])("opens the row's own context menu on a %s row", (_kind, row) => {
+ const { button } = renderRow(row)
+ expect(openMenuTexts()).toEqual([])
+ fireEvent.click(button)
+ expect(openMenuTexts()).toEqual(["rename"])
+ })
+
+ it("anchors the menu at the button's box, not at the click point", () => {
+ const { button } = renderRow(fileRow)
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue({
+ bottom: 48,
+ left: 120,
+ } as DOMRect)
+ const seen = vi.fn()
+ button.addEventListener("contextmenu", seen as EventListener)
+
+ // A keyboard activation (Enter/Space on a focused button) reports
+ // clientX/clientY as 0 — anchoring on those would park the menu in the
+ // viewport's top-left corner instead of next to the row.
+ fireEvent.click(button, { clientX: 0, clientY: 0 })
+
+ const event = seen.mock.calls[0][0] as MouseEvent
+ expect(event.type).toBe("contextmenu")
+ expect(event.button).toBe(2)
+ expect(event.bubbles).toBe(true)
+ expect(event.cancelable).toBe(true)
+ expect([event.clientX, event.clientY]).toEqual([120, 48])
+ })
+
+ it.each([
+ ["file", fileRow],
+ ["folder", folderRow],
+ ])("does not leak the click into the %s row's own handler", (_kind, row) => {
+ const { button, onRowSelect } = renderRow(row)
+ fireEvent.click(button)
+ expect(onRowSelect).not.toHaveBeenCalled()
+ })
+
+ it("stays out of the tab order inside a roving-focus tree", () => {
+ // The tree container is the single tab stop and owns the arrow keys; one
+ // focusable widget per row would put every row back in the tab sequence.
+ const { button } = renderRow(fileRow, { keyboardNavigation: true })
+ expect(button.tabIndex).toBe(-1)
+ })
+
+ it("keeps its default tab stop in trees without roving focus", () => {
+ const { button } = renderRow(fileRow)
+ expect(button.tabIndex).toBe(0)
+ })
+})
diff --git a/src/components/layout/row-more-button.tsx b/src/components/layout/row-more-button.tsx
new file mode 100644
index 000000000..acccb1c72
--- /dev/null
+++ b/src/components/layout/row-more-button.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import { MoreHorizontal } from "lucide-react"
+import type { MouseEvent as ReactMouseEvent } from "react"
+import { useTranslations } from "next-intl"
+
+import { useFileTreeRovingFocus } from "@/components/ai-elements/file-tree"
+import { cn } from "@/lib/utils"
+
+interface RowMoreButtonProps {
+ /** Optional className overrides. */
+ className?: string
+}
+
+/**
+ * Tiny horizontal-three-dots button rendered on the right of a tree row.
+ * Clicking it dispatches a synthetic `contextmenu` MouseEvent that bubbles to
+ * the enclosing Radix `ContextMenuTrigger`, which opens the very same menu
+ * right-click opens — one source of truth, nothing duplicated. Same trick as
+ * the sidebar conversation row's ⋯ button.
+ *
+ * The menu is anchored at the button's own box rather than at the click point:
+ * a keyboard or programmatic activation reports `clientX/clientY` as 0, which
+ * would park the menu in the viewport's top-left corner.
+ *
+ * The click is `stopPropagation`-ed so it doesn't fire the row's own `onClick`
+ * (which would open the file preview / toggle the folder).
+ *
+ * Hidden at rest on pointer devices — right-click is the primary affordance
+ * there and one ⋯ per file-tree row is a lot of ink. Pinned visible where there
+ * is no hover to reveal it, which is exactly the touch case this exists for.
+ */
+export function RowMoreButton({ className }: RowMoreButtonProps) {
+ const t = useTranslations("Folder.fileTreeTab")
+ // In roving-focus trees the container is the single tab stop; a focusable
+ // widget per row would break that (and `FileTreeActions` swallows keydown, so
+ // the arrow keys would die on it too).
+ const rovingFocus = useFileTreeRovingFocus()
+ return (
+
) => {
+ // The click must not reach the row's onClick (open preview / toggle the
+ // folder). The synthetic contextmenu below is the sole opener.
+ event.stopPropagation()
+ event.preventDefault()
+ const rect = event.currentTarget.getBoundingClientRect()
+ event.currentTarget.dispatchEvent(
+ new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ button: 2,
+ clientX: rect.left,
+ clientY: rect.bottom,
+ })
+ )
+ }}
+ className={cn(
+ "inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground/70 transition-[opacity,color,background-color] hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
+ "opacity-0 group-hover/file-tree-row:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",
+ className
+ )}
+ >
+
+
+ )
+}
diff --git a/src/hooks/use-long-press-to-open-menu.test.tsx b/src/hooks/use-long-press-to-open-menu.test.tsx
new file mode 100644
index 000000000..33ea125d3
--- /dev/null
+++ b/src/hooks/use-long-press-to-open-menu.test.tsx
@@ -0,0 +1,225 @@
+import { act, render } from "@testing-library/react"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { useLongPressToOpenMenu } from "./use-long-press-to-open-menu"
+
+/**
+ * jsdom's `fireEvent.pointerDown` drops `pointerType` (it builds a plain
+ * MouseEvent), so the tests below construct MouseEvent objects directly and
+ * attach `pointerType` via `Object.defineProperty` — same pattern as
+ * chat-input.test.tsx.
+ */
+function makePointerEvent(
+ type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel",
+ target: Element,
+ init: {
+ clientX?: number
+ clientY?: number
+ pointerType: "mouse" | "touch" | "pen"
+ }
+) {
+ const event = new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ clientX: init.clientX,
+ clientY: init.clientY,
+ })
+ Object.defineProperty(event, "pointerType", { value: init.pointerType })
+ target.dispatchEvent(event)
+ return event
+}
+
+/** Advance vi's fake timers and flush React state queued by their callbacks. */
+function advance(ms: number) {
+ act(() => {
+ vi.advanceTimersByTime(ms)
+ })
+}
+
+interface Fixture {
+ host: HTMLElement
+ onContextMenu: ReturnType
+}
+
+function renderHost(
+ options?: Parameters[0]
+): Fixture {
+ const onContextMenu = vi.fn()
+ function Harness() {
+ const gesture = useLongPressToOpenMenu(options)
+ return
+ }
+ const utils = render( )
+ const host = utils.container.querySelector(
+ "[data-testid='host']"
+ ) as HTMLElement
+ return { host, onContextMenu }
+}
+
+describe("useLongPressToOpenMenu", () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it("ignores mouse pointerdown entirely", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "mouse",
+ clientX: 10,
+ clientY: 20,
+ })
+ advance(1000)
+ expect(onContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("dispatches a synthetic contextmenu after longPressMs of a still touch", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 50,
+ clientY: 60,
+ })
+ advance(499)
+ expect(onContextMenu).not.toHaveBeenCalled()
+
+ advance(1)
+ expect(onContextMenu).toHaveBeenCalledTimes(1)
+ const event = onContextMenu.mock.calls[0][0] as MouseEvent
+ expect(event.button).toBe(2)
+ expect(event.clientX).toBe(50)
+ expect(event.clientY).toBe(60)
+ expect(event.bubbles).toBe(true)
+ expect(event.cancelable).toBe(true)
+ })
+
+ it("also opens on a pen pointer", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "pen",
+ clientX: 5,
+ clientY: 5,
+ })
+ advance(500)
+ expect(onContextMenu).toHaveBeenCalledTimes(1)
+ })
+
+ it("cancels when the touch moves past the move threshold", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 100,
+ clientY: 100,
+ })
+ advance(300)
+ makePointerEvent("pointermove", host, {
+ pointerType: "touch",
+ clientX: 120,
+ clientY: 100,
+ })
+ advance(500)
+ expect(onContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("tolerates micro-moves under the threshold (a still touch)", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 100,
+ clientY: 100,
+ })
+ advance(200)
+ makePointerEvent("pointermove", host, {
+ pointerType: "touch",
+ clientX: 103,
+ clientY: 101,
+ })
+ makePointerEvent("pointermove", host, {
+ pointerType: "touch",
+ clientX: 105,
+ clientY: 99,
+ })
+ advance(300)
+ expect(onContextMenu).toHaveBeenCalledTimes(1)
+ })
+
+ it("cancels on pointerup", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(200)
+ makePointerEvent("pointerup", host, { pointerType: "touch" })
+ advance(500)
+ expect(onContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("cancels on pointercancel", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(200)
+ makePointerEvent("pointercancel", host, { pointerType: "touch" })
+ advance(500)
+ expect(onContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("a second touch during a still hold resets the timer", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(400)
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(400)
+ // First timer (500ms from t=0) would have fired at t=500 — but it was
+ // cleared by the second pointerdown, and a fresh 500ms timer was armed.
+ expect(onContextMenu).not.toHaveBeenCalled()
+ advance(100)
+ expect(onContextMenu).toHaveBeenCalledTimes(1)
+ })
+
+ it("disabled hook never fires", () => {
+ const { host, onContextMenu } = renderHost({ enabled: false })
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(1000)
+ expect(onContextMenu).not.toHaveBeenCalled()
+ })
+
+ it("mouse pointermove after a touch hold doesn't cancel — pointerType is filtered", () => {
+ const { host, onContextMenu } = renderHost()
+ makePointerEvent("pointerdown", host, {
+ pointerType: "touch",
+ clientX: 0,
+ clientY: 0,
+ })
+ advance(200)
+ // A mouse move that happens to bubble through the same element must not
+ // cancel an in-flight touch gesture.
+ makePointerEvent("pointermove", host, {
+ pointerType: "mouse",
+ clientX: 1000,
+ clientY: 1000,
+ })
+ advance(300)
+ expect(onContextMenu).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/hooks/use-long-press-to-open-menu.ts b/src/hooks/use-long-press-to-open-menu.ts
new file mode 100644
index 000000000..0acc9155d
--- /dev/null
+++ b/src/hooks/use-long-press-to-open-menu.ts
@@ -0,0 +1,124 @@
+"use client"
+
+import { useCallback, useEffect, useRef } from "react"
+import type { PointerEvent as ReactPointerEvent } from "react"
+
+interface UseLongPressToOpenMenuOptions {
+ /** When false the hook ignores every gesture and never opens the menu. */
+ enabled?: boolean
+ /** Hold duration before the synthetic contextmenu fires. */
+ longPressMs?: number
+ /**
+ * Movement in either axis beyond this cancels the in-flight gesture.
+ * Mirrors the threshold used by `useLongPressDrag` so both gestures behave
+ * the same way when both hooks are attached to the same element.
+ */
+ moveThresholdPx?: number
+}
+
+/**
+ * Pointer handlers that open a Radix `ContextMenu` from a touch / pen
+ * long-press, while leaving desktop right-click to Radix's own contextmenu
+ * handler.
+ *
+ * Spread the four handlers onto a `` (or any
+ * element that Radix already listens on). The pointerdown handler composes
+ * with Radix's via `composeEventHandlers` — Radix's own 700ms long-press
+ * timer keeps running in parallel, but it clears on any `pointermove`,
+ * including the micro-moves a stationary touch can produce on mobile
+ * browsers. This hook tolerates movement below `moveThresholdPx` and only
+ * fires after the finger has been still for the full `longPressMs`.
+ *
+ * On fire, it dispatches a synthetic `MouseEvent("contextmenu", { button: 2,
+ * clientX, clientY })` from the current target. The synthetic event bubbles
+ * to Radix's `onContextMenu`, which opens the same menu the desktop right-
+ * click does — single source of truth, no duplication. Mouse pointers are
+ * ignored so desktop right-click keeps using Radix's native handler.
+ *
+ * NEVER spread this onto NESTED triggers (a tree row whose trigger encloses its
+ * descendants' triggers, say). One pointerdown bubbles through every ancestor,
+ * so each arms its own timer and each dispatches its own contextmenu from its
+ * OWN element — the ancestors' menus open right after the intended one and the
+ * outermost wins the screen. Radix's built-in long-press is safe there because
+ * all the triggers share ONE bubbling event and the innermost `preventDefault`s
+ * it; separate dispatches carry no such interlock. Use it on flat lists, or on
+ * a single trigger with no trigger ancestors.
+ */
+export function useLongPressToOpenMenu({
+ enabled = true,
+ longPressMs = 500,
+ moveThresholdPx = 10,
+}: UseLongPressToOpenMenuOptions = {}) {
+ const timerRef = useRef(null)
+ const startRef = useRef<{ x: number; y: number } | null>(null)
+
+ const clear = useCallback(() => {
+ if (timerRef.current != null) {
+ window.clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+ startRef.current = null
+ }, [])
+
+ useEffect(
+ () => () => {
+ clear()
+ },
+ [clear]
+ )
+
+ const onPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!enabled) return
+ // Desktop right-click has its own contextmenu event — keep Radix's
+ // native handler in charge of opening the menu there.
+ if (event.pointerType === "mouse") return
+ clear()
+ // Capture the target now — `event.currentTarget` is nulled out by React
+ // after the handler returns, and we need it 500ms down the line.
+ const target = event.currentTarget
+ startRef.current = { x: event.clientX, y: event.clientY }
+ timerRef.current = window.setTimeout(() => {
+ timerRef.current = null
+ target.dispatchEvent(
+ new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ button: 2,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ })
+ )
+ }, longPressMs)
+ },
+ [enabled, longPressMs, clear]
+ )
+
+ const onPointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!enabled) return
+ if (event.pointerType === "mouse") return
+ const start = startRef.current
+ if (!start) return
+ const dx = Math.abs(event.clientX - start.x)
+ const dy = Math.abs(event.clientY - start.y)
+ if (dx > moveThresholdPx || dy > moveThresholdPx) clear()
+ },
+ [enabled, moveThresholdPx, clear]
+ )
+
+ const onPointerUp = useCallback(() => {
+ clear()
+ }, [clear])
+
+ const onPointerCancel = useCallback(() => {
+ clear()
+ }, [clear])
+
+ return {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onPointerCancel,
+ }
+}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index a785ae9aa..d596812f0 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "الطرفية",
"openInCode": "VS Code",
"linkedFolder": "مجلد مرتبط",
+ "moreActions": "المزيد من الإجراءات",
"copyPath": "نسخ المسار",
"upload": "رفع ملفات/مجلد",
"download": "تنزيل ملف",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 0c990450b..7cc608930 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "Terminal",
"openInCode": "VS Code",
"linkedFolder": "Verknüpfter Ordner",
+ "moreActions": "Weitere Aktionen",
"copyPath": "Pfad kopieren",
"upload": "Dateien/Ordner hochladen",
"download": "Datei herunterladen",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 8e8143af4..6ed241707 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "Terminal",
"openInCode": "VS Code",
"linkedFolder": "Linked folder",
+ "moreActions": "More actions",
"copyPath": "Copy path",
"upload": "Upload files/folder",
"download": "Download file",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 08f02a968..457db3b4f 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "Terminal",
"openInCode": "VS Code",
"linkedFolder": "Carpeta vinculada",
+ "moreActions": "Más acciones",
"copyPath": "Copiar ruta",
"upload": "Subir archivos/carpeta",
"download": "Descargar archivo",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index b6f83dbbd..fd9c2f8a9 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "Terminal",
"openInCode": "VS Code",
"linkedFolder": "Dossier lié",
+ "moreActions": "Plus d'actions",
"copyPath": "Copier le chemin",
"upload": "Téléverser fichiers/dossier",
"download": "Télécharger le fichier",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index f930c0d67..ddaca4ccd 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "ターミナル",
"openInCode": "VS Code",
"linkedFolder": "リンク済みフォルダー",
+ "moreActions": "その他のアクション",
"copyPath": "パスをコピー",
"upload": "ファイル/フォルダをアップロード",
"download": "ファイルをダウンロード",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index b074cb958..5fd2cd814 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "터미널",
"openInCode": "VS Code",
"linkedFolder": "연결된 폴더",
+ "moreActions": "더 많은 작업",
"copyPath": "경로 복사",
"upload": "파일/폴더 업로드",
"download": "파일 다운로드",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 7853c774f..7da8317f4 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "Terminal",
"openInCode": "VS Code",
"linkedFolder": "Pasta vinculada",
+ "moreActions": "Mais ações",
"copyPath": "Copiar caminho",
"upload": "Enviar arquivos/pasta",
"download": "Baixar arquivo",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index c8c082e24..42a142673 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "终端",
"openInCode": "VS Code",
"linkedFolder": "关联的文件夹",
+ "moreActions": "更多操作",
"copyPath": "复制路径",
"upload": "上传文件/目录",
"download": "下载文件",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 1c142ea97..1d76b2266 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -2605,6 +2605,7 @@
"openInTerminal": "終端",
"openInCode": "VS Code",
"linkedFolder": "已連結的資料夾",
+ "moreActions": "更多操作",
"copyPath": "複製路徑",
"upload": "上傳檔案/目錄",
"download": "下載檔案",