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.
This commit is contained in:
2026-06-03 15:15:47 +00:00
parent ef3b998826
commit 7ff99238c9
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>
);
}