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 {
55 getVisibleThreadsForProject,
66 getProjectSortTimestamp,
77 hasUnseenCompletion,
8 isContextMenuPointerDown,
89 resolveProjectStatusIndicator,
910 resolveSidebarNewThreadEnvMode,
1011 resolveThreadRowClassName,
......@@ -96,6 +97,38 @@ describe("resolveSidebarNewThreadEnvMode", () => {
9697 });
9798});
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
99132describe("resolveThreadStatusPill", () => {
100133 const baseThread = {
101134 interactionMode: "plan" as const,
apps/web/src/components/Sidebar.logic.ts+9
......@@ -67,6 +67,15 @@ export function resolveSidebarNewThreadEnvMode(input: {
6767 return input.requestedEnvMode ?? input.defaultEnvMode;
6868}
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
7079export function resolveThreadRowClassName(input: {
7180 isActive: boolean;
7281 isSelected: boolean;
apps/web/src/components/Sidebar.tsx+36-4
......@@ -13,7 +13,15 @@ import {
1313} from "lucide-react";
1414import { ProjectFavicon } from "./ProjectFavicon";
1515import { 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";
1725import {
1826 DndContext,
1927 type DragCancelEvent,
......@@ -93,6 +101,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
93101import { isNonEmpty as isNonEmptyString } from "effect/String";
94102import {
95103 getVisibleThreadsForProject,
104 isContextMenuPointerDown,
96105 resolveProjectStatusIndicator,
97106 resolveSidebarNewThreadEnvMode,
98107 resolveThreadRowClassName,
......@@ -348,6 +357,7 @@ export default function Sidebar() {
348357 const renamingInputRef = useRef<HTMLInputElement | null>(null);
349358 const dragInProgressRef = useRef(false);
350359 const suppressProjectClickAfterDragRef = useRef(false);
360 const suppressProjectClickForContextMenuRef = useRef(false);
351361 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
352362 const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds);
353363 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
......@@ -941,9 +951,24 @@ export default function Sidebar() {
941951 animatedThreadListsRef.current.add(node);
942952 }, []);
943953
944 const handleProjectTitlePointerDownCapture = useCallback(() => {
945 suppressProjectClickAfterDragRef.current = false;
946 }, []);
954 const handleProjectTitlePointerDownCapture = useCallback(
955 (event: PointerEvent<HTMLButtonElement>) => {
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
948973 const visibleThreads = useMemo(
949974 () => threads.filter((thread) => thread.archivedAt === null),
......@@ -1242,6 +1267,7 @@ export default function Sidebar() {
12421267 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
12431268 onContextMenu={(event) => {
12441269 event.preventDefault();
1270 suppressProjectClickForContextMenuRef.current = true;
12451271 void handleProjectContextMenu(project.id, {
12461272 x: event.clientX,
12471273 y: event.clientY,
......@@ -1351,6 +1377,12 @@ export default function Sidebar() {
13511377
13521378 const handleProjectTitleClick = useCallback(
13531379 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1380 if (suppressProjectClickForContextMenuRef.current) {
1381 suppressProjectClickForContextMenuRef.current = false;
1382 event.preventDefault();
1383 event.stopPropagation();
1384 return;
1385 }
13541386 if (dragInProgressRef.current) {
13551387 event.preventDefault();
13561388 event.stopPropagation();