authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-06 11:39:29-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 15:08:10-07:00
loga6b22ec1c9b247536111dd2c2e8682f9b3f550c5
treec572c37306f1eaa293a1666a5b3991b29b766d11
parentbb49ebf6e54a078f9937f85de338ac5c8fc4a236

Fix project drag clicks and sidebar collapse/scroll behavior

- prevent accidental project toggles after drag operations - switch project rows to Collapsible for stable expand/collapse animation - add optional hidden scrollbar mode to ScrollArea and apply it in SidebarContent

4 files changed, 32 insertions(+), 89 deletions(-)

apps/web/src/components/Sidebar.tsx+19-83
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61} from "./desktopUpdate.logic";61} from "./desktopUpdate.logic";
62import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";62import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
63import { Button } from "./ui/button";63import { Button } from "./ui/button";
64import { Collapsible, CollapsibleContent } from "./ui/collapsible";
64import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";65import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
65import {66import {
66 SidebarContent,67 SidebarContent,
...@@ -231,18 +232,20 @@ function SortableProjectItem({...@@ -231,18 +232,20 @@ function SortableProjectItem({
231 const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =232 const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } =
232 useSortable({ id: projectId });233 useSortable({ id: projectId });
233 return (234 return (
234 <div235 <li
235 ref={setNodeRef}236 ref={setNodeRef}
236 style={{237 style={{
237 transform: CSS.Translate.toString(transform),238 transform: CSS.Translate.toString(transform),
238 transition,239 transition,
239 }}240 }}
240 className={`rounded-md ${241 className={`group/menu-item relative rounded-md ${
241 isDragging ? "z-20 opacity-80" : ""242 isDragging ? "z-20 opacity-80" : ""
242 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}243 } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`}
244 data-sidebar="menu-item"
245 data-slot="sidebar-menu-item"
243 >246 >
244 {children({ attributes, listeners })}247 {children({ attributes, listeners })}
245 </div>248 </li>
246 );249 );
247}250}
248251
...@@ -294,14 +297,7 @@ export default function Sidebar() {...@@ -294,14 +297,7 @@ export default function Sidebar() {
294 const renamingCommittedRef = useRef(false);297 const renamingCommittedRef = useRef(false);
295 const renamingInputRef = useRef<HTMLInputElement | null>(null);298 const renamingInputRef = useRef<HTMLInputElement | null>(null);
296 const dragInProgressRef = useRef(false);299 const dragInProgressRef = useRef(false);
297 const suppressProjectClickFromGestureRef = useRef(false);300 const suppressProjectClickAfterDragRef = useRef(false);
298 const suppressProjectClickResetTimerRef = useRef<number | null>(null);
299 const projectTitlePointerRef = useRef<{
300 pointerId: number;
301 startX: number;
302 startY: number;
303 moved: boolean;
304 } | null>(null);
305 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);301 const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null);
306 const shouldBrowseForProjectImmediately = isElectron;302 const shouldBrowseForProjectImmediately = isElectron;
307 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;303 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
...@@ -861,64 +857,20 @@ export default function Sidebar() {...@@ -861,64 +857,20 @@ export default function Sidebar() {
861857
862 const handleProjectDragStart = useCallback((_event: DragStartEvent) => {858 const handleProjectDragStart = useCallback((_event: DragStartEvent) => {
863 dragInProgressRef.current = true;859 dragInProgressRef.current = true;
860 suppressProjectClickAfterDragRef.current = true;
864 }, []);861 }, []);
865862
866 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {863 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
867 dragInProgressRef.current = false;864 dragInProgressRef.current = false;
868 }, []);865 }, []);
869866
870 const handleProjectTitlePointerDownCapture = useCallback(867 const handleProjectTitlePointerDownCapture = useCallback(() => {
871 (event: React.PointerEvent<HTMLButtonElement>) => {868 suppressProjectClickAfterDragRef.current = false;
872 projectTitlePointerRef.current = {
873 pointerId: event.pointerId,
874 startX: event.clientX,
875 startY: event.clientY,
876 moved: false,
877 };
878 },
879 [],
880 );
881
882 const handleProjectTitlePointerMoveCapture = useCallback(
883 (event: React.PointerEvent<HTMLButtonElement>) => {
884 const pointer = projectTitlePointerRef.current;
885 if (!pointer || pointer.pointerId !== event.pointerId) return;
886 const movedX = Math.abs(event.clientX - pointer.startX);
887 const movedY = Math.abs(event.clientY - pointer.startY);
888 if (movedX > 3 || movedY > 3) {
889 pointer.moved = true;
890 }
891 },
892 [],
893 );
894
895 const handleProjectTitlePointerUpCapture = useCallback(
896 (event: React.PointerEvent<HTMLButtonElement>) => {
897 const pointer = projectTitlePointerRef.current;
898 if (pointer?.pointerId === event.pointerId) {
899 if (pointer.moved) {
900 suppressProjectClickFromGestureRef.current = true;
901 if (suppressProjectClickResetTimerRef.current !== null) {
902 window.clearTimeout(suppressProjectClickResetTimerRef.current);
903 }
904 suppressProjectClickResetTimerRef.current = window.setTimeout(() => {
905 suppressProjectClickFromGestureRef.current = false;
906 suppressProjectClickResetTimerRef.current = null;
907 }, 0);
908 }
909 projectTitlePointerRef.current = null;
910 }
911 },
912 [],
913 );
914
915 const handleProjectTitlePointerCancelCapture = useCallback(() => {
916 projectTitlePointerRef.current = null;
917 }, []);869 }, []);
918870
919 const handleProjectTitleClick = useCallback(871 const handleProjectTitleClick = useCallback(
920 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {872 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
921 if (dragInProgressRef.current || suppressProjectClickFromGestureRef.current) {873 if (dragInProgressRef.current || suppressProjectClickAfterDragRef.current) {
922 event.preventDefault();874 event.preventDefault();
923 event.stopPropagation();875 event.stopPropagation();
924 return;876 return;
...@@ -940,14 +892,6 @@ export default function Sidebar() {...@@ -940,14 +892,6 @@ export default function Sidebar() {
940 [toggleProject],892 [toggleProject],
941 );893 );
942894
943 useEffect(() => {
944 return () => {
945 if (suppressProjectClickResetTimerRef.current !== null) {
946 window.clearTimeout(suppressProjectClickResetTimerRef.current);
947 }
948 };
949 }, []);
950
951 useEffect(() => {895 useEffect(() => {
952 const onWindowKeyDown = (event: KeyboardEvent) => {896 const onWindowKeyDown = (event: KeyboardEvent) => {
953 const activeThread = routeThreadId897 const activeThread = routeThreadId
...@@ -1318,7 +1262,10 @@ export default function Sidebar() {...@@ -1318,7 +1262,10 @@ export default function Sidebar() {
1318 return (1262 return (
1319 <SortableProjectItem key={project.id} projectId={project.id}>1263 <SortableProjectItem key={project.id} projectId={project.id}>
1320 {(dragHandleProps) => (1264 {(dragHandleProps) => (
1321 <SidebarMenuItem className="group/collapsible">1265 <Collapsible
1266 className="group/collapsible"
1267 open={project.expanded}
1268 >
1322 <div className="group/project-header relative">1269 <div className="group/project-header relative">
1323 <SidebarMenuButton1270 <SidebarMenuButton
1324 size="sm"1271 size="sm"
...@@ -1326,9 +1273,6 @@ export default function Sidebar() {...@@ -1326,9 +1273,6 @@ export default function Sidebar() {
1326 {...dragHandleProps.attributes}1273 {...dragHandleProps.attributes}
1327 {...dragHandleProps.listeners}1274 {...dragHandleProps.listeners}
1328 onPointerDownCapture={handleProjectTitlePointerDownCapture}1275 onPointerDownCapture={handleProjectTitlePointerDownCapture}
1329 onPointerMoveCapture={handleProjectTitlePointerMoveCapture}
1330 onPointerUpCapture={handleProjectTitlePointerUpCapture}
1331 onPointerCancelCapture={handleProjectTitlePointerCancelCapture}
1332 onClick={(event) => handleProjectTitleClick(event, project.id)}1276 onClick={(event) => handleProjectTitleClick(event, project.id)}
1333 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}1277 onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)}
1334 onContextMenu={(event) => {1278 onContextMenu={(event) => {
...@@ -1379,15 +1323,8 @@ export default function Sidebar() {...@@ -1379,15 +1323,8 @@ export default function Sidebar() {
1379 </Tooltip>1323 </Tooltip>
1380 </div>1324 </div>
13811325
1382 <div1326 <CollapsibleContent keepMounted>
1383 className={`grid transition-[grid-template-rows,opacity] duration-200 ease-out ${1327 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1384 project.expanded
1385 ? "grid-rows-[1fr] opacity-100"
1386 : "pointer-events-none grid-rows-[0fr] opacity-0"
1387 }`}
1388 >
1389 <div className="min-h-0 overflow-hidden">
1390 <SidebarMenuSub className="mx-1 my-0 w-full translate-x-0 gap-0 px-1.5 py-0">
1391 {visibleThreads.map((thread) => {1328 {visibleThreads.map((thread) => {
1392 const isActive = routeThreadId === thread.id;1329 const isActive = routeThreadId === thread.id;
1393 const threadStatus = resolveThreadStatusPill({1330 const threadStatus = resolveThreadStatusPill({
...@@ -1558,9 +1495,8 @@ export default function Sidebar() {...@@ -1558,9 +1495,8 @@ export default function Sidebar() {
1558 </SidebarMenuSubItem>1495 </SidebarMenuSubItem>
1559 )}1496 )}
1560 </SidebarMenuSub>1497 </SidebarMenuSub>
1561 </div>1498 </CollapsibleContent>
1562 </div>1499 </Collapsible>
1563 </SidebarMenuItem>
1564 )}1500 )}
1565 </SortableProjectItem>1501 </SortableProjectItem>
1566 );1502 );
apps/web/src/components/ui/collapsible.tsx+1-1
...@@ -22,7 +22,7 @@ function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Pr...@@ -22,7 +22,7 @@ function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Pr
22 return (22 return (
23 <CollapsiblePrimitive.Panel23 <CollapsiblePrimitive.Panel
24 className={cn(24 className={cn(
25 "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0",25 "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 data-ending-style:h-0 data-starting-style:h-0 data-open:data-ending-style:[height:var(--collapsible-panel-height)]",
26 className,26 className,
27 )}27 )}
28 data-slot="collapsible-panel"28 data-slot="collapsible-panel"
apps/web/src/components/ui/scroll-area.tsx+10-3
...@@ -9,10 +9,12 @@ function ScrollArea({...@@ -9,10 +9,12 @@ function ScrollArea({
9 children,9 children,
10 scrollFade = false,10 scrollFade = false,
11 scrollbarGutter = false,11 scrollbarGutter = false,
12 hideScrollbars = false,
12 ...props13 ...props
13}: ScrollAreaPrimitive.Root.Props & {14}: ScrollAreaPrimitive.Root.Props & {
14 scrollFade?: boolean;15 scrollFade?: boolean;
15 scrollbarGutter?: boolean;16 scrollbarGutter?: boolean;
17 hideScrollbars?: boolean;
16}) {18}) {
17 return (19 return (
18 <ScrollAreaPrimitive.Root className={cn("size-full min-h-0", className)} {...props}>20 <ScrollAreaPrimitive.Root className={cn("size-full min-h-0", className)} {...props}>
...@@ -22,14 +24,19 @@ function ScrollArea({...@@ -22,14 +24,19 @@ function ScrollArea({
22 scrollFade &&24 scrollFade &&
23 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",25 "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",
24 scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",26 scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5",
27 hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
25 )}28 )}
26 data-slot="scroll-area-viewport"29 data-slot="scroll-area-viewport"
27 >30 >
28 {children}31 {children}
29 </ScrollAreaPrimitive.Viewport>32 </ScrollAreaPrimitive.Viewport>
30 <ScrollBar orientation="vertical" />33 {!hideScrollbars && (
31 <ScrollBar orientation="horizontal" />34 <>
32 <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />35 <ScrollBar orientation="vertical" />
36 <ScrollBar orientation="horizontal" />
37 <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
38 </>
39 )}
33 </ScrollAreaPrimitive.Root>40 </ScrollAreaPrimitive.Root>
34 );41 );
35}42}
apps/web/src/components/ui/sidebar.tsx+2-2
...@@ -651,10 +651,10 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S...@@ -651,10 +651,10 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
651651
652function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {652function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
653 return (653 return (
654 <ScrollArea className="**:data-[slot=scroll-area-scrollbar]:hidden" scrollFade>654 <ScrollArea hideScrollbars scrollFade className="h-auto min-h-0 flex-1">
655 <div655 <div
656 className={cn(656 className={cn(
657 "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",657 "flex w-full min-w-0 flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
658 className,658 className,
659 )}659 )}
660 data-sidebar="content"660 data-sidebar="content"