diff --git a/docs/demo/mobile-ungroup-dropzone.png b/docs/demo/mobile-ungroup-dropzone.png new file mode 100644 index 0000000000..8fcd50f64b Binary files /dev/null and b/docs/demo/mobile-ungroup-dropzone.png differ diff --git a/tests/e2e_ui/sessions/test_sidebar_project_context_menu.py b/tests/e2e_ui/sessions/test_sidebar_project_context_menu.py new file mode 100644 index 0000000000..9436adc263 --- /dev/null +++ b/tests/e2e_ui/sessions/test_sidebar_project_context_menu.py @@ -0,0 +1,280 @@ +"""Browser e2e for the project-folder header's context menu. + +Project folder headers used to expose their actions only through a +hover-revealed kebab: a right-click fell through to the browser's native menu, +and touch — which has no right-click at all — could not reach them. The header +button now also carries a Radix ``ContextMenuTrigger``, which brings BOTH +gestures with it (``contextmenu`` for mouse; a built-in 700ms pointerdown timer +for touch), so the same actions open either way. The kebab is unchanged. + +Both menus render from one shared item body (``ProjectFolderMenuItems`` in +``Sidebar.tsx``) — one under a Radix ``DropdownMenu`` (the kebab), one under a +``ContextMenu`` (these gestures) — mirroring how the session rows do it. + +This guards the wiring the mocked unit tests can't exercise end-to-end: + +- Desktop right-click suppresses the native menu and opens the app's, and + picking an item drives the real dialog. +- A **real touch long-press** opens the same menu. Dispatched via CDP + ``Input.dispatchTouchEvent`` (touchStart → hold → touchEnd) on a + ``has_touch`` context, because that is the only way to produce genuine + ``touchstart``/``pointerdown`` sequences here: ``page.touchscreen.tap()`` is + instantaneous and cannot hold, and a synthetic ``dispatch_event("pointerdown")`` + emits no ``touchstart`` at all — it under-exercises the pipeline and can pass + while real touch fails. +- Neither gesture toggles the folder's expand/collapse (the header button's + onClick), while a plain left-click still does. +""" + +from __future__ import annotations + +import time +import uuid +from collections.abc import Iterator + +import httpx +import pytest +from playwright.sync_api import Browser, Locator, Page, expect + +# Phone-width viewport, below the 768px `md` breakpoint: the sidebar is the +# mobile overlay there, which is where a long-press actually matters. +_MOBILE_VIEWPORT = {"width": 390, "height": 844} + +# Comfortably past Radix's 700ms long-press timer. +_LONG_PRESS_MS = 900 + + +def _set_title(base_url: str, session_id: str, title: str) -> None: + """Give a session a unique title so its row is easy to spot on the shared + server (other tests' sessions live there too).""" + resp = httpx.patch( + f"{base_url}/v1/sessions/{session_id}", + json={"title": title}, + timeout=10.0, + ) + resp.raise_for_status() + + +def _create_project(base_url: str, name: str) -> None: + """Create an empty project via ``POST /v1/projects``. + + Seeded over the API rather than through the sidebar's + button: that button + is hover-revealed on desktop and hidden entirely on the mobile overlay, and + what's under test here is the folder header's context menu — not project + creation (``test_sidebar_projects.py`` already covers that UI path). + """ + resp = httpx.post(f"{base_url}/v1/projects", json={"name": name}, timeout=10.0) + resp.raise_for_status() + + +def _folder_header(page: Page, project: str) -> Locator: + """The project folder's collapse-toggle header button. + + A DOM locator rather than ``get_by_role``: an open Radix menu is modal and + ``aria-hidden``s the rest of the tree, so role queries can't see the header + while its own context menu is up — which is exactly when these tests need to + read ``aria-expanded``. Project names here are unique hex, so the text filter + is unambiguous. + """ + return page.locator("h2 button").filter(has_text=project) + + +def _expanded(page: Page, project: str) -> str: + """Read the folder's current expand state as the raw ``aria-expanded`` value. + + Read rather than asserted against a constant: a freshly created project may + render already-expanded, and what the context-menu tests care about is that + the gesture leaves the state UNCHANGED, whatever it started as. + """ + value = _folder_header(page, project).get_attribute("aria-expanded") + assert value is not None, "folder header is missing aria-expanded" + return value + + +def _long_press( + page: Page, + target: Locator, + menu_testid: str = "rename-project", + hold_ms: int = _LONG_PRESS_MS, +) -> bool: + """Long-press *target* with real touch events and report whether the menu opened. + + Uses CDP ``Input.dispatchTouchEvent`` so the page sees a genuine + ``touchstart`` → ``pointerdown`` sequence (Playwright's ``touchscreen.tap`` + is instantaneous and cannot hold a press, and a synthetic + ``dispatch_event("pointerdown")`` emits no ``touchstart`` at all). + + Polls for the menu *during* the hold rather than only after release: the + menu opens mid-gesture off Radix's 700ms timer, and a post-release-only + check can miss it entirely. + + :param page: The page whose CDP session dispatches the touch. + :param target: Element to press; its bounding box centre is the touch point. + :param menu_testid: An item test-id that only the expected menu renders. + :param hold_ms: How long to hold before releasing, in milliseconds. + :returns: Whether the menu was observed open during (or at the end of) the press. + """ + box = target.bounding_box() + assert box is not None, "long-press target has no bounding box" + x = box["x"] + box["width"] / 2 + y = box["y"] + box["height"] / 2 + + cdp = page.context.new_cdp_session(page) + opened = False + try: + cdp.send( + "Input.dispatchTouchEvent", + {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]}, + ) + # Peak-poll through the hold; stop as soon as the menu is up. + deadline = time.monotonic() + hold_ms / 1000 + while time.monotonic() < deadline: + if page.get_by_test_id(menu_testid).count() > 0: + opened = True + break + page.wait_for_timeout(50) + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + # A menu that opens right at the release still counts. + if not opened: + opened = page.get_by_test_id(menu_testid).count() > 0 + finally: + cdp.detach() + return opened + + +@pytest.fixture +def project_page(page: Page, seeded_session: tuple[str, str]) -> Iterator[tuple[Page, str]]: + """A desktop page with a fresh, empty project folder in the sidebar. + + :param page: Playwright page fixture (fresh context per test). + :param seeded_session: ``(base_url, session_id)`` for a pre-created session. + :returns: ``(page, project_name)``. + """ + base_url, session_id = seeded_session + _set_title(base_url, session_id, f"e2e-projctx-{uuid.uuid4().hex[:8]}") + project = f"Project {uuid.uuid4().hex[:6]}" + + _create_project(base_url, project) + page.goto(f"{base_url}/c/{session_id}") + expect(_folder_header(page, project)).to_be_visible() + + yield (page, project) + + +def test_right_click_opens_project_folder_menu(project_page: tuple[Page, str]) -> None: + """Right-clicking a folder header opens the kebab's actions, and they work. + + :param project_page: ``(page, project_name)`` with the folder in the sidebar. + """ + page, project = project_page + header = _folder_header(page, project) + + # Whatever state the folder is in, the right-click must not change it. + before = _expanded(page, project) + + # Radix's ContextMenuTrigger preventDefaults the native contextmenu and + # opens the app menu at the cursor. + header.click(button="right") + + # The full kebab action set, same testids — it renders from the shared + # ProjectFolderMenuItems body. (The kebab DropdownMenu is closed, so these + # are the context menu's own items.) + expect(page.get_by_test_id("rename-project")).to_be_visible() + expect(page.get_by_test_id("project-settings")).to_be_visible() + expect(page.get_by_test_id("delete-project")).to_be_visible() + + # Opening the menu did NOT collapse/expand the folder. + expect(header).to_have_attribute("aria-expanded", before) + + # The item drives the real dialog, same as the kebab's Rename. + page.get_by_test_id("rename-project").click() + expect(page.get_by_test_id("rename-project-confirm")).to_be_visible() + + +def test_left_click_still_toggles_the_folder(project_page: tuple[Page, str]) -> None: + """A plain left-click on the header still expands and collapses the folder. + + :param project_page: ``(page, project_name)`` with the folder in the sidebar. + """ + page, project = project_page + header = _folder_header(page, project) + + # Toggle relative to the starting state (a fresh project may already be + # expanded), so this asserts the flip rather than an absolute value. + before = _expanded(page, project) + flipped = "false" if before == "true" else "true" + + header.click() + expect(header).to_have_attribute("aria-expanded", flipped) + header.click() + expect(header).to_have_attribute("aria-expanded", before) + + +def test_touch_long_press_opens_project_folder_menu( + browser: Browser, + seeded_session: tuple[str, str], +) -> None: + """A real touch long-press on the folder header opens the same menu. + + Runs on its own ``has_touch``/``is_mobile`` phone-width context: mobile is + the case that had no way to reach these actions at all before, since touch + cannot right-click. + + Includes a **positive control** — the same press on a session row, whose + long-press menu already worked before this change — so a negative result on + the folder header means the folder is broken rather than that the CDP touch + plumbing failed to reach the page. + + :param browser: Playwright browser fixture (a fresh context is made here). + :param seeded_session: ``(base_url, session_id)`` for a pre-created session. + """ + base_url, session_id = seeded_session + _set_title(base_url, session_id, f"e2e-projctx-touch-{uuid.uuid4().hex[:8]}") + project = f"Project {uuid.uuid4().hex[:6]}" + + _create_project(base_url, project) + + context = browser.new_context( + has_touch=True, + is_mobile=True, + viewport=_MOBILE_VIEWPORT, + ) + try: + page = context.new_page() + # The mobile sidebar starts closed; ?sidebar=open is the one-shot param + # the notification-tap destination uses. + page.goto(f"{base_url}/c/{session_id}?sidebar=open") + + header = _folder_header(page, project) + expect(header).to_be_visible() + before = _expanded(page, project) + + # Detector validation: prove the CDP touch pipeline actually reaches the + # page BEFORE trusting a negative on the folder. A session row's + # long-press menu is the known-good reference; if this press produces + # nothing, the plumbing is at fault and the folder result is meaningless. + row = page.locator(f'a[href="/c/{session_id}"]') + expect(row).to_be_visible() + assert _long_press(page, row, menu_testid="rename-conversation"), ( + "CDP touch long-press did not reach the page (control failed) — " + "the folder assertion below would be meaningless" + ) + # Dismiss the control's menu so it can't be mistaken for the folder's. + page.keyboard.press("Escape") + expect(page.get_by_test_id("rename-conversation")).to_have_count(0) + + # The actual assertion: long-press the folder header. + assert _long_press(page, header), ( + "long-press on the project folder header did not open the context menu" + ) + + expect(page.get_by_test_id("rename-project")).to_be_visible() + expect(page.get_by_test_id("project-settings")).to_be_visible() + expect(page.get_by_test_id("delete-project")).to_be_visible() + + # The long-press must not have toggled the folder: Radix opens the menu + # mid-gesture, and the press's trailing click would otherwise collapse + # or expand it under the just-opened menu. + expect(_folder_header(page, project)).to_have_attribute("aria-expanded", before) + finally: + context.close() diff --git a/tests/e2e_ui/sessions/test_sidebar_session_gestures.py b/tests/e2e_ui/sessions/test_sidebar_session_gestures.py new file mode 100644 index 0000000000..43539adbc3 --- /dev/null +++ b/tests/e2e_ui/sessions/test_sidebar_session_gestures.py @@ -0,0 +1,224 @@ +"""Browser e2e coverage for touch gestures on draggable session rows. + +The sidebar uses dnd-kit for touch dragging and Radix for its context menu. +These tests drive Chromium through CDP so the page receives a genuine touch +sequence; synthetic pointer events do not arm dnd-kit's ``TouchSensor``. +""" + +from __future__ import annotations + +import re +import uuid + +import httpx +from playwright.sync_api import Browser, Locator, Page, expect + +_MOBILE_VIEWPORT = {"width": 390, "height": 844} + + +def _set_title(base_url: str, session_id: str, title: str) -> None: + """Give the test session a unique, visible sidebar label.""" + response = httpx.patch( + f"{base_url}/v1/sessions/{session_id}", + json={"title": title}, + timeout=10.0, + ) + response.raise_for_status() + + +def _create_project(base_url: str, name: str) -> None: + """Create an empty project for a drag target.""" + response = httpx.post(f"{base_url}/v1/projects", json={"name": name}, timeout=10.0) + response.raise_for_status() + + +def _row_link(page: Page, session_id: str) -> Locator: + """Locate the sidebar link for ``session_id``.""" + return page.locator(f'a[href="/c/{session_id}"]') + + +def _section(page: Page, title: str) -> Locator: + """Locate the sidebar section headed by ``title``.""" + return page.locator("section").filter(has=page.get_by_role("button", name=title, exact=True)) + + +def test_still_touch_opens_session_context_menu_without_dragging( + browser: Browser, + seeded_session: tuple[str, str], +) -> None: + """A still touch opens the context menu; only a moving finger drags.""" + base_url, session_id = seeded_session + title = f"e2e-touch-hold-{uuid.uuid4().hex[:8]}" + _set_title(base_url, session_id, title) + + context = browser.new_context( + has_touch=True, + is_mobile=True, + viewport=_MOBILE_VIEWPORT, + ) + try: + page = context.new_page() + page.goto(f"{base_url}/c/{session_id}?sidebar=open") + + link = _row_link(page, session_id) + expect(link).to_be_visible() + row = link.locator("xpath=ancestor::li[1]") + box = link.bounding_box() + assert box is not None, "session row has no touchable bounding box" + x = box["x"] + box["width"] / 2 + y = box["y"] + box["height"] / 2 + + cdp = page.context.new_cdp_session(page) + try: + cdp.send( + "Input.dispatchTouchEvent", + {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]}, + ) + + # The hold arms and lifts the row; the menu itself waits for release, + # so a finger that never moves is never picked up as a drag. + expect(row).to_have_class(re.compile(r"\bscale-\[1\.01\]"), timeout=2000) + expect(row).not_to_have_class(re.compile(r"\bopacity-40\b")) + + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + expect(page.get_by_test_id("rename-conversation")).to_have_count(1, timeout=2000) + expect(row).not_to_have_class(re.compile(r"\bopacity-40\b")) + finally: + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + cdp.detach() + finally: + context.close() + + +def test_vertical_touch_scroll_still_works_on_session_row( + browser: Browser, + seeded_session: tuple[str, str], +) -> None: + """Moving before the hold delay scrolls the sidebar instead of dragging.""" + base_url, session_id = seeded_session + _set_title(base_url, session_id, f"e2e-touch-scroll-{uuid.uuid4().hex[:8]}") + + context = browser.new_context( + has_touch=True, + is_mobile=True, + viewport=_MOBILE_VIEWPORT, + ) + try: + page = context.new_page() + page.goto(f"{base_url}/c/{session_id}?sidebar=open") + + link = _row_link(page, session_id) + expect(link).to_be_visible() + row = link.locator("xpath=ancestor::li[1]") + scroll_area = page.locator("aside nav") + conversation_list = page.get_by_test_id("sidebar-conversation-list") + + # One seeded row does not naturally overflow. Extend the real list's + # layout so Chromium has native scroll range without adding fake rows. + conversation_list.evaluate("element => { element.style.minHeight = '1800px'; }") + scroll_area.evaluate("element => { element.scrollTop = 0; }") + assert scroll_area.evaluate("element => element.scrollHeight > element.clientHeight") + + box = link.bounding_box() + assert box is not None, "session row has no touchable bounding box" + x = box["x"] + box["width"] / 2 + y = box["y"] + box["height"] / 2 + + cdp = page.context.new_cdp_session(page) + try: + cdp.send( + "Input.dispatchTouchEvent", + {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]}, + ) + for offset in (20, 45, 70, 95, 120): + cdp.send( + "Input.dispatchTouchEvent", + {"type": "touchMove", "touchPoints": [{"x": x, "y": y - offset}]}, + ) + page.wait_for_timeout(20) + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + finally: + cdp.detach() + + page.wait_for_function( + "element => element.scrollTop > 20", + arg=scroll_area.element_handle(), + ) + assert "opacity-40" not in (row.get_attribute("class") or "") + expect(page.get_by_test_id("rename-conversation")).to_have_count(0) + finally: + context.close() + + +def test_touch_drag_moves_session_into_project( + browser: Browser, + seeded_session: tuple[str, str], +) -> None: + """A touch drag still drops the session into a project folder.""" + base_url, session_id = seeded_session + _set_title(base_url, session_id, f"e2e-touch-drop-{uuid.uuid4().hex[:8]}") + project = f"Project {uuid.uuid4().hex[:6]}" + _create_project(base_url, project) + + context = browser.new_context( + has_touch=True, + is_mobile=True, + viewport=_MOBILE_VIEWPORT, + ) + try: + page = context.new_page() + page.goto(f"{base_url}/c/{session_id}?sidebar=open") + + link = _row_link(page, session_id) + header = page.get_by_role("button", name=project, exact=True) + expect(link).to_be_visible() + expect(header).to_be_visible() + row = link.locator("xpath=ancestor::li[1]") + + source = link.bounding_box() + target = header.bounding_box() + assert source is not None, "session row has no touchable bounding box" + assert target is not None, "project header has no droppable bounding box" + start_x = source["x"] + source["width"] / 2 + start_y = source["y"] + source["height"] / 2 + end_x = target["x"] + target["width"] / 2 + end_y = target["y"] + target["height"] / 2 + + cdp = page.context.new_cdp_session(page) + try: + cdp.send( + "Input.dispatchTouchEvent", + { + "type": "touchStart", + "touchPoints": [{"x": start_x, "y": start_y}], + }, + ) + # Hold until the row lifts, then the first move picks it up as a drag. + expect(row).to_have_class(re.compile(r"\bscale-\[1\.01\]"), timeout=2000) + + for step in range(1, 6): + progress = step / 5 + cdp.send( + "Input.dispatchTouchEvent", + { + "type": "touchMove", + "touchPoints": [ + { + "x": start_x + (end_x - start_x) * progress, + "y": start_y + (end_y - start_y) * progress, + } + ], + }, + ) + page.wait_for_timeout(20) + if step == 1: + # Moving out of the armed hold is what hands the row to dnd-kit. + expect(row).to_have_class(re.compile(r"\bopacity-40\b"), timeout=1000) + cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) + finally: + cdp.detach() + + expect(_section(page, project).locator(f'a[href="/c/{session_id}"]')).to_be_visible() + expect(_section(page, "Sessions").locator(f'a[href="/c/{session_id}"]')).to_have_count(0) + finally: + context.close() diff --git a/web/android/app/src/main/AndroidManifest.xml b/web/android/app/src/main/AndroidManifest.xml index 7ce4b42622..05ab580cf4 100644 --- a/web/android/app/src/main/AndroidManifest.xml +++ b/web/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + diff --git a/web/src/components/ui/context-menu.tsx b/web/src/components/ui/context-menu.tsx index 964ee261cb..e575ac2e5c 100644 --- a/web/src/components/ui/context-menu.tsx +++ b/web/src/components/ui/context-menu.tsx @@ -36,7 +36,10 @@ function ContextMenuContent({ void): () => void { - if (typeof window === "undefined" || !window.matchMedia) return () => {}; - const mql = window.matchMedia(MOBILE_QUERY); - mql.addEventListener("change", callback); - return () => mql.removeEventListener("change", callback); -} - -function getSnapshot(): boolean { - if (typeof window === "undefined" || !window.matchMedia) return false; - return window.matchMedia(MOBILE_QUERY).matches; -} - /** * True when the viewport is narrower than Tailwind's `md` breakpoint (768px) * — i.e. the "mobile" layout the shell's `max-md:` classes target. Reactive: @@ -31,5 +19,5 @@ function getSnapshot(): boolean { * (returns `false` on the server, matching `initialSidebarOpen`). */ export function useIsMobileViewport(): boolean { - return useSyncExternalStore(subscribe, getSnapshot, () => false); + return useMediaQuery(MOBILE_QUERY); } diff --git a/web/src/hooks/useMediaQuery.ts b/web/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000000..1a6aa6ee5f --- /dev/null +++ b/web/src/hooks/useMediaQuery.ts @@ -0,0 +1,57 @@ +// Shared reactive media-query subscription. Each distinct query string gets +// ONE module-level MediaQueryList and one native "change" listener fanning out +// to a shared listener set — so many subscribers (e.g. every sidebar row) cost +// a single matchMedia registration instead of one each. + +import { useSyncExternalStore } from "react"; + +interface MediaQueryStore { + subscribe: (onChange: () => void) => () => void; + getSnapshot: () => boolean; +} + +const stores = new Map(); + +function createStore(query: string): MediaQueryStore { + const listeners = new Set<() => void>(); + // The native registration is created lazily (SSR-safe import) and kept for + // the app's lifetime; queries are static, so this never accumulates. + let registered = false; + return { + subscribe(onChange) { + if (!registered && typeof window !== "undefined" && window.matchMedia) { + registered = true; + window.matchMedia(query).addEventListener("change", () => { + for (const listener of listeners) listener(); + }); + } + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + getSnapshot() { + // Read fresh rather than off a cached MediaQueryList: `matches` is a + // cheap live getter, and tests stub window.matchMedia per case. + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(query).matches; + }, + }; +} + +function storeFor(query: string): MediaQueryStore { + let store = stores.get(query); + if (store === undefined) { + store = createStore(query); + stores.set(query, store); + } + return store; +} + +/** + * True while `query` matches. Reactive (re-renders on change) and SSR-safe + * (returns `false` on the server). Subscriptions for the same query share one + * MediaQueryList and one native listener. + */ +export function useMediaQuery(query: string): boolean { + const store = storeFor(query); + return useSyncExternalStore(store.subscribe, store.getSnapshot, () => false); +} diff --git a/web/src/hooks/useRowGesture.ts b/web/src/hooks/useRowGesture.ts new file mode 100644 index 0000000000..bb1e533cef --- /dev/null +++ b/web/src/hooks/useRowGesture.ts @@ -0,0 +1,468 @@ +import { + type PointerEvent as ReactPointerEvent, + type SyntheticEvent, + type TouchEvent as ReactTouchEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + type DraggableSyntheticListeners, + TouchSensor, + type TouchSensorOptions, +} from "@dnd-kit/core"; +import type { SwipeAction, SwipeActionPreferences } from "@/lib/swipeActionPreferences"; + +export const ROW_GESTURE_HOLD_MS = 400; +export const ROW_SWIPE_ACTIVATE_PX = 12; +export const ROW_SWIPE_COMMIT_PX = 72; +// Native touch slop is typically 8-10dp; 10px filters hold tremble while +// keeping a deliberate pull immediate. +export const ROW_DRAG_ACTIVATE_PX = 10; +// Marks the recognizer's own contextmenu dispatch so the mid-gesture guards +// can suppress the OS long-press contextmenu without eating their own. +export const ROW_MENU_SYNTHETIC = Symbol("row-menu-synthetic"); + +const ROW_SCROLL_ACTIVATE_PX = 25; +const ROW_HOLD_TOLERANCE_PX = 20; +const ROW_SWIPE_MAX_PX = 96; +const ROW_SWIPE_RESIST = 1 / 3; + +export type RowGesturePhase = "idle" | "pending" | "swipe" | "scroll" | "armed" | "drag"; + +interface ActiveRowGesture { + pointerId: number; + startX: number; + startY: number; + lastX: number; + lastY: number; + armX: number; + armY: number; + phase: Exclude; + target: Element; + sensorTarget: Element; + offset: number; +} + +interface RowGestureDndState { + shouldStartDrag: () => boolean; +} + +export interface RowGestureDndData { + rowGesture: RowGestureDndState; +} + +interface RowGestureActivationContext { + active: { data: { current?: unknown } }; +} + +type RowGestureReset = (cancelDrag: boolean) => void; + +const rowGestureResets = new Set(); + +function handleGlobalTouchStart(event: TouchEvent) { + if (event.touches.length <= 1) return; + for (const reset of rowGestureResets) reset(true); +} + +function registerRowGestureReset(reset: RowGestureReset) { + if (rowGestureResets.size === 0) document.addEventListener("touchstart", handleGlobalTouchStart); + rowGestureResets.add(reset); + return () => { + rowGestureResets.delete(reset); + if (rowGestureResets.size === 0) { + document.removeEventListener("touchstart", handleGlobalTouchStart); + } + }; +} + +/** Clears the recognizer after dnd-kit has ended or cancelled its drag. */ +export function finishActiveRowGesture() { + for (const reset of rowGestureResets) reset(false); +} + +const rowGestureActivators = [ + { + eventName: "onTouchMove", + handler: ( + { nativeEvent: event }: ReactTouchEvent, + { onActivation }: TouchSensorOptions, + { active }: RowGestureActivationContext, + ) => { + if (event.touches.length !== 1) return false; + const data = active.data.current as Partial | undefined; + if (!data?.rowGesture?.shouldStartDrag()) return false; + onActivation?.({ event }); + return true; + }, + }, +]; + +/** A touch sensor that is instantiated only after the row recognizer chooses drag. */ +export class RowGestureTouchSensor extends TouchSensor { + static override activators = rowGestureActivators as unknown as typeof TouchSensor.activators; +} + +function swipeOffset(deltaX: number): number { + const direction = Math.sign(deltaX); + const travel = Math.abs(deltaX); + if (travel <= ROW_SWIPE_COMMIT_PX) return deltaX; + const damped = ROW_SWIPE_COMMIT_PX + (travel - ROW_SWIPE_COMMIT_PX) * ROW_SWIPE_RESIST; + return direction * Math.min(damped, ROW_SWIPE_MAX_PX); +} + +function callDndListener( + listeners: DraggableSyntheticListeners, + name: string, + event: SyntheticEvent, +) { + const listener = listeners?.[name] as ((event: SyntheticEvent) => void) | undefined; + listener?.(event); +} + +export function useRowGesture({ + enabled, + swipeEnabled, + dragEnabled, + actions, + onAction, + onLongPress, + onDragStart, + onPickUp, +}: { + enabled: boolean; + swipeEnabled: boolean; + dragEnabled: boolean; + actions: SwipeActionPreferences; + onAction: (action: Exclude) => void; + onLongPress: (point: { clientX: number; clientY: number }) => void; + onDragStart?: () => void; + onPickUp?: () => void; +}) { + const [dx, setDx] = useState(0); + const [phase, setPhase] = useState("idle"); + const state = useRef(null); + const holdTimer = useRef(null); + const suppressClick = useRef(false); + const touchMoveGuard = useRef<(() => void) | null>(null); + + const clearHoldTimer = useCallback(() => { + if (holdTimer.current === null) return; + window.clearTimeout(holdTimer.current); + holdTimer.current = null; + }, []); + + // Chrome samples touch-action at touchstart, so the armed-state class swap + // to touch-none can't stop an in-flight touch from being claimed as a native + // pan. A held finger hasn't started a scroll yet, so its touchmoves are + // still cancelable — preventDefault here forestalls the pan until the + // gesture resolves to drag or resets. React's delegated listeners are + // passive, so this has to be a real native listener. + const armTouchMoveGuard = useCallback(() => { + if (touchMoveGuard.current) return; + const handler = (event: TouchEvent) => { + if (event.cancelable) event.preventDefault(); + }; + // The OS long-press contextmenu hit-tests the element under the finger — + // once the row menu opens there, that is the menu itself, past every + // row-level guard. Unprevented it starts text selection and cancels the + // pointer stream, so suppress it document-wide while the gesture owns the + // touch. The recognizer's own tagged dispatch passes through. + const contextMenuHandler = (event: Event) => { + if (ROW_MENU_SYNTHETIC in event) return; + event.preventDefault(); + }; + const selectStartHandler = (event: Event) => event.preventDefault(); + touchMoveGuard.current = () => { + document.removeEventListener("touchmove", handler, { + passive: false, + } as EventListenerOptions); + document.removeEventListener("contextmenu", contextMenuHandler, true); + document.removeEventListener("selectstart", selectStartHandler, true); + }; + document.addEventListener("touchmove", handler, { passive: false }); + document.addEventListener("contextmenu", contextMenuHandler, true); + document.addEventListener("selectstart", selectStartHandler, true); + }, []); + + const disarmTouchMoveGuard = useCallback(() => { + const teardown = touchMoveGuard.current; + if (!teardown) return; + touchMoveGuard.current = null; + teardown(); + }, []); + + const releaseCapture = useCallback((gesture: ActiveRowGesture | null) => { + if (!gesture) return; + try { + if (gesture.target.hasPointerCapture(gesture.pointerId)) { + gesture.target.releasePointerCapture(gesture.pointerId); + } + } catch { + // The browser may have released capture during pointer cancellation. + } + }, []); + + const reset = useCallback( + (cancelDrag = false) => { + const gesture = state.current; + if (!gesture) return; + clearHoldTimer(); + releaseCapture(gesture); + disarmTouchMoveGuard(); + state.current = null; + setDx(0); + setPhase("idle"); + if (cancelDrag && gesture.phase === "drag") { + gesture.sensorTarget.dispatchEvent( + new Event("touchcancel", { bubbles: true, cancelable: true }), + ); + } + }, + [clearHoldTimer, disarmTouchMoveGuard, releaseCapture], + ); + + // Armed until the trailing click arrives or the next press clears it. A timer + // would race the click: the browser does not guarantee dispatch inside the + // same task, and losing that race navigates into the row just swiped away. + const suppressTrailingClick = useCallback(() => { + suppressClick.current = true; + }, []); + + const consumeClick = useCallback(() => { + if (!suppressClick.current) return false; + suppressClick.current = false; + return true; + }, []); + + const setGesturePhase = useCallback( + (gesture: ActiveRowGesture, next: ActiveRowGesture["phase"]) => { + gesture.phase = next; + setPhase(next); + }, + [], + ); + + const capturePointer = useCallback((gesture: ActiveRowGesture) => { + try { + gesture.target.setPointerCapture(gesture.pointerId); + } catch { + // Capture can fail if the pointer ended in the timer callback's turn. + } + }, []); + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + suppressClick.current = false; + if (event.pointerType !== "touch") return; + if (!event.isPrimary) { + reset(true); + return; + } + if (!enabled) return; + const target = event.target; + if (target instanceof Node && !event.currentTarget.contains(target)) return; + + reset(); + const gesture: ActiveRowGesture = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + armX: event.clientX, + armY: event.clientY, + phase: "pending", + target: event.currentTarget, + sensorTarget: target instanceof Element ? target : event.currentTarget, + offset: 0, + }; + state.current = gesture; + setPhase("pending"); + holdTimer.current = window.setTimeout(() => { + if (state.current !== gesture || gesture.phase !== "pending") return; + // A finger still creeping hasn't held still, it's scrolling slowly — and + // arming would capture the pointer and take the scroll away. + const drift = Math.hypot(gesture.lastX - gesture.startX, gesture.lastY - gesture.startY); + if (drift > ROW_HOLD_TOLERANCE_PX) { + holdTimer.current = null; + setGesturePhase(gesture, "scroll"); + return; + } + holdTimer.current = null; + gesture.armX = gesture.lastX; + gesture.armY = gesture.lastY; + setGesturePhase(gesture, "armed"); + capturePointer(gesture); + armTouchMoveGuard(); + if (typeof navigator.vibrate === "function") navigator.vibrate(10); + onPickUp?.(); + onLongPress({ clientX: gesture.lastX, clientY: gesture.lastY }); + }, ROW_GESTURE_HOLD_MS); + }, + [armTouchMoveGuard, capturePointer, enabled, onLongPress, onPickUp, reset, setGesturePhase], + ); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const gesture = state.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const deltaX = event.clientX - gesture.startX; + const deltaY = event.clientY - gesture.startY; + const moved = event.clientX !== gesture.lastX || event.clientY !== gesture.lastY; + gesture.lastX = event.clientX; + gesture.lastY = event.clientY; + + if (gesture.phase === "armed") { + if ( + !moved || + Math.hypot(event.clientX - gesture.armX, event.clientY - gesture.armY) < + ROW_DRAG_ACTIVATE_PX + ) { + return; + } + if (dragEnabled) { + onDragStart?.(); + setGesturePhase(gesture, "drag"); + } else { + setGesturePhase(gesture, "scroll"); + releaseCapture(gesture); + } + return; + } + if (gesture.phase === "drag" || gesture.phase === "scroll") return; + if (gesture.phase === "swipe") { + // Reversing past the origin crosses into the other direction, which may + // be configured inert. Rest the row there and stop claiming the gesture, + // rather than translating with nothing revealed behind it. + const reversedInto = deltaX < 0 ? actions.left : actions.right; + if (reversedInto === "none") { + gesture.offset = 0; + setDx(0); + return; + } + event.preventDefault(); + gesture.offset = swipeOffset(deltaX); + setDx(gesture.offset); + return; + } + + const horizontal = Math.abs(deltaX); + const vertical = Math.abs(deltaY); + // Horizontal-dominant travel locks swipe at 12px. The regions overlap + // (e.g. 18,17.5 satisfies both) — this check running first is what gives + // swipe precedence; everything it declines waits for the 25px circle. + if (horizontal >= ROW_SWIPE_ACTIVATE_PX && horizontal > vertical) { + const action = deltaX < 0 ? actions.left : actions.right; + if (!swipeEnabled || action === "none") { + clearHoldTimer(); + setGesturePhase(gesture, "scroll"); + return; + } + clearHoldTimer(); + setGesturePhase(gesture, "swipe"); + capturePointer(gesture); + event.preventDefault(); + gesture.offset = swipeOffset(deltaX); + setDx(gesture.offset); + return; + } + if (Math.hypot(deltaX, deltaY) >= ROW_SCROLL_ACTIVATE_PX) { + clearHoldTimer(); + setGesturePhase(gesture, "scroll"); + } + }, + [ + actions.left, + actions.right, + capturePointer, + clearHoldTimer, + dragEnabled, + onDragStart, + releaseCapture, + setGesturePhase, + swipeEnabled, + ], + ); + + const onPointerUp = useCallback( + (event: ReactPointerEvent) => { + const gesture = state.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const resolvedPhase = gesture.phase; + const offset = gesture.offset; + const action = offset < 0 ? actions.left : actions.right; + reset(); + + if (resolvedPhase !== "pending") suppressTrailingClick(); + if ( + resolvedPhase === "swipe" && + Math.abs(offset) >= ROW_SWIPE_COMMIT_PX && + action !== "none" + ) { + onAction(action); + } + }, + [actions.left, actions.right, onAction, reset, suppressTrailingClick], + ); + + const onPointerCancel = useCallback( + (event: ReactPointerEvent) => { + if (state.current?.pointerId !== event.pointerId) return; + reset(true); + }, + [reset], + ); + + const onTouchStart = useCallback( + (event: ReactTouchEvent) => { + if (event.touches.length > 1) reset(true); + }, + [reset], + ); + + const dndData = useMemo( + () => ({ shouldStartDrag: () => state.current?.phase === "drag" }), + [], + ); + + const bindListeners = useCallback( + (dragListeners: DraggableSyntheticListeners) => ({ + ...dragListeners, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onTouchStart: (event: ReactTouchEvent) => { + onTouchStart(event); + callDndListener(dragListeners, "onTouchStart", event); + }, + }), + [onPointerCancel, onPointerDown, onPointerMove, onPointerUp, onTouchStart], + ); + + useEffect(() => { + if (!enabled) reset(true); + }, [enabled, reset]); + + useEffect(() => registerRowGestureReset(reset), [reset]); + + useEffect( + () => () => { + clearHoldTimer(); + releaseCapture(state.current); + disarmTouchMoveGuard(); + }, + [clearHoldTimer, disarmTouchMoveGuard, releaseCapture], + ); + + return { + dx, + phase, + listeners: bindListeners, + dndData, + consumeClick, + }; +} diff --git a/web/src/lib/swipeActionPreferences.test.ts b/web/src/lib/swipeActionPreferences.test.ts new file mode 100644 index 0000000000..475d42c1f1 --- /dev/null +++ b/web/src/lib/swipeActionPreferences.test.ts @@ -0,0 +1,172 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + DEFAULT_SWIPE_ACTIONS, + isSwipeAction, + normalizeSwipeActions, + readSwipeActions, + useSwipeActions, + writeSwipeActions, +} from "./swipeActionPreferences"; + +const STORAGE_KEY = "omnigent:swipe-actions"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("swipeActionPreferences — read/write", () => { + it("returns the defaults when nothing is stored", () => { + expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("round-trips a written preference", () => { + writeSwipeActions({ left: "delete", right: "archive" }); + expect(readSwipeActions()).toEqual({ left: "delete", right: "archive" }); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({ + left: "delete", + right: "archive", + }); + }); + + it("allows both directions to map to the same action", () => { + writeSwipeActions({ left: "archive", right: "archive" }); + expect(readSwipeActions()).toEqual({ left: "archive", right: "archive" }); + }); + + it("persists a 'none' direction", () => { + writeSwipeActions({ left: "none", right: "delete" }); + expect(readSwipeActions()).toEqual({ left: "none", right: "delete" }); + }); +}); + +describe("isSwipeAction", () => { + it("accepts the known actions and rejects everything else", () => { + expect(isSwipeAction("archive")).toBe(true); + expect(isSwipeAction("delete")).toBe(true); + expect(isSwipeAction("none")).toBe(true); + expect(isSwipeAction("bogus")).toBe(false); + expect(isSwipeAction(null)).toBe(false); + expect(isSwipeAction(undefined)).toBe(false); + expect(isSwipeAction(42)).toBe(false); + }); +}); + +describe("normalizeSwipeActions", () => { + it("passes through a valid object", () => { + expect(normalizeSwipeActions({ left: "delete", right: "none" })).toEqual({ + left: "delete", + right: "none", + }); + }); + + it("fills absent directions from the defaults", () => { + expect(normalizeSwipeActions({ left: "delete" })).toEqual({ + left: "delete", + right: DEFAULT_SWIPE_ACTIONS.right, + }); + }); + + it("makes a present but unrecognized action inert", () => { + expect(normalizeSwipeActions({ left: "trash", right: "archive" })).toEqual({ + left: "none", + right: "archive", + }); + }); + + it("maps null, arrays, and primitives to the defaults", () => { + expect(normalizeSwipeActions(null)).toEqual(DEFAULT_SWIPE_ACTIONS); + expect(normalizeSwipeActions("nope")).toEqual(DEFAULT_SWIPE_ACTIONS); + expect(normalizeSwipeActions(123)).toEqual(DEFAULT_SWIPE_ACTIONS); + expect(normalizeSwipeActions([])).toEqual(DEFAULT_SWIPE_ACTIONS); + }); +}); + +describe("readSwipeActions — corrupt storage", () => { + it("returns the safe defaults when localStorage access throws", () => { + const descriptor = Object.getOwnPropertyDescriptor(window, "localStorage")!; + Object.defineProperty(window, "localStorage", { + configurable: true, + get: () => { + throw new DOMException("Storage is unavailable", "SecurityError"); + }, + }); + + try { + expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS); + } finally { + Object.defineProperty(window, "localStorage", descriptor); + } + }); + + it("falls back to the defaults on unparseable JSON", () => { + localStorage.setItem(STORAGE_KEY, "{not json"); + expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS); + }); + + it("normalizes a partially-valid stored object", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "delete", right: "bogus" })); + expect(readSwipeActions()).toEqual({ left: "delete", right: DEFAULT_SWIPE_ACTIONS.right }); + }); + + it("makes an unrecognized stored action inert", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "trash", right: "archive" })); + expect(readSwipeActions()).toEqual({ left: "none", right: "archive" }); + }); +}); + +describe("useSwipeActions — live updates", () => { + it("starts at the current stored value", () => { + writeSwipeActions({ left: "delete", right: "archive" }); + const { result } = renderHook(() => useSwipeActions()); + expect(result.current).toEqual({ left: "delete", right: "archive" }); + }); + + it("re-renders on a same-tab write (Settings change updates mounted rows)", () => { + const { result } = renderHook(() => useSwipeActions()); + expect(result.current).toEqual(DEFAULT_SWIPE_ACTIONS); + + act(() => { + writeSwipeActions({ left: "delete", right: "delete" }); + }); + expect(result.current).toEqual({ left: "delete", right: "delete" }); + }); + + it("re-renders on a cross-tab storage event", () => { + const { result } = renderHook(() => useSwipeActions()); + + // Another tab wrote the new value; simulate the DOM `storage` broadcast. + localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "none", right: "archive" })); + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY })); + }); + expect(result.current).toEqual({ left: "none", right: "archive" }); + }); + + it("ignores storage events for unrelated keys", () => { + const { result } = renderHook(() => useSwipeActions()); + const before = result.current; + + localStorage.setItem("some:other:key", "x"); + act(() => { + window.dispatchEvent(new StorageEvent("storage", { key: "some:other:key" })); + }); + // Same reference — no spurious re-render/value change. + expect(result.current).toBe(before); + }); + + it("keeps a stable snapshot reference when the value is unchanged", () => { + writeSwipeActions({ left: "archive", right: "delete" }); + const { result } = renderHook(() => useSwipeActions()); + const first = result.current; + + // A write of the identical value still pings subscribers; the value must + // stay referentially stable so consumers don't needlessly re-run effects. + act(() => { + writeSwipeActions({ left: "archive", right: "delete" }); + }); + expect(result.current).toBe(first); + }); +}); diff --git a/web/src/lib/swipeActionPreferences.ts b/web/src/lib/swipeActionPreferences.ts new file mode 100644 index 0000000000..13a4c139df --- /dev/null +++ b/web/src/lib/swipeActionPreferences.ts @@ -0,0 +1,148 @@ +// Persisted, per-device preference for what a horizontal swipe on a session +// row does on touch devices. Two independent directions — swipe-left and +// swipe-right — each map to an action. Modeled after the other `*Preferences` +// helpers: a localStorage-backed value, SSR-safe reads, and writes that swallow +// quota/access errors so a corrupt entry can't break app boot. +// +// Unlike the simpler helpers this one also exposes a live subscription +// (useSwipeActions): Settings and every mounted session row read one source, so +// changing the preference updates open rows in the same session without a +// reload. writeSwipeActions notifies same-tab subscribers; a `storage` listener +// covers other tabs. +// +// Defaults mirror a phone mail app: swipe-left archives (the common tidy-away +// gesture), swipe-right is inert. Both directions may map to the SAME action — +// nothing prevents e.g. archiving on either side. + +import { useSyncExternalStore } from "react"; + +const STORAGE_KEY = "omnigent:swipe-actions"; +// Same-tab change signal. The DOM `storage` event only fires in OTHER tabs, so +// writeSwipeActions dispatches this to refresh subscribers in the writing tab. +const SWIPE_ACTIONS_EVENT = "omnigent:swipe-actions-changed"; + +export const swipeActions = ["archive", "delete", "none"] as const; +export type SwipeAction = (typeof swipeActions)[number]; + +export type SwipeDirection = "left" | "right"; + +export interface SwipeActionPreferences { + left: SwipeAction; + right: SwipeAction; +} + +/** Default: swipe-left archives, swipe-right does nothing. */ +export const DEFAULT_SWIPE_ACTIONS: SwipeActionPreferences = { + left: "archive", + right: "none", +}; + +/** Return whether a string is one of the selectable swipe actions. */ +export function isSwipeAction(value: unknown): value is SwipeAction { + return value === "archive" || value === "delete" || value === "none"; +} + +/** + * Normalize an arbitrary parsed value to a valid preferences object. Missing + * directions use the defaults; present but unknown actions become inert. + * Used both on read (localStorage drift / manual edits) and to sanitize writes. + */ +export function normalizeSwipeActions(value: unknown): SwipeActionPreferences { + const obj = typeof value === "object" && value !== null ? (value as Record) : {}; + // A direction set to something unrecognized goes inert rather than inheriting + // the default, which would silently arm archive on a swipe meant to be safe. + function actionFor(direction: SwipeDirection): SwipeAction { + const action = obj[direction]; + if (isSwipeAction(action)) return action; + return Object.hasOwn(obj, direction) ? "none" : DEFAULT_SWIPE_ACTIONS[direction]; + } + return { left: actionFor("left"), right: actionFor("right") }; +} + +/** + * Read the persisted swipe actions. Returns the defaults when nothing is + * stored, on a server render (no `window`), or when the stored value is + * missing/malformed — never throws, so a corrupt entry can't break app boot. + */ +export function readSwipeActions(): SwipeActionPreferences { + if (typeof window === "undefined") return { ...DEFAULT_SWIPE_ACTIONS }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_SWIPE_ACTIONS }; + return normalizeSwipeActions(JSON.parse(raw)); + } catch { + return { ...DEFAULT_SWIPE_ACTIONS }; + } +} + +/** + * Persist the swipe actions. Normalizes first so only valid values land in + * storage. Swallows quota/access errors so a failed write can't break the app. + */ +export function writeSwipeActions(value: SwipeActionPreferences): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeSwipeActions(value))); + } catch { + // localStorage quota or access errors shouldn't break the app. + } + // Refresh same-tab subscribers (the `storage` event only fires in other tabs). + window.dispatchEvent(new Event(SWIPE_ACTIONS_EVENT)); +} + +// Cached snapshot so useSyncExternalStore's getSnapshot is a cheap identity +// read on every render — a fresh object every call would loop it, and a +// re-parse every call would tax every row render. Refreshed on the change +// events and when a subscriber attaches (covering writes that happened while +// nothing was mounted); swapped only on a real change so the reference is +// stable between changes. +let snapshot: SwipeActionPreferences = readSwipeActions(); + +function refreshSnapshot(): void { + const next = readSwipeActions(); + if (next.left !== snapshot.left || next.right !== snapshot.right) snapshot = next; +} + +function getSnapshot(): SwipeActionPreferences { + return snapshot; +} + +// One module-level window listener pair fanning out to a shared set (the +// useMediaQuery shape): N mounted rows cost one registration, not 2N. +const listeners = new Set<() => void>(); + +function handleChange(e: Event): void { + // Ignore unrelated `storage` events; refresh on our key or the same-tab ping. + if (e instanceof StorageEvent && e.key !== null && e.key !== STORAGE_KEY) return; + refreshSnapshot(); + for (const listener of listeners) listener(); +} + +function subscribe(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + if (listeners.size === 0) { + window.addEventListener("storage", handleChange); + window.addEventListener(SWIPE_ACTIONS_EVENT, handleChange); + // Catch up on writes made while no subscriber was mounted (React re-checks + // the snapshot right after subscribing, so a change here still renders). + refreshSnapshot(); + } + listeners.add(onChange); + return () => { + listeners.delete(onChange); + if (listeners.size === 0) { + window.removeEventListener("storage", handleChange); + window.removeEventListener(SWIPE_ACTIONS_EVENT, handleChange); + } + }; +} + +/** + * Subscribe to the live swipe-action preference. Returns the current value and + * re-renders on same-tab writes (via writeSwipeActions) and cross-tab `storage` + * events, so Settings and every mounted session row share one source of truth. + * SSR-safe: renders the defaults on the server. + */ +export function useSwipeActions(): SwipeActionPreferences { + return useSyncExternalStore(subscribe, getSnapshot, () => DEFAULT_SWIPE_ACTIONS); +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1289576409..fb02bee2c8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -143,6 +143,15 @@ import { readHideUnconfiguredHarnesses, writeHideUnconfiguredHarnesses, } from "@/lib/harnessVisibilityPreferences"; +import { + DEFAULT_SWIPE_ACTIONS, + isSwipeAction, + type SwipeAction, + swipeActions as swipeActionOptions, + type SwipeDirection, + useSwipeActions, + writeSwipeActions, +} from "@/lib/swipeActionPreferences"; import { applyThemePalette, DEFAULT_PALETTE, @@ -823,6 +832,75 @@ function HideUnconfiguredHarnessesControl() { ); } +const SWIPE_ACTION_LABELS: Record = { + archive: "Archive", + delete: "Delete", + none: "None", +}; + +/** + * Per-device swipe-action preference for session rows on touch devices. Two + * selects — one per direction — each mapping a horizontal swipe to Archive, + * Delete, or None. Both directions may map to the same action. Delete stays + * behind the row's confirm dialog; None makes that direction inert. + */ +function SwipeActionsControl() { + // Live subscription so the selects reflect writes from anywhere (other tabs, + // and the shared source every session row reads). writeSwipeActions notifies. + const actions = useSwipeActions(); + const labelId = useId(); + + const choose = useCallback( + (direction: SwipeDirection, next: SwipeAction) => { + writeSwipeActions({ ...actions, [direction]: next }); + }, + [actions], + ); + + return ( + +
+ {(["left", "right"] as const).map((direction) => ( +
+ + Swipe {direction} + + +
+ ))} +
+
+ ); +} + function AppearanceSection() { // Embedded: the host owns light/dark, so the Mode and Color theme pickers // would be no-ops — hide them and say so (matching ThemeModeMenu). Terminal @@ -848,6 +926,8 @@ function AppearanceSection() { writeHideUnconfiguredHarnesses(DEFAULT_HIDE_UNCONFIGURED_HARNESSES); + writeSwipeActions(DEFAULT_SWIPE_ACTIONS); + applyDesktopUiFontSize(UI_FONT_SIZE_DEFAULT); applyUiFontFamily(UI_FONT_FAMILY_DEFAULT); @@ -870,6 +950,7 @@ function AppearanceSection() { "omnigent:custom-theme", "omnigent:default-workspace-panel", "omnigent:hide-unconfigured-harnesses", + "omnigent:swipe-actions", ]) { window.localStorage.removeItem(key); } @@ -914,6 +995,8 @@ function AppearanceSection() { + + diff --git a/web/src/shell/NewChatDialog.test.tsx b/web/src/shell/NewChatDialog.test.tsx index df3ef7eb60..e2242a852c 100644 --- a/web/src/shell/NewChatDialog.test.tsx +++ b/web/src/shell/NewChatDialog.test.tsx @@ -2132,20 +2132,15 @@ describe("NewChatLandingScreen", () => { // The sandbox option is pinned FIRST in the menu, above the host list — // DOCUMENT_POSITION_FOLLOWING means the host item comes after it. const sandboxOption = screen.getByTestId("new-chat-landing-sandbox-option"); - const hostItem = screen - .getAllByText("This machine") - .find((el) => el.closest('[role="menuitem"]') !== null); - expect(hostItem).toBeTruthy(); + const hostItem = screen.getByTestId("new-chat-landing-host-host_1"); expect( - sandboxOption.compareDocumentPosition(hostItem!) & Node.DOCUMENT_POSITION_FOLLOWING, + sandboxOption.compareDocumentPosition(hostItem) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); // Picking the host restores the workspace flow (file-browser chip, // worktree chip) — the sandbox default doesn't wedge the normal path. - fireEvent.click(hostItem!); + fireEvent.click(hostItem); await waitFor(() => - expect(screen.getByTestId("new-chat-landing-host-chip").textContent).toContain( - "This machine", - ), + expect(screen.getByTestId("new-chat-landing-host-chip")).toHaveTextContent("machine-1"), ); expect(screen.getByTestId("new-chat-landing-workspace-chip")).toBeTruthy(); expect(screen.getByTestId("new-chat-landing-branch-chip")).toBeTruthy(); @@ -3119,15 +3114,9 @@ describe("NewChatLandingScreen custom-agent sandbox gating", () => { expect(screen.getByTestId("new-chat-landing-host-chip").textContent).toContain("Sandbox"), ); fireEvent.pointerDown(screen.getByTestId("new-chat-landing-host-chip"), { button: 0 }); - const hostItem = screen - .getAllByText("This machine") - .find((el) => el.closest('[role="menuitem"]') !== null); - expect(hostItem).toBeTruthy(); - fireEvent.click(hostItem!); + fireEvent.click(screen.getByTestId("new-chat-landing-host-host_1")); await waitFor(() => - expect(screen.getByTestId("new-chat-landing-host-chip").textContent).toContain( - "This machine", - ), + expect(screen.getByTestId("new-chat-landing-host-chip")).toHaveTextContent("machine-1"), ); // With no custom agents yet, the create item is a top-level row (no // "Custom agents" submenu to hide it behind) and opens the dialog. @@ -3146,14 +3135,9 @@ describe("NewChatLandingScreen custom-agent sandbox gating", () => { expect(screen.getByTestId("new-chat-landing-host-chip").textContent).toContain("Sandbox"), ); fireEvent.pointerDown(screen.getByTestId("new-chat-landing-host-chip"), { button: 0 }); - const hostItem = screen - .getAllByText("This machine") - .find((el) => el.closest('[role="menuitem"]') !== null); - fireEvent.click(hostItem!); + fireEvent.click(screen.getByTestId("new-chat-landing-host-host_1")); await waitFor(() => - expect(screen.getByTestId("new-chat-landing-host-chip").textContent).toContain( - "This machine", - ), + expect(screen.getByTestId("new-chat-landing-host-chip")).toHaveTextContent("machine-1"), ); fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 }); fireEvent.click(screen.getByTestId("new-chat-landing-create-agent")); diff --git a/web/src/shell/Sidebar.projectContextMenu.test.tsx b/web/src/shell/Sidebar.projectContextMenu.test.tsx new file mode 100644 index 0000000000..6764ea74c3 --- /dev/null +++ b/web/src/shell/Sidebar.projectContextMenu.test.tsx @@ -0,0 +1,474 @@ +// Tests for the project-folder header's right-click / long-press context menu. +// +// Project folder headers historically had only a hover-revealed kebab: a +// right-click fell through to the browser's native menu, and touch (which has +// no right-click) had no way to reach the actions at all. The header button now +// also carries a Radix `ContextMenuTrigger`, which supplies BOTH gestures — +// `contextmenu` for mouse and a built-in 700ms pointerdown timer for touch — so +// the same actions are reachable either way. The kebab stays. +// +// The menu body is authored once (`ProjectFolderMenuItems`) and rendered under +// both primitive families via the dropdown/context `MenuComponents` bundles, the +// same pattern the session rows use (`ConversationMenuItems`). +// +// What's locked in here: +// 1. Parity — the context menu carries the kebab's exact items, and each one +// opens the same dialog as the kebab's. +// 2. Left-click still expands/collapses the folder. +// 3. Opening the context menu does NOT toggle expansion (the pre-existing +// trap: the header button's onClick flips expand/collapse, and a +// long-press's trailing click would otherwise fire it). +// 4. A nested session row keeps its OWN context menu — the trigger wraps the +// header button, not the section, so right-click isn't hijacked. +// 5. Bulk-selection mode suppresses the header context menu, matching rows. + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +// `isMobile` drives the mocked `useIsMobileViewport` (jsdom doesn't evaluate +// media queries), so the mobile-only "New session" item can be exercised. +const mocks = vi.hoisted(() => ({ + isMobile: false, + renameProject: { mutate: vi.fn() }, + deleteProject: { mutate: vi.fn() }, +})); + +vi.mock("@/hooks/useIsMobileViewport", () => ({ + useIsMobileViewport: () => mocks.isMobile, +})); + +vi.mock("@/hooks/useConversations", () => ({ + useConversations: vi.fn(), + useConnectedConversations: () => [], + useStopAndDeleteConversation: () => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + variables: undefined, + }), + usePinnedConversations: () => ({ + data: { conversations: [], filterHonored: true }, + isSuccess: true, + }), + useTogglePinnedConversation: () => ({ mutate: vi.fn() }), + setConversationPinned: vi.fn(() => Promise.resolve({})), + PINNED_CONVERSATIONS_KEY: ["pinned-conversations"], + useRenameConversation: () => ({ mutate: vi.fn() }), + useArchiveConversation: () => ({ mutate: vi.fn() }), + useBulkArchiveConversations: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useBulkDeleteConversations: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useBulkStopSessions: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useStopSession: () => ({ mutate: vi.fn() }), + useProjects: () => ({ data: [{ id: PROJECT_ID, name: PROJECT_NAME }] }), + // The folder sources its members from the globally-loaded window too, so the + // nested-row test doesn't need this query to return anything. + useProjectSessions: () => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + }), + useMoveToProject: () => ({ mutate: vi.fn() }), + useDeleteProject: () => ({ ...mocks.deleteProject, isPending: false, isError: false }), + useRenameProject: () => ({ ...mocks.renameProject, isPending: false, isError: false }), + useCreateProject: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useProjectConfig: () => ({ data: undefined, isLoading: false }), + useUpdateProjectConfig: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + fetchProjectSessionIds: () => Promise.resolve([]), + PROJECT_LABEL_KEY: "omni_project", +})); + +vi.mock("./AgentTypeFilter", () => ({ AgentTypeFilter: () => null })); +vi.mock("./ReportIssueButton", () => ({ ReportIssueButton: () => null })); +vi.mock("@/components/PermissionsModal", () => ({ PermissionsModal: () => null })); + +import { type Conversation, useConversations } from "@/hooks/useConversations"; +import { Sidebar } from "./Sidebar"; + +const PROJECT_NAME = "Sprint 42"; +const PROJECT_ID = "p_sprint42"; + +const useConvMock = vi.mocked(useConversations); + +/** A session filed under PROJECT_NAME, so the folder holds a nested row. */ +const FILED_CONV: Conversation = { + id: "conv_1", + object: "conversation", + title: "My Session", + created_at: 1_700_000_000, + updated_at: 1_700_000_000, + labels: {}, + project_id: PROJECT_ID, + permission_level: null, + status: "idle", +}; + +function mockConversations(conversations: Conversation[]) { + const result = { + data: { + pages: [ + { + data: conversations, + first_id: conversations[0]?.id ?? null, + last_id: conversations.at(-1)?.id ?? null, + has_more: false, + }, + ], + pageParams: [undefined], + }, + isLoading: false, + isError: false, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + } as unknown as ReturnType; + useConvMock.mockImplementation(() => result); +} + +function renderSidebar() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + , + ); +} + +/** The project folder's collapse-toggle header button. An open Radix menu is + * modal and `aria-hidden`s the rest of the tree, so role queries can't see the + * header while the menu is up — match on the accessible name via the DOM. */ +function folderHeader(): HTMLElement { + const header = Array.from(document.querySelectorAll("h2 button")).find( + (b) => b.textContent === PROJECT_NAME, + ); + if (header === undefined) throw new Error(`no folder header for ${PROJECT_NAME}`); + return header as HTMLElement; +} + +/** With `asChild`, Radix's ContextMenuTrigger renders no wrapper — its props + * (data-slot, the long-press pointer handlers) merge onto the header button + * itself, so `closest` matches the button and this equals folderHeader(). */ +function contextTrigger(): HTMLElement { + const trigger = folderHeader().closest('[data-slot="context-menu-trigger"]'); + if (trigger === null) throw new Error("folder header has no context-menu trigger"); + return trigger as HTMLElement; +} + +/** Simulate a touch long-press on the folder header: Radix arms a 700ms timer + * on a non-mouse pointerdown, so advance fake timers past it. */ +function longPressHeader() { + fireEvent.pointerDown(contextTrigger(), { pointerType: "touch", button: 0 }); + // The open happens inside the timer callback, so flush it under act(). + act(() => { + vi.advanceTimersByTime(750); + }); +} + +/** Dismiss the open menu with Escape, aimed at whatever the menu focused. */ +function dismissMenu() { + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); +} + +beforeEach(() => { + mocks.isMobile = false; + mocks.renameProject.mutate.mockReset(); + mocks.deleteProject.mutate.mockReset(); + useConvMock.mockReset(); + localStorage.clear(); + mockConversations([FILED_CONV]); +}); + +afterEach(cleanup); + +describe("project folder header context menu", () => { + it("opens the kebab's exact action set on right-click", () => { + renderSidebar(); + + // Nothing rendered until the header is right-clicked (the kebab is closed). + expect(screen.queryByTestId("rename-project")).toBeNull(); + + fireEvent.contextMenu(folderHeader()); + + // Same testids as the kebab body — it renders from the shared + // ProjectFolderMenuItems, so the two menus can't diverge. + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + expect(screen.getByTestId("project-settings")).toBeInTheDocument(); + expect(screen.getByTestId("delete-project")).toBeInTheDocument(); + // "New session" is present but mobile-only (md:hidden), matching the kebab. + expect(screen.getByTestId("project-new-session-menu")).toHaveClass("md:hidden"); + }); + + it("carries exactly the same items as the kebab", () => { + // Parity asserted item-for-item rather than by a hand-written list, so a + // future kebab item that skips the shared body fails here. + renderSidebar(); + + fireEvent.pointerDown(screen.getByTestId("project-actions"), { button: 0 }); + const kebabItems = screen + .getAllByRole("menuitem") + .map((el) => el.getAttribute("data-testid")) + .filter((id): id is string => id !== null); + expect(kebabItems.length).toBeGreaterThan(0); + + cleanup(); + renderSidebar(); + + fireEvent.contextMenu(folderHeader()); + const contextItems = screen + .getAllByRole("menuitem") + .map((el) => el.getAttribute("data-testid")) + .filter((id): id is string => id !== null); + + expect(contextItems).toEqual(kebabItems); + }); + + it("keeps the kebab working unchanged", () => { + renderSidebar(); + + fireEvent.pointerDown(screen.getByTestId("project-actions"), { button: 0 }); + + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("rename-project")); + expect(screen.getByTestId("rename-project-confirm")).toBeInTheDocument(); + }); + + it("drives Rename from the context menu into the same dialog and mutation", () => { + renderSidebar(); + + fireEvent.contextMenu(folderHeader()); + fireEvent.click(screen.getByTestId("rename-project")); + + // The shared rename dialog — same testid the kebab path opens. + const confirm = screen.getByTestId("rename-project-confirm"); + expect(confirm).toBeInTheDocument(); + + const input = screen.getByDisplayValue(PROJECT_NAME); + fireEvent.change(input, { target: { value: "Sprint 43" } }); + fireEvent.click(confirm); + + expect(mocks.renameProject.mutate).toHaveBeenCalledWith( + { id: PROJECT_ID, oldName: PROJECT_NAME, newName: "Sprint 43" }, + expect.anything(), + ); + }); + + it("drives Project settings from the context menu", () => { + renderSidebar(); + + fireEvent.contextMenu(folderHeader()); + fireEvent.click(screen.getByTestId("project-settings")); + + expect(screen.getByRole("dialog")).toHaveTextContent(/Project settings/i); + }); + + it("drives Delete from the context menu into the same confirm + mutation", () => { + renderSidebar(); + + fireEvent.contextMenu(folderHeader()); + fireEvent.click(screen.getByTestId("delete-project")); + + // The confirm dialog (delete archives every member, so it's gated). + expect(screen.getByText("Delete project?")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Delete project" })); + + expect(mocks.deleteProject.mutate).toHaveBeenCalledWith( + { id: PROJECT_ID, name: PROJECT_NAME }, + expect.anything(), + ); + }); + + it("exposes the mobile-only New session item pre-filed under the project", () => { + mocks.isMobile = true; + renderSidebar(); + + fireEvent.contextMenu(folderHeader()); + + // asChild renders the item as the itself, so the href lives on it. + expect(screen.getByTestId("project-new-session-menu")).toHaveAttribute( + "href", + `/?project=${encodeURIComponent(PROJECT_NAME)}`, + ); + }); + + it("still expands and collapses the folder on plain left-click", () => { + renderSidebar(); + + // Project folders render collapsed by default. + const header = folderHeader(); + expect(header).toHaveAttribute("aria-expanded", "false"); + + fireEvent.click(header); + expect(folderHeader()).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(folderHeader()); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles on the first left-click after the mouse context menu is dismissed", () => { + renderSidebar(); + + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + + fireEvent.pointerDown(folderHeader(), { pointerType: "mouse", button: 2 }); + fireEvent.contextMenu(folderHeader()); + + // The menu is open and the folder stayed collapsed — Radix preventDefaults + // the native contextmenu, so no click reaches the collapse toggle. + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + + dismissMenu(); + fireEvent.click(folderHeader()); + expect(folderHeader()).toHaveAttribute("aria-expanded", "true"); + }); + + it("toggles on the first keyboard activation after its context menu is dismissed", async () => { + renderSidebar(); + + const header = folderHeader(); + header.focus(); + + // jsdom does not synthesize contextmenu or click from keyboard keys, so + // dispatch the browser-generated events explicitly without pointer events. + fireEvent.keyDown(header, { key: "F10", shiftKey: true }); + fireEvent.contextMenu(header); + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + + dismissMenu(); + await waitFor(() => expect(header).toHaveFocus()); + + fireEvent.click(header, { detail: 0 }); + expect(folderHeader()).toHaveAttribute("aria-expanded", "true"); + }); + + it("suppresses native text selection of the header title", () => { + // A long-press must open the menu, not select the project name. Session + // rows suppress selection by preventDefault-ing touch pointerdown, but on + // this trigger that would also cancel Radix's composed long-press timer — + // so the header relies on user-select: none instead. jsdom cannot run a + // real selection, so pin the class that carries the behavior. + renderSidebar(); + expect(folderHeader()).toHaveClass("select-none"); + }); + + it("does not toggle the folder when a long-press opens the menu", () => { + // The gotcha this PR had to solve: the long-press fires mid-gesture off + // Radix's pointerdown timer, but the trailing pointerup still produces a + // click — which would collapse/expand the folder under the just-opened menu. + vi.useFakeTimers(); + try { + renderSidebar(); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + + longPressHeader(); + + // The menu opened from touch alone — no custom gesture code, just Radix's + // 700ms timer on the same trigger. + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + + // The trailing click of the press must NOT toggle the folder. + fireEvent.pointerUp(contextTrigger(), { pointerType: "touch", button: 0 }); + fireEvent.click(folderHeader()); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + } finally { + vi.useRealTimers(); + } + }); + + it("still toggles on a plain click after a long-press opened the menu", () => { + // The click-swallow must be one-shot: a later ordinary click still expands. + vi.useFakeTimers(); + try { + renderSidebar(); + + longPressHeader(); + fireEvent.pointerUp(contextTrigger(), { pointerType: "touch", button: 0 }); + fireEvent.click(folderHeader()); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + + // A fresh, separate click (its own pointerdown) toggles as normal. + fireEvent.pointerDown(folderHeader(), { pointerType: "mouse", button: 0 }); + fireEvent.click(folderHeader()); + expect(folderHeader()).toHaveAttribute("aria-expanded", "true"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not swallow a keyboard toggle after a clickless non-mouse open", () => { + // A pen barrel-button right-click (or a touch release over the portaled + // menu) arms the swallow but never delivers a trailing click. Closing the + // menu must disarm it, or the next keyboard Enter is silently eaten. + renderSidebar(); + expect(folderHeader()).toHaveAttribute("aria-expanded", "false"); + + fireEvent.pointerDown(contextTrigger(), { pointerType: "pen", button: 2 }); + fireEvent.contextMenu(folderHeader()); + expect(screen.getByTestId("rename-project")).toBeInTheDocument(); + + dismissMenu(); + + // Keyboard activation (click with no preceding pointerdown) still toggles. + fireEvent.click(folderHeader(), { detail: 0 }); + expect(folderHeader()).toHaveAttribute("aria-expanded", "true"); + }); + + it("scopes the header trigger to the header button, clear of the nested rows", () => { + // Structural guard on placement. The trigger must wrap the header BUTTON, + // never an ancestor that also contains the child rows (the outer folder + // div, or the
) — those would put every nested session row inside + // the project's trigger and hijack right-click on them. + renderSidebar(); + + // Expand the folder so its member row renders inside the section. + fireEvent.click(folderHeader()); + const row = screen.getByRole("link", { name: /My Session/ }); + + const trigger = contextTrigger(); + expect(trigger.contains(folderHeader())).toBe(true); + expect(trigger.contains(row)).toBe(false); + }); + + it("leaves a nested session row's own context menu intact", () => { + renderSidebar(); + + fireEvent.click(folderHeader()); + const row = screen.getByRole("link", { name: /My Session/ }); + + fireEvent.contextMenu(row); + + // The SESSION's menu opened (row actions), not the project's. + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + expect(screen.getByTestId("archive-conversation")).toBeInTheDocument(); + expect(screen.queryByTestId("rename-project")).toBeNull(); + expect(screen.queryByTestId("delete-project")).toBeNull(); + }); + + it("suppresses the header context menu in bulk-selection mode", () => { + // Selection mode owns the rows, and the session rows already drop their + // context menus there — the folder header matches that. + renderSidebar(); + + fireEvent.click(folderHeader()); + fireEvent.pointerDown(screen.getByTestId("project-list-actions"), { button: 0 }); + fireEvent.click(screen.getByTestId("projects-select-sessions")); + + // In selection mode the header no longer carries a context-menu trigger, + // and right-clicking it opens nothing. + expect(folderHeader().closest('[data-slot="context-menu-trigger"]')).toBeNull(); + fireEvent.contextMenu(folderHeader()); + expect(screen.queryByTestId("rename-project")).toBeNull(); + }); +}); diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index 850572a9b6..1f49bf97cb 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -6,9 +6,10 @@ // `onDoubleClick`), gated on edit permission. // See ConversationRow / ConversationEditRow in Sidebar.tsx. -import { useSyncExternalStore } from "react"; +import { Suspense, startTransition, useSyncExternalStore } from "react"; +import type * as DndKitCore from "@dnd-kit/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -16,10 +17,9 @@ import type { ServerInfo } from "@/lib/capabilities"; import { CapabilitiesProvider } from "@/lib/CapabilitiesContext"; // Controllable rename mutation so the double-click test can assert the -// committed title was forwarded to the PATCH. `isMobile` toggles the mocked -// `useIsMobileViewport` so a test can render the row on a mobile viewport (the -// project flyout is disabled there). Declared via vi.hoisted so the vi.mock -// factories (hoisted above imports) can reference them. +// committed title was forwarded to the PATCH. The media-query hooks use +// separate flags so viewport layout and touch capability can vary independently. +// Declared via vi.hoisted so their mock factories can reference them. const mocks = vi.hoisted(() => { // Tiny reactive store for the server-authoritative pinned set, so a quick-pin // click re-renders the sidebar (mirrors the real query's refetch). Holds ids; @@ -48,12 +48,39 @@ const mocks = vi.hoisted(() => { // it keeps showing the new title until the PATCH settles. rename: { mutate: vi.fn(), isSuccess: false, isError: false }, isMobile: false, + hasCoarsePointer: false, // Projects surfaced by the picker + the move-to-project mutation, so the // mobile in-place project view test can assert both the list and the pick. projects: [] as string[], moveToProject: { mutate: vi.fn() }, conversations: [] as unknown[], + isDragging: null as boolean | null, + suspendDraggable: false, + suspendedRender: new Promise(() => {}), pinnedStore, + // Archive + stop mutations, so the swipe tests can assert the swipe→archive + // path drives the same stop→archive handler the kebab uses. + archive: { mutate: vi.fn() }, + archiveOverride: null as null | { mutate: ReturnType }, + stopSession: { mutate: vi.fn() }, + // Stop-and-delete, so the swipe→delete test can assert the row deletes only + // after the confirm dialog is accepted. + del: { mutate: vi.fn(), reset: vi.fn() }, + }; +}); + +vi.mock("@dnd-kit/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDraggable: (args: Parameters[0]) => { + const draggable = actual.useDraggable(args); + if (mocks.suspendDraggable) throw mocks.suspendedRender; + return { + ...draggable, + isDragging: mocks.isDragging ?? draggable.isDragging, + }; + }, }; }); @@ -64,12 +91,16 @@ vi.mock("@/hooks/useIsMobileViewport", () => ({ useIsMobileViewport: () => mocks.isMobile, })); +vi.mock("@/hooks/useCoarsePointer", () => ({ + useCoarsePointer: () => mocks.hasCoarsePointer, +})); + vi.mock("@/hooks/useConversations", () => ({ useConversations: vi.fn(), useConnectedConversations: () => [], useStopAndDeleteConversation: () => ({ - mutate: vi.fn(), - reset: vi.fn(), + mutate: mocks.del.mutate, + reset: mocks.del.reset, isPending: false, isError: false, variables: undefined, @@ -94,11 +125,11 @@ vi.mock("@/hooks/useConversations", () => ({ setConversationPinned: vi.fn(() => Promise.resolve({})), PINNED_CONVERSATIONS_KEY: ["pinned-conversations"], useRenameConversation: () => mocks.rename, - useArchiveConversation: () => ({ mutate: vi.fn() }), + useArchiveConversation: () => mocks.archiveOverride ?? mocks.archive, useBulkArchiveConversations: () => ({ mutate: vi.fn(), isPending: false, isError: false }), useBulkDeleteConversations: () => ({ mutate: vi.fn(), isPending: false, isError: false }), useBulkStopSessions: () => ({ mutate: vi.fn(), isPending: false, isError: false }), - useStopSession: () => ({ mutate: vi.fn() }), + useStopSession: () => mocks.stopSession, useProjects: () => ({ data: mocks.projects.map((name: string) => ({ id: `p_${name}`, name })) }), // A non-empty `useProjects` renders a project folder, which queries its // sessions — return the collapsed (disabled) shape so the folder is inert @@ -134,6 +165,8 @@ vi.mock("@/lib/serverOrigin", () => ({ isCurrentServerLocal: () => false })); import { type Conversation, useConversations } from "@/hooks/useConversations"; import { resetReadStateForTests, seedReadState } from "@/hooks/useUnseenConversations"; +import { ROW_DRAG_ACTIVATE_PX, ROW_GESTURE_HOLD_MS } from "@/hooks/useRowGesture"; +import { writeSwipeActions } from "@/lib/swipeActionPreferences"; import { Sidebar } from "./Sidebar"; const useConvMock = vi.mocked(useConversations); @@ -203,11 +236,16 @@ function serverInfo(overrides: Partial = {}): ServerInfo { // server sharing policy via CapabilitiesProvider (default "loading" → on). function renderSidebar(activeId?: string, info?: ServerInfo) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onClose = vi.fn(); // Build a FRESH element tree per render: re-rendering the identical element // reference lets React bail out without re-invoking the sidebar, which // would swallow a `mockConversations` swap applied mid-test. const makeUi = () => { - const sidebar = ; + const sidebar = ( + }> + + + ); const tree = ( @@ -230,7 +268,10 @@ function renderSidebar(activeId?: string, info?: ServerInfo) { const view = render(makeUi()); // Re-render so a test can apply a new `mockConversations` list mid-flight // (e.g. simulating a reorder pushed between user clicks). - return Object.assign(view, { rerenderSidebar: () => view.rerender(makeUi()) }); + return Object.assign(view, { + onClose, + rerenderSidebar: () => view.rerender(makeUi()), + }); } beforeEach(() => { @@ -238,9 +279,31 @@ beforeEach(() => { mocks.rename.isSuccess = false; mocks.rename.isError = false; mocks.moveToProject.mutate.mockReset(); + // Resolve archive callbacks synchronously so the transient "Archiving…" + // status row settles back to the interactive row within the test. + mocks.archive.mutate.mockReset(); + mocks.archive.mutate.mockImplementation( + (_args: unknown, opts?: { onSuccess?: () => void; onSettled?: () => void }) => { + opts?.onSuccess?.(); + opts?.onSettled?.(); + }, + ); + // The stop that precedes archiving (runArchive) fires its `onSettled` once + // the runner stop resolves; invoke it synchronously so the follow-on + // archive.mutate runs in tests. + mocks.stopSession.mutate.mockReset(); + mocks.stopSession.mutate.mockImplementation((_id: string, opts?: { onSettled?: () => void }) => { + opts?.onSettled?.(); + }); + mocks.del.mutate.mockReset(); + mocks.del.reset.mockReset(); mocks.projects = []; + mocks.isDragging = null; + mocks.suspendDraggable = false; + mocks.archiveOverride = null; // Default every test to the desktop viewport; the mobile flyout test opts in. mocks.isMobile = false; + mocks.hasCoarsePointer = false; useConvMock.mockReset(); localStorage.clear(); // Reset the server pinned set between tests. @@ -251,7 +314,12 @@ beforeEach(() => { mockConversations([CONV]); }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + // dnd-kit removes its capture-phase click blocker on a delayed timer. + if (vi.isFakeTimers()) vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); describe("quick pin/unpin hover button", () => { it("keeps the row full-width and the trailing controls inset from the right edge", () => { @@ -291,6 +359,22 @@ describe("quick pin/unpin hover button", () => { expect(rowLink).toHaveClass("md:pr-14"); }); + it("keeps the menu Pin item at desktop widths when a coarse pointer exists", () => { + // Desktop hides the menu Pin item behind the hover-revealed quick-pin + // button, but a touch device has no hover at any width — the menu must + // keep carrying Pin there or wide touch devices cannot pin at all. + mocks.hasCoarsePointer = true; + renderSidebar(); + fireEvent.pointerDown(screen.getByTestId("conversation-actions"), { button: 0 }); + expect(screen.getByTestId("pin-conversation")).not.toHaveClass("md:hidden"); + }); + + it("hides the menu Pin item at desktop widths without a coarse pointer", () => { + renderSidebar(); + fireEvent.pointerDown(screen.getByTestId("conversation-actions"), { button: 0 }); + expect(screen.getByTestId("pin-conversation")).toHaveClass("md:hidden"); + }); + it("sizes the project-folder header controls to match the session-row kebab", () => { // The folder-header pencil + kebab share the right-edge column with the // session-row kebab, so they must be the same compact `icon-xs` (size-6) @@ -299,14 +383,10 @@ describe("quick pin/unpin hover button", () => { mocks.projects = ["Sprint 42"]; renderSidebar(); - const projectActions = screen.getByTestId("project-actions"); - const projectNewSession = screen.getByTestId("project-new-session"); - for (const button of [projectActions, projectNewSession]) { - expect(button).toHaveClass("size-6", "text-muted-foreground", "hover:text-foreground"); - expect(button).not.toHaveClass("size-7"); - expect(button.querySelector("svg")).toHaveClass("size-3.5"); - expect(button.querySelector("svg")).toHaveAttribute("data-icon-size", "14"); - } + expect(screen.getByTestId("project-actions")).toHaveClass("size-6"); + expect(screen.getByTestId("project-actions")).not.toHaveClass("size-7"); + expect(screen.getByTestId("project-new-session")).toHaveClass("size-6"); + expect(screen.getByTestId("project-new-session")).not.toHaveClass("size-7"); // Same compact size as the session-row kebab it aligns with. expect(screen.getByTestId("conversation-actions")).toHaveClass("size-6"); }); @@ -319,13 +399,10 @@ describe("quick pin/unpin hover button", () => { mocks.projects = ["Sprint 42"]; renderSidebar(); - for (const button of [ - screen.getByTestId("new-project"), - screen.getByTestId("project-list-actions"), - ]) { - expect(button).toHaveClass("size-6", "text-muted-foreground", "hover:text-foreground"); - expect(button).not.toHaveClass("size-7"); - } + expect(screen.getByTestId("new-project")).toHaveClass("size-6"); + expect(screen.getByTestId("new-project")).not.toHaveClass("size-7"); + expect(screen.getByTestId("project-list-actions")).toHaveClass("size-6"); + expect(screen.getByTestId("project-list-actions")).not.toHaveClass("size-7"); }); it("hides the Projects list-actions kebab when there are no projects", () => { @@ -346,13 +423,6 @@ describe("quick pin/unpin hover button", () => { expect(screen.queryByText("Pinned")).toBeNull(); const pinButton = screen.getByTestId("quick-pin-conversation"); expect(pinButton).toHaveAttribute("aria-label", "Pin conversation"); - expect(pinButton).toHaveClass("text-muted-foreground", "hover:text-foreground"); - expect(pinButton.querySelector("svg")).toHaveClass("size-3.5"); - expect(pinButton.querySelector("svg")).toHaveAttribute("data-icon-size", "14"); - const actionsButton = screen.getByTestId("conversation-actions"); - expect(actionsButton).toHaveClass("text-muted-foreground", "hover:text-foreground"); - expect(actionsButton.querySelector("svg")).toHaveClass("size-3.5"); - expect(actionsButton.querySelector("svg")).toHaveAttribute("data-icon-size", "14"); fireEvent.click(pinButton); @@ -629,10 +699,11 @@ describe("pinned row project flyout", () => { expect(within(flyout).getByText("Moonshot")).toBeInTheDocument(); const flyoutTitle = within(flyout).getByText("My Session"); expect(flyoutTitle).toBeInTheDocument(); - // The flyout title matches sidebar row names through the shared compact - // class, which resolves to the same text-ui step as Appearance content. + // The flyout title is sized to match the sidebar row name + // (`sidebar-compact-text`, 13px at the default), not the larger `text-sm`. + // Both scale with the UI font-size setting via the rem-based root. expect(flyoutTitle).toHaveClass("sidebar-compact-text"); - expect(flyoutTitle).not.toHaveClass("text-ui"); + expect(flyoutTitle).not.toHaveClass("text-sm"); expect(within(flyout).getByTestId("pinned-project-flyout-branch")).toHaveTextContent( "fix/sidebar-row-height", ); @@ -805,6 +876,35 @@ describe("mark as unread", () => { }); describe("right-click context menu", () => { + it("does not open while the row is being dragged", () => { + mocks.isDragging = true; + renderSidebar(); + + fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ })); + + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("leaves keyboard link activation untouched after a drag ends", async () => { + mocks.isMobile = true; + mocks.isDragging = true; + const view = renderSidebar(); + + mocks.isDragging = false; + view.rerenderSidebar(); + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }), + ); + + // Enter/Space activation reaches a link as a click with no pointerdown. + // The drag guard must not require or consume a preceding pointer gesture. + fireEvent.click(screen.getByRole("link", { name: /My Session/ }), { detail: 0 }); + expect(view.onClose).toHaveBeenCalledOnce(); + }); + it("opens the same action items as the kebab and drives the same handlers", () => { renderSidebar(); @@ -949,6 +1049,1167 @@ describe("right-click context menu", () => { }); }); +describe("row context menus render non-modally", () => { + // react-remove-scroll's modal lock hit-tests through the sidebar overlay to + // the chat behind it (pointer-events: none on body only blocks the sidebar's + // own subtree from taking the hit, not the underlying page). modal={false} + // must be set on all three row variants so a touch continuing past the menu + // never reaches native text selection in the chat. + it("does not lock body pointer-events for the desktop context menu", () => { + renderSidebar(); + fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ })); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + expect(document.body).not.toHaveAttribute("data-scroll-locked"); + expect(document.body.style.pointerEvents).not.toBe("none"); + }); + + it("does not lock body pointer-events for the mobile context menu", () => { + mocks.isMobile = true; + renderSidebar(); + fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ })); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + expect(document.body).not.toHaveAttribute("data-scroll-locked"); + expect(document.body.style.pointerEvents).not.toBe("none"); + }); + + it("does not lock body pointer-events for a pinned row's project-flyout context menu", () => { + mocks.pinnedStore.set(["conv_1"]); + mockConversations([{ ...CONV, labels: { omni_project: "Moonshot" } }]); + renderSidebar(); + fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ })); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + expect(document.body).not.toHaveAttribute("data-scroll-locked"); + expect(document.body.style.pointerEvents).not.toBe("none"); + }); + + it("marks the menu content select-none with a scrollable, page-safe touch-action", () => { + renderSidebar(); + fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ })); + const menu = screen.getByRole("menu"); + expect(menu).toHaveClass("select-none"); + expect(menu).toHaveClass("touch-pan-y"); + expect(menu).not.toHaveClass("touch-none"); + }); +}); + +const TOUCH_POINTER = { pointerId: 1, isPrimary: true, pointerType: "touch" as const }; +// A second finger: non-primary, so the recognizer cancels the gesture in flight. +const SECOND_TOUCH_POINTER = { pointerId: 2, isPrimary: false, pointerType: "touch" as const }; + +function touchTarget() { + // ContextMenu marks the app tree aria-hidden while portalled; pointer capture + // still routes the held touch to this same row underneath it. + const link = screen.getByRole("link", { name: /My Session/, hidden: true }); + return { link, row: link.closest("li")! }; +} + +function touchPoint(target: Element, x: number, y: number, identifier = 0) { + return { + identifier, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + screenX: x, + screenY: y, + }; +} + +function startTouch(x = 100, y = 100) { + const { link } = touchTarget(); + const touch = touchPoint(link, x, y); + fireEvent.pointerDown(link, { ...TOUCH_POINTER, clientX: x, clientY: y }); + fireEvent.touchStart(link, { touches: [touch], changedTouches: [touch] }); +} + +function moveTouch(x: number, y: number, touches = 1) { + const { link } = touchTarget(); + const points = Array.from({ length: touches }, (_, index) => + touchPoint(link, x + index, y + index, index), + ); + const allowsNativeScroll = fireEvent.pointerMove(link, { + ...TOUCH_POINTER, + clientX: x, + clientY: y, + }); + fireEvent.touchMove(link, { touches: points, changedTouches: points }); + return allowsNativeScroll; +} + +function endTouch(x = 100, y = 100) { + const { link } = touchTarget(); + const touch = touchPoint(link, x, y); + fireEvent.pointerUp(link, { ...TOUCH_POINTER, clientX: x, clientY: y }); + fireEvent.touchEnd(link, { touches: [], changedTouches: [touch] }); +} + +describe("touch gesture arbitration", () => { + beforeEach(() => { + mocks.hasCoarsePointer = true; + }); + + function advanceHold(ms = ROW_GESTURE_HOLD_MS) { + act(() => vi.advanceTimersByTime(ms)); + } + + it("opens once at hold fire without Radix adding a second menu", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link, row } = touchTarget(); + const contextMenus: MouseEvent[] = []; + link.addEventListener("contextmenu", (event) => contextMenus.push(event)); + + // The recognizer opens first; holding past Radix's 700ms timer must not + // add a second menu or dispatch another contextmenu event. + startTouch(140, 220); + advanceHold(); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(contextMenus).toHaveLength(1); + expect(row).toHaveClass("scale-[1.01]"); + + advanceHold(500); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(contextMenus).toHaveLength(1); + endTouch(140, 220); + expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1); + expect(contextMenus).toHaveLength(1); + }); + + it("leaves a pen long-press to Radix, which the recognizer never claims", () => { + // The recognizer is touch-only, so suppressing Radix for pen would strip + // stylus users of the long-press menu they had before. + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link } = touchTarget(); + const pen = { pointerId: 1, isPrimary: true, pointerType: "pen" as const }; + + fireEvent.pointerDown(link, { ...pen, clientX: 140, clientY: 220 }); + act(() => vi.advanceTimersByTime(900)); + + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + }); + + it("opens at the current finger point and stays open through release and trailing click", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + const view = renderSidebar(); + const { link, row } = touchTarget(); + const contextMenus: MouseEvent[] = []; + link.addEventListener("contextmenu", (event) => contextMenus.push(event)); + + startTouch(140, 220); + moveTouch(145, 224); + advanceHold(); + expect(row).toHaveClass("scale-[1.01]"); + expect(row).not.toHaveClass("opacity-40"); + expect(screen.getByTestId("archive-conversation")).toBeInTheDocument(); + expect(contextMenus.at(-1)).toMatchObject({ clientX: 145, clientY: 224 }); + + endTouch(145, 224); + fireEvent.click(link); + expect(screen.getByTestId("archive-conversation")).toBeInTheDocument(); + expect(row).not.toHaveClass("opacity-40"); + expect(contextMenus).toHaveLength(1); + expect(view.onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("archive-conversation")); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("suppresses the OS's native contextmenu while the recognizer owns the touch", () => { + // Android fires its own contextmenu at ~500ms — after the 400ms arm + // already opened the menu. Capture retargets it to the row root, + // bypassing Radix's trigger; unprevented it starts text selection and + // cancels the pointer stream, killing the pending drag. The recognizer's + // own dispatch carries a tag; the OS's does not. + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(); + // The tagged synthetic dispatch got through: the menu is open. + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + + const nativeLongPress = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + row.dispatchEvent(nativeLongPress); + expect(nativeLongPress.defaultPrevented).toBe(true); + + // The OS hit-tests the element under the finger — once the menu opens + // there, that's the menu portal, far from the row. The document-level + // guard must still catch it. + const offRowLongPress = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + document.body.dispatchEvent(offRowLongPress); + expect(offRowLongPress.defaultPrevented).toBe(true); + + const selection = new Event("selectstart", { bubbles: true, cancelable: true }); + document.body.dispatchEvent(selection); + expect(selection.defaultPrevented).toBe(true); + endTouch(); + + // Released: the guards are gone. + const afterRelease = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + document.body.dispatchEvent(afterRelease); + expect(afterRelease.defaultPrevented).toBe(false); + }); + + it("renders the row link non-draggable so a stationary long-press survives", () => { + // Links are natively draggable; Chrome Android hands a stationary + // long-press on one to native drag with an unconditional pointercancel — + // fired before the contextmenu event, so no preventDefault can save the + // pointer stream. draggable=false removes that claimant entirely. + renderSidebar(); + const rowLink = screen.getByRole("link", { name: /My Session/ }); + expect(rowLink).toHaveAttribute("draggable", "false"); + }); + + it("leaves the contextmenu alone when no gesture owns the touch", () => { + // Desktop right-click arrives with the recognizer idle — it must reach + // Radix untouched or the mouse context menu dies. + renderSidebar(); + const { row } = touchTarget(); + + const rightClick = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + row.dispatchEvent(rightClick); + expect(rightClick.defaultPrevented).toBe(false); + }); + + it("keeps a threshold-minus-one wiggle armed with the menu open", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(); + expect(row).not.toHaveClass("opacity-40"); + expect(row).toHaveClass("touch-none"); + expect(row).not.toHaveClass("touch-pan-y"); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + + moveTouch(104, 104); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX - 1, 100); + expect(row).not.toHaveClass("opacity-40"); + expect(row).toHaveClass("touch-none"); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + endTouch(100 + ROW_DRAG_ACTIVATE_PX - 1, 100); + }); + + it("measures the drag threshold from the arm point, not the press origin", () => { + // A press may legally drift up to the hold tolerance before the timer + // fires; that drift must not pre-spend the drag budget, or the first + // post-arm tremble would dismiss the fresh menu into a drag. + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + // Vertical-dominant 15px drift: inside the 20px hold tolerance, outside + // the swipe wedge, under the 25px scroll circle. + moveTouch(100, 115); + advanceHold(); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + + // ~3px wiggle from the ARM point (but ~17px from the press origin). + moveTouch(102, 117); + expect(row).not.toHaveClass("opacity-40"); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + + // A deliberate pull measured from the arm point still drags. + moveTouch(100, 115 + ROW_DRAG_ACTIVATE_PX); + expect(row).toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + endTouch(100, 115 + ROW_DRAG_ACTIVATE_PX); + }); + + it("starts drag at the threshold without a menu-dismissal keydown", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const escapeKeydowns = vi.fn((event: KeyboardEvent) => event.key); + document.addEventListener("keydown", escapeKeydowns, { capture: true }); + + startTouch(); + advanceHold(); + expect(screen.getByTestId("rename-conversation")).toBeInTheDocument(); + + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + expect(row).toHaveClass("opacity-40"); + expect(row).toHaveClass("touch-none"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + + moveTouch(100 + ROW_DRAG_ACTIVATE_PX + 10, 100); + expect(row).toHaveClass("opacity-40"); + document.removeEventListener("keydown", escapeKeydowns, { capture: true }); + expect(escapeKeydowns).not.toHaveBeenCalled(); + endTouch(100 + ROW_DRAG_ACTIVATE_PX + 10, 100); + }); + + it("does not restart a drag after viewport resize cancels the sensor", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + expect(row).toHaveClass("opacity-40"); + + act(() => window.dispatchEvent(new Event("resize"))); + expect(row).not.toHaveClass("opacity-40"); + + moveTouch(100 + ROW_DRAG_ACTIVATE_PX + 1, 100); + expect(row).not.toHaveClass("opacity-40"); + endTouch(100 + ROW_DRAG_ACTIVATE_PX + 1, 100); + }); + + it("resets the recognizer when dnd-kit ends the drag", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link, row } = touchTarget(); + + startTouch(); + advanceHold(); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + expect(row).toHaveClass("opacity-40"); + + const touch = touchPoint(link, 100 + ROW_DRAG_ACTIVATE_PX, 100); + fireEvent.touchEnd(link, { touches: [], changedTouches: [touch] }); + + expect(row).not.toHaveClass("opacity-40"); + expect(row).not.toHaveClass("touch-none"); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX + 1, 100); + expect(row).not.toHaveClass("opacity-40"); + }); + + it("arms after ordinary sixteen-pixel hold drift", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + moveTouch(100, 116); + advanceHold(); + + expect(row).toHaveClass("scale-[1.01]"); + endTouch(100, 116); + }); + + it("arms at exactly 20px of hold drift", () => { + vi.useFakeTimers(); + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + moveTouch(112, 116); + advanceHold(); + + expect(row).toHaveClass("scale-[1.01]"); + endTouch(112, 116); + }); + + it("rejects 21px of hold drift below the scroll boundary", () => { + // A slow scroll drifts under the 25px scroll threshold but is not holding + // still. Arming here would capture the pointer and hand the gesture to + // dnd-kit, which preventDefaults the scroll away for the rest of the touch. + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + // Reach 21px before the timer: past hold tolerance, still short of scroll. + for (let step = 1; step <= 3; step += 1) { + advanceHold(90); + moveTouch(100, 100 + step * 7); + } + advanceHold(130); + + expect(row).not.toHaveClass("scale-[1.01]"); + expect(row).not.toHaveClass("opacity-40"); + endTouch(100, 121); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("leaves a normal-speed vertical flick to the list scroller", () => { + // Scrolling must stay native even though the finger starts on a row: the + // flick clears 25px within a frame, long before the hold could arm. + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(16); + expect(moveTouch(100, 125)).toBe(true); + advanceHold(600); + + expect(row).not.toHaveClass("scale-[1.01]"); + expect(row).not.toHaveClass("opacity-40"); + endTouch(100, 125); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("keeps a slow pre-hold horizontal gesture as a swipe", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(260); + moveTouch(94, 100); + advanceHold(80); + moveTouch(80, 100); + expect(row).not.toHaveClass("opacity-40"); + moveTouch(10, 100); + expect(row.querySelector("div.relative")?.style.marginRight).not.toBe(""); + endTouch(10, 100); + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("yields vertical travel to native scroll with no other outcome", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + expect(moveTouch(100, 125)).toBe(true); + advanceHold(ROW_GESTURE_HOLD_MS + 1); + endTouch(100, 125); + + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("assigns vertical-dominant travel exactly at the 25px boundary to scroll", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + // The 15-20-25 triangle pins the Euclidean boundary with integer deltas. + startTouch(); + expect(moveTouch(115, 120)).toBe(true); + + // Decided here, at move time — not later by the hold's drift check. Turning + // back onto the horizontal axis must stay inert, which it only does if the + // gesture already resolved to scroll. + moveTouch(60, 120); + expect(row.querySelector("div.relative")?.style.marginRight).toBe(""); + + advanceHold(ROW_GESTURE_HOLD_MS + 1); + endTouch(60, 120); + + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("stops tracking when a locked swipe reverses into an inert direction", () => { + vi.useFakeTimers(); + // Left archives; right is inert. Locking left then dragging back past the + // origin crosses into "none", which must not translate the row with an + // empty reveal behind it. + writeSwipeActions({ left: "archive", right: "none" }); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const surface = row.querySelector("div.relative"); + + startTouch(); + moveTouch(80, 100); + expect(surface?.style.marginRight).toBe("20px"); + + moveTouch(190, 100); + expect(surface?.style.marginLeft).toBe(""); + expect(surface?.style.marginRight).toBe(""); + + endTouch(190, 100); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("keeps 24px below the explicit scroll boundary pending", () => { + vi.useFakeTimers(); + writeSwipeActions({ left: "archive", right: "archive" }); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const surface = row.querySelector("div.relative"); + + // The browser normally claims this through pan-y. Without pointercancel, + // the explicit fallback must remain pending until the exact 25px boundary. + startTouch(); + moveTouch(100, 124); + expect(surface?.style.marginRight).toBe(""); + moveTouch(10, 100); + expect(surface?.style.marginRight).not.toBe(""); + endTouch(10, 100); + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("keeps 11px horizontal travel below swipe activation", () => { + vi.useFakeTimers(); + writeSwipeActions({ left: "archive", right: "archive" }); + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + moveTouch(111, 100); + const surface = row.querySelector("div.relative"); + expect(surface?.style.marginLeft).toBe(""); + advanceHold(); + expect(row).toHaveClass("scale-[1.01]"); + endTouch(111, 100); + }); + + it("assigns exactly 12px horizontal travel to swipe", () => { + vi.useFakeTimers(); + writeSwipeActions({ left: "archive", right: "archive" }); + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + moveTouch(112, 100); + const surface = row.querySelector("div.relative"); + expect(surface?.style.marginLeft).toBe("12px"); + advanceHold(ROW_GESTURE_HOLD_MS + 1); + expect(row).not.toHaveClass("opacity-40"); + endTouch(112, 100); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("arms touch drag on a wide viewport with a coarse pointer", () => { + vi.useFakeTimers(); + mocks.isMobile = false; + renderSidebar(); + const { row } = touchTarget(); + + startTouch(); + advanceHold(); + expect(row).toHaveClass("scale-[1.01]"); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + + expect(row).toHaveClass("opacity-40"); + endTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + }); + + it("leaves a short still press as plain link activation", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + const view = renderSidebar(); + const { link, row } = touchTarget(); + + startTouch(); + advanceHold(ROW_GESTURE_HOLD_MS - 1); + endTouch(); + fireEvent.click(link); + + expect(view.onClose).toHaveBeenCalledOnce(); + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("does not swallow a mouse click after a desktop touch scroll gesture", () => { + vi.useFakeTimers(); + const view = renderSidebar(); + const { link } = touchTarget(); + + startTouch(); + moveTouch(80, 100); + endTouch(80, 100); + fireEvent.pointerDown(link, { + pointerType: "mouse", + button: 0, + isPrimary: true, + pointerId: 9, + }); + fireEvent.click(link); + + expect(view.onClose).toHaveBeenCalledOnce(); + }); + + it("selects a row on the first tap after a short swipe", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + startTouch(); + moveTouch(80, 100); + endTouch(80, 100); + fireEvent.click(screen.getByRole("button", { name: "Select sessions" })); + const { link } = touchTarget(); + fireEvent.pointerDown(link, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.click(link); + + expect(screen.getByText("1 selected")).toBeInTheDocument(); + }); + + it("fully resets when the pointer is cancelled", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link, row } = touchTarget(); + + startTouch(); + moveTouch(80, 100); + const surface = row.querySelector("div.relative"); + expect(surface?.style.marginRight).toBe("20px"); + fireEvent.pointerCancel(link, { ...TOUCH_POINTER, clientX: 80, clientY: 100 }); + fireEvent.touchCancel(link, { touches: [], changedTouches: [touchPoint(link, 80, 100)] }); + advanceHold(ROW_GESTURE_HOLD_MS + 1); + + expect(surface?.style.marginRight).toBe(""); + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("rejects a second touch before drag activation", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link, row } = touchTarget(); + + startTouch(); + fireEvent.pointerDown(link, { + pointerId: 2, + isPrimary: false, + pointerType: "touch", + clientX: 104, + clientY: 104, + }); + const touches = [touchPoint(link, 100, 100), touchPoint(link, 104, 104, 1)]; + fireEvent.touchStart(link, { touches, changedTouches: [touches[1]] }); + advanceHold(ROW_GESTURE_HOLD_MS + 1); + moveTouch(120, 100, 2); + + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + fireEvent.pointerCancel(link, { ...TOUCH_POINTER, clientX: 120, clientY: 100 }); + }); + + it("cancels the first row when a second touch starts on another row", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + mockConversations([ + { ...CONV, id: "conv_a", title: "Session A" }, + { ...CONV, id: "conv_b", title: "Session B" }, + ]); + renderSidebar(); + const linkA = screen.getByRole("link", { name: /Session A/ }); + const linkB = screen.getByRole("link", { name: /Session B/ }); + const first = touchPoint(linkA, 100, 100); + const second = touchPoint(linkB, 104, 104, 1); + + fireEvent.pointerDown(linkA, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.touchStart(linkA, { touches: [first], changedTouches: [first] }); + fireEvent.pointerDown(linkB, { ...SECOND_TOUCH_POINTER, clientX: 104, clientY: 104 }); + fireEvent.touchStart(linkB, { + touches: [first, second], + changedTouches: [second], + }); + + advanceHold(ROW_GESTURE_HOLD_MS + 1); + expect(linkA.closest("li")).not.toHaveClass("scale-[1.01]"); + fireEvent.pointerMove(linkA, { ...TOUCH_POINTER, clientX: 10, clientY: 100 }); + fireEvent.pointerUp(linkA, { ...TOUCH_POINTER, clientX: 10, clientY: 100 }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(screen.queryByRole("menu")).toBeNull(); + expect(linkA.closest("li")).not.toHaveClass("scale-[1.01]"); + }); + + it("cancels dnd-kit when a second touch starts on another row mid-drag", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + mockConversations([ + { ...CONV, id: "conv_a", title: "Session A" }, + { ...CONV, id: "conv_b", title: "Session B" }, + ]); + renderSidebar(); + const linkA = screen.getByRole("link", { name: /Session A/ }); + const linkB = screen.getByRole("link", { name: /Session B/ }); + const rowA = linkA.closest("li")!; + const first = touchPoint(linkA, 100, 100); + const moved = touchPoint(linkA, 100 + ROW_DRAG_ACTIVATE_PX, 100); + + fireEvent.pointerDown(linkA, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.touchStart(linkA, { touches: [first], changedTouches: [first] }); + advanceHold(); + fireEvent.pointerMove(linkA, { + ...TOUCH_POINTER, + clientX: 100 + ROW_DRAG_ACTIVATE_PX, + clientY: 100, + }); + fireEvent.touchMove(linkA, { touches: [moved], changedTouches: [moved] }); + expect(rowA).toHaveClass("opacity-40"); + + const second = touchPoint(linkB, 104, 104, 1); + fireEvent.pointerDown(linkB, { ...SECOND_TOUCH_POINTER, clientX: 104, clientY: 104 }); + fireEvent.touchStart(linkB, { + touches: [moved, second], + changedTouches: [second], + }); + + expect(rowA).not.toHaveClass("opacity-40"); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("cancels dnd-kit when a second touch joins the active row", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link, row } = touchTarget(); + + startTouch(); + advanceHold(); + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + expect(row).toHaveClass("opacity-40"); + + fireEvent.pointerDown(link, { ...SECOND_TOUCH_POINTER, clientX: 104, clientY: 104 }); + + expect(row).not.toHaveClass("opacity-40"); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + // Chrome samples touch-action at touchstart; the armed-state touch-none + // class swap can't retroactively stop an in-flight pan. Only a real native + // non-passive touchmove listener, held from arm to full reset, forestalls it. + describe("native touchmove guard", () => { + function nativeTouchMove() { + const event = new Event("touchmove", { bubbles: true, cancelable: true }); + document.dispatchEvent(event); + return event; + } + + it("does not guard before the hold arms", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const addSpy = vi.spyOn(document, "addEventListener"); + + startTouch(); + expect(addSpy).not.toHaveBeenCalledWith("touchmove", expect.any(Function), { + passive: false, + }); + expect(nativeTouchMove().defaultPrevented).toBe(false); + endTouch(); + addSpy.mockRestore(); + }); + + it("registers the guard exactly when armed and removes it on release", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const addSpy = vi.spyOn(document, "addEventListener"); + const removeSpy = vi.spyOn(document, "removeEventListener"); + + startTouch(); + advanceHold(); + expect(addSpy).toHaveBeenCalledWith("touchmove", expect.any(Function), { passive: false }); + const touchmoveAdds = addSpy.mock.calls.filter((call) => call[0] === "touchmove"); + expect(touchmoveAdds).toHaveLength(1); + expect(removeSpy).not.toHaveBeenCalledWith( + "touchmove", + expect.any(Function), + expect.anything(), + ); + + endTouch(); + expect(removeSpy).toHaveBeenCalledWith("touchmove", expect.any(Function), { passive: false }); + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("preventDefaults a cancelable touchmove while armed and through drag", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + + startTouch(); + advanceHold(); + expect(nativeTouchMove().defaultPrevented).toBe(true); + + moveTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + expect(nativeTouchMove().defaultPrevented).toBe(true); + endTouch(100 + ROW_DRAG_ACTIVATE_PX, 100); + }); + + it("removes the guard on pointercancel", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link } = touchTarget(); + + startTouch(); + advanceHold(); + const removeSpy = vi.spyOn(document, "removeEventListener"); + fireEvent.pointerCancel(link, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.touchCancel(link, { touches: [], changedTouches: [touchPoint(link, 100, 100)] }); + + expect(removeSpy).toHaveBeenCalledWith("touchmove", expect.any(Function), { passive: false }); + expect(nativeTouchMove().defaultPrevented).toBe(false); + removeSpy.mockRestore(); + }); + + it("removes the guard when a second touch cancels the gesture", () => { + vi.useFakeTimers(); + mocks.isMobile = true; + renderSidebar(); + const { link } = touchTarget(); + + startTouch(); + advanceHold(); + const removeSpy = vi.spyOn(document, "removeEventListener"); + fireEvent.pointerDown(link, { ...SECOND_TOUCH_POINTER, clientX: 104, clientY: 104 }); + + expect(removeSpy).toHaveBeenCalledWith("touchmove", expect.any(Function), { passive: false }); + expect(nativeTouchMove().defaultPrevented).toBe(false); + removeSpy.mockRestore(); + }); + }); +}); + +describe("touch swipe actions", () => { + beforeEach(() => { + mocks.hasCoarsePointer = true; + }); + + // jsdom has no real touch, so drive the gesture with pointer events. The row + // recognizer gates on a primary touch pointer; pair each pointer event with + // its touch event so the real dnd-kit activator also sees the sequence. + + function swipeRow(dx: number) { + startTouch(); + // First move locks the axis; the second carries it past the commit point. + moveTouch(100 + Math.sign(dx) * 20, 100); + moveTouch(100 + dx, 100); + endTouch(100 + dx, 100); + } + + it("runs the archive path when swiping the archive-configured direction", () => { + // Default: swipe-left → archive. Swipe left past the commit threshold. + mocks.isMobile = true; + renderSidebar(); + + swipeRow(-90); + + // Archiving is a single PATCH — the server stops the runner once the flag + // commits, so a client-side stop would race it (see runArchive). + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + expect(mocks.stopSession.mutate).not.toHaveBeenCalled(); + // Archive never routes through delete. + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); + + it("commits on a wide viewport with a coarse pointer", () => { + mocks.isMobile = false; + renderSidebar(); + + swipeRow(-90); + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("opens the delete confirm dialog (no immediate delete) when swiping the delete direction", () => { + // Map swipe-right → delete, then swipe right. + writeSwipeActions({ left: "archive", right: "delete" }); + mocks.isMobile = true; + renderSidebar(); + + swipeRow(90); + + // The confirm dialog opens — delete stays behind it, so no mutation yet. + expect(screen.getByText("Delete conversation?")).toBeInTheDocument(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + + // Confirming in the dialog fires the same delete the kebab flow uses. + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(mocks.del.mutate).toHaveBeenCalledWith({ id: "conv_1", deleteBranch: false }); + }); + + it("does nothing when swiping a direction mapped to none", () => { + // Default: swipe-right → none. Swipe right; nothing should fire. + mocks.isMobile = true; + renderSidebar(); + + swipeRow(90); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + expect(screen.queryByText("Delete conversation?")).toBeNull(); + }); + + it("supports both directions mapped to the same action", () => { + // Both directions archive — a swipe either way runs the archive path. + writeSwipeActions({ left: "archive", right: "archive" }); + mocks.isMobile = true; + renderSidebar(); + + swipeRow(90); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + + // Archiving replaces the interactive row with its in-flight status until + // the list refetch removes it, so exercise the opposite mapping fresh. + cleanup(); + mocks.archive.mutate.mockClear(); + renderSidebar(); + swipeRow(-90); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("commits a fast flick from the release position before React renders", () => { + mocks.isMobile = true; + renderSidebar(); + const { link } = touchTarget(); + startTouch(200, 100); + + act(() => { + for (const x of [180, 100]) { + const touch = touchPoint(link, x, 100); + fireEvent.pointerMove(link, { ...TOUCH_POINTER, clientX: x, clientY: 100 }); + fireEvent.touchMove(link, { touches: [touch], changedTouches: [touch] }); + } + const touch = touchPoint(link, 100, 100); + fireEvent.pointerUp(link, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.touchEnd(link, { touches: [], changedTouches: [touch] }); + }); + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("keeps the committed swipe action when a concurrent render is abandoned", () => { + mocks.isMobile = true; + const view = renderSidebar(); + const abandonedArchive = { mutate: vi.fn() }; + const { link } = touchTarget(); + startTouch(200, 100); + + mocks.archiveOverride = abandonedArchive; + mocks.suspendDraggable = true; + act(() => { + startTransition(() => view.rerenderSidebar()); + }); + expect(screen.queryByTestId("suspended-sidebar")).toBeNull(); + + for (const x of [180, 100]) { + const touch = touchPoint(link, x, 100); + fireEvent.pointerMove(link, { ...TOUCH_POINTER, clientX: x, clientY: 100 }); + fireEvent.touchMove(link, { touches: [touch], changedTouches: [touch] }); + } + const touch = touchPoint(link, 100, 100); + fireEvent.pointerUp(link, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.touchEnd(link, { touches: [], changedTouches: [touch] }); + mocks.suspendDraggable = false; + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + expect(abandonedArchive.mutate).not.toHaveBeenCalled(); + }); + + it("does not commit after a flick returns below the threshold before release", () => { + mocks.isMobile = true; + renderSidebar(); + const { link } = touchTarget(); + startTouch(200, 100); + + act(() => { + for (const x of [100, 190]) { + const touch = touchPoint(link, x, 100); + fireEvent.pointerMove(link, { ...TOUCH_POINTER, clientX: x, clientY: 100 }); + fireEvent.touchMove(link, { touches: [touch], changedTouches: [touch] }); + } + const touch = touchPoint(link, 190, 100); + fireEvent.pointerUp(link, { ...TOUCH_POINTER, clientX: 190, clientY: 100 }); + fireEvent.touchEnd(link, { touches: [], changedTouches: [touch] }); + }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); + + it("resists travel past commit and caps the row offset", () => { + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const surface = row.querySelector("div.relative")!; + + startTouch(400, 100); + moveTouch(340, 100); + expect(surface.style.marginRight).toBe("60px"); + moveTouch(100, 100); + expect(surface.style.marginRight).toBe("96px"); + endTouch(100, 100); + + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("applies one-third resistance immediately past the commit distance", () => { + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const surface = row.querySelector("div.relative")!; + + startTouch(100, 100); + moveTouch(10, 100); + + expect(surface.style.marginRight).toBe("78px"); + endTouch(10, 100); + }); + + it("ignores touch gestures that bubble from a portalled dialog", () => { + mocks.isMobile = true; + renderSidebar(); + const { row } = touchTarget(); + const surface = row.querySelector("div.relative")!; + + fireEvent.pointerDown(screen.getByTestId("conversation-actions"), { button: 0 }); + fireEvent.click(screen.getByTestId("delete-conversation")); + const confirm = screen.getByRole("button", { name: "Delete" }); + fireEvent.pointerDown(confirm, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(confirm, { ...TOUCH_POINTER, clientX: 10, clientY: 100 }); + fireEvent.pointerUp(confirm, { ...TOUCH_POINTER, clientX: 10, clientY: 100 }); + + expect(surface.style.marginLeft).toBe(""); + expect(surface.style.marginRight).toBe(""); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); + + it("does not fire the action for a short swipe below the commit threshold", () => { + mocks.isMobile = true; + renderSidebar(); + + // Past the activation lock (20px) but short of the commit distance (72px). + swipeRow(-40); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("commits at exactly 72px but not at 71px", () => { + mocks.isMobile = true; + renderSidebar(); + + swipeRow(-71); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + + swipeRow(-72); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + it("ignores touch gestures without a coarse pointer", () => { + vi.useFakeTimers(); + mocks.hasCoarsePointer = false; + renderSidebar(); + + swipeRow(-90); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + + const { row } = touchTarget(); + startTouch(); + act(() => vi.advanceTimersByTime(ROW_GESTURE_HOLD_MS)); + moveTouch(101, 100); + expect(row).not.toHaveClass("scale-[1.01]"); + expect(row).not.toHaveClass("opacity-40"); + endTouch(101, 100); + }); + + it("ignores non-touch pointers (pen/stylus/mouse) on mobile", () => { + // The gesture is touch-only. Swipe LEFT, which is configured to archive, so + // the direction itself can't be what makes this inert — and assert the row + // never moves, not just that no mutation fired. + vi.useFakeTimers(); + writeSwipeActions({ left: "archive", right: "archive" }); + mocks.isMobile = true; + renderSidebar(); + + const link = screen.getByRole("link", { name: /My Session/ }); + const li = link.closest("li")!; + const surface = li.querySelector("div.relative"); + const pen = { pointerId: 1, isPrimary: true, pointerType: "pen" as const }; + fireEvent.pointerDown(li, { ...pen, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(li, { ...pen, clientX: 80, clientY: 100 }); + fireEvent.pointerMove(li, { ...pen, clientX: 10, clientY: 100 }); + + // No translate, and holding a pen still never arms the long-press. + expect(surface?.style.marginRight).toBe(""); + act(() => vi.advanceTimersByTime(ROW_GESTURE_HOLD_MS + 1)); + expect(li).not.toHaveClass("scale-[1.01]"); + + fireEvent.pointerUp(li, { ...pen, clientX: 10, clientY: 100 }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + expect(screen.queryByTestId("rename-conversation")).toBeNull(); + }); + + it("yields a primarily-vertical drag to scroll (no action)", () => { + // A mostly-vertical move is scroll intent — the gesture bails and never + // fires, even though it travels well past the commit distance horizontally. + mocks.isMobile = true; + renderSidebar(); + + const li = screen.getByRole("link", { name: /My Session/ }).closest("li")!; + fireEvent.pointerDown(li, { ...TOUCH_POINTER, clientX: 100, clientY: 100 }); + // First move is dominated by the vertical axis → decided="other". + fireEvent.pointerMove(li, { ...TOUCH_POINTER, clientX: 105, clientY: 140 }); + fireEvent.pointerMove(li, { ...TOUCH_POINTER, clientX: 10, clientY: 180 }); + fireEvent.pointerUp(li, { ...TOUCH_POINTER, clientX: 10, clientY: 180 }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); +}); + describe("sharing kill switch", () => { it("disables the row's Share item for a manager when sharing_mode is off", () => { // CONV is owner-level (permission_level null → canManage), yet a server diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index 772ed4a4d0..2d1557a100 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -6,12 +6,22 @@ // are no longer listed here — they live on the Settings page. import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useEffect } from "react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { Conversation } from "@/hooks/useConversations"; +import { ROW_GESTURE_HOLD_MS } from "@/hooks/useRowGesture"; +import { notifyResizeObservers, resetMockViewportWidth, setMockViewportWidth } from "@/test-setup"; + +// Controllable coarse-pointer capability, defaulting to true: the recognizer +// touch tests need it (the global matchMedia stub reports no coarse pointer), +// and fine-pointer cases opt out explicitly. +const coarsePointer = vi.hoisted(() => ({ current: true })); +vi.mock("@/hooks/useCoarsePointer", () => ({ + useCoarsePointer: () => coarsePointer.current, +})); // Project mocks are declared via vi.hoisted so they exist before the hoisted // vi.mock factory runs. projectsMock is mutated per-test to drive project @@ -233,6 +243,105 @@ function selectSessionFilter(value: "all" | "mine" | "shared" | "archived") { fireEvent.click(screen.getByTestId(`session-filter-${value}`)); } +function dndRect(top: number, height: number): DOMRect { + return { + x: 0, + y: top, + width: 240, + height, + top, + right: 240, + bottom: top + height, + left: 0, + toJSON: () => ({}), + } as DOMRect; +} + +/** Spy getBoundingClientRect, serving rects by data-testid from the returned + mutable table (mutate it to shift layout mid-test); LI rows share one rect + and everything else parks off-screen. */ +function mockRectsByTestId(rects: Record): Record { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ): DOMRect { + const rect = rects[this.getAttribute("data-testid") ?? ""]; + if (rect) return rect; + if (this.tagName === "LI") return dndRect(20, 32); + return dndRect(-1_000, 1); + }); + return rects; +} + +function mockSidebarDropLayout(): void { + mockRectsByTestId({ + "sidebar-chats-drop-zone": dndRect(100, 120), + "sidebar-ungroup-drop-zone": dndRect(260, 40), + }); +} + +function mockShiftingUngroupLayout(): { shift: () => void } { + const rects = mockRectsByTestId({ + "sidebar-project-drop-zone": dndRect(100, 120), + "sidebar-ungroup-drop-zone": dndRect(260, 40), + }); + return { + shift: () => { + rects["sidebar-project-drop-zone"] = dndRect(100, 220); + rects["sidebar-ungroup-drop-zone"] = dndRect(360, 40); + }, + }; +} + +/** Rect mock for the seam-placement logic: a 400px-tall list frame with the + inline slot at a controllable offset, plus a floating strip parked at the + frame's bottom edge (360–390) for drop targeting. */ +function mockSeamLayout({ slotTop }: { slotTop: number }): { + frameTop: number; + frameBottom: number; + floatingCenterY: number; + setSlotTop: (top: number) => void; +} { + const frameTop = 0; + const frameBottom = 400; + const rects = mockRectsByTestId({ + "sidebar-scroll-frame": dndRect(frameTop, frameBottom - frameTop), + "sidebar-ungroup-drop-zone": dndRect(slotTop, 30), + "sidebar-ungroup-floating-strip": dndRect(360, 30), + }); + return { + frameTop, + frameBottom, + floatingCenterY: 375, + setSlotTop: (top: number) => { + rects["sidebar-ungroup-drop-zone"] = dndRect(top, 30); + }, + }; +} + +function startMouseDrag(row: HTMLElement): void { + fireEvent.mouseDown(row, { button: 0, buttons: 1, clientX: 10, clientY: 20 }); + fireEvent.mouseMove(document, { buttons: 1, clientX: 20, clientY: 30 }); +} + +/** Drag to `y` in two steps: dnd-kit needs a move to measure before it collides. */ +function moveMouseTo(y: number): void { + fireEvent.mouseMove(document, { buttons: 1, clientX: 120, clientY: y - 1 }); + fireEvent.mouseMove(document, { buttons: 1, clientX: 120, clientY: y }); +} + +function touchAt(target: Element, y: number): TouchInit { + return { + identifier: 1, + target, + clientX: 120, + clientY: y, + pageX: 120, + pageY: y, + screenX: 120, + screenY: y, + }; +} + /** Show only sessions others shared with the viewer. */ function showSharedTab() { selectSessionFilter("shared"); @@ -275,7 +384,11 @@ beforeEach(() => { function seedPins(ids: string[]) { pinnedIdsRef.current = ids; } -afterEach(cleanup); +afterEach(() => { + cleanup(); + resetMockViewportWidth(); + vi.restoreAllMocks(); +}); describe("Sidebar session list", () => { it("uses the interface text token for the empty session-list state", () => { @@ -1344,9 +1457,7 @@ describe("Sidebar project sections", () => { }); it("closes the mobile overlay when the project pencil is tapped", () => { - // jsdom's matchMedia mock reports non-desktop, so isMobileViewport() is - // true: a plain pencil tap must close the full-screen sidebar overlay, - // otherwise the pre-filed new-session page is left hidden behind it. + setMockViewportWidth(375); projectsMock.push("Customer X"); mockConversations([ conv("conv_filed", "Claude Code", { labels: { omni_project: "Customer X" } }), @@ -1613,14 +1724,15 @@ describe("Sidebar project sections", () => { // Pencil stays in the tree but is hidden below the md breakpoint. expect(screen.getByTestId("project-new-session")).toHaveClass("max-md:hidden"); - // Open the kebab → a mobile-only "New session" item linking to the same - // pre-filed composer. + // Open the kebab → a "New session" item linking to the same pre-filed + // composer. This file mocks a coarse pointer, so the item stays visible + // at every width — hover can't reveal the pencil there. fireEvent.pointerDown(screen.getByRole("button", { name: "Project actions for Customer X" }), { button: 0, ctrlKey: false, }); const menuItem = await screen.findByTestId("project-new-session-menu"); - expect(menuItem).toHaveClass("md:hidden"); + expect(menuItem).not.toHaveClass("md:hidden"); expect(menuItem.closest("a")).toHaveAttribute("href", "/?project=Customer%20X"); }); }); @@ -1828,6 +1940,225 @@ describe("Sidebar move-to-project action", () => { }); }); +/** Render a single pinned session filed under `project` and return its row. */ +function renderPinnedFiledSession(id: string, project = "Sprint 42"): HTMLElement { + projectsMock.push(project); + seedPins([id]); + mockConversations([conv(id, "Claude Code", { labels: { omni_project: project } })]); + renderSidebar(); + return screen.getByRole("link", { name: id }).closest("li")!; +} + +describe("Sidebar ungroup drag target", () => { + beforeEach(() => setMockViewportWidth(375)); + + it("mounts at the seam: after the projects group, before the unfiled sessions", async () => { + projectsMock.push("Sprint 42"); + mockConversations([ + conv("conv_filed", "Claude Code", { labels: { omni_project: "Sprint 42" } }), + conv("conv_flat", "Codex"), + ]); + renderSidebar(); + + fireEvent.click(screen.getByRole("button", { name: "Sprint 42" })); + const list = screen.getByTestId("sidebar-conversation-list"); + const rowsBefore = Array.from(list.querySelectorAll("li")); + const filedRow = screen.getByRole("link", { name: "conv_filed" }).closest("li")!; + + startMouseDrag(filedRow); + + const dropZone = await screen.findByTestId("sidebar-ungroup-drop-zone"); + // The slot lands where the drop will: below the projects, above the flat + // "Chats" list. Rows themselves must not reorder around it. + const projectsHeader = screen.getByRole("button", { name: "Sprint 42" }); + expect( + dropZone.compareDocumentPosition(projectsHeader) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + expect(dropZone.nextElementSibling).toBe(screen.getByTestId("sidebar-chats-drop-zone")); + expect(Array.from(list.querySelectorAll("li"))).toEqual(rowsBefore); + + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("mounts the strip during a desktop drag", async () => { + // The strip is the advertised ungroup target at every width and pointer + // type; ChatsDropZone remains an additional desktop target. + setMockViewportWidth(1024); + const row = renderPinnedFiledSession("conv_desktop_filed"); + + startMouseDrag(row); + await waitFor(() => expect(screen.getAllByText("conv_desktop_filed")).toHaveLength(2)); + expect(screen.getByTestId("sidebar-ungroup-drop-zone")).toBeInTheDocument(); + + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("keeps the slot in flow with no floating fallback while the seam is visible", async () => { + const row = renderPinnedFiledSession("conv_mobile_filed"); + + startMouseDrag(row); + const slot = await screen.findByTestId("sidebar-ungroup-drop-zone"); + // In flow at the seam — the drop lands where the eye is told it will. + expect(slot).not.toHaveClass("fixed"); + expect(slot).not.toHaveClass("sticky"); + expect(screen.queryByTestId("sidebar-ungroup-floating-strip")).toBeNull(); + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("floats a fixed strip at the bottom edge while the seam is below the fold", async () => { + const layout = mockSeamLayout({ slotTop: 900 }); + const row = renderPinnedFiledSession("conv_below_fold"); + + startMouseDrag(row); + await screen.findByTestId("sidebar-ungroup-drop-zone"); + const floating = await screen.findByTestId("sidebar-ungroup-floating-strip"); + expect(floating).toHaveClass("fixed"); + expect(floating.style.bottom).toBe(`${window.innerHeight - layout.frameBottom + 8}px`); + expect(floating.style.top).toBe(""); + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("floats the strip at the top edge when the seam is scrolled above the view", async () => { + const layout = mockSeamLayout({ slotTop: -200 }); + const row = renderPinnedFiledSession("conv_above_view"); + + startMouseDrag(row); + const floating = await screen.findByTestId("sidebar-ungroup-floating-strip"); + expect(floating).toHaveClass("fixed"); + expect(floating.style.top).toBe(`${layout.frameTop + 8}px`); + expect(floating.style.bottom).toBe(""); + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("latches back to the inline slot once the seam scrolls into view", async () => { + const layout = mockSeamLayout({ slotTop: 900 }); + const row = renderPinnedFiledSession("conv_latching"); + + startMouseDrag(row); + await screen.findByTestId("sidebar-ungroup-floating-strip"); + + layout.setSlotTop(200); + fireEvent.scroll(screen.getByTestId("sidebar-scroll-frame")); + + await waitFor(() => expect(screen.queryByTestId("sidebar-ungroup-floating-strip")).toBeNull()); + // The inline slot itself never unmounted — the fallback just stood down. + expect(screen.getByTestId("sidebar-ungroup-drop-zone")).toBeInTheDocument(); + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("promotes the floating strip when the seam moves off-screen without a scroll", async () => { + const layout = mockSeamLayout({ slotTop: 200 }); + const row = renderPinnedFiledSession("conv_layout_shift"); + + startMouseDrag(row); + await screen.findByTestId("sidebar-ungroup-drop-zone"); + expect(screen.queryByTestId("sidebar-ungroup-floating-strip")).toBeNull(); + + // Rows inserted above the seam push it below the fold with no scroll event. + layout.setSlotTop(900); + act(() => notifyResizeObservers(screen.getByTestId("sidebar-conversation-list"))); + + expect(await screen.findByTestId("sidebar-ungroup-floating-strip")).toBeInTheDocument(); + fireEvent.mouseUp(document, { button: 0, clientX: 20, clientY: 30 }); + }); + + it("resolves an ungroup drop on the floating strip", async () => { + const layout = mockSeamLayout({ slotTop: 900 }); + const row = renderPinnedFiledSession("conv_floating_drop"); + + startMouseDrag(row); + const floating = await screen.findByTestId("sidebar-ungroup-floating-strip"); + + moveMouseTo(layout.floatingCenterY); + await waitFor(() => expect(floating).toHaveClass("bg-[var(--sidebar-active)]")); + fireEvent.mouseUp(document, { button: 0, clientX: 120, clientY: layout.floatingCenterY }); + + await waitFor(() => { + expect(moveToProjectSpy).toHaveBeenCalledTimes(1); + expect(moveToProjectSpy).toHaveBeenCalledWith({ id: "conv_floating_drop", project: "" }); + }); + }); + + it("ungroups and unpins a pinned project session dropped on the bottom strip", async () => { + mockSidebarDropLayout(); + const pinnedRow = renderPinnedFiledSession("conv_pinned_filed"); + + startMouseDrag(pinnedRow); + const dropZone = await screen.findByTestId("sidebar-ungroup-drop-zone"); + + moveMouseTo(280); + await waitFor(() => expect(dropZone).toHaveClass("bg-[var(--sidebar-active)]")); + fireEvent.mouseUp(document, { button: 0, clientX: 120, clientY: 280 }); + + await waitFor(() => { + expect(moveToProjectSpy).toHaveBeenCalledTimes(1); + expect(moveToProjectSpy).toHaveBeenCalledWith({ id: "conv_pinned_filed", project: "" }); + }); + expect(pinnedIdsRef.current).toEqual([]); + }); + + it("remeasures a strip that moves without resizing", async () => { + const layout = mockShiftingUngroupLayout(); + const row = renderPinnedFiledSession("conv_shifted", "Source"); + + startMouseDrag(row); + const strip = await screen.findByTestId("sidebar-ungroup-drop-zone"); + const project = screen.getByTestId("sidebar-project-drop-zone"); + + await act(async () => { + layout.shift(); + notifyResizeObservers(project); + // dnd-kit debounces its resize-driven remeasure by 25ms. + await new Promise((resolve) => { + setTimeout(resolve, 35); + }); + }); + + moveMouseTo(380); + await waitFor(() => expect(strip).toHaveClass("bg-[var(--sidebar-active)]")); + fireEvent.mouseUp(document, { button: 0, clientX: 120, clientY: 380 }); + + await waitFor(() => expect(moveToProjectSpy).toHaveBeenCalledTimes(1)); + expect(moveToProjectSpy).toHaveBeenCalledWith({ id: "conv_shifted", project: "" }); + }); + + it("completes an ungroup drop through the touch sensor", () => { + // The unified row recognizer owns touch drags: a press must hold still for + // ROW_GESTURE_HOLD_MS to arm, and only then does movement start the drag. + vi.useFakeTimers(); + try { + mockSidebarDropLayout(); + const row = renderPinnedFiledSession("conv_touch_filed"); + const touch = { pointerId: 1, isPrimary: true, pointerType: "touch" as const }; + const start = touchAt(row, 20); + fireEvent.pointerDown(row, { ...touch, clientX: 120, clientY: 20 }); + fireEvent.touchStart(row, { + touches: [start], + targetTouches: [start], + changedTouches: [start], + }); + act(() => vi.advanceTimersByTime(ROW_GESTURE_HOLD_MS + 1)); + + const approaching = touchAt(row, 279); + const destination = touchAt(row, 280); + fireEvent.pointerMove(row, { ...touch, clientX: 120, clientY: 279 }); + fireEvent.touchMove(row, { touches: [approaching], changedTouches: [approaching] }); + const strip = screen.getByTestId("sidebar-ungroup-drop-zone"); + fireEvent.pointerMove(row, { ...touch, clientX: 120, clientY: 280 }); + fireEvent.touchMove(row, { touches: [destination], changedTouches: [destination] }); + expect(strip).toHaveClass("bg-[var(--sidebar-active)]"); + fireEvent.pointerUp(row, { ...touch, clientX: 120, clientY: 280 }); + fireEvent.touchEnd(row, { touches: [], targetTouches: [], changedTouches: [destination] }); + + expect(moveToProjectSpy).toHaveBeenCalledTimes(1); + expect(moveToProjectSpy).toHaveBeenCalledWith({ id: "conv_touch_filed", project: "" }); + expect(pinnedIdsRef.current).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("Sidebar mobile overlay background", () => { it("keeps the opaque bg-card-solid override for the mobile full-screen overlay", () => { mockConversations(THREE_TYPE_CONVERSATIONS); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 6cc33f9a6a..9f44fed4e8 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -58,7 +58,6 @@ import { MeasuringStrategy, MouseSensor, pointerWithin, - TouchSensor, useDraggable, useDroppable, useSensor, @@ -147,10 +146,19 @@ import { useUnseenTick, } from "@/hooks/useUnseenConversations"; import { cn } from "@/lib/utils"; +import { type SwipeAction, useSwipeActions } from "@/lib/swipeActionPreferences"; +import { useCoarsePointer } from "@/hooks/useCoarsePointer"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useResizableSidebar } from "@/hooks/useResizableSidebar"; import { useSessionSwitchHotkey } from "@/hooks/useSessionSwitchHotkey"; import { usePinnedSessionHotkeys } from "@/hooks/usePinnedSessionHotkeys"; +import { + finishActiveRowGesture, + ROW_MENU_SYNTHETIC, + ROW_SWIPE_COMMIT_PX, + RowGestureTouchSensor, + useRowGesture, +} from "@/hooks/useRowGesture"; import { isCurrentServerLocal } from "@/lib/serverOrigin"; import { type SessionFilter, @@ -191,6 +199,20 @@ const SIDEBAR_HOVER_HIGHLIGHT = "hover:bg-muted hover:text-foreground dark:hover const SIDEBAR_ACTIVE_HIGHLIGHT = "bg-[var(--sidebar-active)] text-[var(--sidebar-active-foreground)] hover:bg-[var(--sidebar-active)] hover:text-[var(--sidebar-active-foreground)] dark:hover:bg-[var(--sidebar-active)] dark:hover:text-[var(--sidebar-active-foreground)]"; const DROP_TARGET_HIGHLIGHT = SIDEBAR_ACTIVE_HIGHLIGHT; +// Shared sizing for the sidebar's dropdown / context menus so they can't drift. +const SIDEBAR_MENU_CONTENT_CLASS = "min-w-40"; +const UNGROUP_DROP_ZONE_ID = "__ungroup__"; +const UNGROUP_FLOATING_ZONE_ID = "__ungroup_floating__"; +// A resized section can move later drop zones without resizing them. +const SIDEBAR_RESIZE_OBSERVER_CONFIG = { updateMeasurementsFor: [] }; +// The inline ungroup slot mounts mid-drag at the seam between projects and +// unfiled sessions, shifting everything below it — remeasure droppables on +// registry changes so their stored rects track the shifted layout. +const SIDEBAR_DND_MEASURING = { droppable: { strategy: MeasuringStrategy.Always } }; +// Where the inline ungroup slot sits relative to the list viewport, reported +// by the slot itself. "unmounted" (slot not rendered, e.g. search results) +// falls back to the floating strip at the bottom edge. +type UngroupSlotPlacement = "inline" | "above" | "below" | "unmounted"; // Maps a first-class project id → its name, provided once at the list level so // each row resolves its ``project_id`` to a folder name without its own @@ -915,6 +937,7 @@ export function Sidebar({