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:
@@ -73,6 +73,7 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
|||||||
|
|
||||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||||
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
|
import { MobileProjectEditSurface } from './MobileProjectEditSurface';
|
||||||
|
import { useEdgeSwipe } from './useEdgeSwipe';
|
||||||
|
|
||||||
type MobileSessionsSheetProps = {
|
type MobileSessionsSheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -272,9 +273,11 @@ const NewWorktreeIconButton: React.FC<{
|
|||||||
const ROW_ACTIONS_WIDTH = 144;
|
const ROW_ACTIONS_WIDTH = 144;
|
||||||
const ROW_SWIPE_SNAP_MS = 180;
|
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
|
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<{
|
const MobileSwipeActionsRow: React.FC<{
|
||||||
actionsWidth: number;
|
actionsWidth: number;
|
||||||
actions: React.ReactNode;
|
actions: React.ReactNode;
|
||||||
@@ -298,7 +301,7 @@ const MobileSwipeActionsRow: React.FC<{
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
revealedRef.current = revealed;
|
revealedRef.current = revealed;
|
||||||
applyOffset(revealed ? -actionsWidth : 0, true);
|
applyOffset(revealed ? actionsWidth : 0, true);
|
||||||
}, [actionsWidth, applyOffset, revealed]);
|
}, [actionsWidth, applyOffset, revealed]);
|
||||||
|
|
||||||
const handleTouchStart = (event: React.TouchEvent) => {
|
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;
|
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
draggingRef.current = true;
|
draggingRef.current = true;
|
||||||
}
|
}
|
||||||
const base = revealedRef.current ? -actionsWidth : 0;
|
const base = revealedRef.current ? actionsWidth : 0;
|
||||||
applyOffset(Math.min(0, Math.max(-actionsWidth, base + dx)), false);
|
applyOffset(Math.max(0, Math.min(actionsWidth, base + dx)), false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
startRef.current = null;
|
startRef.current = null;
|
||||||
if (!draggingRef.current) return;
|
if (!draggingRef.current) return;
|
||||||
draggingRef.current = false;
|
draggingRef.current = false;
|
||||||
const shouldReveal = offsetRef.current < -actionsWidth / 2;
|
const shouldReveal = offsetRef.current > actionsWidth / 2;
|
||||||
applyOffset(shouldReveal ? -actionsWidth : 0, true);
|
applyOffset(shouldReveal ? actionsWidth : 0, true);
|
||||||
if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal);
|
if (shouldReveal !== revealedRef.current) onRevealedChange(shouldReveal);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -339,7 +342,7 @@ const MobileSwipeActionsRow: React.FC<{
|
|||||||
onTouchCancel={handleTouchEnd}
|
onTouchCancel={handleTouchEnd}
|
||||||
style={{ touchAction: 'pan-y' }}
|
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}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
<div ref={contentRef} className="relative flex w-full items-center bg-background">
|
<div ref={contentRef} className="relative flex w-full items-center bg-background">
|
||||||
@@ -445,7 +448,7 @@ const SessionRow: React.FC<{
|
|||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onToggleChildren?: () => void;
|
onToggleChildren?: () => void;
|
||||||
onSelect: () => 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;
|
revealed?: boolean;
|
||||||
onRevealedChange?: (revealed: boolean) => void;
|
onRevealedChange?: (revealed: boolean) => void;
|
||||||
confirmingDelete?: boolean;
|
confirmingDelete?: boolean;
|
||||||
@@ -508,7 +511,7 @@ const SessionRow: React.FC<{
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
revealedRef.current = revealed;
|
revealedRef.current = revealed;
|
||||||
applyOffset(revealed ? -ROW_ACTIONS_WIDTH : 0, true);
|
applyOffset(revealed ? ROW_ACTIONS_WIDTH : 0, true);
|
||||||
}, [applyOffset, revealed]);
|
}, [applyOffset, revealed]);
|
||||||
|
|
||||||
const handleTouchStart = (event: React.TouchEvent) => {
|
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;
|
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
draggingRef.current = true;
|
draggingRef.current = true;
|
||||||
}
|
}
|
||||||
const base = revealedRef.current ? -ROW_ACTIONS_WIDTH : 0;
|
const base = revealedRef.current ? ROW_ACTIONS_WIDTH : 0;
|
||||||
const next = Math.min(0, Math.max(-ROW_ACTIONS_WIDTH, base + dx));
|
const next = Math.max(0, Math.min(ROW_ACTIONS_WIDTH, base + dx));
|
||||||
applyOffset(next, false);
|
applyOffset(next, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -536,8 +539,8 @@ const SessionRow: React.FC<{
|
|||||||
startRef.current = null;
|
startRef.current = null;
|
||||||
if (!draggingRef.current) return;
|
if (!draggingRef.current) return;
|
||||||
draggingRef.current = false;
|
draggingRef.current = false;
|
||||||
const shouldReveal = offsetRef.current < -ROW_ACTIONS_WIDTH / 2;
|
const shouldReveal = offsetRef.current > ROW_ACTIONS_WIDTH / 2;
|
||||||
applyOffset(shouldReveal ? -ROW_ACTIONS_WIDTH : 0, true);
|
applyOffset(shouldReveal ? ROW_ACTIONS_WIDTH : 0, true);
|
||||||
if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal);
|
if (shouldReveal !== revealedRef.current) onRevealedChange?.(shouldReveal);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -554,32 +557,14 @@ const SessionRow: React.FC<{
|
|||||||
>
|
>
|
||||||
{swipeEnabled ? (
|
{swipeEnabled ? (
|
||||||
<div
|
<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 }}
|
style={{ width: ROW_ACTIONS_WIDTH }}
|
||||||
aria-hidden={!revealed}
|
aria-hidden={!revealed}
|
||||||
>
|
>
|
||||||
{/* Icon-only actions on the row's own background — they read as the
|
{/* Icon-only actions on the row's own background — they read as the
|
||||||
row extending to reveal extra controls, not a separate panel. */}
|
row extending to reveal extra controls, not a separate panel.
|
||||||
<button
|
Ordered outward from the content, so a partial drag exposes
|
||||||
type="button"
|
delete first, exactly as the right-side version did. */}
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={revealed ? 0 : -1}
|
tabIndex={revealed ? 0 : -1}
|
||||||
@@ -597,6 +582,26 @@ const SessionRow: React.FC<{
|
|||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-[18px]" />
|
<RiDeleteBinLine className="size-[18px]" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
@@ -895,12 +900,12 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
|
const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
|
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.
|
// delete button is armed (two-step). One row at a time.
|
||||||
const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null);
|
const [revealedSessionId, setRevealedSessionId] = React.useState<string | null>(null);
|
||||||
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
|
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState<string | null>(null);
|
||||||
const [renamingSessionId, setRenamingSessionId] = 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.
|
// separate from session rows, but mutually exclusive with them.
|
||||||
const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null);
|
const [revealedRowId, setRevealedRowId] = React.useState<string | null>(null);
|
||||||
const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = 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)}
|
onRevealedChange={(nextRevealed) => handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)}
|
||||||
actions={(
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
|
tabIndex={revealedRowId === `project:${node.project.id}` ? 0 : -1}
|
||||||
@@ -1742,6 +1734,19 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-[18px]" />
|
<RiDeleteBinLine className="size-[18px]" />
|
||||||
</button>
|
</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
|
<MobileSessionsDrawerContainer
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
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')}
|
ariaLabel={t('mobile.sessions.sheet.title')}
|
||||||
>
|
>
|
||||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
<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)';
|
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||||
|
|
||||||
/** Full-width left drawer for the phone sessions list: covers the whole app
|
/** 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
|
and slides in from the left edge. Closes via the header X, a right-edge
|
||||||
Android back button (handled by MobileShell).
|
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
|
Stays MOUNTED while closed (parked off-screen, hidden): the sessions
|
||||||
sheet's project/worktree state stays warm, so reopening shows the tree
|
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<{
|
const MobileSessionsDrawerContainer: React.FC<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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;
|
ariaLabel: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}> = ({ open, onClose, ariaLabel, children }) => {
|
}> = ({ open, onClose, onSwipeClose, ariaLabel, children }) => {
|
||||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||||
|
const drawerRef = React.useRef<HTMLElement>(null);
|
||||||
const [entered, setEntered] = React.useState(false);
|
const [entered, setEntered] = React.useState(false);
|
||||||
// Kept visible through the exit slide; flipped to hidden once it finishes.
|
// Kept visible through the exit slide; flipped to hidden once it finishes.
|
||||||
const [visible, setVisible] = React.useState(open);
|
const [visible, setVisible] = React.useState(open);
|
||||||
@@ -2051,6 +2074,18 @@ const MobileSessionsDrawerContainer: React.FC<{
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
}, [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) {
|
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||||
let root = document.getElementById(DRAWER_ROOT_ID);
|
let root = document.getElementById(DRAWER_ROOT_ID);
|
||||||
@@ -2091,6 +2126,7 @@ const MobileSessionsDrawerContainer: React.FC<{
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<section
|
<section
|
||||||
|
ref={drawerRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useMcpStore } from '@/stores/useMcpStore';
|
|||||||
|
|
||||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||||
|
import { useEdgeSwipe } from './useEdgeSwipe';
|
||||||
|
|
||||||
const DRAWER_ROOT_ID = 'mobile-surface-root';
|
const DRAWER_ROOT_ID = 'mobile-surface-root';
|
||||||
const ENTER_DELAY_MS = 16;
|
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
|
beside the chat (tablet, landscape). The caller owns the width and the
|
||||||
open/close animation there; this component only fills it.
|
open/close animation there; this component only fills it.
|
||||||
|
|
||||||
Closes via the header X, Escape (unless the terminal tab owns the keys), or
|
Closes via the header X, a left-edge swipe back toward the right (the
|
||||||
the Android back button (handled by MobileShell). */
|
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<{
|
export const MobileWorkspaceDrawer: React.FC<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -113,6 +115,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
|||||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
|
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||||
|
const drawerRef = React.useRef<HTMLElement>(null);
|
||||||
const [entered, setEntered] = React.useState(false);
|
const [entered, setEntered] = React.useState(false);
|
||||||
// Kept visible through the exit slide; flipped to hidden once it finishes.
|
// Kept visible through the exit slide; flipped to hidden once it finishes.
|
||||||
const [visible, setVisible] = React.useState(open);
|
const [visible, setVisible] = React.useState(open);
|
||||||
@@ -125,6 +128,15 @@ export const MobileWorkspaceDrawer: React.FC<{
|
|||||||
tabRef.current = tab;
|
tabRef.current = tab;
|
||||||
}, [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.
|
// Tabs the user has actually opened — their panes stay mounted afterwards.
|
||||||
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
|
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -278,6 +290,7 @@ export const MobileWorkspaceDrawer: React.FC<{
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<section
|
<section
|
||||||
|
ref={drawerRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
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.
|
* the very left/right screen edge and drag toward the centre.
|
||||||
*
|
*
|
||||||
* - Left edge → centre = open the sessions drawer
|
* On the chat that opens a drawer (left edge → sessions, right edge →
|
||||||
* - Right edge → centre = open the most recent overflow surface
|
* 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
|
* Only `touchstart`/`touchend` are observed (both passive), so this never
|
||||||
* interferes with vertical chat scrolling or the horizontal scroll inside code
|
* interferes with vertical chat scrolling or the horizontal scroll inside code
|
||||||
@@ -27,6 +28,9 @@ export interface EdgeSwipeOptions {
|
|||||||
onLeftEdgeSwipe?: () => void;
|
onLeftEdgeSwipe?: () => void;
|
||||||
/** Swipe that started at the right edge and travelled left. */
|
/** Swipe that started at the right edge and travelled left. */
|
||||||
onRightEdgeSwipe?: () => void;
|
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 = (
|
export const useEdgeSwipe = (
|
||||||
@@ -37,7 +41,10 @@ export const useEdgeSwipe = (
|
|||||||
const optionsRef = React.useRef(options);
|
const optionsRef = React.useRef(options);
|
||||||
optionsRef.current = options;
|
optionsRef.current = options;
|
||||||
|
|
||||||
|
const enabled = options.enabled ?? true;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const element = ref.current;
|
const element = ref.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||||
@@ -87,5 +94,5 @@ export const useEdgeSwipe = (
|
|||||||
element.removeEventListener('touchstart', onTouchStart);
|
element.removeEventListener('touchstart', onTouchStart);
|
||||||
element.removeEventListener('touchend', onTouchEnd);
|
element.removeEventListener('touchend', onTouchEnd);
|
||||||
};
|
};
|
||||||
}, [ref]);
|
}, [enabled, ref]);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user