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.
239 lines
8.5 KiB
TypeScript
239 lines
8.5 KiB
TypeScript
import { useState } from 'react';
|
|
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 {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu';
|
|
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: TabDescriptor[];
|
|
// v2.6.x (Batch 3a): stable session-scoped tab number per id.
|
|
tabNumbers: Record<string, number>;
|
|
onSwitchTab: (tabIdx: number) => void;
|
|
onRemoveTab: (id: string) => void;
|
|
onCloseOthers: (id: string) => void;
|
|
onCloseToRight: (id: string) => void;
|
|
onCloseAll: () => 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,
|
|
tabNumbers,
|
|
onSwitchTab,
|
|
onRemoveTab,
|
|
onCloseOthers,
|
|
onCloseToRight,
|
|
onCloseAll,
|
|
onNewTab,
|
|
onSplitPane,
|
|
onReopenPane,
|
|
onShowHistory,
|
|
onRename,
|
|
onRemovePane,
|
|
onTerminalPaste,
|
|
}: 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.
|
|
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(id: string, currentName: string | null) {
|
|
setRenamingId(id);
|
|
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((tab, tabIdx) => {
|
|
const isActive = tabIdx === pane.activeChatIdx;
|
|
const isLast = tabIdx === tabs.length - 1;
|
|
const onlyTab = tabs.length === 1;
|
|
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={tab.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
data-tab-id={tab.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'
|
|
)}
|
|
>
|
|
<TabIcon size={12} className="shrink-0" />
|
|
{tab.kind !== 'terminal' && <StatusDot chatId={tab.id} />}
|
|
{renamingId === tab.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(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"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<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={() => onRemoveTab(tab.id)}>
|
|
Close
|
|
</ContextMenuItem>
|
|
<ContextMenuItem disabled={onlyTab} onSelect={() => onCloseOthers(tab.id)}>
|
|
Close others
|
|
</ContextMenuItem>
|
|
<ContextMenuItem disabled={isLast} onSelect={() => onCloseToRight(tab.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="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>
|
|
);
|
|
}
|