authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-03-29 12:09:19+13:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 23:09:19+00:00
log64d21bd6d29fbf795262fd6f031db81e3ddbad02
treee8505c4af8caf234522fd6d332f8b5e8e9a885a3
parent5210a833a58d24388611fb6a47b56a460a96422f
signature Signed by PGP key B5690EEEBB952194

fix: Prevent sidebar project dragging when using macos control+click to open context menu (#873)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 78 insertions(+), 4 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+33
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 getVisibleThreadsForProject,5 getVisibleThreadsForProject,
6 getProjectSortTimestamp,6 getProjectSortTimestamp,
7 hasUnseenCompletion,7 hasUnseenCompletion,
8 isContextMenuPointerDown,
8 resolveProjectStatusIndicator,9 resolveProjectStatusIndicator,
9 resolveSidebarNewThreadEnvMode,10 resolveSidebarNewThreadEnvMode,
10 resolveThreadRowClassName,11 resolveThreadRowClassName,
...@@ -96,6 +97,38 @@ describe("resolveSidebarNewThreadEnvMode", () => {...@@ -96,6 +97,38 @@ describe("resolveSidebarNewThreadEnvMode", () => {
96 });97 });
97});98});
9899
100describe("isContextMenuPointerDown", () => {
101 it("treats secondary-button presses as context menu gestures on all platforms", () => {
102 expect(
103 isContextMenuPointerDown({
104 button: 2,
105 ctrlKey: false,
106 isMac: false,
107 }),
108 ).toBe(true);
109 });
110
111 it("treats ctrl+primary-click as a context menu gesture on macOS", () => {
112 expect(
113 isContextMenuPointerDown({
114 button: 0,
115 ctrlKey: true,
116 isMac: true,
117 }),
118 ).toBe(true);
119 });
120
121 it("does not treat ctrl+primary-click as a context menu gesture off macOS", () => {
122 expect(
123 isContextMenuPointerDown({
124 button: 0,
125 ctrlKey: true,
126 isMac: false,
127 }),
128 ).toBe(false);
129 });
130});
131
99describe("resolveThreadStatusPill", () => {132describe("resolveThreadStatusPill", () => {
100 const baseThread = {133 const baseThread = {
101 interactionMode: "plan" as const,134 interactionMode: "plan" as const,
apps/web/src/components/Sidebar.logic.ts+9
...@@ -67,6 +67,15 @@ export function resolveSidebarNewThreadEnvMode(input: {...@@ -67,6 +67,15 @@ export function resolveSidebarNewThreadEnvMode(input: {
67 return input.requestedEnvMode ?? input.defaultEnvMode;67 return input.requestedEnvMode ?? input.defaultEnvMode;
68}68}
6969
70export function isContextMenuPointerDown(input: {
71 button: number;
72 ctrlKey: boolean;
73 isMac: boolean;
74}): boolean {
75 if (input.button === 2) return true;
76 return input.isMac && input.button === 0 && input.ctrlKey;
77}
78
70export function resolveThreadRowClassName(input: {79export function resolveThreadRowClassName(input: {
71 isActive: boolean;80 isActive: boolean;
72 isSelected: boolean;81 isSelected: boolean;
apps/web/src/components/Sidebar.tsx+36-4
...@@ -13,7 +13,15 @@ import {...@@ -13,7 +13,15 @@ import {
13} from "lucide-react";13} from "lucide-react";
14import { ProjectFavicon } from "./ProjectFavicon";14import { ProjectFavicon } from "./ProjectFavicon";
15import { autoAnimate } from "@formkit/auto-animate";15import { autoAnimate } from "@formkit/auto-animate";
16import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";16import {
17 useCallback,
18 useEffect,
19 useMemo,
20 useRef,
21 useState,
22 type MouseEvent,
23 type PointerEvent,
24} from "react";
17import {25import {
18 DndContext,26 DndContext,
19 type DragCancelEvent,27 type DragCancelEvent,
...@@ -93,6 +101,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";...@@ -93,6 +101,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
93import { isNonEmpty as isNonEmptyString } from "effect/String";101import { isNonEmpty as isNonEmptyString } from "effect/String";
94import {102import {
95 getVisibleThreadsForProject,103 getVisibleThreadsForProject,
104 isContextMenuPointerDown,
96 resolveProjectStatusIndicator,105 resolveProjectStatusIndicator,
97 resolveSidebarNewThreadEnvMode,106 resolveSidebarNewThreadEnvMode,
98 resolveThreadRowClassName,107 resolveThreadRowClassName,
...@@ -348,6 +357,7 @@ export default function Sidebar() {...@@ -348,6 +357,7 @@ export default function Sidebar() {
348 const renamingInputRef = useRef<HTMLInputElement | null>(null);357 const renamingInputRef = useRef<HTMLInputElement | null>(null);
349 const dragInProgressRef = useRef(false);358 const dragInProgressRef = useRef(false);
350 const suppressProjectClickAfterDragRef = useRef(false);359 const suppressProjectClickAfterDragRef = useRef(false);
360 const suppressProjectClickForContextMenuRef = useRef(false);
351 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);361 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
352 const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);362 const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
353 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);363 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
...@@ -941,9 +951,24 @@ export default function Sidebar() {...@@ -941,9 +951,24 @@ export default function Sidebar() {
941 animatedThreadListsRef.current.add(node);951 animatedThreadListsRef.current.add(node);
942 }, []);952 }, []);
943953
944 const handleProjectTitlePointerDownCapture = useCallback(() => {954 const handleProjectTitlePointerDownCapture = useCallback(
945 suppressProjectClickAfterDragRef.current = false;955 (event: PointerEvent<HTMLButtonElement>) => {
946 }, []);956 suppressProjectClickForContextMenuRef.current = false;
957 if (
958 isContextMenuPointerDown({
959 button: event.button,
960 ctrlKey: event.ctrlKey,
961 isMac: isMacPlatform(navigator.platform),
962 })
963 ) {
964 // Keep context-menu gestures from arming the sortable drag sensor.
965 event.stopPropagation();
966 }
967
968 suppressProjectClickAfterDragRef.current = false;
969 },
970 [],
971 );
947972
948 const visibleThreads = useMemo(973 const visibleThreads = useMemo(
949 () => threads.filter((thread) => thread.archivedAt === null),974 () => threads.filter((thread) => thread.archivedAt === null),
...@@ -1242,6 +1267,7 @@ export default function Sidebar() {...@@ -1242,6 +1267,7 @@ export default function Sidebar() {
1242 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}1267 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1243 onContextMenu={(event) => {1268 onContextMenu={(event) => {
1244 event.preventDefault();1269 event.preventDefault();
1270 suppressProjectClickForContextMenuRef.current = true;
1245 void handleProjectContextMenu(project.id, {1271 void handleProjectContextMenu(project.id, {
1246 x: event.clientX,1272 x: event.clientX,
1247 y: event.clientY,1273 y: event.clientY,
...@@ -1351,6 +1377,12 @@ export default function Sidebar() {...@@ -1351,6 +1377,12 @@ export default function Sidebar() {
13511377
1352 const handleProjectTitleClick = useCallback(1378 const handleProjectTitleClick = useCallback(
1353 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {1379 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1380 if (suppressProjectClickForContextMenuRef.current) {
1381 suppressProjectClickForContextMenuRef.current = false;
1382 event.preventDefault();
1383 event.stopPropagation();
1384 return;
1385 }
1354 if (dragInProgressRef.current) {1386 if (dragInProgressRef.current) {
1355 event.preventDefault();1387 event.preventDefault();
1356 event.stopPropagation();1388 event.stopPropagation();