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:
@@ -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
|
||||
|
||||
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal file
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
75
apps/web/src/hooks/useLongPress.ts
Normal file
75
apps/web/src/hooks/useLongPress.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user