feat(mobile): single-pane stack + long-press tab menu + swipe-to-close

- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
  When panes.length > 1, adds a horizontal pane-navigator strip built
  from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
  written by switchActivePane (user-initiated only) and read on URL
  change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
  contextmenu event on target.closest('[data-tab-id]') so the existing
  Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
  the synthetic click that follows touchend. Cancels on touchmove /
  touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
  useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
  text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
  exceeds 30px (so vertical scroll still works); past 60px on release
  fires onClose (removePane), else snaps back. Opacity fades 1->0.4
  approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
  anyway; mobile uses the navigator + swipe-to-close instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 05:55:05 +00:00
parent a643b5f67f
commit cd897d6893
4 changed files with 285 additions and 16 deletions

View File

@@ -8,6 +8,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
interface Props {
@@ -40,6 +41,18 @@ export function ChatTabBar({
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Long-press: dispatch a synthetic contextmenu event on the tab so the
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
// because asChild composition makes the tab div the trigger element.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
if (!tab) return;
tab.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
@@ -63,7 +76,13 @@ export function ChatTabBar({
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={chat.id}
onClick={() => onSwitchTab(tabIdx)}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive

View File

@@ -0,0 +1,103 @@
import { useRef, useState } from 'react';
import type { TouchEvent } from 'react';
import { cn } from '@/lib/utils';
interface Props {
label: string;
isActive: boolean;
onTap: () => void;
onClose: () => void;
canClose: boolean;
}
const CLOSE_THRESHOLD = 60;
const MAX_TRAVEL = 120;
const VERTICAL_BAIL = 30;
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
const [translateX, setTranslateX] = useState(0);
const [dragging, setDragging] = useState(false);
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
const onTouchStart = (e: TouchEvent) => {
if (!canClose) return;
const t = e.touches[0];
if (!t) return;
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
setDragging(true);
};
const onTouchMove = (e: TouchEvent) => {
const start = startRef.current;
if (!start || start.bailed) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
if (Math.abs(dy) > VERTICAL_BAIL) {
start.bailed = true;
setTranslateX(0);
setDragging(false);
return;
}
if (dx < 0) {
setTranslateX(Math.max(dx, -MAX_TRAVEL));
} else {
setTranslateX(0);
}
};
const onTouchEnd = () => {
const start = startRef.current;
startRef.current = null;
setDragging(false);
if (!start || start.bailed) {
setTranslateX(0);
return;
}
const tx = translateX;
if (tx <= -CLOSE_THRESHOLD) {
onClose();
// Don't reset translateX; the parent will unmount this tab.
} else {
setTranslateX(0);
}
};
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
const opacity =
translateX < 0
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
: 1;
return (
<button
type="button"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={{
transform: `translateX(${translateX}px)`,
opacity,
// Only animate when releasing (snap-back); during drag the transform
// tracks the finger 1:1 for a tight feel.
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
}}
className={cn(
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
isActive
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:bg-muted/40',
)}
aria-current={isActive ? 'true' : undefined}
>
<span className="truncate max-w-[140px] inline-block">{label}</span>
</button>
);
}

View File

@@ -1,11 +1,14 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
import {
DropdownMenu,
DropdownMenuContent,
@@ -67,12 +70,59 @@ export function Workspace({ sessionId, projectId }: Props) {
initializeFirstChatIfEmpty,
});
const { isMobile } = useViewport();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// URL -> state (mobile only). Handles deep-link arrival and Back button
// history pops. On a bare URL (no ?pane), reset to first pane so Back
// from a ?pane URL returns the user to a sensible default.
useEffect(() => {
if (!isMobile || panes.length === 0) return;
const paneId = searchParams.get('pane');
if (!paneId) {
if (activePaneIdx !== 0) setActivePaneIdx(0);
return;
}
const idx = panes.findIndex((p) => p.id === paneId);
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
// Switch active pane and push URL (mobile only). User-initiated only;
// never called from URL-sync effect.
const switchActivePane = useCallback(
(idx: number) => {
setActivePaneIdx(idx);
if (isMobile) {
const pane = panes[idx];
if (!pane) return;
const params = new URLSearchParams(location.search);
params.set('pane', pane.id);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
.filter((c): c is Chat => c !== undefined);
}
function paneLabel(pane: WorkspacePane): string {
const activeChatId = pane.chatId;
if (activeChatId) {
const chat = chats.find((c) => c.id === activeChatId);
if (chat) return chat.name ?? 'New chat';
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
return 'Empty';
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
@@ -82,7 +132,7 @@ export function Workspace({ sessionId, projectId }: Props) {
type="button"
disabled={panes.length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted max-md:min-h-[44px] max-md:px-3',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
@@ -104,30 +154,51 @@ export function Workspace({ sessionId, projectId }: Props) {
</DropdownMenu>
</div>
{isMobile && panes.length > 1 && (
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-muted/10 px-2 py-1 shrink-0">
{panes.map((pane, idx) => (
<SwipeablePaneTab
key={pane.id}
label={paneLabel(pane)}
isActive={idx === activePaneIdx}
onTap={() => switchActivePane(idx)}
onClose={() => removePane(idx)}
canClose={panes.length > 1}
/>
))}
</div>
)}
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
style={
isMobile
? undefined
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
}
>
{panes.map((pane, idx) => (
{panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx;
if (!visible) return null;
return (
<div
key={pane.id}
className={cn(
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
dragOverIdx === idx && draggingIdxRef.current !== idx &&
isMobile ? 'flex-1 w-full' : undefined,
!isMobile && idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
!isMobile && dragOverIdx === idx && draggingIdxRef.current !== idx &&
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={panes.length > 1}
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane}
@@ -165,7 +236,8 @@ export function Workspace({ sessionId, projectId }: Props) {
)}
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,75 @@
import { useCallback, useRef } from 'react';
import type { TouchEvent } from 'react';
interface LongPressHandlers {
onTouchStart: (e: TouchEvent) => void;
onTouchMove: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
onTouchCancel: (e: TouchEvent) => void;
}
interface Options {
ms?: number;
// Suppress the synthetic click that follows touchend when long-press fired.
suppressClickOnFire?: boolean;
}
// Hand-rolled long-press detector. Starts a timer on touchstart; cancels on
// touchmove or early touchend; fires the callback on timer expiry. Caller is
// expected to suppress text-selection callout via CSS (-webkit-touch-callout).
export function useLongPress(
callback: (touch: { clientX: number; clientY: number; target: EventTarget | null }) => void,
{ ms = 500, suppressClickOnFire = true }: Options = {},
): LongPressHandlers {
const timerRef = useRef<number | null>(null);
const firedRef = useRef(false);
const clear = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const onTouchStart = useCallback(
(e: TouchEvent) => {
firedRef.current = false;
const touch = e.touches[0];
if (!touch) return;
const x = touch.clientX;
const y = touch.clientY;
const target = e.target;
clear();
timerRef.current = window.setTimeout(() => {
firedRef.current = true;
callback({ clientX: x, clientY: y, target });
}, ms);
},
[callback, ms, clear],
);
const onTouchMove = useCallback(() => {
clear();
}, [clear]);
const onTouchEnd = useCallback(
(e: TouchEvent) => {
clear();
if (firedRef.current && suppressClickOnFire) {
// Block the synthetic click that follows touchend; the long-press
// already handled the gesture.
e.preventDefault();
}
},
[clear, suppressClickOnFire],
);
const onTouchCancel = useCallback(
(_e: TouchEvent) => {
clear();
},
[clear],
);
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
}