Skip to content

Commit 4aa898e

Browse files
Merge branch 'main' into t3code/claude-stream-crash
2 parents ab42d76 + 64d21bd commit 4aa898e

3 files changed

Lines changed: 78 additions & 4 deletions

File tree

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

100+
describe("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+
99132
describe("resolveThreadStatusPill", () => {
100133
const baseThread = {
101134
interactionMode: "plan" as const,

apps/web/src/components/Sidebar.logic.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ export function resolveSidebarNewThreadEnvMode(input: {
6767
return input.requestedEnvMode ?? input.defaultEnvMode;
6868
}
6969

70+
export 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+
7079
export function resolveThreadRowClassName(input: {
7180
isActive: boolean;
7281
isSelected: boolean;

apps/web/src/components/Sidebar.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ import {
1313
} from "lucide-react";
1414
import { ProjectFavicon } from "./ProjectFavicon";
1515
import { autoAnimate } from "@formkit/auto-animate";
16-
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
16+
import {
17+
useCallback,
18+
useEffect,
19+
useMemo,
20+
useRef,
21+
useState,
22+
type MouseEvent,
23+
type PointerEvent,
24+
} from "react";
1725
import {
1826
DndContext,
1927
type DragCancelEvent,
@@ -93,6 +101,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
93101
import { isNonEmpty as isNonEmptyString } from "effect/String";
94102
import {
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();

0 commit comments

Comments
 (0)