From 7e6d3f161ac118ffc9cb6e72720c3214a94d4354 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 11 Mar 2026 13:15:13 +1300 Subject: [PATCH 1/4] 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 | 39 +++++++++++++++++-- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6925b5391c..08c7c9afc0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -95,6 +96,35 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +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 9ce12d3b8c..e17052325a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -98,6 +98,11 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +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 a8a58a13b2..813a9c0de1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,7 +12,15 @@ import { TriangleAlertIcon, } from "lucide-react"; import { autoAnimate } from "@formkit/auto-animate"; -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, @@ -92,6 +100,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleThreadsForProject, + isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -407,6 +416,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); @@ -1084,9 +1094,23 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - 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 sortedProjects = useMemo( () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder), @@ -1294,6 +1318,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, @@ -1405,6 +1430,12 @@ export default function Sidebar() { 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(); From e882bb79489b9b75948774482c46596185eabc3e Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 13 Mar 2026 09:24:40 +1300 Subject: [PATCH 2/4] chore: update logic to be mac specific --- apps/web/src/components/Sidebar.logic.test.ts | 29 ------------------- apps/web/src/components/Sidebar.logic.ts | 16 ++++++++++ apps/web/src/components/Sidebar.tsx | 2 ++ 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 08c7c9afc0..a24cbfcec3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -96,35 +96,6 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); -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 e17052325a..cb6594cbc2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -67,6 +67,22 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +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.isMac && input.button === 0 && input.ctrlKey; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 813a9c0de1..e9507f96ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -101,6 +101,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleThreadsForProject, isContextMenuPointerDown, + isMacOS, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -1101,6 +1102,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 ac752dbb52d2d4c58c6af5150c96459b3327c7c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 16:25:37 -0700 Subject: [PATCH 3/4] 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 cb6594cbc2..13a6c6d744 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -67,13 +67,6 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } -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 e9507f96ed..6609af3806 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -101,7 +101,6 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleThreadsForProject, isContextMenuPointerDown, - isMacOS, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -1102,7 +1101,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. From b510ba5a7c8ad108a630607883568b9d6c09929f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 16:07:25 -0700 Subject: [PATCH 4/4] Refactor sidebar context-menu gesture detection - Add tests for secondary-button and macOS ctrl-click gestures - Keep context-menu pointer handling aligned with platform behavior --- apps/web/src/components/Sidebar.logic.test.ts | 32 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 5 --- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e48d059193..cc49a82e69 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -97,6 +97,38 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("isContextMenuPointerDown", () => { + it("treats secondary-button presses as context menu gestures on all platforms", () => { + expect( + isContextMenuPointerDown({ + button: 2, + ctrlKey: false, + isMac: false, + }), + ).toBe(true); + }); + + it("treats ctrl+primary-click as a context menu gesture on macOS", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: true, + isMac: true, + }), + ).toBe(true); + }); + + it("does not treat ctrl+primary-click as a context menu gesture off macOS", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: true, + isMac: 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 c1b3d25b76..22a6268ac7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -107,11 +107,6 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } -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;