- 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>
245 lines
8.7 KiB
TypeScript
245 lines
8.7 KiB
TypeScript
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,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
projectId: string;
|
|
}
|
|
|
|
export function Workspace({ sessionId, projectId }: Props) {
|
|
const {
|
|
panes,
|
|
activePaneIdx,
|
|
setActivePaneIdx,
|
|
activePaneIdxRef,
|
|
openChatInPane,
|
|
switchTab,
|
|
removeTab,
|
|
closeOtherTabs,
|
|
closeTabsToRight,
|
|
closeAllTabs,
|
|
showLandingPage,
|
|
addSplitPane,
|
|
removePane,
|
|
removeChatFromPanes,
|
|
initializeFirstChatIfEmpty,
|
|
handlePaneDragStart,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handlePaneDragEnd,
|
|
dragOverIdx,
|
|
draggingIdxRef,
|
|
} = useWorkspacePanes(sessionId);
|
|
|
|
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
|
|
// without knowing about pane indexing.
|
|
const openChatInActivePane = useCallback(
|
|
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
|
[openChatInPane, activePaneIdxRef],
|
|
);
|
|
|
|
const {
|
|
chats,
|
|
createChat,
|
|
archiveChat,
|
|
unarchiveChat,
|
|
deleteChat,
|
|
renameChat,
|
|
handleLandingSend,
|
|
} = useSessionChats(sessionId, {
|
|
removeChatFromPanes,
|
|
openChatInPane,
|
|
openChatInActivePane,
|
|
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">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={panes.length >= MAX_PANES}
|
|
className={cn(
|
|
'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'
|
|
)}
|
|
>
|
|
<PanelRight size={14} />
|
|
Split
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
|
<MessageSquare size={14} /> Chat
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
|
<Terminal size={14} /> Terminal
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
|
<Bot size={14} /> Agent
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</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={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
|
style={
|
|
isMobile
|
|
? undefined
|
|
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
|
}
|
|
>
|
|
{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',
|
|
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={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
|
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
|
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
|
>
|
|
<div
|
|
draggable={!isMobile && panes.length > 1}
|
|
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
|
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
|
>
|
|
<ChatTabBar
|
|
pane={pane}
|
|
tabs={chatsForPane(pane)}
|
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
|
onCloseAll={() => closeAllTabs(idx)}
|
|
onNewChat={() => void createChat(idx)}
|
|
onShowHistory={() => showLandingPage(idx)}
|
|
onRename={renameChat}
|
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
{pane.kind === 'chat' && pane.chatId ? (
|
|
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
|
|
) : (
|
|
<SessionLandingPage
|
|
sessionId={sessionId}
|
|
projectId={projectId}
|
|
chats={chats}
|
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
|
onSend={(content) => void handleLandingSend(idx, content)}
|
|
onReopenChat={async (chatId) => {
|
|
await unarchiveChat(chatId);
|
|
openChatInPane(idx, chatId);
|
|
}}
|
|
onArchiveChat={archiveChat}
|
|
onRenameChat={renameChat}
|
|
onDeleteChat={deleteChat}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|