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
|
||||
|
||||
Reference in New Issue
Block a user