From cd897d6893aa4b40d122f23450e688f1783bd544 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sat, 16 May 2026 05:55:05 +0000 Subject: [PATCH] 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= 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) --- apps/web/src/components/ChatTabBar.tsx | 19 ++++ apps/web/src/components/SwipeablePaneTab.tsx | 103 ++++++++++++++++++ apps/web/src/components/Workspace.tsx | 104 ++++++++++++++++--- apps/web/src/hooks/useLongPress.ts | 75 +++++++++++++ 4 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/SwipeablePaneTab.tsx create mode 100644 apps/web/src/hooks/useLongPress.ts diff --git a/apps/web/src/components/ChatTabBar.tsx b/apps/web/src/components/ChatTabBar.tsx index 7e9b124..dbb63cf 100644 --- a/apps/web/src/components/ChatTabBar.tsx +++ b/apps/web/src/components/ChatTabBar.tsx @@ -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(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({
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 diff --git a/apps/web/src/components/SwipeablePaneTab.tsx b/apps/web/src/components/SwipeablePaneTab.tsx new file mode 100644 index 0000000..5e894d6 --- /dev/null +++ b/apps/web/src/components/SwipeablePaneTab.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index aaf4777..10a77ab 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -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 (
@@ -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) {
+ {isMobile && panes.length > 1 && ( +
+ {panes.map((pane, idx) => ( + switchActivePane(idx)} + onClose={() => removePane(idx)} + canClose={panes.length > 1} + /> + ))} +
+ )} +
- {panes.map((pane, idx) => ( + {panes.map((pane, idx) => { + const visible = !isMobile || idx === activePaneIdx; + if (!visible) return null; + return (
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} >
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} >
- ))} + ); + })}
); diff --git a/apps/web/src/hooks/useLongPress.ts b/apps/web/src/hooks/useLongPress.ts new file mode 100644 index 0000000..d4649d1 --- /dev/null +++ b/apps/web/src/hooks/useLongPress.ts @@ -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(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 }; +}