A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
|
import { api } from '@/api/client';
|
|
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
|
import { MAX_PANES, activePaneChatId, 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 { CoderPane } from '@/components/panes/CoderPane';
|
|
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<void>;
|
|
// 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;
|
|
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
|
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
|
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
|
|
}
|
|
|
|
export function Workspace({
|
|
sessionId,
|
|
projectId,
|
|
agentId,
|
|
onAgentChange,
|
|
panesHook,
|
|
chatsHook,
|
|
session,
|
|
project,
|
|
onCoderConnectedChange,
|
|
onAddPane,
|
|
}: Props) {
|
|
const {
|
|
panes,
|
|
tabNumbers,
|
|
activePaneIdx,
|
|
setActivePaneIdx,
|
|
openChatInPane,
|
|
switchTab,
|
|
removeTab,
|
|
closeOtherTabs,
|
|
closeTabsToRight,
|
|
closeAllTabs,
|
|
showLandingPage,
|
|
addSplitPane,
|
|
removePane,
|
|
reopenPane,
|
|
hasClosedPanes,
|
|
isPaneChatPending,
|
|
handlePaneDragStart,
|
|
handlePaneDragOver,
|
|
handlePaneDragLeave,
|
|
handlePaneDrop,
|
|
handlePaneDragEnd,
|
|
dragOverIdx,
|
|
draggingIdxRef,
|
|
} = panesHook;
|
|
const {
|
|
chats,
|
|
createChat,
|
|
archiveChat,
|
|
unarchiveChat,
|
|
deleteChat,
|
|
renameChat,
|
|
handleLandingSend,
|
|
handleLandingSkill,
|
|
} = 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<string, string>();
|
|
let n = 0;
|
|
for (const p of panes) {
|
|
if (p.kind === 'terminal') {
|
|
n += 1;
|
|
out.set(p.id, `Terminal ${n}`);
|
|
}
|
|
}
|
|
return out;
|
|
}, [panes]);
|
|
|
|
// Per-coder-pane WS connection (status dot lives in the pane header).
|
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
|
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div
|
|
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
|
style={
|
|
isMobile
|
|
? undefined
|
|
: maximized && settingsIdx >= 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 isCoder = pane.kind === 'coder';
|
|
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 <div key={pane.id} className="hidden" />;
|
|
}
|
|
return null;
|
|
}
|
|
// Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
|
|
const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
|
|
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 && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
|
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
|
|
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
|
>
|
|
<div
|
|
draggable={!isMobile && !isChromeless && panes.length > 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 && (
|
|
<ChatTabBar
|
|
pane={pane}
|
|
tabs={chatsForPane(pane)}
|
|
tabNumbers={tabNumbers}
|
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
|
onCloseAll={() => closeAllTabs(idx)}
|
|
onNewTab={() => void createChat(idx)}
|
|
onSplitPane={(kind) => onAddPane(kind)}
|
|
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
|
onShowHistory={() => showLandingPage(idx)}
|
|
onRename={renameChat}
|
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
|
/>
|
|
)}
|
|
{isCoder && !isMobile && (
|
|
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
|
<Code size={12} className="text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground">BooCode</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
aria-label="New pane"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-fit">
|
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
|
<MessageSquare size={14} /> New BooChat
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
|
<Terminal size={14} /> New BooTerm
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
|
<Code size={14} /> New BooCode
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{panes.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
aria-label="Close pane"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isTerminal && (
|
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
|
<Terminal size={12} className="text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground">
|
|
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
|
</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
|
aria-label="New pane"
|
|
title="New pane"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-fit">
|
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
|
<MessageSquare size={14} /> New BooChat
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
|
<Terminal size={14} /> New BooTerm
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
|
<Code size={14} /> New BooCode
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{/* 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. */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
terminalsRegistry.get(pane.id)?.paste();
|
|
}}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
|
aria-label="Paste from clipboard"
|
|
title="Paste from clipboard"
|
|
>
|
|
<Clipboard size={12} />
|
|
</button>
|
|
{panes.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removePane(idx);
|
|
}}
|
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
|
aria-label="Close terminal pane"
|
|
title="Close terminal pane"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
{isSettings && project ? (
|
|
<SettingsPane
|
|
session={session}
|
|
project={project}
|
|
maximized={maximized}
|
|
onToggleMaximize={() => setMaximized((v) => !v)}
|
|
onClose={() => removePane(idx)}
|
|
isMobile={isMobile}
|
|
/>
|
|
) : isTerminal ? (
|
|
<TerminalPane
|
|
sessionId={sessionId}
|
|
paneId={pane.id}
|
|
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
|
active={idx === activePaneIdx}
|
|
/>
|
|
) : pane.kind === 'coder' ? (
|
|
<CoderPane
|
|
sessionId={sessionId}
|
|
paneId={pane.id}
|
|
chatId={activePaneChatId(pane)}
|
|
chatPending={isPaneChatPending(pane.id)}
|
|
projectPath={project?.path}
|
|
onConnectedChange={(connected) => {
|
|
setCoderConnected((prev) =>
|
|
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
|
);
|
|
onCoderConnectedChange?.(pane.id, connected);
|
|
}}
|
|
onAgentLabelChange={(label) =>
|
|
setCoderLabels((prev) =>
|
|
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
|
|
)
|
|
}
|
|
/>
|
|
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
|
<MarkdownArtifactPane
|
|
chatId={pane.markdown_artifact_state.chat_id}
|
|
state={pane.markdown_artifact_state}
|
|
onClose={() => removePane(idx)}
|
|
/>
|
|
) : pane.kind === 'html_artifact' && pane.html_artifact_state ? (
|
|
<HtmlArtifactPane
|
|
chatId={pane.html_artifact_state.chat_id}
|
|
state={pane.html_artifact_state}
|
|
onClose={() => removePane(idx)}
|
|
/>
|
|
) : pane.kind === 'chat' && pane.chatId ? (
|
|
<ChatPane
|
|
sessionId={sessionId}
|
|
chatId={pane.chatId}
|
|
projectId={projectId}
|
|
agentId={agentId}
|
|
onAgentChange={onAgentChange}
|
|
sessionChats={chats}
|
|
webSearchEnabled={session.web_search_enabled}
|
|
/>
|
|
) : (
|
|
<SessionLandingPage
|
|
projectId={projectId}
|
|
sessionId={sessionId}
|
|
agentId={agentId}
|
|
onAgentChange={onAgentChange}
|
|
createChat={() => api.chats.create(sessionId)}
|
|
onSend={(content) => void handleLandingSend(idx, content)}
|
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
|
chats={chats}
|
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
|
onUnarchiveChat={unarchiveChat}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|