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>
281 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|