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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user