Files
boocode/apps/web/src/components/Workspace.tsx
indifferentketchup e09c67d65c tab-close + chat archive/delete + landing-card buttons + 1000px content cap
Feature 1 — Tab close menu (pure local pane state, no API):
- ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all
- Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch)
- Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path

Feature 2 — Chat archive + delete:
- chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk
- POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only
- 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed)
- Same dedup discipline: server-only publish, no local sessionEvents.emit in client
- SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore
- Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore

UX — chat content width cap:
- ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge
- No new deps, no media queries (narrow viewports collapse to width naturally)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:21:26 +00:00

498 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { Chat, WorkspacePane } from '@/api/types';
import { ChatPane } from '@/components/panes/ChatPane';
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;
}
const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
}
export function Workspace({ sessionId, projectId }: Props) {
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
});
const [activePaneIdx, setActivePaneIdx] = useState(0);
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(openChat.id)];
}
return prev;
});
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanes(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanes(event.chat_id);
}
});
}, [sessionId]);
function removeChatFromPanes(chatId: string) {
setPanes((prev) => prev.map((p) => {
const idx = p.chatIds.indexOf(chatId);
if (idx < 0) return p;
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId, openChatInPane]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId, openChatInPane]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
.filter((c): c is Chat => c !== undefined);
}
return (
<div className="flex flex-col h-full min-h-0">
<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"
disabled={panes.length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.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>
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
>
{panes.map((pane, idx) => (
<div
key={pane.id}
className={cn(
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
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={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={panes.length > 1}
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
>
<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)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
) : (
<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>
);
}