feat(mobile): close the drawers with the reverse edge swipe

The chat opens the sessions drawer with a left-edge swipe and the
workspace drawer with a right-edge one, but neither could be closed the
same way: the only way back was the header X or the Android back button.

Reuse the edge-swipe hook on both open drawers so the mirrored gesture
closes them. To keep that free of the row swipe actions in the sessions
list, those are mirrored too: session, project and worktree rows now
reveal their actions on the LEFT with a swipe to the right, in the
mirrored button order so a partial drag still exposes delete first. When
a row has its actions out, the closing swipe puts them away first
instead of closing the drawer.

The hook grew an `enabled` flag so the listeners re-attach when a drawer
opens or a tablet rotates the workspace between panel and drawer.

Testing: package type-check and lint; gestures need a device check.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:14 +03:00
parent af84735388
commit 0841ccdd57
3 changed files with 116 additions and 60 deletions
+90 -54
View File
@@ -73,6 +73,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
type MobileSessionsSheetProps = {
open: boolean;
@@ -272,9 +273,11 @@ const NewWorktreeIconButton: React.FC<{
const ROW_ACTIONS_WIDTH = 144;
const ROW_SWIPE_SNAP_MS = 180;
/** Generic swipe-left-to-reveal wrapper for drawer rows (projects, worktrees).
/** Generic swipe-right-to-reveal wrapper for drawer rows (projects, worktrees).
Same gesture mechanics as SessionRow's swipe actions: horizontal intent
detection, imperative transform during the drag, snap on release. */
detection, imperative transform during the drag, snap on release. The
actions sit on the LEFT so the opposite direction stays free for the
drawer's own close swipe. */
const MobileSwipeActionsRow: React.FC<{
actionsWidth: number;
actions: React.ReactNode;
@@ -298,7 +301,7 @@ const MobileSwipeActionsRow: React.FC<{
React.useEffect(() => {
revealedRef.current = revealed;
applyOffset(revealed ? -actionsWidth : 0, true);
applyOffset(revealed ? actionsWidth : 0, true);
}, [actionsWidth, applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => {
@@ -317,16 +320,16 @@ const MobileSwipeActionsRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true;
}
const base = revealedRef.current ? -actionsWidth : 0;
applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false);
const base = revealedRef.current ? actionsWidth : 0;
applyOffset(Math.max(0, Math.min(actionsWidth, base + dx)), false);
};
const handleTouchEnd = () => {
startRef.current = null;
if (!draggingRef.current) return;
draggingRef.current = false;
const shouldReveal = offsetRef.current < -actionsWidth / 2;
applyOffset(shouldReveal ? -actionsWidth : 0, true);
const shouldReveal = offsetRef.current > actionsWidth / 2;
applyOffset(shouldReveal ? actionsWidth : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal);
};
@@ -339,7 +342,7 @@ const MobileSwipeActionsRow: React.FC<{
onTouchCancel={handleTouchEnd}
style={{ touchAction: 'pan-y' }}
>
<div className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
<div className="absolute inset-y-0 left-0 flex items-stretch" style={{ width: actionsWidth }} aria-hidden={!revealed}>
{actions}
</div>
<div ref={contentRef} className="relative flex w-full items-center bg-background">
@@ -445,7 +448,7 @@ const SessionRow: React.FC<{
expanded?: boolean;
onToggleChildren?: () => void;
onSelect: () => void;
/** Swipe-left actions. When omitted, the row is a plain non-swipeable row. */
/** Swipe-right actions. When omitted, the row is a plain non-swipeable row. */
revealed?: boolean;
onRevealedChange?: (revealed: boolean) => void;
confirmingDelete?: boolean;
@@ -508,7 +511,7 @@ const SessionRow: React.FC<{
React.useEffect(() => {
revealedRef.current = revealed;
applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true);
applyOffset(revealed ? ROW_ACTIONS_WIDTH : 0, true);
}, [applyOffset, revealed]);
const handleTouchStart = (event: React.TouchEvent) => {
@@ -527,8 +530,8 @@ const SessionRow: React.FC<{
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
draggingRef.current = true;
}
const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0;
const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx));
const base = revealedRef.current ? ROW_ACTIONS_WIDTH : 0;
const next = Math.max(0, Math.min(ROW_ACTIONS_WIDTH, base + dx));
applyOffset(next, false);
};
@@ -536,8 +539,8 @@ const SessionRow: React.FC<{
startRef.current = null;
if (!draggingRef.current) return;
draggingRef.current = false;
const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2;
applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true);
const shouldReveal = offsetRef.current > ROW_ACTIONS_WIDTH / 2;
applyOffset(shouldReveal ? ROW_ACTIONS_WIDTH : 0, true);
if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal);
};
@@ -554,32 +557,14 @@ const SessionRow: React.FC<{
>
{swipeEnabled ? (
<div
className="absolute inset-y-0 right-0 flex items-stretch"
className="absolute inset-y-0 left-0 flex items-stretch"
style={{ width: ROW_ACTIONS_WIDTH }}
aria-hidden={!revealed}
>
{/* Icon-only actions on the row's own background — they read as the
row extending to reveal extra controls, not a separate panel. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
row extending to reveal extra controls, not a separate panel.
Ordered outward from the content, so a partial drag exposes
delete first, exactly as the right-side version did. */}
<button
type="button"
tabIndex={revealed ? 0 : -1}
@@ -597,6 +582,26 @@ const SessionRow: React.FC<{
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.archiveSessionAria', { title })}
onClick={onArchive}
style={{ touchAction: 'manipulation' }}
>
<RiArchiveLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealed ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.renameSessionAria', { title })}
onClick={onRequestRename}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</div>
) : null}
<div
@@ -895,12 +900,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
const [query, setQuery] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
// Swipe-left actions: which row has its actions revealed, and whether its
// Swipe-right actions: which row has its actions revealed, and whether its
// delete button is armed (two-step). One row at a time.
const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
const [renamingSessionId, setRenamingSessionId] = React.useState<string | null>(null);
// Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) —
// Swipe-right actions on group headers (`project:{id}` / `wt:{bucketKey}`) —
// separate from session rows, but mutually exclusive with them.
const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null);
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState<string | null>(null);
@@ -1703,19 +1708,6 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
actions={(
<>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
@@ -1742,6 +1734,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
>
<RiDeleteBinLine className="size-[18px]" />
</button>
<button
type="button"
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
className="flex flex-1 items-center justify-center text-muted-foreground transition-colors active:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
aria-label={t('mobile.sessions.editProjectAria', { label: node.project.label })}
onClick={() => {
setRevealedRowId(null);
setEditingProjectId(node.project.id);
}}
style={{ touchAction: 'manipulation' }}
>
<RiEdit2Line className="size-[18px]" />
</button>
</>
)}
>
@@ -1998,6 +2003,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
<MobileSessionsDrawerContainer
open={open}
onClose={() => onOpenChange(false)}
// The mirror of the swipe that opened the drawer closes it again —
// except while a row has its actions out: then the same swipe is the
// user putting those away, so it only clears them.
onSwipeClose={() => {
if (revealedSessionId || revealedRowId) {
setRevealedSessionId(null);
setRevealedRowId(null);
setConfirmingDeleteSessionId(null);
setConfirmingRemoveProjectId(null);
return;
}
onOpenChange(false);
}}
ariaLabel={t('mobile.sessions.sheet.title')}
>
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
@@ -2030,8 +2048,9 @@ const DRAWER_ENTER_DURATION_MS = 320;
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
/** Full-width left drawer for the phone sessions list: covers the whole app
and slides in from the left edge. Closes via the header X, Escape, or the
Android back button (handled by MobileShell).
and slides in from the left edge. Closes via the header X, a right-edge
swipe back toward the left (the mirror of the gesture that opened it),
Escape, or the Android back button (handled by MobileShell).
Stays MOUNTED while closed (parked off-screen, hidden): the sessions
sheet's project/worktree state stays warm, so reopening shows the tree
@@ -2040,10 +2059,14 @@ const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
const MobileSessionsDrawerContainer: React.FC<{
open: boolean;
onClose: () => void;
/** What the closing edge swipe does; the drawer's owner may want it to undo
a lighter state first. Falls back to `onClose`. */
onSwipeClose?: () => void;
ariaLabel: string;
children: React.ReactNode;
}> = ({ open, onClose, ariaLabel, children }) => {
}> = ({ open, onClose, onSwipeClose, ariaLabel, children }) => {
const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open);
@@ -2051,6 +2074,18 @@ const MobileSessionsDrawerContainer: React.FC<{
React.useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const onSwipeCloseRef = React.useRef(onSwipeClose);
React.useEffect(() => {
onSwipeCloseRef.current = onSwipeClose;
}, [onSwipeClose]);
// Swipe from the drawer's right edge back toward the left = close, the
// reverse of the left-edge swipe that opened it from the chat. Rows inside
// reveal their actions in the opposite direction, so the two never fight.
useEdgeSwipe(drawerRef, {
enabled: open,
onRightEdgeSwipe: () => (onSwipeCloseRef.current ?? onCloseRef.current)(),
});
if (typeof document !== 'undefined' && !rootRef.current) {
let root = document.getElementById(DRAWER_ROOT_ID);
@@ -2091,6 +2126,7 @@ const MobileSessionsDrawerContainer: React.FC<{
return createPortal(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
+15 -2
View File
@@ -17,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
import { MobileChangesSurface } from './MobileChangesSurface';
import { MobileFilesSurface } from './MobileFilesSurface';
import { useEdgeSwipe } from './useEdgeSwipe';
const DRAWER_ROOT_ID = 'mobile-surface-root';
const ENTER_DELAY_MS = 16;
@@ -96,8 +97,9 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM
beside the chat (tablet, landscape). The caller owns the width and the
open/close animation there; this component only fills it.
Closes via the header X, Escape (unless the terminal tab owns the keys), or
the Android back button (handled by MobileShell). */
Closes via the header X, a left-edge swipe back toward the right (the
mirror of the gesture that opened it), Escape (unless the terminal tab owns
the keys), or the Android back button (handled by MobileShell). */
export const MobileWorkspaceDrawer: React.FC<{
open: boolean;
onClose: () => void;
@@ -113,6 +115,7 @@ export const MobileWorkspaceDrawer: React.FC<{
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
const { t } = useI18n();
const rootRef = React.useRef<HTMLElement | null>(null);
const drawerRef = React.useRef<HTMLElement>(null);
const [entered, setEntered] = React.useState(false);
// Kept visible through the exit slide; flipped to hidden once it finishes.
const [visible, setVisible] = React.useState(open);
@@ -125,6 +128,15 @@ export const MobileWorkspaceDrawer: React.FC<{
tabRef.current = tab;
}, [tab]);
// Swipe from the drawer's left edge back toward the right = close, the
// reverse of the right-edge swipe that opened it from the chat. Only the
// full-cover drawer has an edge to grab; the tablet panel is closed from the
// header instead.
useEdgeSwipe(drawerRef, {
enabled: variant === 'drawer' && open,
onLeftEdgeSwipe: () => onCloseRef.current(),
});
// Tabs the user has actually opened — their panes stay mounted afterwards.
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
React.useEffect(() => {
@@ -278,6 +290,7 @@ export const MobileWorkspaceDrawer: React.FC<{
return createPortal(
<section
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={t('mobile.header.openWorkspaceAria')}
+11 -4
View File
@@ -1,11 +1,12 @@
import React from 'react';
/**
* Native-feeling edge swipes on the mobile chat: start a horizontal swipe from
* Native-feeling edge swipes on the mobile shell: start a horizontal swipe from
* the very left/right screen edge and drag toward the centre.
*
* - Left edge centre = open the sessions drawer
* - Right edge centre = open the most recent overflow surface
* On the chat that opens a drawer (left edge sessions, right edge
* workspace); on an open drawer the mirrored swipe closes it (sessions drawer
* closes from the right edge, workspace drawer from the left edge).
*
* Only `touchstart`/`touchend` are observed (both passive), so this never
* interferes with vertical chat scrolling or the horizontal scroll inside code
@@ -27,6 +28,9 @@ export interface EdgeSwipeOptions {
onLeftEdgeSwipe?: () => void;
/** Swipe that started at the right edge and travelled left. */
onRightEdgeSwipe?: () => void;
/** Defaults to on. Flipping it re-attaches the listeners, which is what a
drawer needs: its element only exists (or only matters) while open. */
enabled?: boolean;
}
export const useEdgeSwipe = (
@@ -37,7 +41,10 @@ export const useEdgeSwipe = (
const optionsRef = React.useRef(options);
optionsRef.current = options;
const enabled = options.enabled ?? true;
React.useEffect(() => {
if (!enabled) return;
const element = ref.current;
if (!element) return;
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
@@ -87,5 +94,5 @@ export const useEdgeSwipe = (
element.removeEventListener('touchstart', onTouchStart);
element.removeEventListener('touchend', onTouchEnd);
};
}, [ref]);
}, [enabled, ref]);
};