wip: pane/session + tab-bar checkpoint

Second checkpoint of in-flight work (sessions route, api types, ChatTabBar,
PaneHeaderActions, Workspace, useWorkspacePanes) so the Orchestrator branch
can rebase onto current main before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:15:47 +00:00
parent 38a0d47bcc
commit 519b1d2ca1
6 changed files with 404 additions and 317 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Code, History, MessageSquare, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { Clipboard, Code, History, MessageSquare, Terminal, X } from 'lucide-react';
import type { WorkspacePane, WorkspaceTabKind } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
import {
@@ -14,32 +14,51 @@ import { useLongPress } from '@/hooks/useLongPress';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
// Mixed tabs: a pane can hold tabs of different kinds. Each tab is described by
// its id, kind, and label (terminal tabs have no chats row, so their label is
// supplied by the caller).
export interface TabDescriptor {
id: string;
kind: WorkspaceTabKind;
name: string | null;
}
interface Props {
pane: WorkspacePane;
tabs: Chat[];
// Host pane kind — 'coder' shows the Code glyph + routes the "+" to a new
// BooCode tab. Defaults to 'chat' (the BooChat tab bar).
tabKind?: 'chat' | 'coder';
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
// chat.id, NEVER by tab position.
tabs: TabDescriptor[];
// v2.6.x (Batch 3a): stable session-scoped tab number per id.
tabNumbers: Record<string, number>;
onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void;
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onRemoveTab: (id: string) => void;
onCloseOthers: (id: string) => void;
onCloseToRight: (id: string) => void;
onCloseAll: () => void;
onNewTab: () => void;
// Mixed tabs: the "+" adds a tab of the chosen kind to THIS pane.
onNewTab: (kind: WorkspaceTabKind) => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
// iOS-safe terminal paste, shown only when the active tab is a terminal.
onTerminalPaste?: () => void;
}
function iconForKind(kind: WorkspaceTabKind) {
if (kind === 'coder') return Code;
if (kind === 'terminal') return Terminal;
return MessageSquare;
}
function defaultName(kind: WorkspaceTabKind): string {
if (kind === 'coder') return 'BooCoder';
if (kind === 'terminal') return 'Terminal';
return 'New chat';
}
export function ChatTabBar({
pane,
tabs,
tabKind = 'chat',
tabNumbers,
onSwitchTab,
onRemoveTab,
@@ -52,15 +71,13 @@ export function ChatTabBar({
onShowHistory,
onRename,
onRemovePane,
onTerminalPaste,
}: Props) {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const TabIcon = tabKind === 'coder' ? Code : MessageSquare;
const newLabel = tabKind === 'coder' ? 'New BooCode' : 'New chat';
// 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.
// existing Radix ContextMenuTrigger opens at the touch coordinates.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
@@ -70,8 +87,8 @@ export function ChatTabBar({
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
function startRename(id: string, currentName: string | null) {
setRenamingId(id);
setRenameValue(currentName ?? '');
}
@@ -84,19 +101,19 @@ export function ChatTabBar({
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) => {
{tabs.map((tab, 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];
const TabIcon = iconForKind(tab.kind);
const label = tab.name ?? defaultName(tab.kind);
const canRename = tab.kind !== 'terminal';
const tabNumber = tabNumbers[tab.id];
return (
<ContextMenu key={chat.id}>
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={chat.id}
data-tab-id={tab.id}
onClick={() => onSwitchTab(tabIdx)}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
@@ -111,8 +128,8 @@ export function ChatTabBar({
)}
>
<TabIcon size={12} className="shrink-0" />
<StatusDot chatId={chat.id} />
{renamingId === chat.id ? (
{tab.kind !== 'terminal' && <StatusDot chatId={tab.id} />}
{renamingId === tab.id ? (
<input
autoFocus
value={renameValue}
@@ -137,7 +154,7 @@ export function ChatTabBar({
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTab(chat.id);
onRemoveTab(tab.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"
@@ -147,34 +164,34 @@ export function ChatTabBar({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onNewTab}>
{newLabel}
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
}
>
Open in new pane
<ContextMenuItem onSelect={() => onNewTab(tab.kind)}>
New {defaultName(tab.kind)}
</ContextMenuItem>
{tab.kind !== 'terminal' && (
<ContextMenuItem
onSelect={() =>
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: tab.id })
}
>
Open in new pane
</ContextMenuItem>
)}
{canRename && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(tab.id, tab.name)}>
Rename
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
<ContextMenuItem onSelect={() => onRemoveTab(tab.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
disabled={onlyTab}
onSelect={() => onCloseOthers(chat.id)}
>
<ContextMenuItem disabled={onlyTab} onSelect={() => onCloseOthers(tab.id)}>
Close others
</ContextMenuItem>
<ContextMenuItem
disabled={isLast}
onSelect={() => onCloseToRight(chat.id)}
>
<ContextMenuItem disabled={isLast} onSelect={() => onCloseToRight(tab.id)}>
Close to right
</ContextMenuItem>
<ContextMenuItem onSelect={() => onCloseAll()}>
@@ -192,16 +209,30 @@ export function ChatTabBar({
</div>
)}
<PaneHeaderActions
className="ml-auto px-1"
onNewTab={onNewTab}
tabKind={tabKind}
onSplitPane={onSplitPane}
onReopenPane={onReopenPane}
onShowHistory={onShowHistory}
onRemovePane={onRemovePane}
historyActive={pane.kind === 'empty'}
/>
<div className="ml-auto flex items-center px-1 shrink-0">
{onTerminalPaste && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onTerminalPaste();
}}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Paste from clipboard"
title="Paste from clipboard"
>
<Clipboard size={12} />
</button>
)}
<PaneHeaderActions
onNewTab={onNewTab}
onSplitPane={onSplitPane}
onReopenPane={onReopenPane}
onShowHistory={onShowHistory}
onRemovePane={onRemovePane}
historyActive={pane.kind === 'empty'}
/>
</div>
</div>
);
}

View File

@@ -12,14 +12,9 @@ import { cn } from '@/lib/utils';
// desktop coder + terminal pane headers (Workspace) so all pane kinds share one
// control set. Extracted to avoid a divergent copy per header.
interface Props {
// When provided, the "+" menu item matching `tabKind` opens an in-pane tab
// (e.g. chat panes: New BooChat → tab; coder panes: New BooCode → tab). Every
// OTHER kind splits into a new pane. When onNewTab is omitted (terminal
// panes, which can't host tabs) all three items split.
onNewTab?: () => void;
// The host pane's own kind — the "+" item of this kind becomes "new tab".
// Defaults to 'chat' for back-compat with the chat tab bar.
tabKind?: 'chat' | 'terminal' | 'coder';
// Mixed tabs: the "+" menu adds a tab of the chosen kind to THIS pane. Split
// (the second control) adds a new pane.
onNewTab: (kind: 'chat' | 'terminal' | 'coder') => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
@@ -35,7 +30,6 @@ const BTN =
export function PaneHeaderActions({
onNewTab,
tabKind = 'chat',
onSplitPane,
onReopenPane,
onShowHistory,
@@ -43,10 +37,6 @@ export function PaneHeaderActions({
historyActive,
className,
}: Props) {
// The "+" item of the host pane's own kind adds a tab; every other kind
// splits into a new pane. Falls back to split when onNewTab is absent.
const newOrSplit = (kind: 'chat' | 'terminal' | 'coder') =>
onNewTab && tabKind === kind ? onNewTab : () => onSplitPane(kind);
return (
<div className={cn('flex items-center gap-0.5 shrink-0', className)}>
<DropdownMenu>
@@ -55,22 +45,21 @@ export function PaneHeaderActions({
type="button"
onClick={(e) => e.stopPropagation()}
className={BTN}
aria-label="New chat, terminal, or coder"
title="New chat / terminal / coder"
aria-label="New tab"
title="New tab (chat / terminal / coder)"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
{/* The item matching the host pane's kind opens an in-pane tab; the
others split into a new pane. (tabKind defaults to 'chat'.) */}
<DropdownMenuItem onSelect={newOrSplit('chat')}>
{/* Mixed tabs: every item adds a tab of that kind to THIS pane. */}
<DropdownMenuItem onSelect={() => onNewTab('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={newOrSplit('terminal')}>
<DropdownMenuItem onSelect={() => onNewTab('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={newOrSplit('coder')}>
<DropdownMenuItem onSelect={() => onNewTab('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Terminal, Clipboard } from 'lucide-react';
import { api } from '@/api/client';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import type { Project, Session, WorkspacePane, WorkspaceTabKind } from '@/api/types';
import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
@@ -12,11 +11,17 @@ import { TerminalPane } from '@/components/panes/TerminalPane';
import { CoderPane } from '@/components/panes/CoderPane';
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { PaneHeaderActions } from '@/components/PaneHeaderActions';
import { ChatTabBar, type TabDescriptor } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import { cn } from '@/lib/utils';
// Mixed tabs: the kind of tab `i` in a pane, with a legacy fallback to pane.kind.
function tabKindAt(pane: WorkspacePane, i: number): WorkspaceTabKind {
const k = pane.tabKinds?.[i];
if (k) return k;
return pane.kind === 'coder' ? 'coder' : pane.kind === 'terminal' ? 'terminal' : 'chat';
}
interface Props {
sessionId: string;
projectId: string;
@@ -59,8 +64,7 @@ export function Workspace({
historyPaneId,
openSessionHistory,
closeSessionHistory,
addSplitPane,
createCoderTab,
createTab,
removePane,
reopenPane,
hasClosedPanes,
@@ -75,7 +79,6 @@ export function Workspace({
} = panesHook;
const {
chats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
@@ -121,26 +124,35 @@ export function Workspace({
if (maximized && settingsIdx < 0) setMaximized(false);
}, [maximized, settingsIdx]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
.map((id) => chats.find((c) => c.id === id))
.filter((c): c is Chat => c !== undefined);
}
// v1.10 booterm: per-terminal label used by the registry that powers the
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
// v1.10 booterm + mixed tabs: per-terminal-TAB label, keyed by the terminal
// tab id (which keys its tmux session). Numbered across the workspace.
const terminalLabels = useMemo(() => {
const out = new Map<string, string>();
let n = 0;
for (const p of panes) {
if (p.kind === 'terminal') {
n += 1;
out.set(p.id, `Terminal ${n}`);
}
p.chatIds.forEach((id, i) => {
if (tabKindAt(p, i) === 'terminal') {
n += 1;
out.set(id, `Terminal ${n}`);
}
});
}
return out;
}, [panes]);
// Mixed tabs: descriptors for the tab strip — each tab's id, kind, and label.
// Terminal tabs have no chats row, so their label comes from terminalLabels.
function paneTabs(pane: WorkspacePane): TabDescriptor[] {
return pane.chatIds.map((id, i) => {
const kind = tabKindAt(pane, i);
const name =
kind === 'terminal'
? terminalLabels.get(id) ?? 'Terminal'
: chats.find((c) => c.id === id)?.name ?? null;
return { id, kind, name };
});
}
return (
<div className="flex flex-col h-full min-h-0">
<div
@@ -191,61 +203,35 @@ export function Workspace({
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile; settings/terminal/artifact panes own their
own header. Chat and coder panes share this strip — tabKind
and onNewTab differ between the two. */}
{!isMobile && (isCoder || !isChromeless) && (
{/* Mixed tabs: one unified strip for every tabbed pane
(chat / coder / terminal / empty-landing). The "+" adds a tab
of any kind; Split adds a pane. Settings/artifact panes own
their own headers. Hidden on mobile (mobile uses pane panes). */}
{!isMobile && !isSettings && !isArtifact && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
tabKind={isCoder ? 'coder' : undefined}
tabs={paneTabs(pane)}
tabNumbers={tabNumbers}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)}
onNewTab={(kind) => void createTab(idx, kind)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => openSessionHistory(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
// iOS-safe terminal paste (real click is a user gesture), shown
// only while the active tab is a terminal.
onTerminalPaste={
isTerminal
? () => terminalsRegistry.get(activePaneChatId(pane) ?? pane.id)?.paste()
: undefined
}
/>
)}
{isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
<div className="ml-auto flex items-center gap-0.5">
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
outside direct user gestures. A real button click IS a
gesture, so this works where keystroke-driven paste may
not on iOS. The action lives in TerminalPane behind the
registry's paste() callback. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
terminalsRegistry.get(pane.id)?.paste();
}}
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="Paste from clipboard"
title="Paste from clipboard"
>
<Clipboard size={12} />
</button>
<PaneHeaderActions
onSplitPane={onAddPane}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => openSessionHistory(idx)}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
</div>
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -260,9 +246,10 @@ export function Workspace({
/>
) : isTerminal ? (
<TerminalPane
key={activePaneChatId(pane) ?? pane.id}
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
paneId={activePaneChatId(pane) ?? pane.id}
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'coder' ? (