agent-ecosystem/src/renderer/components/layout/TabBar.tsx
iliya 126f8e2865 feat: add Extension Store with plugin catalog and MCP registry
Full Extension Store implementation (Phases 0-6):
- Plugin marketplace catalog with ETag caching and search/filter/sort
- MCP server registry with Official + Glama aggregation
- Install/uninstall flows for both plugins and MCP servers via CLI
- Per-tab UI state, skeleton loading, dashed empty states, card polish
- Input validation and security hardening (scope allowlists, env/header
  key regex, projectPath validation, HTTP body size limits)
- 8 test suites covering catalog, install, aggregation, normalizers
2026-03-08 01:00:18 +02:00

516 lines
19 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 { Bell, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
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,
unreadCount,
openNotificationsTab,
openTeamsTab,
openExtensionsTab,
openSettingsTab,
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,
unreadCount: s.unreadCount,
openNotificationsTab: s.openNotificationsTab,
openTeamsTab: s.openTeamsTab,
openExtensionsTab: s.openExtensionsTab,
openSettingsTab: s.openSettingsTab,
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 [notificationsHover, setNotificationsHover] = useState(false);
const [teamsHover, setTeamsHover] = useState(false);
const [extensionsHover, setExtensionsHover] = useState(false);
const [githubHover, setGithubHover] = useState(false);
const [settingsHover, setSettingsHover] = 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 flex-1 items-center gap-1"
style={
{
WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px',
overflowX: 'auto',
overflowY: 'hidden',
} 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="min-w-[48px] shrink-0 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>
{/* 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>
{/* Teams icon */}
<button
onClick={openTeamsTab}
onMouseEnter={() => setTeamsHover(true)}
onMouseLeave={() => setTeamsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: teamsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: teamsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Teams"
>
<Users className="size-4" />
</button>
{/* Extensions icon */}
<button
onClick={openExtensionsTab}
onMouseEnter={() => setExtensionsHover(true)}
onMouseLeave={() => setExtensionsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: extensionsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: extensionsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Extensions"
>
<Puzzle className="size-4" />
</button>
{/* GitHub link */}
<button
onClick={() =>
void window.electronAPI.openExternal(
'https://github.com/777genius/claude_agent_teams_ui'
)
}
onMouseEnter={() => setGithubHover(true)}
onMouseLeave={() => setGithubHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="GitHub"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
</svg>
</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>
{/* More menu (Search, Export, Analyze, Settings) */}
<MoreMenu
activeTab={activeTab}
activeTabSessionDetail={activeTabSessionDetail}
activeTabId={activeTabId}
/>
</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>
);
};