From 6e2350c5f2d5df7ee46da6e16cad007e4d262b1c Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 11 Mar 2026 13:15:13 +1300 Subject: [PATCH 1/3] 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, From 44f88266b7666ae731233dca8c4e590d320bb857 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 13 Mar 2026 09:24:40 +1300 Subject: [PATCH 2/3] chore: update logic to be mac specific --- apps/web/src/components/Sidebar.logic.test.ts | 30 ------------------- apps/web/src/components/Sidebar.logic.ts | 15 ++++++++-- apps/web/src/components/Sidebar.tsx | 2 ++ 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d18b14754..f35f87826 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { hasUnseenCompletion, - isContextMenuPointerDown, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -64,35 +63,6 @@ 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 2ad52e38d..421386da4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -38,9 +38,20 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } -export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean }): boolean { +export function isMacOS(): boolean { + const nav = window.navigator as Navigator & { + userAgentData?: { platform: string }; + }; + return nav.userAgentData ? nav.userAgentData.platform === "macOS" : /Mac/i.test(nav.userAgent); +} + +export function isContextMenuPointerDown(input: { + button: number; + ctrlKey: boolean; + isMac: boolean; +}): boolean { if (input.button === 2) return true; - return input.button === 0 && input.ctrlKey; + return input.isMac && input.button === 0 && input.ctrlKey; } export function resolveThreadRowClassName(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 64840e5d6..b20f4f741 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -93,6 +93,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; import { isContextMenuPointerDown, + isMacOS, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -927,6 +928,7 @@ export default function Sidebar() { isContextMenuPointerDown({ button: event.button, ctrlKey: event.ctrlKey, + isMac: isMacOS(), }) ) { // Keep context-menu gestures from arming the sortable drag sensor. From 83ad0cb0c66434c867d0dac176306ac95ec668bb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 16:25:37 -0700 Subject: [PATCH 3/3] reuse existing macos helper --- apps/web/src/components/Sidebar.logic.ts | 7 ------- apps/web/src/components/Sidebar.tsx | 3 +-- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 421386da4..881b3ccdb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -38,13 +38,6 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } -export function isMacOS(): boolean { - const nav = window.navigator as Navigator & { - userAgentData?: { platform: string }; - }; - return nav.userAgentData ? nav.userAgentData.platform === "macOS" : /Mac/i.test(nav.userAgent); -} - export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b20f4f741..e099a642e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -93,7 +93,6 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; import { isContextMenuPointerDown, - isMacOS, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, @@ -928,7 +927,7 @@ export default function Sidebar() { isContextMenuPointerDown({ button: event.button, ctrlKey: event.ctrlKey, - isMac: isMacOS(), + isMac: isMacPlatform(navigator.platform), }) ) { // Keep context-menu gestures from arming the sortable drag sensor.