import { useRef, useState } from 'react'; import { Bot, ChevronDown, Edit2, MessageSquare, MoreHorizontal, Settings as SettingsIcon, Terminal, X, } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat, WorkspacePane } from '@/api/types'; import { BottomSheet } from '@/components/BottomSheet'; import { StatusDot } from '@/components/StatusDot'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useLongPress } from '@/hooks/useLongPress'; import { cn } from '@/lib/utils'; interface Props { panes: WorkspacePane[]; activePaneIdx: number; chats: Chat[]; onSwitchPane: (idx: number) => void; onRemovePane: (idx: number) => void; onRenameChat: (chatId: string, name: string) => Promise; } // v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec // (80px). Vertical bail-out at 30px because the pill sits inside a vertical // scrollable header — diagonal-ish swipes shouldn't accidentally close panes. const SWIPE_CLOSE_PX = 80; const SWIPE_VERTICAL_BAIL_PX = 30; // Visual cap: pill translates left up to this much. Past this, dragX stays // pinned so the user has a clear "release to close" indicator. const SWIPE_VISUAL_CAP = 120; function paneIcon(kind: WorkspacePane['kind']) { if (kind === 'terminal') return ; if (kind === 'agent') return ; if (kind === 'settings') return ; return ; } function paneActiveChatId(pane: WorkspacePane | undefined): string | null { if (!pane) return null; if (pane.chatId) return pane.chatId; const idx = pane.activeChatIdx; if (idx < 0 || idx >= pane.chatIds.length) return null; return pane.chatIds[idx] ?? null; } function paneLabel(pane: WorkspacePane, chats: Chat[]): string { const cid = paneActiveChatId(pane); if (cid) { const c = chats.find((x) => x.id === cid); if (c) return c.name ?? 'New chat'; } if (pane.kind === 'chat') return 'Chat'; if (pane.kind === 'terminal') return 'Terminal'; if (pane.kind === 'agent') return 'Agent'; if (pane.kind === 'settings') return 'Settings'; return 'Empty'; } export function MobileTabSwitcher({ panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat, }: Props) { const [open, setOpen] = useState(false); const [renamingChatId, setRenamingChatId] = useState(null); const [renameValue, setRenameValue] = useState(''); // v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset // in px. suppressClick latches when a swipe completes so the trailing click // doesn't pop open the BottomSheet on the just-closed pane. const [dragX, setDragX] = useState(0); const swipeStart = useRef<{ x: number; y: number } | null>(null); const swipeBailed = useRef(false); const suppressClick = useRef(false); const active = panes[activePaneIdx]; const activeLabel = active ? paneLabel(active, chats) : 'Empty'; const activeChatId = paneActiveChatId(active); function onPillTouchStart(e: React.TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; swipeStart.current = { x: t.clientX, y: t.clientY }; swipeBailed.current = false; setDragX(0); } function onPillTouchMove(e: React.TouchEvent): void { if (!swipeStart.current || swipeBailed.current) return; if (e.touches.length !== 1) return; const t = e.touches[0]!; const dx = t.clientX - swipeStart.current.x; const dy = t.clientY - swipeStart.current.y; // Bail to scroll if vertical motion dominates before horizontal. if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) { swipeBailed.current = true; setDragX(0); return; } // Only allow leftward drag (negative). Cap visual displacement. const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx)); setDragX(clamped); } function onPillTouchEnd(): void { const finalDx = dragX; swipeStart.current = null; if (swipeBailed.current) { setDragX(0); return; } if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) { suppressClick.current = true; // Reset dragX after the close so subsequent re-renders look right. setDragX(0); onRemovePane(activePaneIdx); return; } setDragX(0); } function onPillClick(): void { if (suppressClick.current) { suppressClick.current = false; return; } setOpen(true); } const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX); // Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row // so the trailing kebab's Radix DropdownMenu opens at the touch point. const longPress = useLongPress(({ clientX, clientY, target }) => { if (!target || !(target instanceof Element)) return; const row = target.closest('[data-pane-id]') as HTMLElement | null; if (!row) return; const trigger = row.querySelector('[data-pane-kebab]') as HTMLElement | null; if (trigger) { trigger.click(); return; } row.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }), ); }); function startRename(chatId: string, currentName: string | null) { setRenamingChatId(chatId); setRenameValue(currentName ?? ''); } async function finishRename() { if (renamingChatId && renameValue.trim()) { try { await onRenameChat(renamingChatId, renameValue.trim()); } catch (err) { toast.error(err instanceof Error ? err.message : 'rename failed'); } } setRenamingChatId(null); } function handleSwitchPane(idx: number) { onSwitchPane(idx); setOpen(false); } return ( <>
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the swipe progress (0 at rest, 1 at the close threshold). aria-hidden because the actionable affordance is the swipe, not this label. */}
setOpen(false)} title="Panes">
    {panes.map((pane, idx) => { const isActive = idx === activePaneIdx; const cid = paneActiveChatId(pane); const chat = cid ? chats.find((c) => c.id === cid) ?? null : null; const label = paneLabel(pane, chats); return (
  • handleSwitchPane(idx)} style={{ WebkitTouchCallout: 'none' }} className={cn( 'flex items-center gap-2 px-3 py-2 rounded min-h-[48px] cursor-default select-none', isActive ? 'bg-accent/40 border-l-2 border-primary' : 'hover:bg-muted/50', )} > {paneIcon(pane.kind)} {renamingChatId === cid && cid ? ( setRenameValue(e.target.value)} onBlur={() => void finishRename()} onKeyDown={(e) => { if (e.key === 'Enter') void finishRename(); if (e.key === 'Escape') setRenamingChatId(null); }} onClick={(e) => e.stopPropagation()} className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0" /> ) : ( {label} )} {isActive && ( )} {chat && ( startRename(chat.id, chat.name)}> Rename chat )} onRemovePane(idx)} > Close pane
  • ); })}
{/* v1.8: New-pane button moved out of the sheet to the header row 2 (see NewPaneMenu). Sheet is for switching only. */}
); }