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>
This commit is contained in:
@@ -16,11 +16,15 @@ import {
|
||||
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;
|
||||
@@ -37,6 +41,7 @@ interface Props {
|
||||
export function ChatTabBar({
|
||||
pane,
|
||||
tabs,
|
||||
tabNumbers,
|
||||
onSwitchTab,
|
||||
onRemoveTab,
|
||||
onCloseOthers,
|
||||
@@ -83,6 +88,9 @@ export function ChatTabBar({
|
||||
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>
|
||||
@@ -117,8 +125,11 @@ export function ChatTabBar({
|
||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[140px]" title={label}>
|
||||
{label}
|
||||
<span
|
||||
className="truncate max-w-[140px]"
|
||||
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
>
|
||||
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -138,6 +149,13 @@ export function ChatTabBar({
|
||||
<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
|
||||
@@ -174,15 +192,31 @@ export function ChatTabBar({
|
||||
)}
|
||||
|
||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewTab}
|
||||
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 tab"
|
||||
title="New tab"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user