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,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,6 +41,18 @@ export function ChatTabBar({
|
|||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
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) {
|
function startRename(chatId: string, currentName: string | null) {
|
||||||
setRenamingId(chatId);
|
setRenamingId(chatId);
|
||||||
setRenameValue(currentName ?? '');
|
setRenameValue(currentName ?? '');
|
||||||
@@ -63,7 +76,13 @@ export function ChatTabBar({
|
|||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
|
data-tab-id={chat.id}
|
||||||
onClick={() => onSwitchTab(tabIdx)}
|
onClick={() => onSwitchTab(tabIdx)}
|
||||||
|
onTouchStart={longPress.onTouchStart}
|
||||||
|
onTouchMove={longPress.onTouchMove}
|
||||||
|
onTouchEnd={longPress.onTouchEnd}
|
||||||
|
onTouchCancel={longPress.onTouchCancel}
|
||||||
|
style={{ WebkitTouchCallout: 'none' }}
|
||||||
className={cn(
|
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',
|
'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
|
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 { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
||||||
import { useSessionChats } from '@/hooks/useSessionChats';
|
import { useSessionChats } from '@/hooks/useSessionChats';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
|
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -67,12 +70,59 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
initializeFirstChatIfEmpty,
|
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[] {
|
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||||
return pane.chatIds
|
return pane.chatIds
|
||||||
.map((id) => chats.find((c) => c.id === id))
|
.map((id) => chats.find((c) => c.id === id))
|
||||||
.filter((c): c is Chat => c !== undefined);
|
.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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<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">
|
<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"
|
type="button"
|
||||||
disabled={panes.length >= MAX_PANES}
|
disabled={panes.length >= MAX_PANES}
|
||||||
className={cn(
|
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'
|
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -104,30 +154,51 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="flex-1 grid min-h-0"
|
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
||||||
style={{
|
style={
|
||||||
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
|
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
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
|
'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',
|
isMobile ? 'flex-1 w-full' : undefined,
|
||||||
dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
!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'
|
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActivePaneIdx(idx)}
|
onClick={() => setActivePaneIdx(idx)}
|
||||||
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||||
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
|
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||||
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable={panes.length > 1}
|
draggable={!isMobile && panes.length > 1}
|
||||||
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
@@ -165,7 +236,8 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</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