Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread through inference (agents.ts parser → Agent type → stream-phase → sentinel summaries). Null means omit from request body, preserving provider defaults. Wire ask_user_input interactive card into both BooCoder frontends: the CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard instead of ToolCallLine for ask_user_input tool calls) and the standalone coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives). Additional fixes: SessionLandingPage uses ChatInput with slash-command support and lazy chat creation; Session.tsx hydrate-race fix for empty pane promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width; Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext host port exposed in docker-compose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
410 lines
17 KiB
TypeScript
410 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;
|
|
}
|
|
|
|
export function Workspace({
|
|
sessionId,
|
|
projectId,
|
|
agentId,
|
|
onAgentChange,
|
|
panesHook,
|
|
chatsHook,
|
|
session,
|
|
project,
|
|
onAddPane,
|
|
}: Props) {
|
|
const {
|
|
panes,
|
|
activePaneIdx,
|
|
setActivePaneIdx,
|
|
openChatInPane,
|
|
switchTab,
|
|
removeTab,
|
|
closeOtherTabs,
|
|
closeTabsToRight,
|
|
closeAllTabs,
|
|
showLandingPage,
|
|
addSplitPane,
|
|
removePane,
|
|
isPaneChatPending,
|
|
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]);
|
|
|
|
// Per-coder-pane WS connection (status dot lives in the pane header).
|
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
|
|
|
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)}
|
|
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 onAddPane(kind);
|
|
}}
|
|
onShowHistory={() => showLandingPage(idx)}
|
|
onRename={renameChat}
|
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
|
/>
|
|
)}
|
|
{isCoder && (
|
|
<div className="flex items-center gap-2 border-b border-border bg-muted/30 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.5">
|
|
<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 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={() => 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>
|
|
<span
|
|
className={cn(
|
|
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
|
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
|
|
)}
|
|
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
|
|
/>
|
|
{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 BooCode pane"
|
|
title="Close BooCode 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="min-w-40">
|
|
<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 },
|
|
)
|
|
}
|
|
/>
|
|
) : 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}
|
|
createChat={() => api.chats.create(sessionId)}
|
|
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>
|
|
);
|
|
}
|