import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import type { Chat, WorkspacePane } from '@/api/types'; import { ChatPane } from '@/components/panes/ChatPane'; import { ChatTabBar } from '@/components/ChatTabBar'; import { SessionLandingPage } from '@/components/SessionLandingPage'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; interface Props { sessionId: string; projectId: string; } const MAX_PANES = 5; const STORAGE_KEY = 'boocode.workspace.panes'; function generateId(): string { return crypto.randomUUID(); } function emptyPane(): WorkspacePane { return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 }; } function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; } function loadPanes(sessionId: string): WorkspacePane[] | null { try { const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`); if (!raw) return null; const parsed = JSON.parse(raw) as WorkspacePane[]; if (!Array.isArray(parsed) || parsed.length === 0) return null; return parsed; } catch { return null; } } function savePanes(sessionId: string, panes: WorkspacePane[]): void { try { localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes)); } catch { /* quota or disabled */ } } export function Workspace({ sessionId, projectId }: Props) { const [panes, setPanes] = useState(() => { return loadPanes(sessionId) ?? [emptyPane()]; }); const [activePaneIdx, setActivePaneIdx] = useState(0); const [chats, setChats] = useState([]); const chatsRef = useRef([]); chatsRef.current = chats; const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); useEffect(() => { let cancelled = false; api.chats.listForSession(sessionId).then((list) => { if (cancelled) return; setChats(list); const openChat = list.find((c) => c.status === 'open'); if (openChat) { setPanes((prev) => { if (prev.length === 1 && prev[0]!.kind === 'empty') { return [chatPane(openChat.id)]; } return prev; }); } }).catch(() => {}); return () => { cancelled = true; }; }, [sessionId]); useEffect(() => { savePanes(sessionId, panes); }, [sessionId, panes]); useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type === 'chat_created' && event.session_id === sessionId) { setChats((prev) => { if (prev.some((c) => c.id === event.chat.id)) return prev; return [event.chat, ...prev]; }); } if (event.type === 'chat_updated') { setChats((prev) => prev.map((c) => c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c )); } if (event.type === 'chat_archived') { setChats((prev) => prev.map((c) => c.id === event.chat_id ? { ...c, status: 'archived' as const } : c )); removeChatFromPanes(event.chat_id); } if (event.type === 'chat_unarchived') { setChats((prev) => { if (prev.some((c) => c.id === event.chat.id)) { return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c); } return [event.chat, ...prev]; }); } if (event.type === 'chat_deleted') { setChats((prev) => prev.filter((c) => c.id !== event.chat_id)); removeChatFromPanes(event.chat_id); } }); }, [sessionId]); function removeChatFromPanes(chatId: string) { setPanes((prev) => prev.map((p) => { const idx = p.chatIds.indexOf(chatId); if (idx < 0) return p; const nextIds = p.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1); return { ...p, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; })); } const openChatInPane = useCallback((paneIdx: number, chatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const existing = pane.chatIds.indexOf(chatId); if (existing >= 0) { next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing }; } else { const newIds = [...pane.chatIds, chatId]; next[paneIdx] = { ...pane, kind: 'chat', chatId, chatIds: newIds, activeChatIdx: newIds.length - 1, }; } return next; }); setActivePaneIdx(paneIdx); }, []); const switchTab = useCallback((paneIdx: number, tabIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const chatId = pane.chatIds[tabIdx]; if (!chatId) return prev; next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx }; return next; }); }, []); const removeTab = useCallback((paneIdx: number, chatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const nextIds = pane.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; } else { const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; } return next; }); }, []); // Keep only the right-clicked tab open in this pane. const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const keepIdx = pane.chatIds.indexOf(keepChatId); if (keepIdx < 0) return prev; next[paneIdx] = { ...pane, kind: 'chat', chatId: keepChatId, chatIds: [keepChatId], activeChatIdx: 0, }; return next; }); }, []); // Close every tab to the right of the right-clicked one. const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const pivotIdx = pane.chatIds.indexOf(pivotChatId); if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev; const nextIds = pane.chatIds.slice(0, pivotIdx + 1); const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; return next; }); }, []); // Close every tab in this pane; land on landing page. const closeAllTabs = useCallback((paneIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; return next; }); }, []); const createChat = useCallback(async (paneIdx: number) => { try { const chat = await api.chats.create(sessionId); // Optimistic local insert; the WS chat_created echo will be deduped by id. setChats((prev) => { if (prev.some((c) => c.id === chat.id)) return prev; return [chat, ...prev]; }); openChatInPane(paneIdx, chat.id); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create chat'); } }, [sessionId, openChatInPane]); const archiveChat = useCallback(async (chatId: string) => { try { await api.chats.archive(chatId); // Server publishes chat_archived; bus forwarder updates state. } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to archive chat'); } }, []); const unarchiveChat = useCallback(async (chatId: string) => { try { await api.chats.unarchive(chatId); // Server publishes chat_unarchived. } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to restore chat'); } }, []); const deleteChat = useCallback(async (chatId: string) => { try { await api.chats.remove(chatId); setChats((prev) => prev.filter((c) => c.id !== chatId)); removeChatFromPanes(chatId); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete chat'); } }, []); const renameChat = useCallback(async (chatId: string, name: string) => { try { await api.chats.update(chatId, { name }); setChats((prev) => prev.map((c) => c.id === chatId ? { ...c, name } : c )); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to rename chat'); } }, []); const showLandingPage = useCallback((paneIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; return next; }); }, []); const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => { if (kind === 'terminal') { toast('Terminal panes coming in BooTerm'); return; } if (kind === 'agent') { toast('Agent panes coming in BooCoder'); return; } setPanes((prev) => { if (prev.length >= MAX_PANES) { toast.error(`Maximum ${MAX_PANES} panes`); return prev; } const next = [...prev, emptyPane()]; setActivePaneIdx(next.length - 1); return next; }); }, []); const removePane = useCallback((idx: number) => { setPanes((prev) => { if (prev.length <= 1) return prev; const next = prev.filter((_, i) => i !== idx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, []); const handlePaneDragStart = useCallback( (idx: number) => (e: DragEvent) => { draggingIdxRef.current = idx; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); }, [] ); const handlePaneDragOver = useCallback( (idx: number) => (e: DragEvent) => { if (draggingIdxRef.current === null) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragOverIdx !== idx) setDragOverIdx(idx); }, [dragOverIdx] ); const handlePaneDragLeave = useCallback(() => { setDragOverIdx(null); }, []); const handlePaneDrop = useCallback( (targetIdx: number) => (e: DragEvent) => { e.preventDefault(); const fromIdx = draggingIdxRef.current; draggingIdxRef.current = null; setDragOverIdx(null); if (fromIdx === null || fromIdx === targetIdx) return; setPanes((prev) => { const next = [...prev]; const [moved] = next.splice(fromIdx, 1); if (!moved) return prev; next.splice(targetIdx, 0, moved); // Keep active selection on the same logical pane (the one being dragged). setActivePaneIdx(targetIdx); return next; }); }, [] ); const handlePaneDragEnd = useCallback(() => { draggingIdxRef.current = null; setDragOverIdx(null); }, []); const handleLandingSend = useCallback(async (paneIdx: number, content: string) => { try { const chat = await api.chats.create(sessionId); setChats((prev) => { if (prev.some((c) => c.id === chat.id)) return prev; return [chat, ...prev]; }); openChatInPane(paneIdx, chat.id); await api.messages.send(chat.id, content); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to send'); } }, [sessionId, openChatInPane]); function chatsForPane(pane: WorkspacePane): Chat[] { return pane.chatIds .map((id) => chats.find((c) => c.id === id)) .filter((c): c is Chat => c !== undefined); } return (
addSplitPane('chat')}> Chat addSplitPane('terminal')}> Terminal addSplitPane('agent')}> Agent
{panes.map((pane, idx) => (
setActivePaneIdx(idx)} onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined} onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined} onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined} >
1} onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined} onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined} > 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} />
{pane.kind === 'chat' && pane.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} /> )}
))}
); }