agent-ecosystem/src/renderer/components/layout/TabBar.tsx
2026-02-23 22:14:01 +08:00

494 lines
18 KiB
TypeScript

/**
* TabBar - Displays open tabs with close buttons and action buttons.
* Accepts a paneId prop to scope to a specific pane's tabs.
* Supports tab switching, closing, horizontal scrolling on overflow,
* right-click context menu, middle-click to close, Shift/Ctrl+click multi-select,
* and drag-and-drop reordering/cross-pane movement via @dnd-kit.
* When sidebar is collapsed, shows expand button on the left with macOS traffic light spacing.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { Activity, Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ExportDropdown } from '../common/ExportDropdown';
import { SortableTab } from './SortableTab';
import { TabContextMenu } from './TabContextMenu';
interface TabBarProps {
paneId: string;
}
export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
const {
pane,
isFocused,
paneCount,
setActiveTab,
closeTab,
closeOtherTabs,
closeAllTabs,
closeTabs,
setSelectedTabIds,
clearTabSelection,
openDashboard,
fetchSessionDetail,
fetchSessions,
openCommandPalette,
unreadCount,
openNotificationsTab,
openSettingsTab,
openSessionReport,
sidebarCollapsed,
toggleSidebar,
splitPane,
togglePinSession,
pinnedSessionIds,
toggleHideSession,
hiddenSessionIds,
tabSessionData,
} = useStore(
useShallow((s) => ({
pane: s.paneLayout.panes.find((p) => p.id === paneId),
isFocused: s.paneLayout.focusedPaneId === paneId,
paneCount: s.paneLayout.panes.length,
setActiveTab: s.setActiveTab,
closeTab: s.closeTab,
closeOtherTabs: s.closeOtherTabs,
closeAllTabs: s.closeAllTabs,
closeTabs: s.closeTabs,
setSelectedTabIds: s.setSelectedTabIds,
clearTabSelection: s.clearTabSelection,
openDashboard: s.openDashboard,
fetchSessionDetail: s.fetchSessionDetail,
fetchSessions: s.fetchSessions,
openCommandPalette: s.openCommandPalette,
unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab,
openSettingsTab: s.openSettingsTab,
openSessionReport: s.openSessionReport,
sidebarCollapsed: s.sidebarCollapsed,
toggleSidebar: s.toggleSidebar,
splitPane: s.splitPane,
togglePinSession: s.togglePinSession,
pinnedSessionIds: s.pinnedSessionIds,
toggleHideSession: s.toggleHideSession,
hiddenSessionIds: s.hiddenSessionIds,
tabSessionData: s.tabSessionData,
}))
);
const openTabs = useMemo(() => pane?.tabs ?? [], [pane?.tabs]);
const activeTabId = pane?.activeTabId ?? null;
const selectedTabIds = useMemo(() => pane?.selectedTabIds ?? [], [pane?.selectedTabIds]);
// Derive Set for O(1) lookups
const selectedSet = useMemo(() => new Set(selectedTabIds), [selectedTabIds]);
// Derive stable tab IDs array for SortableContext
const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]);
// Derive session detail for the active tab (used by export dropdown)
const activeTabSessionDetail = activeTabId
? (tabSessionData[activeTabId]?.sessionDetail ?? null)
: null;
// Hover states for buttons
const [expandHover, setExpandHover] = useState(false);
const [refreshHover, setRefreshHover] = useState(false);
const [newTabHover, setNewTabHover] = useState(false);
const [searchHover, setSearchHover] = useState(false);
const [notificationsHover, setNotificationsHover] = useState(false);
const [settingsHover, setSettingsHover] = useState(false);
const [analyzeHover, setAnalyzeHover] = useState(false);
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(
null
);
// Track last clicked tab for Shift range selection
const lastClickedTabIdRef = useRef<string | null>(null);
// Get the active tab
const activeTab = openTabs.find((tab) => tab.id === activeTabId);
// Refs for auto-scrolling to active tab
const tabRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// Make the tab bar area droppable for cross-pane drops
const { setNodeRef: setDroppableRef, isOver: isDroppableOver } = useDroppable({
id: `tabbar-${paneId}`,
data: {
type: 'tabbar',
paneId,
},
});
// Auto-scroll to active tab when it changes
useEffect(() => {
if (!activeTabId) return;
const tabElement = tabRefsMap.current.get(activeTabId);
if (tabElement && scrollContainerRef.current) {
tabElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}, [activeTabId]);
// Clear selection on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && selectedTabIds.length > 0) {
clearTabSelection();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedTabIds.length, clearTabSelection]);
// Handle tab click with multi-select support
const handleTabClick = useCallback(
(tabId: string, e: React.MouseEvent) => {
const isMeta = e.metaKey || e.ctrlKey;
const isShift = e.shiftKey;
if (isMeta) {
// Ctrl/Cmd+click: toggle tab in selection
if (selectedSet.has(tabId)) {
setSelectedTabIds(selectedTabIds.filter((id) => id !== tabId));
} else {
setSelectedTabIds([...selectedTabIds, tabId]);
}
lastClickedTabIdRef.current = tabId;
return;
}
if (isShift && lastClickedTabIdRef.current) {
// Shift+click: range selection from last clicked to current
const lastIndex = openTabs.findIndex((t) => t.id === lastClickedTabIdRef.current);
const currentIndex = openTabs.findIndex((t) => t.id === tabId);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const rangeIds = openTabs.slice(start, end + 1).map((t) => t.id);
// Merge with existing selection
const merged = new Set([...selectedTabIds, ...rangeIds]);
setSelectedTabIds([...merged]);
}
return;
}
// Plain click: clear selection, switch tab
clearTabSelection();
lastClickedTabIdRef.current = tabId;
setActiveTab(tabId);
},
[openTabs, selectedTabIds, selectedSet, setActiveTab, setSelectedTabIds, clearTabSelection]
);
// Middle-click to close + prevent text selection on Shift/Cmd click
const handleMouseDown = useCallback(
(tabId: string, e: React.MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
closeTab(tabId);
return;
}
// Prevent native text selection when Shift or Cmd/Ctrl clicking tabs
if (e.button === 0 && (e.shiftKey || e.metaKey || e.ctrlKey)) {
e.preventDefault();
}
},
[closeTab]
);
// Right-click context menu
const handleContextMenu = useCallback((tabId: string, e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}, []);
// Handle refresh for active session tab
const handleRefresh = async (): Promise<void> => {
if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) {
await Promise.all([
fetchSessionDetail(activeTab.projectId, activeTab.sessionId, activeTabId ?? undefined),
fetchSessions(activeTab.projectId),
]);
}
};
// Ref setter for SortableTab
const setTabRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
if (el) {
tabRefsMap.current.set(tabId, el);
} else {
tabRefsMap.current.delete(tabId);
}
}, []);
// Context menu helpers
const contextMenuTabId = contextMenu?.tabId ?? null;
const effectiveSelectedCount =
contextMenuTabId && selectedSet.has(contextMenuTabId) ? selectedTabIds.length : 0;
// Pin state for context menu tab
const contextMenuTab = contextMenuTabId ? openTabs.find((t) => t.id === contextMenuTabId) : null;
const isContextMenuTabSession = contextMenuTab?.type === 'session';
const isContextMenuTabPinned =
isContextMenuTabSession && contextMenuTab?.sessionId
? pinnedSessionIds.includes(contextMenuTab.sessionId)
: false;
const isContextMenuTabHidden =
isContextMenuTabSession && contextMenuTab?.sessionId
? hiddenSessionIds.includes(contextMenuTab.sessionId)
: false;
// Show sidebar expand button only in the leftmost pane
const isLeftmostPane = useStore(
(s) => s.paneLayout.panes.length === 0 || s.paneLayout.panes[0]?.id === paneId
);
return (
<div
className="flex items-center justify-between pr-2"
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
paddingLeft:
sidebarCollapsed && isLeftmostPane
? 'var(--macos-traffic-light-padding-left, 72px)'
: '8px',
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
opacity: isFocused || paneCount === 1 ? 1 : 0.7,
} as React.CSSProperties
}
>
{/* Expand sidebar button - show when collapsed (only in leftmost pane) */}
{sidebarCollapsed && isLeftmostPane && (
<button
onClick={toggleSidebar}
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-2 shrink-0 rounded-md p-1.5 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="Expand sidebar"
>
<PanelLeft className="size-4" />
</button>
)}
{/* Tab list with horizontal scroll, sortable DnD, and droppable area.
Capped at 75% so the drag spacer always has room to the right. */}
<div
ref={(el) => {
scrollContainerRef.current = el;
setDroppableRef(el);
}}
className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto"
style={
{
maxWidth: '75%',
WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px',
} as React.CSSProperties
}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{openTabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
paneId={paneId}
isActive={tab.id === activeTabId}
isSelected={selectedSet.has(tab.id)}
onTabClick={handleTabClick}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onClose={closeTab}
setRef={setTabRef}
/>
))}
</SortableContext>
{/* Refresh button - show only for session tabs */}
{activeTab?.type === 'session' && (
<button
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
style={{
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
}}
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
title={`Refresh Session (${formatShortcut('R')})`}
>
<RefreshCw className="size-4" />
</button>
)}
</div>
{/* Drag spacer — fills empty space between tab list and action buttons.
Gives users a reliable window-drag target regardless of how many tabs are open.
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
<div
className="flex-1 self-stretch"
style={
{
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
} as React.CSSProperties
}
/>
{/* Right side actions */}
<div
className="ml-2 flex shrink-0 items-center gap-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{/* New tab button */}
<button
onClick={openDashboard}
onMouseEnter={() => setNewTabHover(true)}
onMouseLeave={() => setNewTabHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="New tab (Dashboard)"
>
<Plus className="size-4" />
</button>
{/* Search button (icon only) */}
<button
onClick={openCommandPalette}
onMouseEnter={() => setSearchHover(true)}
onMouseLeave={() => setSearchHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: searchHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: searchHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title={`Search (${formatShortcut('K')})`}
>
<Search className="size-4" />
</button>
{/* Export dropdown - show only for session tabs with loaded data */}
{activeTab?.type === 'session' && activeTabSessionDetail && (
<ExportDropdown sessionDetail={activeTabSessionDetail} />
)}
{/* Analyze button - show only for session tabs with loaded data */}
{activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && (
<button
onClick={() => openSessionReport(activeTabId)}
onMouseEnter={() => setAnalyzeHover(true)}
onMouseLeave={() => setAnalyzeHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: analyzeHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: analyzeHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Analyze Session"
>
<Activity className="size-4" />
</button>
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}
onMouseEnter={() => setNotificationsHover(true)}
onMouseLeave={() => setNotificationsHover(false)}
className="relative rounded-md p-2 transition-colors"
style={{
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Notifications"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Settings gear icon */}
<button
onClick={() => openSettingsTab()}
onMouseEnter={() => setSettingsHover(true)}
onMouseLeave={() => setSettingsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Settings"
>
<Settings className="size-4" />
</button>
</div>
{/* Context menu */}
{contextMenu && contextMenuTabId && (
<TabContextMenu
x={contextMenu.x}
y={contextMenu.y}
tabId={contextMenuTabId}
paneId={paneId}
selectedCount={effectiveSelectedCount}
onClose={() => setContextMenu(null)}
onCloseTab={() => closeTab(contextMenuTabId)}
onCloseOtherTabs={() => closeOtherTabs(contextMenuTabId)}
onCloseAllTabs={() => closeAllTabs()}
onCloseSelectedTabs={
effectiveSelectedCount > 1 ? () => closeTabs([...selectedTabIds]) : undefined
}
onSplitRight={() => splitPane(paneId, contextMenuTabId, 'right')}
onSplitLeft={() => splitPane(paneId, contextMenuTabId, 'left')}
disableSplit={paneCount >= 4}
isSessionTab={isContextMenuTabSession}
isPinned={isContextMenuTabPinned}
onTogglePin={
isContextMenuTabSession && contextMenuTab?.sessionId
? () => togglePinSession(contextMenuTab.sessionId!)
: undefined
}
isHidden={isContextMenuTabHidden}
onToggleHide={
isContextMenuTabSession && contextMenuTab?.sessionId
? () => toggleHideSession(contextMenuTab.sessionId!)
: undefined
}
/>
)}
</div>
);
};