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>
498 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|