From 8af9f5626a2558ee4103974da134ff0fc07db476 Mon Sep 17 00:00:00 2001 From: Krish Kalaria Date: Sat, 7 Mar 2026 11:45:03 +0530 Subject: [PATCH 1/3] Fix desktop context menu rendering --- apps/desktop/src/main.ts | 88 ----------------------------- apps/desktop/src/preload.ts | 2 - apps/web/src/components/Sidebar.tsx | 2 + apps/web/src/contextMenuFallback.ts | 55 +++++++++++++----- apps/web/src/wsNativeApi.test.ts | 10 ++-- apps/web/src/wsNativeApi.ts | 6 +- packages/contracts/src/ipc.ts | 4 -- 7 files changed, 50 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..4bba5e130 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,7 +10,6 @@ import { dialog, ipcMain, Menu, - nativeImage, nativeTheme, protocol, shell, @@ -24,7 +23,6 @@ import type { } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; -import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; @@ -49,7 +47,6 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -92,7 +89,6 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -248,29 +244,6 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { } initializePackagedLogging(); - -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} let updatePollTimer: ReturnType | null = null; let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; @@ -1084,67 +1057,6 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - })); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - - const menu = Menu.buildFromTemplate(template); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { const externalUrl = getSafeExternalUrl(rawUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..72ba4b69e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,7 +4,6 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; @@ -18,7 +17,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5bb0b84f7..89ae8e161 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1439,6 +1439,7 @@ export default function Sidebar() { onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); + event.stopPropagation(); void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, @@ -1545,6 +1546,7 @@ export default function Sidebar() { }} onContextMenu={(event) => { event.preventDefault(); + event.stopPropagation(); if ( selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id) diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 63cdef848..b5363f412 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,7 +1,7 @@ import type { ContextMenuItem } from "@t3tools/contracts"; /** - * Imperative DOM-based context menu for non-Electron environments. + * Imperative DOM-based context menu used for browser and desktop renderer surfaces. * Shows a positioned dropdown and returns a promise that resolves * with the clicked item id, or null if dismissed. */ @@ -10,21 +10,22 @@ export function showContextMenuFallback( position?: { x: number; y: number }, ): Promise { return new Promise((resolve) => { - const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + "fixed z-[10000] min-w-32 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg/5"; + menu.style.visibility = "hidden"; const x = position?.x ?? 0; const y = position?.y ?? 0; menu.style.top = `${y}px`; menu.style.left = `${x}px`; + let outsidePressEnabled = false; + let enableOutsidePressFrame = 0; function cleanup(result: T | null) { document.removeEventListener("keydown", onKeyDown); - overlay.remove(); + document.removeEventListener("pointerdown", onPointerDown, true); + cancelAnimationFrame(enableOutsidePressFrame); menu.remove(); resolve(result); } @@ -36,26 +37,49 @@ export function showContextMenuFallback( } } - overlay.addEventListener("mousedown", () => cleanup(null)); + function onPointerDown(event: PointerEvent) { + if (!outsidePressEnabled) return; + const target = event.target; + if (target instanceof Node && menu.contains(target)) { + return; + } + cleanup(null); + } + document.addEventListener("keydown", onKeyDown); + document.addEventListener("pointerdown", onPointerDown, true); + let hasInsertedDestructiveSeparator = false; for (const item of items) { + const isDestructiveAction = item.destructive === true || item.id === "delete"; + if (isDestructiveAction && !hasInsertedDestructiveSeparator && menu.childElementCount > 0) { + const separator = document.createElement("div"); + separator.className = "mx-2 my-1 h-px bg-border"; + menu.appendChild(separator); + hasInsertedDestructiveSeparator = true; + } + const btn = document.createElement("button"); btn.type = "button"; btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; + btn.style.appearance = "none"; + btn.style.setProperty("-webkit-appearance", "none"); + btn.style.border = "0"; + btn.style.background = "transparent"; + btn.style.boxShadow = "none"; + btn.style.outline = "none"; + btn.style.font = "inherit"; + btn.style.margin = "0"; btn.className = isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + ? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-destructive outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm" + : "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-foreground outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm"; btn.addEventListener("click", () => cleanup(item.id)); menu.appendChild(btn); } - document.body.appendChild(overlay); document.body.appendChild(menu); - - // Adjust if menu overflows viewport - requestAnimationFrame(() => { + // Position the menu before revealing it so edge clamping does not cause a visible jump. + enableOutsidePressFrame = requestAnimationFrame(() => { const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { menu.style.left = `${window.innerWidth - rect.width - 4}px`; @@ -63,6 +87,9 @@ export function showContextMenuFallback( if (rect.bottom > window.innerHeight) { menu.style.top = `${window.innerHeight - rect.height - 4}px`; } + menu.classList.add("animate-in", "fade-in", "zoom-in-95"); + menu.style.visibility = "visible"; + outsidePressEnabled = true; }); }); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da..89e8078db 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -336,14 +336,12 @@ describe("wsNativeApi", () => { }); }); - it("forwards context menu metadata to desktop bridge", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); + it("uses fallback context menu even when desktop bridge is available", async () => { + showContextMenuFallbackMock.mockResolvedValue("delete"); Object.defineProperty(getWindowForTest(), "desktopBridge", { configurable: true, writable: true, - value: { - showContextMenu, - }, + value: {}, }); const { createWsNativeApi } = await import("./wsNativeApi"); @@ -356,7 +354,7 @@ describe("wsNativeApi", () => { { x: 200, y: 300 }, ); - expect(showContextMenu).toHaveBeenCalledWith( + expect(showContextMenuFallbackMock).toHaveBeenCalledWith( [ { id: "rename", label: "Rename thread" }, { id: "delete", label: "Delete", destructive: true }, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..314f60806 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -151,9 +151,9 @@ export function createWsNativeApi(): NativeApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; - } + // Use the in-app menu on desktop too. Native Electron menus render an + // additional composited surface in the frameless window that clashes + // with the app UI and cannot be styled away from the renderer. return showContextMenuFallback(items, position); }, }, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..245f1ca90 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -99,10 +99,6 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; - showContextMenu: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; From 424397e6afab37e5284e8831cf53f5e5a36cc73e Mon Sep 17 00:00:00 2001 From: Krish Kalaria Date: Sat, 7 Mar 2026 11:54:46 +0530 Subject: [PATCH 2/3] Consume context menu dismiss clicks --- apps/web/src/contextMenuFallback.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index b5363f412..010c46ba6 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -43,6 +43,8 @@ export function showContextMenuFallback( if (target instanceof Node && menu.contains(target)) { return; } + event.preventDefault(); + event.stopPropagation(); cleanup(null); } From 00e67e8b2f1ddf27dca8cf974bba9fd8d532c338 Mon Sep 17 00:00:00 2001 From: Krish Kalaria Date: Fri, 13 Mar 2026 11:10:19 +0530 Subject: [PATCH 3/3] Use Base UI context menus --- apps/web/src/components/Sidebar.tsx | 494 ++++++++++++-------- apps/web/src/components/ui/context-menu.tsx | 96 ++++ apps/web/src/contextMenuFallback.ts | 97 ---- apps/web/src/wsNativeApi.test.ts | 68 --- apps/web/src/wsNativeApi.ts | 13 - packages/contracts/src/ipc.ts | 12 - 6 files changed, 387 insertions(+), 393 deletions(-) create mode 100644 apps/web/src/components/ui/context-menu.tsx delete mode 100644 apps/web/src/contextMenuFallback.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0748ac061..3d3b2e6c9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { flushSync } from "react-dom"; import { DndContext, type DragCancelEvent, @@ -64,6 +65,13 @@ import { import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuPopup, + ContextMenuSeparator, + ContextMenuTrigger, +} from "./ui/context-menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -118,6 +126,9 @@ interface PrStatusIndicator { } type ThreadPr = GitStatusResult["pr"]; +type ThreadContextMenuMode = "single" | "multi"; +type ThreadContextAction = "rename" | "mark-unread" | "copy-thread-id" | "delete"; +type ProjectContextAction = "delete"; function terminalStatusFromRunningIds( runningTerminalIds: string[], @@ -294,6 +305,10 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [activeThreadContextMenu, setActiveThreadContextMenu] = useState<{ + threadId: ThreadId; + mode: ThreadContextMenuMode; + } | null>(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -681,38 +696,28 @@ export default function Sidebar() { }); }, }); - const handleThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { + const handleThreadContextAction = useCallback( + async (threadId: ThreadId, action: ThreadContextAction) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-thread-id", label: "Copy Thread ID" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); const thread = threads.find((t) => t.id === threadId); if (!thread) return; - if (clicked === "rename") { + if (action === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; return; } - if (clicked === "mark-unread") { + if (action === "mark-unread") { markThreadUnread(threadId); return; } - if (clicked === "copy-thread-id") { + if (action === "copy-thread-id") { copyToClipboard(threadId, { threadId }); return; } - if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ @@ -729,23 +734,15 @@ export default function Sidebar() { [appSettings.confirmThreadDelete, copyToClipboard, deleteThread, markThreadUnread, threads], ); - const handleMultiSelectContextMenu = useCallback( - async (position: { x: number; y: number }) => { + const handleMultiSelectContextAction = useCallback( + async (action: "mark-unread" | "delete") => { const api = readNativeApi(); if (!api) return; const ids = [...selectedThreadIds]; if (ids.length === 0) return; const count = ids.length; - const clicked = await api.contextMenu.show( - [ - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, - ], - position, - ); - - if (clicked === "mark-unread") { + if (action === "mark-unread") { for (const id of ids) { markThreadUnread(id); } @@ -753,8 +750,6 @@ export default function Sidebar() { return; } - if (clicked !== "delete") return; - if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ @@ -819,15 +814,11 @@ export default function Sidebar() { ], ); - const handleProjectContextMenu = useCallback( - async (projectId: ProjectId, position: { x: number; y: number }) => { + const handleProjectContextAction = useCallback( + async (projectId: ProjectId, action: ProjectContextAction) => { const api = readNativeApi(); if (!api) return; - const clicked = await api.contextMenu.show( - [{ id: "delete", label: "Remove project", destructive: true }], - position, - ); - if (clicked !== "delete") return; + if (action !== "delete") return; const project = projects.find((entry) => entry.id === projectId); if (!project) return; @@ -875,6 +866,27 @@ export default function Sidebar() { ], ); + const prepareThreadContextMenu = useCallback( + (event: MouseEvent, threadId: ThreadId) => { + event.stopPropagation(); + + const mode: ThreadContextMenuMode = + selectedThreadIds.size > 0 && selectedThreadIds.has(threadId) ? "multi" : "single"; + + flushSync(() => { + if (mode === "single" && selectedThreadIds.size > 0) { + clearSelection(); + } + setActiveThreadContextMenu({ threadId, mode }); + }); + }, + [clearSelection, selectedThreadIds], + ); + + const clearActiveThreadContextMenu = useCallback((threadId: ThreadId) => { + setActiveThreadContextMenu((current) => (current?.threadId === threadId ? null : current)); + }, []); + const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, @@ -1314,33 +1326,46 @@ export default function Sidebar() { {(dragHandleProps) => (
- handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - - - - {project.name} - - + + handleProjectTitleClick(event, project.id)} + onKeyDown={(event) => + handleProjectTitleKeyDown(event, project.id) + } + /> + } + onContextMenu={(event) => { + event.stopPropagation(); + }} + > + + + + {project.name} + + + + { + void handleProjectContextAction(project.id, "delete"); + }} + > + Remove project + + + { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); + const threadContextMenuMode = + activeThreadContextMenu?.threadId === thread.id + ? activeThreadContextMenu.mode + : isSelected && selectedThreadIds.size > 0 + ? "multi" + : "single"; const isHighlighted = isActive || isSelected; const threadStatus = resolveThreadStatusPill({ thread, @@ -1403,160 +1434,217 @@ export default function Sidebar() { className="w-full" data-thread-item > - } - size="sm" - isActive={isActive} - className={resolveThreadRowClassName({ - isActive, - isSelected, - })} - onClick={(event) => { - handleThreadClick( - event, - thread.id, - orderedProjectThreadIds, - ); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); - }} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - if ( - selectedThreadIds.size > 0 && - selectedThreadIds.has(thread.id) - ) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + { + if (!open) { + clearActiveThreadContextMenu(thread.id); } }} > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - + } + size="sm" + isActive={isActive} + className={resolveThreadRowClassName({ + isActive, + isSelected, + })} + onClick={(event) => { + handleThreadClick( + event, + thread.id, + orderedProjectThreadIds, + ); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; } - /> - - {prStatus.tooltip} - - - )} - {threadStatus && ( - + event.preventDefault(); + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(thread.id); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} + /> + } + onContextMenu={(event) => { + prepareThreadContextMenu(event, thread.id); + }} + > +
+ {prStatus && ( + + { + openPrLink(event, prStatus.url); + }} + > + + + } + /> + + {prStatus.tooltip} + + + )} + {threadStatus && ( + + + {threadStatus.label} + + + )} + {renamingThreadId === thread.id ? ( + { + if (el && renamingInputRef.current !== el) { + renamingInputRef.current = el; + el.focus(); + el.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={renamingTitle} + onChange={(e) => setRenamingTitle(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + renamingCommittedRef.current = true; + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } else if (e.key === "Escape") { + e.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }} + onBlur={() => { + if (!renamingCommittedRef.current) { + void commitRename( + thread.id, + renamingTitle, + thread.title, + ); + } + }} + onClick={(e) => e.stopPropagation()} /> - - {threadStatus.label} + ) : ( + + {thread.title} + + )} +
+
+ {terminalStatus && ( + + + )} + + {formatRelativeTime(thread.createdAt)} - )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename( +
+ + + {threadContextMenuMode === "multi" ? ( + <> + { + void handleMultiSelectContextAction("mark-unread"); + }} + > + Mark unread ({selectedThreadIds.size}) + + + { + void handleMultiSelectContextAction("delete"); + }} + > + Delete ({selectedThreadIds.size}) + + + ) : ( + <> + { + void handleThreadContextAction(thread.id, "rename"); + }} + > + Rename thread + + { + void handleThreadContextAction( thread.id, - renamingTitle, - thread.title, + "mark-unread", ); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename( + }} + > + Mark unread + + { + void handleThreadContextAction( thread.id, - renamingTitle, - thread.title, + "copy-thread-id", ); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {thread.title} - - )} -
-
- {terminalStatus && ( - - - + }} + > + Copy Thread ID + + + { + void handleThreadContextAction(thread.id, "delete"); + }} + > + Delete + + )} - - {formatRelativeTime(thread.createdAt)} - -
-
+ + ); })} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..c35ffed6b --- /dev/null +++ b/apps/web/src/components/ui/context-menu.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; + +import { cn } from "~/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +function ContextMenuTrigger({ + className, + children, + ...props +}: ContextMenuPrimitive.Trigger.Props) { + return ( + + {children} + + ); +} + +function ContextMenuPopup({ + children, + className, + sideOffset = 4, + align = "center", + alignOffset, + side = "bottom", + ...props +}: ContextMenuPrimitive.Popup.Props & { + align?: ContextMenuPrimitive.Positioner.Props["align"]; + sideOffset?: ContextMenuPrimitive.Positioner.Props["sideOffset"]; + alignOffset?: ContextMenuPrimitive.Positioner.Props["alignOffset"]; + side?: ContextMenuPrimitive.Positioner.Props["side"]; +}) { + return ( + + + +
{children}
+
+
+
+ ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: ContextMenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + svg]:-mx-0.5 flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:shrink-0", + className, + )} + data-inset={inset} + data-slot="context-menu-item" + data-variant={variant} + {...props} + /> + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: ContextMenuPrimitive.Separator.Props) { + return ( + + ); +} + +export { ContextMenu, ContextMenuItem, ContextMenuPopup, ContextMenuSeparator, ContextMenuTrigger }; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts deleted file mode 100644 index 010c46ba6..000000000 --- a/apps/web/src/contextMenuFallback.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; - -/** - * Imperative DOM-based context menu used for browser and desktop renderer surfaces. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. - */ -export function showContextMenuFallback( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, -): Promise { - return new Promise((resolve) => { - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-32 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg/5"; - menu.style.visibility = "hidden"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; - let outsidePressEnabled = false; - let enableOutsidePressFrame = 0; - - function cleanup(result: T | null) { - document.removeEventListener("keydown", onKeyDown); - document.removeEventListener("pointerdown", onPointerDown, true); - cancelAnimationFrame(enableOutsidePressFrame); - menu.remove(); - resolve(result); - } - - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); - cleanup(null); - } - } - - function onPointerDown(event: PointerEvent) { - if (!outsidePressEnabled) return; - const target = event.target; - if (target instanceof Node && menu.contains(target)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - cleanup(null); - } - - document.addEventListener("keydown", onKeyDown); - document.addEventListener("pointerdown", onPointerDown, true); - - let hasInsertedDestructiveSeparator = false; - for (const item of items) { - const isDestructiveAction = item.destructive === true || item.id === "delete"; - if (isDestructiveAction && !hasInsertedDestructiveSeparator && menu.childElementCount > 0) { - const separator = document.createElement("div"); - separator.className = "mx-2 my-1 h-px bg-border"; - menu.appendChild(separator); - hasInsertedDestructiveSeparator = true; - } - - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - btn.style.appearance = "none"; - btn.style.setProperty("-webkit-appearance", "none"); - btn.style.border = "0"; - btn.style.background = "transparent"; - btn.style.boxShadow = "none"; - btn.style.outline = "none"; - btn.style.font = "inherit"; - btn.style.margin = "0"; - btn.className = isDestructiveAction - ? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-destructive outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm" - : "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-base text-foreground outline-none hover:bg-accent hover:text-accent-foreground sm:min-h-7 sm:text-sm"; - btn.addEventListener("click", () => cleanup(item.id)); - menu.appendChild(btn); - } - - document.body.appendChild(menu); - // Position the menu before revealing it so edge clamping does not cause a visible jump. - enableOutsidePressFrame = requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; - } - menu.classList.add("animate-in", "fade-in", "zoom-in-95"); - menu.style.visibility = "visible"; - outsidePressEnabled = true; - }); - }); -} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 89e8078db..80fb7ddc9 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -1,6 +1,5 @@ import { CommandId, - type ContextMenuItem, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -18,13 +17,6 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); -const showContextMenuFallbackMock = - vi.fn< - ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise - >(); const channelListeners = new Map void>>(); const latestPushByChannel = new Map(); const subscribeMock = vi.fn< @@ -61,10 +53,6 @@ vi.mock("./wsTransport", () => { }; }); -vi.mock("./contextMenuFallback", () => ({ - showContextMenuFallback: showContextMenuFallbackMock, -})); - let nextPushSequence = 1; function emitPush(channel: C, data: WsPushData): void { @@ -82,16 +70,6 @@ function emitPush(channel: C, data: WsPushData): voi } } -function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { - const testGlobal = globalThis as typeof globalThis & { - window?: Window & typeof globalThis & { desktopBridge?: unknown }; - }; - if (!testGlobal.window) { - testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; - } - return testGlobal.window; -} - const defaultProviders: ReadonlyArray = [ { provider: "codex", @@ -105,12 +83,10 @@ const defaultProviders: ReadonlyArray = [ beforeEach(() => { vi.resetModules(); requestMock.mockReset(); - showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); latestPushByChannel.clear(); nextPushSequence = 1; - Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); afterEach(() => { @@ -335,48 +311,4 @@ describe("wsNativeApi", () => { toTurnCount: 1, }); }); - - it("uses fallback context menu even when desktop bridge is available", async () => { - showContextMenuFallbackMock.mockResolvedValue("delete"); - Object.defineProperty(getWindowForTest(), "desktopBridge", { - configurable: true, - writable: true, - value: {}, - }); - - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); - await api.contextMenu.show( - [ - { id: "rename", label: "Rename thread" }, - { id: "delete", label: "Delete", destructive: true }, - ], - { x: 200, y: 300 }, - ); - - expect(showContextMenuFallbackMock).toHaveBeenCalledWith( - [ - { id: "rename", label: "Rename thread" }, - { id: "delete", label: "Delete", destructive: true }, - ], - { x: 200, y: 300 }, - ); - }); - - it("uses fallback context menu when desktop bridge is unavailable", async () => { - showContextMenuFallbackMock.mockResolvedValue("delete"); - Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); - - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); - await api.contextMenu.show([{ id: "delete", label: "Delete", destructive: true }], { - x: 20, - y: 30, - }); - - expect(showContextMenuFallbackMock).toHaveBeenCalledWith( - [{ id: "delete", label: "Delete", destructive: true }], - { x: 20, y: 30 }, - ); - }); }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 314f60806..dc6051ade 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,7 +1,6 @@ import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, - type ContextMenuItem, type NativeApi, ServerConfigUpdatedPayload, WS_CHANNELS, @@ -9,7 +8,6 @@ import { type WsWelcomePayload, } from "@t3tools/contracts"; -import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; @@ -146,17 +144,6 @@ export function createWsNativeApi(): NativeApi { preparePullRequestThread: (input) => transport.request(WS_METHODS.gitPreparePullRequestThread, input), }, - contextMenu: { - show: async ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ): Promise => { - // Use the in-app menu on desktop too. Native Electron menus render an - // additional composited surface in the frameless window that clashes - // with the app UI and cannot be styled away from the renderer. - return showContextMenuFallback(items, position); - }, - }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 245f1ca90..adc324f6f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -47,12 +47,6 @@ import type { } from "./orchestration"; import { EditorId } from "./editor"; -export interface ContextMenuItem { - id: T; - label: string; - destructive?: boolean; -} - export type DesktopUpdateStatus = | "disabled" | "idle" @@ -146,12 +140,6 @@ export interface NativeApi { status: (input: GitStatusInput) => Promise; runStackedAction: (input: GitRunStackedActionInput) => Promise; }; - contextMenu: { - show: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; - }; server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise;