Files
boocode/apps/web/src/components/NewPaneMenu.tsx
indifferentketchup aec209310e feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
2026-06-08 03:49:22 +00:00

153 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Swords, Terminal, Workflow } from 'lucide-react';
import { api } from '@/api/client';
import type { FlowRunRow } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
disabled?: boolean;
projectId?: string;
}
function statusSymbol(status: FlowRunRow['status']): string {
if (status === 'completed') return '✓';
if (status === 'failed') return '✗';
if (status === 'running') return '⟳';
return '';
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
// Terminal + Coder items pass through to addSplitPane which creates panes
// of the appropriate kind. Phase 11: optional projectId enables "Recent Flows"
// section (runs history).
export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) {
const [runs, setRuns] = useState<FlowRunRow[] | null>(null);
const [loadingRuns, setLoadingRuns] = useState(false);
function handleOpenChange(open: boolean) {
if (!open || !projectId || runs !== null) return;
setLoadingRuns(true);
api.runs.list(projectId)
.then(({ runs: r }) => setRuns(r.slice(0, 8)))
.catch(() => setRuns([]))
.finally(() => setLoadingRuns(false));
}
return (
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="New pane"
>
<Plus size={16} />
</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>
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_flow_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_arena_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Swords size={14} /> New Arena
</DropdownMenuItem>
)}
{projectId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-1.5 text-xs">
<History size={12} />
Recent Flows
</DropdownMenuLabel>
{loadingRuns && (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">Loading</span>
</DropdownMenuItem>
)}
{!loadingRuns && runs !== null && runs.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">No recent flows</span>
</DropdownMenuItem>
)}
{!loadingRuns && runs !== null && runs.map((run) => (
<DropdownMenuItem
key={run.id}
onSelect={() => {
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id: run.id, flow_name: run.flow_name, band: run.band },
});
}}
className="flex flex-col items-start gap-0.5 max-w-[260px]"
>
<div className="flex items-center gap-1.5 w-full min-w-0">
<Workflow size={12} className="shrink-0 text-muted-foreground" />
<span className="font-medium truncate text-sm">{humanize(run.flow_name)}</span>
<span className="text-muted-foreground text-xs shrink-0 capitalize ml-auto">{run.band}</span>
</div>
<div className="flex items-center gap-1.5 w-full text-xs text-muted-foreground pl-[20px]">
<span>{statusSymbol(run.status)}</span>
<span className="truncate flex-1">{run.input.question}</span>
<span className="shrink-0">{relativeTime(run.created_at)}</span>
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}