diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c65ef8379..074bf2c30 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -39,6 +39,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function isContextMenuPointerDown(input: { + button: number; + ctrlKey: boolean; + isMac: boolean; +}): boolean { + if (input.button === 2) return true; + return input.isMac && input.button === 0 && input.ctrlKey; +} + export function resolveSidebarNewThreadEnvMode(input: { requestedEnvMode?: SidebarNewThreadEnvMode; defaultEnvMode: SidebarNewThreadEnvMode; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..717f9d959 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -10,7 +10,15 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, + type PointerEvent, +} from "react"; import { DndContext, type DragCancelEvent, @@ -84,6 +92,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + isContextMenuPointerDown, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -298,6 +307,7 @@ export default function Sidebar() { const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -911,12 +921,33 @@ export default function Sidebar() { dragInProgressRef.current = false; }, []); - const handleProjectTitlePointerDownCapture = useCallback(() => { - suppressProjectClickAfterDragRef.current = false; - }, []); + const handleProjectTitlePointerDownCapture = useCallback( + (event: PointerEvent) => { + suppressProjectClickForContextMenuRef.current = false; + if ( + isContextMenuPointerDown({ + button: event.button, + ctrlKey: event.ctrlKey, + isMac: isMacPlatform(navigator.platform), + }) + ) { + // Keep context-menu gestures from arming the sortable drag sensor. + event.stopPropagation(); + } + + suppressProjectClickAfterDragRef.current = false; + }, + [], + ); const handleProjectTitleClick = useCallback( (event: React.MouseEvent, projectId: ProjectId) => { + if (suppressProjectClickForContextMenuRef.current) { + suppressProjectClickForContextMenuRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } if (dragInProgressRef.current) { event.preventDefault(); event.stopPropagation(); @@ -1324,6 +1355,7 @@ export default function Sidebar() { onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); + suppressProjectClickForContextMenuRef.current = true; void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY,