fix(app): make sidebar reordering respond immediately

Mouse input inherited the touch hold delay. Split activation by input so mouse dragging begins after deliberate movement while touch retains long-press arbitration.
This commit is contained in:
Mohamed Boudra
2026-07-17 13:00:47 +02:00
parent 266d54463b
commit a7cbf4f61d
7 changed files with 125 additions and 255 deletions

View File

@@ -0,0 +1,71 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
async function rowTestIds(rows: Locator) {
return rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid")),
);
}
async function visibleBoundingBox(row: Locator) {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible draggable row");
return box;
}
async function quickDragFirstRowAfterSecond(rows: Locator) {
await expect(rows).toHaveCount(2);
const before = await rowTestIds(rows);
const sourceBox = await visibleBoundingBox(rows.nth(0));
const targetBox = await visibleBoundingBox(rows.nth(1));
const page = rows.page();
const source = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 };
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(source.x, source.y + 7);
await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
try {
const secondWorkspace = await firstProject.client.createWorkspace({
source: {
kind: "directory",
path: firstProject.repoPath,
projectId: firstProject.projectId,
},
title: "Second workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to seed a second workspace");
}
await gotoAppShell(page);
await waitForSidebarHydration(page);
await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]'));
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
);
} finally {
await firstProject.cleanup();
await secondProject.cleanup();
}
});

View File

@@ -1,8 +1,9 @@
export { reorderItemsOnDragEnd } from "./reorder-items";
export type { DragEndInput } from "./reorder-items";
export {
getPointerActivationConstraint,
type PointerActivationConfig,
getDragActivationConstraints,
type DragActivationConfig,
type DragActivationConstraints,
type PointerActivationConstraint,
} from "./pointer-activation";
export {

View File

@@ -1,14 +1,21 @@
import { describe, expect, it } from "vitest";
import { getPointerActivationConstraint } from "./pointer-activation";
import { getDragActivationConstraints } from "./pointer-activation";
const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
const config = { movementDistance: 6, touchHoldDelayMs: 180, touchHoldTolerance: 8 };
describe("getPointerActivationConstraint", () => {
it("uses distance activation for default draggable rows", () => {
expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
describe("getDragActivationConstraints", () => {
it("starts mouse drags after deliberate pointer movement", () => {
expect(getDragActivationConstraints(true, config).mouse).toEqual({ distance: 6 });
});
it("requires a held pointer before activating handle-based drags", () => {
expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
it("requires a short hold before starting touch drags", () => {
expect(getDragActivationConstraints(true, config).touch).toEqual({
delay: 180,
tolerance: 8,
});
});
it("starts ordinary touch rows after deliberate movement", () => {
expect(getDragActivationConstraints(false, config).touch).toEqual({ distance: 6 });
});
});

View File

@@ -2,18 +2,25 @@ export type PointerActivationConstraint =
| { distance: number }
| { delay: number; tolerance: number };
export interface PointerActivationConfig {
defaultDistance: number;
holdDelayMs: number;
holdTolerance: number;
export interface DragActivationConfig {
movementDistance: number;
touchHoldDelayMs: number;
touchHoldTolerance: number;
}
export function getPointerActivationConstraint(
useDragHandle: boolean,
config: PointerActivationConfig,
): PointerActivationConstraint {
if (useDragHandle) {
return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
}
return { distance: config.defaultDistance };
export interface DragActivationConstraints {
mouse: PointerActivationConstraint;
touch: PointerActivationConstraint;
}
export function getDragActivationConstraints(
useDragHandle: boolean,
config: DragActivationConfig,
): DragActivationConstraints {
const movement = { distance: config.movementDistance };
const touch = useDragHandle
? { delay: config.touchHoldDelayMs, tolerance: config.touchHoldTolerance }
: movement;
return { mouse: movement, touch };
}

View File

@@ -4,7 +4,8 @@ import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
MouseSensor,
TouchSensor,
type Modifier,
useSensor,
useSensors,
@@ -17,7 +18,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -27,10 +28,10 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
});
const DND_MODIFIERS = [restrictToVerticalAxis];
const POINTER_ACTIVATION_CONFIG = {
defaultDistance: 6,
holdDelayMs: 250,
holdTolerance: 8,
const DRAG_ACTIVATION_CONFIG = {
movementDistance: 6,
touchHoldDelayMs: 180,
touchHoldTolerance: 8,
};
interface SortableItemProps<T> {
@@ -145,14 +146,14 @@ export function DraggableList<T>({
onDragEnd,
onDragBegin,
});
const pointerActivationConstraint = getPointerActivationConstraint(
useDragHandle,
POINTER_ACTIVATION_CONFIG,
);
const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: pointerActivationConstraint,
useSensor(MouseSensor, {
activationConstraint: activationConstraints.mouse,
}),
useSensor(TouchSensor, {
activationConstraint: activationConstraints.touch,
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,

View File

@@ -2,15 +2,12 @@ import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
StatusBar,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation } from "@tanstack/react-query";
import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -87,7 +84,6 @@ import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
@@ -96,6 +92,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import {
SidebarWorkspaceRowFrame,
@@ -948,223 +945,6 @@ function NewWorkspaceGhostRow({
);
}
function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
const didStartDragRef = useRef(false);
const scrollIntentRef = useRef(false);
const menuOpenedRef = useRef(false);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current);
dragArmTimerRef.current = null;
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current);
contextMenuTimerRef.current = null;
}
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
}, []);
useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
const armTimers = useCallback(() => {
clearTimers();
const DRAG_ARM_DELAY_MS = 180;
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
const CONTEXT_MENU_DELAY_MS = 450;
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
dragArmTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
return;
}
dragArmedRef.current = true;
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || platformIsWeb) {
return;
}
contextMenuTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
return;
}
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
if (!dragActivatedRef.current) {
return;
}
didStartDragRef.current = true;
didLongPressRef.current = true;
clearTimers();
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
},
[clearTimers],
);
const handleScrollIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true;
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handleSwipeIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
didLongPressRef.current = false;
dragArmedRef.current = false;
dragActivatedRef.current = false;
didStartDragRef.current = false;
scrollIntentRef.current = false;
menuOpenedRef.current = false;
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
armTimers();
},
[armTimers],
);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
const start = touchStartRef.current;
if (!start || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
const current = { x, y };
touchCurrentRef.current = current;
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const decision = decideLongPressMove({
dragArmed: dragArmedRef.current,
didStartDrag: didStartDragRef.current,
startPoint: start,
currentPoint: current,
});
if (decision === "vertical_scroll") {
handleScrollIntent({ dx, dy, distance });
return;
}
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
handleSwipeIntent({ dx, dy, distance });
return;
}
if (decision === "start_drag") {
handleDragIntent({ dx, dy, distance });
}
},
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
);
const handlePressOut = useCallback(() => {
clearTimers();
dragArmedRef.current = false;
dragActivatedRef.current = false;
touchStartRef.current = null;
touchCurrentRef.current = null;
}, [clearTimers]);
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handleTouchMove,
handlePressOut,
};
}
function ProjectHeaderRow({
project,
displayName,

View File

@@ -59,6 +59,9 @@ export function useLongPressDragInteraction(input: {
const armTimers = useCallback(() => {
clearTimers();
if (platformIsWeb) {
return;
}
const DRAG_ARM_DELAY_MS = 180;
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
@@ -87,7 +90,7 @@ export function useLongPressDragInteraction(input: {
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || platformIsWeb) {
if (!input.menuController) {
return;
}