import { useEffect, useMemo, useState } from 'react'; import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, Plus, X } from 'lucide-react'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; import { terminalsRegistry } from '@/lib/events'; import { ChatPane } from '@/components/panes/ChatPane'; import { SettingsPane } from '@/components/panes/SettingsPane'; import { TerminalPane } from '@/components/panes/TerminalPane'; import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane'; import { HtmlArtifactPane } from '@/components/HtmlArtifactPane'; 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; // Batch 9: threaded down to ChatPane → ChatInput → AgentPicker. agentId?: string | null; onAgentChange?: (agentId: string | null) => void | Promise; // v1.8: panes + chats hoisted into Session.tsx so the mobile header pill // (MobileTabSwitcher) can share state with the pane grid. panesHook: UseWorkspacePanesResult; chatsHook: UseSessionChatsResult; // v1.9: passed through to SettingsPane when one is mounted in the grid. session: Session; project: Project | null; } export function Workspace({ sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, }: Props) { const { panes, activePaneIdx, setActivePaneIdx, openChatInPane, switchTab, removeTab, closeOtherTabs, closeTabsToRight, closeAllTabs, showLandingPage, addSplitPane, removePane, handlePaneDragStart, handlePaneDragOver, handlePaneDragLeave, handlePaneDrop, handlePaneDragEnd, dragOverIdx, draggingIdxRef, } = panesHook; const { chats, createChat, archiveChat, unarchiveChat, deleteChat, renameChat, handleLandingSend, } = chatsHook; const { isMobile } = useViewport(); // v1.9: workspace-level maximize state for the settings pane. CSS-only: // sibling panes get display:none, the maximized pane fills the grid cell. // ESC listener only mounted while maximized. Mobile is always full-width // for a single pane so maximize doesn't apply. const [maximized, setMaximized] = useState(false); const settingsIdx = panes.findIndex((p) => p.kind === 'settings'); // Esc semantics: maximized → restore; otherwise → close settings pane (only // when it's the active pane). Bail when the user is typing in a field or // inside an open dialog so we don't eat their cancel keystroke. useEffect(() => { if (settingsIdx < 0) return; function onKey(e: KeyboardEvent) { if (e.key !== 'Escape') return; const t = e.target; if (t instanceof HTMLElement) { if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return; if (t.closest('[role="dialog"]')) return; } if (maximized) { setMaximized(false); } else if (activePaneIdx === settingsIdx) { removePane(settingsIdx); } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [maximized, settingsIdx, activePaneIdx, removePane]); // If the settings pane was closed (no longer in panes) while maximized, // clear the maximize state so the grid renders normally. useEffect(() => { if (maximized && settingsIdx < 0) setMaximized(false); }, [maximized, settingsIdx]); function chatsForPane(pane: WorkspacePane): Chat[] { return pane.chatIds .map((id) => chats.find((c) => c.id === id)) .filter((c): c is Chat => c !== undefined); } // v1.10 booterm: per-terminal label used by the registry that powers the // MessageBubble "Send to terminal" submenu. Numbered in workspace order. const terminalLabels = useMemo(() => { const out = new Map(); let n = 0; for (const p of panes) { if (p.kind === 'terminal') { n += 1; out.set(p.id, `Terminal ${n}`); } } return out; }, [panes]); return (
{!isMobile && (
addSplitPane('chat')}> Chat addSplitPane('terminal')}> Terminal addSplitPane('agent')}> Agent
)} {/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header pill (MobileTabSwitcher) is the mobile pane switcher. */}
= 0 ? { gridTemplateColumns: 'minmax(0, 1fr)' } : { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` } } > {panes.map((pane, idx) => { const isSettings = pane.kind === 'settings'; const isTerminal = pane.kind === 'terminal'; const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact'; // v1.9: when maximized, hide every pane except the settings one. // display:none keeps the React tree mounted so streams / drafts // survive the toggle without re-mount cost. const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx; const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize; if (!visible) { if (hiddenForMaximize) { return
; } return null; } // Terminal panes own their tab strip (no chats, no ChatTabBar) and // are not drag-reorderable for now — keeps the layout grid simple. const isChromeless = isSettings || isTerminal || isArtifact; return (
setActivePaneIdx(idx)} onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined} onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined} onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined} >
1} onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined} onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined} > {/* Hidden on mobile per v1.8; settings + terminal panes own their own header (no chats, so no ChatTabBar). */} {!isMobile && !isChromeless && ( switchTab(idx, tabIdx)} onRemoveTab={(chatId) => removeTab(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)} onCloseAll={() => closeAllTabs(idx)} onAddPane={(kind) => { if (kind === 'chat') void createChat(idx); else addSplitPane(kind); }} onShowHistory={() => showLandingPage(idx)} onRename={renameChat} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} /> )} {isTerminal && (
{terminalLabels.get(pane.id) ?? 'Terminal'} addSplitPane('chat')}> New chat addSplitPane('terminal')}> New terminal addSplitPane('agent')}> New agent {/* v1.10.4: iOS Safari restricts navigator.clipboard.readText outside direct user gestures. A real button click IS a gesture, so this works where keystroke-driven paste may not on iOS. The action lives in TerminalPane behind the registry's paste() callback. */} {panes.length > 1 && ( )}
)}
{isSettings && project ? ( setMaximized((v) => !v)} onClose={() => removePane(idx)} isMobile={isMobile} /> ) : isTerminal ? ( ) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? ( removePane(idx)} /> ) : pane.kind === 'html_artifact' && pane.html_artifact_state ? ( removePane(idx)} /> ) : 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} /> )}
); })}
); }