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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user