Files
boocode/apps/web/src/components/ChatTabBar.tsx
indifferentketchup 2fd7e5bf97 feat(web): workspace panes & tabs overhaul
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):

- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
  ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
  lands the fork beside the original instead of replacing the active pane.
  openChatInNewPane detaches the chat from any pane already holding it
  (one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
  term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
  artifact) and its dead code; the artifact-pane machinery is left orphaned for
  a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
  archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
  oldest chat/empty pane instead of discarding them; terminal/coder panes close
  as before. Reopen strips the restored chatIds from all live panes first, so a
  relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
  open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
  nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
  array into the persisted envelope so it survives reload. Hydrate/persist
  normalize the legacy bare-array shape. appendClosed dedupes a value-identical
  top entry to neutralize the StrictMode double-invoke of the setPanes updater.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:03 +00:00

281 lines
11 KiB
TypeScript

import { useState } from 'react';
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
pane: WorkspacePane;
tabs: Chat[];
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
// chat.id, NEVER by tab position.
tabNumbers: Record<string, number>;
onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void;
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onCloseAll: () => void;
onNewTab: () => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
}
export function ChatTabBar({
pane,
tabs,
tabNumbers,
onSwitchTab,
onRemoveTab,
onCloseOthers,
onCloseToRight,
onCloseAll,
onNewTab,
onSplitPane,
onReopenPane,
onShowHistory,
onRename,
onRemovePane,
}: Props) {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Long-press: dispatch a synthetic contextmenu event on the tab so the
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
// because asChild composition makes the tab div the trigger element.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
if (!tab) return;
tab.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const isLast = tabIdx === tabs.length - 1;
const onlyTab = tabs.length === 1;
const label = chat.name ?? 'New chat';
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
// Omit gracefully when not yet assigned.
const tabNumber = tabNumbers[chat.id];
return (
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={chat.id}
onClick={() => onSwitchTab(tabIdx)}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
>
<MessageSquare size={12} className="shrink-0" />
<StatusDot chatId={chat.id} />
{renamingId === chat.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-xs outline-none w-28"
/>
) : (
<span
className="truncate max-w-[140px]"
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
>
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTab(chat.id);
}}
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
aria-label="Close tab"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onNewTab}>
New chat
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
}
>
Open in new pane
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
disabled={onlyTab}
onSelect={() => onCloseOthers(chat.id)}
>
Close others
</ContextMenuItem>
<ContextMenuItem
disabled={isLast}
onSelect={() => onCloseToRight(chat.id)}
>
Close to right
</ContextMenuItem>
<ContextMenuItem onSelect={() => onCloseAll()}>
Close all
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{tabs.length === 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<History size={12} className="shrink-0" />
<span>Session</span>
</div>
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat, terminal, or coder"
title="New chat / terminal / coder"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
tabs, so they split into a new pane (matches the Split menu). */}
<DropdownMenuItem onSelect={onNewTab}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Split pane"
title="Split pane"
>
<Columns2 size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={onReopenPane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button
type="button"
onClick={onShowHistory}
className={cn(
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={onRemovePane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
</div>
);
}