From 1887e3a3866adc3523efd80f344c1eef97d131c1 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Mon, 3 Aug 2026 10:41:44 -0700 Subject: [PATCH 01/30] feat(web): swipe session rows to archive or delete on touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a configurable horizontal swipe gesture on left-panel session rows for touch devices. Each direction (left/right) maps to an action — archive, delete, or none — persisted per-device and set from Settings → Appearance. Swipe→archive reuses the kebab's stop-then-archive handler; swipe→delete opens the same confirm dialog the kebab uses, so delete is never immediate. The gesture locks by axis and yields to dnd-kit's long-press drag, leaving drag-to-file-into-project unaffected. The whole row — link, session-state badge, and pin/kebab controls — lives on one translating surface, so trailing icons travel with the text instead of the text sliding out from under them. Swipe-only markup and the opaque row background are gated on an in-progress swipe, so a row at rest renders exactly as it did before the gesture existed. Co-authored-by: Isaac Signed-off-by: Bryan Li --- web/src/lib/swipeActionPreferences.test.ts | 148 ++++++ web/src/lib/swipeActionPreferences.ts | 124 +++++ web/src/pages/SettingsPage.tsx | 79 +++ web/src/shell/Sidebar.rowActions.test.tsx | 261 ++++++++- web/src/shell/Sidebar.tsx | 588 ++++++++++++++++----- 5 files changed, 1054 insertions(+), 146 deletions(-) create mode 100644 web/src/lib/swipeActionPreferences.test.ts create mode 100644 web/src/lib/swipeActionPreferences.ts diff --git a/web/src/lib/swipeActionPreferences.test.ts b/web/src/lib/swipeActionPreferences.test.ts new file mode 100644 index 0000000000..625c9176da --- /dev/null +++ b/web/src/lib/swipeActionPreferences.test.ts @@ -0,0 +1,148 @@ +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 missing/unknown directions from the defaults", () => { + expect(normalizeSwipeActions({ left: "delete" })).toEqual({ + left: "delete", + right: DEFAULT_SWIPE_ACTIONS.right, + }); + expect(normalizeSwipeActions({ left: "bogus", right: "archive" })).toEqual({ + left: DEFAULT_SWIPE_ACTIONS.left, + 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("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 }); + }); +}); + +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..35eb2f2a54 --- /dev/null +++ b/web/src/lib/swipeActionPreferences.ts @@ -0,0 +1,124 @@ +// 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, filling + * each missing/unknown direction from {@link DEFAULT_SWIPE_ACTIONS}. 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) : {}; + return { + left: isSwipeAction(obj.left) ? obj.left : DEFAULT_SWIPE_ACTIONS.left, + right: isSwipeAction(obj.right) ? obj.right : DEFAULT_SWIPE_ACTIONS.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 returns a stable +// reference between changes — a fresh object every call would loop it. Kept +// current by getSnapshot (re-reads and swaps only on a real change), so it's +// never stale even after a write that had no active subscriber. +let snapshot: SwipeActionPreferences = readSwipeActions(); + +function getSnapshot(): SwipeActionPreferences { + const next = readSwipeActions(); + if (next.left !== snapshot.left || next.right !== snapshot.right) snapshot = next; + return snapshot; +} + +function subscribe(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const handler = (e: Event) => { + // 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; + onChange(); + }; + window.addEventListener("storage", handler); + window.addEventListener(SWIPE_ACTIONS_EVENT, handler); + return () => { + window.removeEventListener("storage", handler); + window.removeEventListener(SWIPE_ACTIONS_EVENT, handler); + }; +} + +/** + * 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..0ec13fdcd1 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -143,6 +143,14 @@ import { readHideUnconfiguredHarnesses, writeHideUnconfiguredHarnesses, } from "@/lib/harnessVisibilityPreferences"; +import { + isSwipeAction, + type SwipeAction, + swipeActions as swipeActionOptions, + type SwipeDirection, + useSwipeActions, + writeSwipeActions, +} from "@/lib/swipeActionPreferences"; import { applyThemePalette, DEFAULT_PALETTE, @@ -823,6 +831,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 @@ -914,6 +991,8 @@ function AppearanceSection() { + + diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index c16aa3c77e..61aa6d6be6 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -6,9 +6,17 @@ // `onDoubleClick`), gated on edit permission. // See ConversationRow / ConversationEditRow in Sidebar.tsx. -import { useSyncExternalStore } from "react"; +import { type PointerEvent as ReactPointerEvent, useSyncExternalStore } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + 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"; @@ -52,6 +60,13 @@ const mocks = vi.hoisted(() => { moveToProject: { mutate: vi.fn() }, conversations: [] as unknown[], 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() }, + 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() }, }; }); @@ -66,8 +81,8 @@ 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, @@ -92,11 +107,11 @@ vi.mock("@/hooks/useConversations", () => ({ setConversationPinned: vi.fn(() => Promise.resolve({})), PINNED_CONVERSATIONS_KEY: ["pinned-conversations"], useRenameConversation: () => mocks.rename, - useArchiveConversation: () => ({ mutate: vi.fn() }), + useArchiveConversation: () => 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 @@ -132,7 +147,8 @@ vi.mock("@/lib/serverOrigin", () => ({ isCurrentServerLocal: () => false })); import { type Conversation, useConversations } from "@/hooks/useConversations"; import { resetReadStateForTests, seedReadState } from "@/hooks/useUnseenConversations"; -import { Sidebar } from "./Sidebar"; +import { writeSwipeActions } from "@/lib/swipeActionPreferences"; +import { Sidebar, useRowSwipe } from "./Sidebar"; const useConvMock = vi.mocked(useConversations); @@ -234,6 +250,24 @@ function renderSidebar(activeId?: string, info?: ServerInfo) { beforeEach(() => { mocks.rename.mutate.mockReset(); 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 = []; // Default every test to the desktop viewport; the mobile flyout test opts in. mocks.isMobile = false; @@ -908,6 +942,219 @@ describe("right-click context menu", () => { }); }); +describe("touch swipe actions", () => { + // jsdom has no real touch, so drive the gesture with pointer events. The row + // handlers gate on a primary, non-mouse pointer (see useRowSwipe); default + // jsdom PointerEvents omit those, so set them explicitly. A swipe is a + // down → horizontal move past the commit threshold → up on the row's
  • . + const POINTER = { pointerId: 1, isPrimary: true, pointerType: "touch" as const }; + + function swipeRow(dx: number) { + const li = screen.getByRole("link", { name: /My Session/ }).closest("li")!; + fireEvent.pointerDown(li, { ...POINTER, clientX: 100, clientY: 100 }); + // First move locks the axis; the second carries it past the commit point. + fireEvent.pointerMove(li, { ...POINTER, clientX: 100 + Math.sign(dx) * 20, clientY: 100 }); + fireEvent.pointerMove(li, { ...POINTER, clientX: 100 + dx, clientY: 100 }); + fireEvent.pointerUp(li, { ...POINTER, clientX: 100 + dx, clientY: 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); + + // runArchive stops the runner first, then flips the archive flag — the same + // stop→archive handler the kebab's Archive item uses. + expect(mocks.stopSession.mutate).toHaveBeenCalledWith("conv_1", expect.anything()); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + // Archive never routes through delete. + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); + + 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 }, + expect.anything(), + ); + }); + + 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(), + ); + + mocks.archive.mutate.mockClear(); + swipeRow(-90); + expect(mocks.archive.mutate).toHaveBeenCalledWith( + { id: "conv_1", archived: true }, + expect.anything(), + ); + }); + + 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("ignores swipes on desktop (non-touch viewport)", () => { + // Desktop (default isMobile=false) leaves the gesture disabled entirely. + renderSidebar(); + + swipeRow(-90); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("ignores non-touch pointers (pen/stylus/mouse) on mobile", () => { + // The gesture is touch-only; a pen swipe past the threshold must not fire. + mocks.isMobile = true; + renderSidebar(); + + const li = screen.getByRole("link", { name: /My Session/ }).closest("li")!; + const pen = { pointerId: 1, isPrimary: true, pointerType: "pen" as const }; + fireEvent.pointerDown(li, { ...pen, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(li, { ...pen, clientX: 120, clientY: 100 }); + fireEvent.pointerMove(li, { ...pen, clientX: 190, clientY: 100 }); + fireEvent.pointerUp(li, { ...pen, clientX: 190, clientY: 100 }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); + + 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, { ...POINTER, clientX: 100, clientY: 100 }); + // First move is dominated by the vertical axis → decided="other". + fireEvent.pointerMove(li, { ...POINTER, clientX: 105, clientY: 140 }); + fireEvent.pointerMove(li, { ...POINTER, clientX: 10, clientY: 180 }); + fireEvent.pointerUp(li, { ...POINTER, clientX: 10, clientY: 180 }); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + }); +}); + +describe("useRowSwipe — dnd coexistence", () => { + // Focused hook tests: driving real dnd-kit dragging in jsdom is unreliable, + // so control `isDragging` directly to prove the swipe yields to the drag. + const ACTIONS = { left: "archive", right: "none" } as const; + + function makePointer(over: { pointerId: number; clientX: number; clientY: number }) { + // Minimal ReactPointerEvent stand-in — only the fields useRowSwipe reads, + // plus pointer-capture no-ops (jsdom's are stubbed in test-setup). + return { + pointerType: "touch", + isPrimary: true, + preventDefault: () => {}, + currentTarget: { + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + hasPointerCapture: () => false, + }, + ...over, + } as unknown as ReactPointerEvent; + } + + it("(a) yields a primarily-vertical move: decided=other, no translate, no action", () => { + const onAction = vi.fn(); + const { result } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging: false, onAction }), + ); + + act(() => { + result.current.onPointerDown(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + act(() => { + // Vertical dominates → the gesture hands off to scroll/drag. + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 105, clientY: 140 })); + }); + // No translate offset accumulated. + expect(result.current.dx).toBe(0); + + act(() => { + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 105, clientY: 140 })); + }); + expect(onAction).not.toHaveBeenCalled(); + }); + + it("(b) bails once a dnd-kit drag begins mid-gesture: dx resets, no action on release", () => { + const onAction = vi.fn(); + let isDragging = false; + const { result, rerender } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging, onAction }), + ); + + act(() => { + result.current.onPointerDown(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + act(() => { + // A real horizontal swipe left (the archive-configured direction) locks + // in and translates the row. + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 70, clientY: 100 })); + }); + expect(result.current.dx).not.toBe(0); + + // dnd-kit's long-press drag activates; re-render with isDragging=true. + isDragging = true; + rerender(); + act(() => { + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 20, clientY: 100 })); + }); + // The swipe bailed: translate snaps back to rest. + expect(result.current.dx).toBe(0); + + act(() => { + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 20, clientY: 100 })); + }); + expect(onAction).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.tsx b/web/src/shell/Sidebar.tsx index 2c9890aedf..6cb1210582 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, type KeyboardEvent, type MouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject, createContext, @@ -149,6 +150,12 @@ import { useUnseenTick, } from "@/hooks/useUnseenConversations"; import { cn } from "@/lib/utils"; +import { + type SwipeAction, + type SwipeActionPreferences, + useSwipeActions, +} from "@/lib/swipeActionPreferences"; +import { useCoarsePointer } from "@/hooks/useCoarsePointer"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; import { useResizableSidebar } from "@/hooks/useResizableSidebar"; import { useSessionSwitchHotkey } from "@/hooks/useSessionSwitchHotkey"; @@ -179,6 +186,190 @@ import { SIDEBAR_ROW } from "./sidebarStyles"; const SESSION_STATE_SLOT_CLASS = "-translate-y-1/2 pointer-events-none absolute top-1/2 right-[4.5rem] flex h-5 items-center transition-opacity md:right-1 md:group-hover:opacity-0 md:group-has-[:focus-visible]:opacity-0 md:group-has-[[aria-expanded=true]]:opacity-0"; +// Horizontal-swipe thresholds for the mobile row gesture (see useRowSwipe). +// ACTIVATE locks the gesture to a swipe once horizontal travel dominates; +// COMMIT is how far the row must be dragged to fire the action on release. +const SWIPE_ACTIVATE_PX = 12; +const SWIPE_COMMIT_PX = 72; +// Cap the visual translate a little past the commit point so the row can't be +// dragged clear across the panel. +const SWIPE_MAX_PX = 96; +// Past the commit point the row only keeps a third of further travel, so the +// gesture resists instead of dragging the title out of the panel — the action is +// already armed there, so extra movement carries no meaning. +const SWIPE_RESIST = 1 / 3; + +/** + * Map raw finger travel to the row's visual offset: 1:1 up to the commit point, + * then damped and hard-capped at {@link SWIPE_MAX_PX}. Keeps the title inside + * the panel while still acknowledging a longer drag. + */ +function swipeOffset(deltaX: number): number { + const dir = Math.sign(deltaX); + const travel = Math.abs(deltaX); + if (travel <= SWIPE_COMMIT_PX) return deltaX; + const damped = SWIPE_COMMIT_PX + (travel - SWIPE_COMMIT_PX) * SWIPE_RESIST; + return dir * Math.min(damped, SWIPE_MAX_PX); +} + +/** The configured action for a horizontal offset's direction (0 → "none"). */ +function actionFor(deltaX: number, actions: SwipeActionPreferences): SwipeAction { + if (deltaX === 0) return "none"; + return deltaX < 0 ? actions.left : actions.right; +} + +/** + * Touch swipe gesture for a session row. Tracks a horizontal drag and, on + * release past {@link SWIPE_COMMIT_PX}, invokes the action configured for that + * direction (see swipeActionPreferences). Disambiguates from dnd-kit's + * drag-to-project by axis (vertical movement yields to scroll/drag) and by + * bailing once a dnd-kit drag is active. A direction mapped to "none" is inert. + * + * Returns the live translate offset (for the row transform + reveal hint) and + * pointer/click handlers to spread on the row element. The click-capture + * handler swallows the click some browsers still synthesize after the drag, so + * a swipe never also navigates into the session. + */ +export function useRowSwipe({ + enabled, + actions, + isDragging, + onAction, +}: { + enabled: boolean; + actions: SwipeActionPreferences; + isDragging: boolean; + onAction: (action: Exclude) => void; +}) { + const [dx, setDx] = useState(0); + // `decided` is null until the first qualifying move locks the gesture to a + // horizontal swipe ("swipe") or hands it back to scroll/drag ("other"). + // `target` holds the element we called setPointerCapture on, so reset() can + // release it (capture lives on the translating row surface; a leak would + // interfere with adjacent rows and the dnd-kit handoff). + // `offset` mirrors the rendered `dx` but updates synchronously, so release + // commits on where the finger actually ended rather than on whatever render + // last landed — a fast flick can otherwise lift before React commits the + // final move, and the action would be decided from a stale offset. + const state = useRef<{ + pointerId: number; + startX: number; + startY: number; + decided: "swipe" | "other" | null; + target: Element | null; + offset: number; + } | null>(null); + // True for the tick after a beyond-slop swipe released, so the click-capture + // handler can swallow the trailing click (pointer-capture + preventDefault + // behavior varies across mobile browsers — don't rely on it). + const justSwipedRef = useRef(false); + + const reset = useCallback(() => { + const s = state.current; + // Release pointer capture if we took it — guarded so it's safe when we + // never captured (vertical/none gestures) or the element is already gone. + if (s?.target && s.target.hasPointerCapture(s.pointerId)) { + s.target.releasePointerCapture(s.pointerId); + } + state.current = null; + setDx(0); + }, []); + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + // Touch-only: pen/stylus/mouse never trigger a swipe. + if (!enabled || e.pointerType !== "touch" || !e.isPrimary) return; + // The row's dialogs/menus are React children but render into portals, and + // React bubbles portal events up the component tree — so a drag inside the + // open delete dialog would otherwise start a swipe on the row behind it. + // Portal content is not a DOM descendant, so containment rejects it. + const target = e.target; + if (target instanceof Node && !e.currentTarget.contains(target)) return; + state.current = { + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + decided: null, + target: null, + offset: 0, + }; + }, + [enabled], + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + const s = state.current; + if (!s || s.pointerId !== e.pointerId) return; + // Once dnd-kit takes over (long-press drag), the swipe yields entirely. + if (isDragging) { + s.decided = "other"; + s.offset = 0; + setDx(0); + return; + } + if (s.decided === "other") return; + const deltaX = e.clientX - s.startX; + const deltaY = e.clientY - s.startY; + if (s.decided === null) { + // Vertical intent → let the list scroll; horizontal → lock to a swipe. + if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > SWIPE_ACTIVATE_PX) { + s.decided = "other"; + return; + } + if (Math.abs(deltaX) < SWIPE_ACTIVATE_PX || Math.abs(deltaX) <= Math.abs(deltaY)) return; + // A direction mapped to "none" is inert — hand back to scroll/drag. + if (actionFor(deltaX, actions) === "none") { + s.decided = "other"; + return; + } + s.decided = "swipe"; + s.target = e.currentTarget; + e.currentTarget.setPointerCapture(e.pointerId); + } + e.preventDefault(); + // A reversal into a direction mapped to "none" rests at 0 instead of + // sliding the row over bare canvas with no hint behind it. + const offset = actionFor(deltaX, actions) === "none" ? 0 : swipeOffset(deltaX); + s.offset = offset; + setDx(offset); + }, + [actions, isDragging], + ); + + const onPointerUp = useCallback( + (e: ReactPointerEvent) => { + const s = state.current; + if (!s || s.pointerId !== e.pointerId) return; + // Commit off the synchronous offset, not the rendered `dx`. + const offset = s.offset; + const committed = s.decided === "swipe" && Math.abs(offset) >= SWIPE_COMMIT_PX; + const action = actionFor(offset, actions); + if (s.decided === "swipe") { + justSwipedRef.current = true; + setTimeout(() => { + justSwipedRef.current = false; + }, 0); + } + reset(); + if (committed && action !== "none") onAction(action); + }, + [actions, onAction, reset], + ); + + const onClickCapture = useCallback((e: MouseEvent) => { + if (!justSwipedRef.current) return; + // Only the trailing click on the row itself — clicks bubbling out of the + // row's portalled dialogs/menus are not descendants and pass through. + const target = e.target; + if (target instanceof Node && !e.currentTarget.contains(target)) return; + e.preventDefault(); + e.stopPropagation(); + }, []); + + return { dx, onPointerDown, onPointerMove, onPointerUp, onPointerCancel: reset, onClickCapture }; +} + // Match the Settings sidebar's ghost-button hover treatment across every home // sidebar row. const SIDEBAR_HOVER_HIGHLIGHT = "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50"; @@ -2909,6 +3100,7 @@ function ConversationRow({ // project flyout's HoverCard and leave it lingering over the chat. Gate the // flyout off below the `md` breakpoint (see `projectFlyoutName`). const isMobile = useIsMobileViewport(); + const hasCoarsePointer = useCoarsePointer(); // Track the *live* active conversation id. Delete is fire-and-forget, // so the user can navigate to another conversation before the mutation // resolves — the onSuccess redirect must key off where they are now, @@ -3089,6 +3281,33 @@ function ConversationRow({ // and only a row that saw both clicks may enter rename. const recentClickTimesRef = useRef([]); + // Touch swipe → archive/delete. `useSwipeActions` is a live subscription, so + // changing the Settings selects updates open rows in the same session (no + // remount needed). Gated to a coarse pointer and to editable, non-selection + // rows so it can't fight bulk-select. Swipe→archive reuses + // runArchive; swipe→delete opens the same confirm dialog the kebab uses + // (never an immediate delete). + const swipeActions = useSwipeActions(); + // useRowSwipe accepts only touch pointers, so a touchscreen laptop's mouse + // path stays untouched. With both directions mapped to "none" the gesture is + // fully disarmed — no handlers fire and the touch-action override below is + // dropped, so the browser keeps its horizontal gestures. + const swipeEnabled = + hasCoarsePointer && + !selectionMode && + isOwner && + !isEditing && + (swipeActions.left !== "none" || swipeActions.right !== "none"); + const swipe = useRowSwipe({ + enabled: swipeEnabled, + actions: swipeActions, + isDragging, + onAction: (action) => { + if (action === "archive") runArchive(); + else setDeleteOpen(true); + }, + }); + if (isEditing) { return (
  • @@ -3287,15 +3506,105 @@ function ConversationRow({ ); + // The action revealed behind the row for the direction currently being + // swiped, and whether the drag has passed the commit point (drives the hint's + // icon and tint). `isSwiping` gates every bit of swipe-only markup/styling, so + // a row at rest renders exactly as it did before the gesture existed. + const swipingAction: SwipeAction = actionFor(swipe.dx, swipeActions); + const isSwiping = swipe.dx !== 0 && swipingAction !== "none"; + const swipeCommitted = Math.abs(swipe.dx) >= SWIPE_COMMIT_PX; + return ( // Drag props on the
  • so the whole row is grabbable; `isDragging` dims // it. `setRowRef` merges the drag node ref with the scroll-into-view ref.
  • - {/* Right-click anywhere on the row opens the same actions as the kebab. + {/* Swipe reveal hint: sits behind the moving surface, showing the + configured action's icon on the side being revealed. Inert (no pointer + events) and only rendered mid-swipe, so at rest the row markup is + exactly what it was before the gesture existed. + Archive uses the accent pair (blue in both modes) rather than + `--primary`, which is near-black in light mode and reads as a flat grey + button instead of a revealed surface. Passing the commit threshold + deepens the tint and scales the glyph, so "will fire on release" isn't + carried by color alone. */} + {isSwiping && ( +
    0 ? "justify-start" : "justify-end", + "transition-colors", + swipingAction === "delete" + ? swipeCommitted + ? "bg-destructive/20 text-destructive" + : "bg-destructive/10 text-destructive/70" + : swipeCommitted + ? "bg-accent text-accent-foreground" + : "bg-accent/50 text-accent-foreground/70", + )} + > + + {swipingAction === "delete" ? ( + + ) : isArchived ? ( + // Archiving toggles, so on an archived row the gesture restores — + // mirror the kebab's Unarchive glyph rather than promising a re-archive. + + ) : ( + + )} + +
    + )} + {/* Moving surface: the WHOLE row — link, session-state badge, and the + pin/kebab controls — moves together, so the trailing icons travel with + the text instead of the text sliding out from under them. + It INSETS from the swiped edge rather than translating: a translate + would push the title past the panel boundary and cut it mid-word (the + hint needs ~48px of gap, but the title only has ~18px of slack). An + inset re-truncates the title with its existing ellipsis instead, and + keeps every row control inside the panel. + `bg-sidebar` only while mid-swipe: an unconditional background would + plate every row and cover the sidebar canvas. */} +
    + {/* Right-click anywhere on the row opens the same actions as the kebab. Suppressed in selection mode (bulk-select owns the row), where the bare link is rendered instead. ContextMenuTrigger preventDefaults the native contextmenu event, so right-click never navigates; asChild @@ -3304,10 +3613,38 @@ function ConversationRow({ hovering surfaces the project flyout — the trigger sits innermost so both the context menu and the hover card keep their handlers/refs on the Link. */} - {selectionMode ? ( - projectFlyoutName ? ( + {selectionMode ? ( + projectFlyoutName ? ( + + {rowLink} + + + ) : isMobile ? ( + rowLink + ) : ( + + {rowLink} + + + ) + ) : projectFlyoutName ? ( - {rowLink} + + + {rowLink} + + + {}} + {...menuItemProps} + /> + + ) : isMobile ? ( - rowLink - ) : ( - - {rowLink} - - - ) - ) : projectFlyoutName ? ( - - - {rowLink} - + {rowLink} - - - ) : isMobile ? ( - - {rowLink} - - {}} - {...menuItemProps} - /> - - - ) : ( - - - -
    - {rowLink} -
    -
    - - {}} - {...menuItemProps} - /> - -
    - -
    - )} - {selectionMode ? ( - - {isSelected ? ( - - ) : ( - - )} - - ) : sessionState !== null ? ( - - - - ) : null} - {/* Trailing controls (pin + kebab) share one absolutely-positioned flex - row, so their spacing is defined once (gap-0.5) and stays aligned - with the project-folder header actions, which use the same pattern. - The kebab is the rightmost child (pinned to right-1); the pin sits a - gap to its left. Hidden entirely while selecting (bulk mode owns the - row controls). */} - {!selectionMode && ( -
    - {/* Archived rows omit the pin entirely: pinning is meaningless there - (archive outranks pin), so there's no pin action even on hover. */} - {!isArchived && ( - - )} - - + ) : ( + + + +
    + {rowLink} +
    +
    + + {}} + {...menuItemProps} + /> + +
    + +
    + )} + {selectionMode ? ( + + {isSelected ? ( + + ) : ( + + )} + + ) : sessionState !== null ? ( + + + + ) : null} + {/* Trailing controls (pin + kebab) share one absolutely-positioned flex + row, so their spacing is defined once (gap-0.5) and stays aligned + with the project-folder header actions, which use the same pattern. + The kebab is the rightmost child (pinned to right-1); the pin sits a + gap to its left. Hidden entirely while selecting (bulk mode owns the + row controls). */} + {!selectionMode && ( +
    + {/* Archived rows omit the pin entirely: pinning is meaningless there + (archive outranks pin), so there's no pin action even on hover. */} + {!isArchived && ( - - - - - -
    - )} + )} + + + + + + + + +
    + )} +
    Date: Mon, 3 Aug 2026 10:55:47 -0700 Subject: [PATCH 02/30] fix(web): harden the row swipe gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from review of the swipe gesture: - Commit off a synchronously-tracked offset instead of the rendered `dx`. A fast flick can lift the finger before React commits the render for the final pointermove, so the action was decided from a stale offset — a swipe past the threshold could fail to fire, and one that snapped back could fire anyway. - Claim the horizontal axis with `touch-pan-y` where a swipe can fire, so the browser can't take the pan (or a back-navigation gesture) and cancel the drag mid-swipe. Vertical scrolling stays native. - Ignore pointerdowns that bubbled out of a portal. The row's dialogs are React children rendered in portals, so a drag inside the open delete dialog would otherwise start a swipe on the row behind it. - Reset the swipe preference with "Reset appearance", which promises to clear every appearance choice. - Transition the row transform only at rest, so it tracks the finger 1:1 while swiping instead of easing behind it. - Scale the hint glyph past the commit point so the "will fire on release" state isn't carried by color alone. Co-authored-by: Isaac Signed-off-by: Bryan Li --- web/src/pages/SettingsPage.tsx | 4 + web/src/shell/Sidebar.rowActions.test.tsx | 101 ++++++++++++++++++++-- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 0ec13fdcd1..fb02bee2c8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -144,6 +144,7 @@ import { writeHideUnconfiguredHarnesses, } from "@/lib/harnessVisibilityPreferences"; import { + DEFAULT_SWIPE_ACTIONS, isSwipeAction, type SwipeAction, swipeActions as swipeActionOptions, @@ -925,6 +926,8 @@ function AppearanceSection() { writeHideUnconfiguredHarnesses(DEFAULT_HIDE_UNCONFIGURED_HARNESSES); + writeSwipeActions(DEFAULT_SWIPE_ACTIONS); + applyDesktopUiFontSize(UI_FONT_SIZE_DEFAULT); applyUiFontFamily(UI_FONT_FAMILY_DEFAULT); @@ -947,6 +950,7 @@ function AppearanceSection() { "omnigent:custom-theme", "omnigent:default-workspace-panel", "omnigent:hide-unconfigured-harnesses", + "omnigent:swipe-actions", ]) { window.localStorage.removeItem(key); } diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index 61aa6d6be6..8c34f72a50 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -1084,18 +1084,30 @@ describe("useRowSwipe — dnd coexistence", () => { // so control `isDragging` directly to prove the swipe yields to the drag. const ACTIONS = { left: "archive", right: "none" } as const; - function makePointer(over: { pointerId: number; clientX: number; clientY: number }) { - // Minimal ReactPointerEvent stand-in — only the fields useRowSwipe reads, - // plus pointer-capture no-ops (jsdom's are stubbed in test-setup). + // Row stand-in: pointer-capture no-ops (jsdom's are stubbed in test-setup) + // plus `contains`, which the hook uses to reject events bubbling out of a + // portal. Defaults to containing everything (the normal in-row case). + const PORTAL_ROW = { + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + hasPointerCapture: () => false, + contains: () => true, + }; + + function makePointer(over: { + pointerId: number; + clientX: number; + clientY: number; + target?: unknown; + currentTarget?: unknown; + }) { + // Minimal ReactPointerEvent stand-in — only the fields useRowSwipe reads. return { pointerType: "touch", isPrimary: true, preventDefault: () => {}, - currentTarget: { - setPointerCapture: () => {}, - releasePointerCapture: () => {}, - hasPointerCapture: () => false, - }, + target: document.createElement("div"), + currentTarget: PORTAL_ROW, ...over, } as unknown as ReactPointerEvent; } @@ -1153,6 +1165,79 @@ describe("useRowSwipe — dnd coexistence", () => { }); expect(onAction).not.toHaveBeenCalled(); }); + + it("(c) commits a fast flick that releases before the last move renders", () => { + // A quick flick can lift the finger before React commits the render for the + // final pointermove. Release must decide on where the finger actually ended, + // so the move + up are dispatched inside ONE act() — no render in between. + const onAction = vi.fn(); + const { result } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging: false, onAction }), + ); + + act(() => { + result.current.onPointerDown(makePointer({ pointerId: 1, clientX: 200, clientY: 100 })); + // Lock the gesture, then travel well past the 72px commit threshold and + // release — all before the hook re-renders with the final offset. + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 180, clientY: 100 })); + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + + // -100px past the threshold in the archive-configured direction. + expect(onAction).toHaveBeenCalledWith("archive"); + expect(result.current.dx).toBe(0); + }); + + it("(d) does not commit a flick that snaps back under the threshold before release", () => { + // The mirror case: travel past the threshold, then return under it and + // release, again with no render between the moves. Nothing should fire. + const onAction = vi.fn(); + const { result } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging: false, onAction }), + ); + + act(() => { + result.current.onPointerDown(makePointer({ pointerId: 1, clientX: 200, clientY: 100 })); + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + // Back to only -10px: under the commit threshold at the moment of release. + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 190, clientY: 100 })); + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 190, clientY: 100 })); + }); + + expect(onAction).not.toHaveBeenCalled(); + }); + + it("(e) ignores a pointerdown that bubbled out of a portal (open dialog)", () => { + // The row's dialogs render in portals but are React children, so their + // events bubble to the row's handlers. A drag inside the open delete dialog + // must not start a swipe on the row behind it. + const onAction = vi.fn(); + const { result } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging: false, onAction }), + ); + + // currentTarget is the row; target is portal content it does NOT contain. + const outside = document.createElement("div"); + act(() => { + result.current.onPointerDown( + makePointer({ + pointerId: 1, + clientX: 200, + clientY: 100, + target: outside, + currentTarget: { ...PORTAL_ROW, contains: () => false }, + }), + ); + }); + act(() => { + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + + expect(result.current.dx).toBe(0); + expect(onAction).not.toHaveBeenCalled(); + }); }); describe("sharing kill switch", () => { From 5fe2ab1e51e6f2d91441f1943706b3d790ac14a6 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Mon, 3 Aug 2026 11:31:29 -0700 Subject: [PATCH 03/30] polish(web): make the swipe reveal read as a surface, not a button The archive direction looked unfinished next to delete: the hint used `bg-primary/15`, and --primary is near-black in light mode, so it rendered as a flat grey block that read as a disabled button. It now uses the accent pair, which is blue in both modes, and deepens its tint past the commit point. The row also inset-slides instead of translating. A translate pushed the title past the panel boundary, where it was cut mid-word: revealing the hint needs ~48px of gap but the title only has ~18px of slack, so clipping was inherent to translating. Insetting from the swiped edge lets the title re-truncate with its existing ellipsis and keeps every row control inside the panel. Travel past the commit point is also damped to a third and hard-capped, so a long drag resists rather than dragging the row further than the action needs. Co-authored-by: Isaac Signed-off-by: Bryan Li --- web/src/shell/Sidebar.rowActions.test.tsx | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index 8c34f72a50..ff49355609 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -1208,6 +1208,42 @@ describe("useRowSwipe — dnd coexistence", () => { expect(onAction).not.toHaveBeenCalled(); }); + it("(f) tracks 1:1 to the commit point, then resists so the title stays in panel", () => { + const onAction = vi.fn(); + const { result } = renderHook(() => + useRowSwipe({ enabled: true, actions: ACTIONS, isDragging: false, onAction }), + ); + + act(() => { + result.current.onPointerDown(makePointer({ pointerId: 1, clientX: 400, clientY: 100 })); + }); + // Up to the threshold the row follows the finger exactly. + act(() => { + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 340, clientY: 100 })); + }); + expect(result.current.dx).toBe(-60); + + // Well past it, travel is damped and never exceeds the cap — so the row + // can't be dragged far enough to push its title out of the panel. + act(() => { + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + expect(Math.abs(result.current.dx)).toBeLessThanOrEqual(96); + expect(Math.abs(result.current.dx)).toBeGreaterThanOrEqual(72); + + // A 300px drag is still capped at 96. + act(() => { + result.current.onPointerMove(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + expect(result.current.dx).toBe(-96); + + // Still commits — damping is visual only. + act(() => { + result.current.onPointerUp(makePointer({ pointerId: 1, clientX: 100, clientY: 100 })); + }); + expect(onAction).toHaveBeenCalledWith("archive"); + }); + it("(e) ignores a pointerdown that bubbled out of a portal (open dialog)", () => { // The row's dialogs render in portals but are React children, so their // events bubble to the row's handlers. A drag inside the open delete dialog From cee23f90ff1ac036d38b3349d05237d5d619fbc7 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Tue, 4 Aug 2026 06:42:02 -0700 Subject: [PATCH 04/30] test(web): match the swipe-archive test to the PATCH-only contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving stopped issuing a client-side stop some time ago — the server stops the runner once the flag commits, and a client stop would race it. The swipe test still asserted the old stop-then-archive sequence, so it failed against code that is behaving correctly. Assert the real contract instead, including that no client stop fires. Signed-off-by: Bryan Li --- web/src/shell/Sidebar.rowActions.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index ff49355609..feeb849c83 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -965,13 +965,13 @@ describe("touch swipe actions", () => { swipeRow(-90); - // runArchive stops the runner first, then flips the archive flag — the same - // stop→archive handler the kebab's Archive item uses. - expect(mocks.stopSession.mutate).toHaveBeenCalledWith("conv_1", expect.anything()); + // 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(); }); From 65f6cac540c52548afa592f37a5472e370a0afa5 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Wed, 5 Aug 2026 04:08:40 -0700 Subject: [PATCH 05/30] test(web): close swipe action coverage gaps Cover the Settings direction mapping end to end, reveal/action pairing, the exact 72px commit boundary, single dispatch, and a throwing localStorage getter. Unrecognized stored actions now normalize to none instead of silently arming archive. Co-authored-by: Isaac Signed-off-by: Bryan Li --- web/src/lib/swipeActionPreferences.test.ts | 30 +++- web/src/lib/swipeActionPreferences.ts | 15 +- web/src/shell/Sidebar.rowActions.test.tsx | 160 ++++++++++++++++++++- 3 files changed, 193 insertions(+), 12 deletions(-) diff --git a/web/src/lib/swipeActionPreferences.test.ts b/web/src/lib/swipeActionPreferences.test.ts index 625c9176da..475d42c1f1 100644 --- a/web/src/lib/swipeActionPreferences.test.ts +++ b/web/src/lib/swipeActionPreferences.test.ts @@ -62,13 +62,16 @@ describe("normalizeSwipeActions", () => { }); }); - it("fills missing/unknown directions from the defaults", () => { + it("fills absent directions from the defaults", () => { expect(normalizeSwipeActions({ left: "delete" })).toEqual({ left: "delete", right: DEFAULT_SWIPE_ACTIONS.right, }); - expect(normalizeSwipeActions({ left: "bogus", right: "archive" })).toEqual({ - left: DEFAULT_SWIPE_ACTIONS.left, + }); + + it("makes a present but unrecognized action inert", () => { + expect(normalizeSwipeActions({ left: "trash", right: "archive" })).toEqual({ + left: "none", right: "archive", }); }); @@ -82,6 +85,22 @@ describe("normalizeSwipeActions", () => { }); 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); @@ -91,6 +110,11 @@ describe("readSwipeActions — corrupt storage", () => { 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", () => { diff --git a/web/src/lib/swipeActionPreferences.ts b/web/src/lib/swipeActionPreferences.ts index 35eb2f2a54..6b83924c80 100644 --- a/web/src/lib/swipeActionPreferences.ts +++ b/web/src/lib/swipeActionPreferences.ts @@ -43,15 +43,20 @@ export function isSwipeAction(value: unknown): value is SwipeAction { } /** - * Normalize an arbitrary parsed value to a valid preferences object, filling - * each missing/unknown direction from {@link DEFAULT_SWIPE_ACTIONS}. Used both - * on read (localStorage drift / manual edits) and to sanitize writes. + * 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) : {}; + const normalizeDirection = (direction: SwipeDirection): SwipeAction => { + const action = obj[direction]; + if (isSwipeAction(action)) return action; + return Object.hasOwn(obj, direction) ? "none" : DEFAULT_SWIPE_ACTIONS[direction]; + }; return { - left: isSwipeAction(obj.left) ? obj.left : DEFAULT_SWIPE_ACTIONS.left, - right: isSwipeAction(obj.right) ? obj.right : DEFAULT_SWIPE_ACTIONS.right, + left: normalizeDirection("left"), + right: normalizeDirection("right"), }; } diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index feeb849c83..c6759a17d9 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -6,7 +6,11 @@ // `onDoubleClick`), gated on edit permission. // See ConversationRow / ConversationEditRow in Sidebar.tsx. -import { type PointerEvent as ReactPointerEvent, useSyncExternalStore } from "react"; +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useSyncExternalStore, +} from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, @@ -145,9 +149,51 @@ vi.mock("@/components/PermissionsModal", () => ({ PermissionsModal: () => null } // the tabs the shared-session row actions rely on. vi.mock("@/lib/serverOrigin", () => ({ isCurrentServerLocal: () => false })); +// Settings uses a Radix Select portal that jsdom cannot drive. Render a native +// select so these tests can exercise the real swipe preference control. +vi.mock("@/components/ui/select", async () => { + const { Children, isValidElement } = await import("react"); + const SelectTrigger = ({ children }: { children?: ReactNode }) => children; + const Select = ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: ReactNode; + }) => { + const kids = Children.toArray(children); + const trigger = kids.find((child) => isValidElement(child) && child.type === SelectTrigger); + const testId = + isValidElement(trigger) && trigger.props && typeof trigger.props === "object" + ? (trigger.props as Record)["data-testid"] + : undefined; + return ( + + ); + }; + return { + Select, + SelectTrigger, + SelectValue: () => null, + SelectContent: ({ children }: { children: ReactNode }) => children, + SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( + + ), + }; +}); + import { type Conversation, useConversations } from "@/hooks/useConversations"; import { resetReadStateForTests, seedReadState } from "@/hooks/useUnseenConversations"; -import { writeSwipeActions } from "@/lib/swipeActionPreferences"; +import { readSwipeActions, writeSwipeActions } from "@/lib/swipeActionPreferences"; +import { SettingsPage } from "@/pages/SettingsPage"; import { Sidebar, useRowSwipe } from "./Sidebar"; const useConvMock = vi.mocked(useConversations); @@ -247,6 +293,16 @@ function renderSidebar(activeId?: string, info?: ServerInfo) { return Object.assign(view, { rerenderSidebar: () => view.rerender(makeUi()) }); } +function renderAppearanceSettings() { + return render( + + + + + , + ); +} + beforeEach(() => { mocks.rename.mutate.mockReset(); mocks.moveToProject.mutate.mockReset(); @@ -949,15 +1005,90 @@ describe("touch swipe actions", () => { // down → horizontal move past the commit threshold → up on the row's
  • . const POINTER = { pointerId: 1, isPrimary: true, pointerType: "touch" as const }; - function swipeRow(dx: number) { - const li = screen.getByRole("link", { name: /My Session/ }).closest("li")!; + function moveSwipeRow(dx: number) { + const link = screen.getByRole("link", { name: /My Session/ }); + const li = link.closest("li")!; fireEvent.pointerDown(li, { ...POINTER, clientX: 100, clientY: 100 }); // First move locks the axis; the second carries it past the commit point. fireEvent.pointerMove(li, { ...POINTER, clientX: 100 + Math.sign(dx) * 20, clientY: 100 }); fireEvent.pointerMove(li, { ...POINTER, clientX: 100 + dx, clientY: 100 }); + return { li, link }; + } + + function releaseSwipeRow(dx: number, row: ReturnType) { + const { li, link } = row; fireEvent.pointerUp(li, { ...POINTER, clientX: 100 + dx, clientY: 100 }); + // Include the browser's trailing click so gesture and row click paths + // cannot double-dispatch; prevent navigation while exercising the handler. + link.addEventListener("click", (event) => event.preventDefault(), { once: true }); + fireEvent.click(link); } + function swipeRow(dx: number) { + const row = moveSwipeRow(dx); + releaseSwipeRow(dx, row); + } + + it("maps the Settings swipe-left selection to the row's left-swipe action", () => { + renderAppearanceSettings(); + fireEvent.change(screen.getByTestId("swipe-action-left"), { + target: { value: "delete" }, + }); + expect(readSwipeActions()).toEqual({ left: "delete", right: "none" }); + + cleanup(); + mocks.isMobile = true; + renderSidebar(); + swipeRow(-90); + + expect(screen.getByText("Delete conversation?")).toBeInTheDocument(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(mocks.del.mutate).toHaveBeenCalledTimes(1); + }); + + it("maps the Settings swipe-right selection to the row's right-swipe action", () => { + renderAppearanceSettings(); + fireEvent.change(screen.getByTestId("swipe-action-right"), { + target: { value: "archive" }, + }); + expect(readSwipeActions()).toEqual({ left: "archive", right: "archive" }); + + cleanup(); + mocks.isMobile = true; + renderSidebar(); + swipeRow(90); + + expect(mocks.archive.mutate).toHaveBeenCalledTimes(1); + expect(mocks.del.mutate).not.toHaveBeenCalled(); + expect(screen.queryByText("Delete conversation?")).toBeNull(); + }); + + it("reveals the same action that fires in both configured directions", () => { + writeSwipeActions({ left: "delete", right: "archive" }); + mocks.isMobile = true; + renderSidebar(); + + const leftSwipe = moveSwipeRow(-90); + const leftReveal = leftSwipe.li.firstElementChild!; + expect(leftReveal).toHaveClass("pointer-events-none"); + expect(leftReveal.querySelector(".lucide-trash-2")).not.toBeNull(); + expect(leftReveal.querySelector(".lucide-archive")).toBeNull(); + releaseSwipeRow(-90, leftSwipe); + expect(screen.getByText("Delete conversation?")).toBeInTheDocument(); + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + const rightSwipe = moveSwipeRow(90); + const rightReveal = rightSwipe.li.firstElementChild!; + expect(rightReveal).toHaveClass("pointer-events-none"); + expect(rightReveal.querySelector(".lucide-archive")).not.toBeNull(); + expect(rightReveal.querySelector(".lucide-trash-2")).toBeNull(); + releaseSwipeRow(90, rightSwipe); + expect(mocks.archive.mutate).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Delete conversation?")).toBeNull(); + }); + it("runs the archive path when swiping the archive-configured direction", () => { // Default: swipe-left → archive. Swipe left past the commit threshold. mocks.isMobile = true; @@ -971,6 +1102,7 @@ describe("touch swipe actions", () => { { id: "conv_1", archived: true }, expect.anything(), ); + expect(mocks.archive.mutate).toHaveBeenCalledTimes(1); expect(mocks.stopSession.mutate).not.toHaveBeenCalled(); // Archive never routes through delete. expect(mocks.del.mutate).not.toHaveBeenCalled(); @@ -994,6 +1126,7 @@ describe("touch swipe actions", () => { { id: "conv_1", deleteBranch: false }, expect.anything(), ); + expect(mocks.del.mutate).toHaveBeenCalledTimes(1); }); it("does nothing when swiping a direction mapped to none", () => { @@ -1037,6 +1170,24 @@ describe("touch swipe actions", () => { expect(mocks.archive.mutate).not.toHaveBeenCalled(); }); + it("does not fire at 71px, immediately below the commit boundary", () => { + mocks.isMobile = true; + renderSidebar(); + + swipeRow(-71); + + expect(mocks.archive.mutate).not.toHaveBeenCalled(); + }); + + it("fires exactly once at the 72px commit boundary", () => { + mocks.isMobile = true; + renderSidebar(); + + swipeRow(-72); + + expect(mocks.archive.mutate).toHaveBeenCalledTimes(1); + }); + it("ignores swipes on desktop (non-touch viewport)", () => { // Desktop (default isMobile=false) leaves the gesture disabled entirely. renderSidebar(); @@ -1186,6 +1337,7 @@ describe("useRowSwipe — dnd coexistence", () => { // -100px past the threshold in the archive-configured direction. expect(onAction).toHaveBeenCalledWith("archive"); + expect(onAction).toHaveBeenCalledTimes(1); expect(result.current.dx).toBe(0); }); From 8161e3612dfc3738744efaefd5a91b49f4069039 Mon Sep 17 00:00:00 2001 From: Bryan Li Date: Wed, 5 Aug 2026 05:10:01 -0700 Subject: [PATCH 06/30] refactor(web): tidy the swipe action test helpers Fold the settings-select drive into one helper, let a held swipe release itself instead of re-passing dx, and share the reveal icon assertions. Co-authored-by: Isaac Signed-off-by: Bryan Li --- web/src/lib/swipeActionPreferences.ts | 11 ++- web/src/shell/Sidebar.rowActions.test.tsx | 88 ++++++++++++----------- 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/web/src/lib/swipeActionPreferences.ts b/web/src/lib/swipeActionPreferences.ts index 6b83924c80..1784b5152c 100644 --- a/web/src/lib/swipeActionPreferences.ts +++ b/web/src/lib/swipeActionPreferences.ts @@ -49,15 +49,14 @@ export function isSwipeAction(value: unknown): value is SwipeAction { */ export function normalizeSwipeActions(value: unknown): SwipeActionPreferences { const obj = typeof value === "object" && value !== null ? (value as Record) : {}; - const normalizeDirection = (direction: SwipeDirection): SwipeAction => { + // 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: normalizeDirection("left"), - right: normalizeDirection("right"), - }; + } + return { left: actionFor("left"), right: actionFor("right") }; } /** diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index c6759a17d9..b4199fe80b 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -8,6 +8,7 @@ import { type PointerEvent as ReactPointerEvent, + type ReactElement, type ReactNode, useSyncExternalStore, } from "react"; @@ -154,6 +155,10 @@ vi.mock("@/lib/serverOrigin", () => ({ isCurrentServerLocal: () => false })); vi.mock("@/components/ui/select", async () => { const { Children, isValidElement } = await import("react"); const SelectTrigger = ({ children }: { children?: ReactNode }) => children; + // The trigger carries the data-testid the tests query for; lift it onto the + // native onValueChange(event.target.value)} > - {kids.filter((child) => !(isValidElement(child) && child.type === SelectTrigger))} + {kids.filter((child) => !isTrigger(child))} ); }; @@ -192,7 +192,12 @@ vi.mock("@/components/ui/select", async () => { import { type Conversation, useConversations } from "@/hooks/useConversations"; import { resetReadStateForTests, seedReadState } from "@/hooks/useUnseenConversations"; -import { readSwipeActions, writeSwipeActions } from "@/lib/swipeActionPreferences"; +import { + readSwipeActions, + type SwipeAction, + type SwipeDirection, + writeSwipeActions, +} from "@/lib/swipeActionPreferences"; import { SettingsPage } from "@/pages/SettingsPage"; import { Sidebar, useRowSwipe } from "./Sidebar"; @@ -293,14 +298,19 @@ function renderSidebar(activeId?: string, info?: ServerInfo) { return Object.assign(view, { rerenderSidebar: () => view.rerender(makeUi()) }); } -function renderAppearanceSettings() { - return render( +// Pick an action for one direction through the real Appearance settings control +// (the Radix Select is mocked to a native and keep the trigger itself out of the option list. - const isTrigger = (child: ReactNode): child is ReactElement<{ "data-testid"?: string }> => - isValidElement(child) && child.type === SelectTrigger; - const Select = ({ - value, - onValueChange, - children, - }: { - value: string; - onValueChange: (value: string) => void; - children: ReactNode; - }) => { - const kids = Children.toArray(children); - return ( - - ); - }; - return { - Select, - SelectTrigger, - SelectValue: () => null, - SelectContent: ({ children }: { children: ReactNode }) => children, - SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( - - ), - }; -}); - import { type Conversation, useConversations } from "@/hooks/useConversations"; import { resetReadStateForTests, seedReadState } from "@/hooks/useUnseenConversations"; -import { - readSwipeActions, - type SwipeAction, - type SwipeDirection, - writeSwipeActions, -} from "@/lib/swipeActionPreferences"; -import { SettingsPage } from "@/pages/SettingsPage"; -import { Sidebar, useRowSwipe } from "./Sidebar"; +import { ROW_GESTURE_HOLD_MS } from "@/hooks/useRowGesture"; +import { writeSwipeActions } from "@/lib/swipeActionPreferences"; +import { Sidebar } from "./Sidebar"; const useConvMock = vi.mocked(useConversations); @@ -269,7 +212,6 @@ function serverInfo(overrides: Partial = {}): ServerInfo { public_sharing_enabled: true, server_version: null, smart_routing_enabled: false, - smart_routing_sources: { external: false, oss: false }, harness_install_enabled: false, installable_harnesses: [], dictation_available: false, @@ -277,23 +219,18 @@ function serverInfo(overrides: Partial = {}): ServerInfo { }; } -// Exposes the router's current pathname so tests can assert whether a click on -// a row link actually navigated (e.g. the swipe suite's trailing-click guard). -function LocationProbe() { - return
    {useLocation().pathname}
    ; -} - // `activeId` mounts the sidebar at `/c/:conversationId` (via a matching // Route so `useParams` populates), making that row the active one — the // rest of the suite renders at `/` where no row is active. `info` pins the // 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 = ( @@ -305,7 +242,6 @@ function renderSidebar(activeId?: string, info?: ServerInfo) { ) : ( sidebar )} - @@ -317,22 +253,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()) }); -} - -// Pick an action for one direction through the real Appearance settings control -// (the Radix Select is mocked to a native