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')}
>
@@ -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(null);
+ const drawerRef = React.useRef(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(
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(null);
+ const drawerRef = React.useRef(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>(() => new Set());
React.useEffect(() => {
@@ -278,6 +290,7 @@ export const MobileWorkspaceDrawer: React.FC<{
return createPortal(
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]);
};