From 6e2350c5f2d5df7ee46da6e16cad007e4d262b1c Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 11 Mar 2026 13:15:13 +1300 Subject: [PATCH] Prevent sidebar project clicks after context-menu pointer gestures - Add shared context-menu pointerdown detection logic with unit tests - Stop propagation on right-click/Ctrl-click to avoid arming drag sensors - Suppress the follow-up project title click when opening the project context menu --- apps/web/src/components/Sidebar.logic.test.ts | 30 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 5 +++ apps/web/src/components/Sidebar.tsx | 44 ++++++++++++++++--- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 634d91e9d..16275c034 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, + isContextMenuPointerDown, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -62,6 +63,35 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { }); }); +describe("isContextMenuPointerDown", () => { + it("treats secondary-button pointerdown as a context-menu gesture", () => { + expect( + isContextMenuPointerDown({ + button: 2, + ctrlKey: false, + }), + ).toBe(true); + }); + + it("treats ctrl-primary-click as a context-menu gesture", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: true, + }), + ).toBe(true); + }); + + it("does not treat primary-button pointerdown as a context-menu gesture", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: false, + }), + ).toBe(false); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f1afa0f64..e1cb4ea04 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -37,6 +37,11 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean }): boolean { + if (input.button === 2) return true; + return input.button === 0 && input.ctrlKey; +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; hasPendingApprovals: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..a8bdbf43c 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, @@ -83,7 +91,11 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; -import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic"; +import { + isContextMenuPointerDown, + resolveThreadStatusPill, + shouldClearThreadSelectionOnMouseDown, +} from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -301,6 +313,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); @@ -998,12 +1011,32 @@ 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, + }) + ) { + // 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(); @@ -1453,6 +1486,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,