Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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<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;
|
|
}
|
|
|
|
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<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]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
{!isMobile && (
|
|
<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"
|
|
// v1.9: settings panes excluded from the MAX cap (decision c).
|
|
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
|
|
className={cn(
|
|
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
|
panes.filter((p) => p.kind !== 'settings').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>
|
|
)}
|
|
|
|
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
|
|
pill (MobileTabSwitcher) is the mobile pane switcher. */}
|
|
|
|
<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 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 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 (
|
|
<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)}
|
|
onSwitchTab={(tabIdx) => 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 && (
|
|
<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="min-w-40">
|
|
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
|
<MessageSquare size={14} /> New chat
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
|
<Terminal size={14} /> New terminal
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
|
<Bot size={14} /> New agent
|
|
</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 === '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
|
|
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>
|
|
);
|
|
}
|