import React, { useState, useRef, useCallback, useEffect, memo, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2, Download, Clipboard, Share2, ChevronsLeft, ChevronsRight, Loader2, } from 'lucide-react'; import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types'; import { hasDraft } from '../utils/tabHelpers'; interface TabBarProps { tabs: AITab[]; activeTabId: string; theme: Theme; onTabSelect: (tabId: string) => void; onTabClose: (tabId: string) => void; onNewTab: () => void; onRequestRename?: (tabId: string) => void; onTabReorder?: (fromIndex: number, toIndex: number) => void; /** Handler to reorder tabs in unified tab order (AI + file tabs) */ onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void; onTabStar?: (tabId: string, starred: boolean) => void; onTabMarkUnread?: (tabId: string) => void; /** Handler to open merge session modal with this tab as source */ onMergeWith?: (tabId: string) => void; /** Handler to open send to agent modal with this tab as source */ onSendToAgent?: (tabId: string) => void; /** Handler to summarize and continue in a new tab */ onSummarizeAndContinue?: (tabId: string) => void; /** Handler to copy conversation context to clipboard */ onCopyContext?: (tabId: string) => void; /** Handler to export tab as HTML */ onExportHtml?: (tabId: string) => void; /** Handler to publish tab context as GitHub Gist */ onPublishGist?: (tabId: string) => void; /** Whether GitHub CLI is available for gist publishing */ ghCliAvailable?: boolean; showUnreadOnly?: boolean; onToggleUnreadFilter?: () => void; onOpenTabSearch?: () => void; /** Handler to close all tabs */ onCloseAllTabs?: () => void; /** Handler to close all tabs except active */ onCloseOtherTabs?: () => void; /** Handler to close tabs to the left of active tab */ onCloseTabsLeft?: () => void; /** Handler to close tabs to the right of active tab */ onCloseTabsRight?: () => void; // === Unified Tab System Props (Phase 3) === /** Merged ordered list of AI and file preview tabs for unified rendering */ unifiedTabs?: UnifiedTab[]; /** Currently active file tab ID (null if an AI tab is active) */ activeFileTabId?: string | null; /** Handler to select a file preview tab */ onFileTabSelect?: (tabId: string) => void; /** Handler to close a file preview tab */ onFileTabClose?: (tabId: string) => void; } interface TabProps { tab: AITab; tabId: string; isActive: boolean; theme: Theme; canClose: boolean; /** Stable callback - receives tabId as first argument */ onSelect: (tabId: string) => void; /** Stable callback - receives tabId as first argument */ onClose: (tabId: string) => void; /** Stable callback - receives tabId and event */ onDragStart: (tabId: string, e: React.DragEvent) => void; /** Stable callback - receives tabId and event */ onDragOver: (tabId: string, e: React.DragEvent) => void; onDragEnd: () => void; /** Stable callback - receives tabId and event */ onDrop: (tabId: string, e: React.DragEvent) => void; isDragging: boolean; isDragOver: boolean; /** Stable callback - receives tabId */ onRename: (tabId: string) => void; /** Stable callback - receives tabId and starred boolean */ onStar?: (tabId: string, starred: boolean) => void; /** Stable callback - receives tabId */ onMarkUnread?: (tabId: string) => void; /** Stable callback - receives tabId */ onMergeWith?: (tabId: string) => void; /** Stable callback - receives tabId */ onSendToAgent?: (tabId: string) => void; /** Stable callback - receives tabId */ onSummarizeAndContinue?: (tabId: string) => void; /** Stable callback - receives tabId */ onCopyContext?: (tabId: string) => void; /** Stable callback - receives tabId */ onExportHtml?: (tabId: string) => void; /** Stable callback - receives tabId */ onPublishGist?: (tabId: string) => void; /** Stable callback - receives tabId */ onMoveToFirst?: (tabId: string) => void; /** Stable callback - receives tabId */ onMoveToLast?: (tabId: string) => void; /** Is this the first tab? */ isFirstTab?: boolean; /** Is this the last tab? */ isLastTab?: boolean; shortcutHint?: number | null; registerRef?: (el: HTMLDivElement | null) => void; hasDraft?: boolean; /** Stable callback - closes all tabs */ onCloseAllTabs?: () => void; /** Stable callback - receives tabId */ onCloseOtherTabs?: (tabId: string) => void; /** Stable callback - receives tabId */ onCloseTabsLeft?: (tabId: string) => void; /** Stable callback - receives tabId */ onCloseTabsRight?: (tabId: string) => void; /** Total number of tabs */ totalTabs?: number; /** Tab index in the full list (0-based) */ tabIndex?: number; } /** * Get the display name for a tab. * Priority: name > truncated session ID > "New" * * Handles different agent session ID formats: * - Claude UUID: "abc123-def456-ghi789" → "ABC123" (first octet) * - OpenCode: "SES_4BCDFE8C5FFE4KC1UV9NSMYEDB" → "SES_4BCD" (prefix + 4 chars) * - Codex: "thread_abc123..." → "THR_ABC1" (prefix + 4 chars) * * Memoized per-tab via useMemo in the Tab component to avoid recalculation on every render. */ function getTabDisplayName(tab: AITab): string { if (tab.name) { return tab.name; } if (tab.agentSessionId) { const id = tab.agentSessionId; // OpenCode format: ses_XXXX... or SES_XXXX... if (id.toLowerCase().startsWith('ses_')) { // Return "SES_" + first 4 chars of the ID portion return `SES_${id.slice(4, 8).toUpperCase()}`; } // Codex format: thread_XXXX... if (id.toLowerCase().startsWith('thread_')) { // Return "THR_" + first 4 chars of the ID portion return `THR_${id.slice(7, 11).toUpperCase()}`; } // Claude UUID format: has dashes, return first octet if (id.includes('-')) { return id.split('-')[0].toUpperCase(); } // Generic fallback: first 8 chars uppercase return id.slice(0, 8).toUpperCase(); } return 'New Session'; } /** * Individual tab component styled like browser tabs (Safari/Chrome). * All tabs have visible borders; active tab connects to content area. * Includes hover overlay with session info and actions. * * Wrapped with React.memo to prevent unnecessary re-renders when sibling tabs change. */ const Tab = memo(function Tab({ tab, tabId, isActive, theme, canClose, onSelect, onClose, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, isDragOver, onRename, onStar, onMarkUnread, onMergeWith, onSendToAgent, onSummarizeAndContinue, onCopyContext, onExportHtml, onPublishGist, onMoveToFirst, onMoveToLast, isFirstTab, isLastTab, shortcutHint, registerRef, hasDraft, onCloseAllTabs: _onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight, totalTabs, tabIndex, }: TabProps) { const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); const [showCopied, setShowCopied] = useState(false); const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number; tabWidth?: number; } | null>(null); const hoverTimeoutRef = useRef | null>(null); const tabRef = useRef(null); // Register ref with parent for scroll-into-view functionality const setTabRef = useCallback( (el: HTMLDivElement | null) => { (tabRef as React.MutableRefObject).current = el; registerRef?.(el); }, [registerRef] ); const handleMouseEnter = () => { setIsHovered(true); // Only show overlay if there's something meaningful to show: // - Tabs with sessions: always show (for session actions) // - Tabs without sessions: show if there are move actions available if (!tab.agentSessionId && isFirstTab && isLastTab) return; // Open overlay after delay hoverTimeoutRef.current = setTimeout(() => { // Calculate position for fixed overlay - connect directly to tab bottom if (tabRef.current) { const rect = tabRef.current.getBoundingClientRect(); // Position overlay directly at tab bottom (no gap) for connected appearance // Store tab width for connector sizing setOverlayPosition({ top: rect.bottom, left: rect.left, tabWidth: rect.width }); } setOverlayOpen(true); }, 400); }; // Ref to track if mouse is over the overlay const isOverOverlayRef = useRef(false); const handleMouseLeave = () => { setIsHovered(false); if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } // Delay closing overlay to allow mouse to reach it (there's a gap between tab and overlay) hoverTimeoutRef.current = setTimeout(() => { if (!isOverOverlayRef.current) { setOverlayOpen(false); } }, 100); }; // Event handlers using stable tabId to avoid inline closure captures const handleMouseDown = useCallback( (e: React.MouseEvent) => { // Middle-click to close if (e.button === 1 && canClose) { e.preventDefault(); onClose(tabId); } }, [canClose, onClose, tabId] ); const handleCloseClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onClose(tabId); }, [onClose, tabId] ); const handleCopySessionId = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (tab.agentSessionId) { navigator.clipboard.writeText(tab.agentSessionId); setShowCopied(true); setTimeout(() => setShowCopied(false), 1500); } }, [tab.agentSessionId] ); const handleStarClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onStar?.(tabId, !tab.starred); }, [onStar, tabId, tab.starred] ); const handleRenameClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); // Call rename immediately (before closing overlay) to ensure prompt isn't blocked // Browsers block window.prompt() when called from setTimeout since it's not a direct user action onRename(tabId); setOverlayOpen(false); }, [onRename, tabId] ); const handleMarkUnreadClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onMarkUnread?.(tabId); setOverlayOpen(false); }, [onMarkUnread, tabId] ); const handleMergeWithClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onMergeWith?.(tabId); setOverlayOpen(false); }, [onMergeWith, tabId] ); const handleSendToAgentClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onSendToAgent?.(tabId); setOverlayOpen(false); }, [onSendToAgent, tabId] ); const handleSummarizeAndContinueClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onSummarizeAndContinue?.(tabId); setOverlayOpen(false); }, [onSummarizeAndContinue, tabId] ); const handleMoveToFirstClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onMoveToFirst?.(tabId); setOverlayOpen(false); }, [onMoveToFirst, tabId] ); const handleMoveToLastClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onMoveToLast?.(tabId); setOverlayOpen(false); }, [onMoveToLast, tabId] ); const handleCopyContextClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onCopyContext?.(tabId); setOverlayOpen(false); }, [onCopyContext, tabId] ); const handleExportHtmlClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onExportHtml?.(tabId); setOverlayOpen(false); }, [onExportHtml, tabId] ); const handlePublishGistClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onPublishGist?.(tabId); setOverlayOpen(false); }, [onPublishGist, tabId] ); const handleCloseTabClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onClose(tabId); setOverlayOpen(false); }, [onClose, tabId] ); const handleCloseOtherTabsClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onCloseOtherTabs?.(tabId); setOverlayOpen(false); }, [onCloseOtherTabs, tabId] ); const handleCloseTabsLeftClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onCloseTabsLeft?.(tabId); setOverlayOpen(false); }, [onCloseTabsLeft, tabId] ); const handleCloseTabsRightClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onCloseTabsRight?.(tabId); setOverlayOpen(false); }, [onCloseTabsRight, tabId] ); // Handlers for drag events using stable tabId const handleTabSelect = useCallback(() => { onSelect(tabId); }, [onSelect, tabId]); const handleTabDragStart = useCallback( (e: React.DragEvent) => { onDragStart(tabId, e); }, [onDragStart, tabId] ); const handleTabDragOver = useCallback( (e: React.DragEvent) => { onDragOver(tabId, e); }, [onDragOver, tabId] ); const handleTabDrop = useCallback( (e: React.DragEvent) => { onDrop(tabId, e); }, [onDrop, tabId] ); // Memoize display name to avoid recalculation on every render const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]); // Memoize tab styles to avoid creating new object references on every render const tabStyle = useMemo( () => ({ // All tabs have rounded top corners borderTopLeftRadius: '6px', borderTopRightRadius: '6px', // Active tab: bright background matching content area // Inactive tabs: transparent with subtle hover backgroundColor: isActive ? theme.colors.bgMain : isHovered ? 'rgba(255, 255, 255, 0.08)' : 'transparent', // Active tab has visible borders, inactive tabs have no borders (cleaner look) borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderRight: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', // Active tab has no bottom border (connects to content) borderBottom: isActive ? `1px solid ${theme.colors.bgMain}` : '1px solid transparent', // Active tab sits on top of the tab bar's bottom border marginBottom: isActive ? '-1px' : '0', // Slight z-index for active tab to cover border properly zIndex: isActive ? 1 : 0, '--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent', }) as React.CSSProperties, [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent] ); // Browser-style tab: all tabs have borders, active tab "connects" to content // Active tab is bright and obvious, inactive tabs are more muted return (
{/* Busy indicator - pulsing dot for tabs in write mode */} {tab.state === 'busy' && (
)} {/* Generating name indicator - spinning loader while tab name is being generated */} {tab.isGeneratingName && tab.state !== 'busy' && ( )} {/* Unread indicator - solid dot for tabs with unread messages (not shown when busy) */} {tab.state !== 'busy' && tab.hasUnread && (
)} {/* Star indicator for starred sessions - only show if tab has a session ID */} {tab.starred && tab.agentSessionId && ( )} {/* Draft indicator - pencil icon for tabs with unsent input or staged images */} {hasDraft && ( )} {/* Shortcut hint badge - shows tab number for Cmd+1-9 navigation */} {shortcutHint !== null && shortcutHint !== undefined && ( {shortcutHint} )} {/* Tab name - show full name for active tab, truncate inactive tabs */} {displayName} {/* Close button - visible on hover or when active, takes space of busy indicator when not busy */} {canClose && (isHovered || isActive) && ( )} {/* Hover overlay with session info and actions - rendered via portal to escape stacking context */} {overlayOpen && overlayPosition && createPortal(
e.stopPropagation()} onMouseEnter={() => { // Keep overlay open when mouse enters it isOverOverlayRef.current = true; if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } }} onMouseLeave={() => { // Close overlay when mouse leaves it isOverOverlayRef.current = false; setOverlayOpen(false); setIsHovered(false); }} > {/* Main overlay content - connects directly to tab like an open folder */}
{/* Header with session name and ID - only show for tabs with sessions */} {tab.agentSessionId && (
{/* Session name display */} {tab.name && (
{tab.name}
)} {/* Session ID display */}
{tab.agentSessionId}
)} {/* Actions */}
{tab.agentSessionId && ( )} {/* Star button - only show for tabs with established session */} {tab.agentSessionId && ( )} {/* Rename button - only show for tabs with established session */} {tab.agentSessionId && ( )} {/* Mark as Unread button - only show for tabs with established session */} {tab.agentSessionId && ( )} {/* Export as HTML - only show if tab has logs */} {(tab.logs?.length ?? 0) >= 1 && onExportHtml && ( )} {/* Context Management Section - divider and grouped options */} {(tab.agentSessionId || (tab.logs?.length ?? 0) >= 1) && (onMergeWith || onSendToAgent || onSummarizeAndContinue || onCopyContext) && (
)} {/* Context: Copy to Clipboard */} {(tab.logs?.length ?? 0) >= 1 && onCopyContext && ( )} {/* Context: Compact */} {(tab.logs?.length ?? 0) >= 5 && onSummarizeAndContinue && ( )} {/* Context: Merge Into */} {tab.agentSessionId && onMergeWith && ( )} {/* Context: Send to Agent */} {tab.agentSessionId && onSendToAgent && ( )} {/* Context: Publish as GitHub Gist - only show if tab has logs and gh CLI is available */} {(tab.logs?.length ?? 0) >= 1 && onPublishGist && ( )} {/* Tab Move Actions Section - divider and move options */} {(onMoveToFirst || onMoveToLast) && (
)} {/* Move to First Position - suppressed if already first tab or no handler */} {onMoveToFirst && ( )} {/* Move to Last Position - suppressed if already last tab or no handler */} {onMoveToLast && ( )} {/* Tab Close Actions Section - divider and close options */}
{/* Close Tab */} {/* Close Other Tabs */} {onCloseOtherTabs && ( )} {/* Close Tabs to Left */} {onCloseTabsLeft && ( )} {/* Close Tabs to Right */} {onCloseTabsRight && ( )}
, document.body )}
); }); /** * Props for the FileTab component. * Similar to TabProps but tailored for file preview tabs. */ interface FileTabProps { tab: FilePreviewTab; isActive: boolean; theme: Theme; /** Stable callback - receives tabId as first argument */ onSelect: (tabId: string) => void; /** Stable callback - receives tabId as first argument */ onClose: (tabId: string) => void; /** Stable callback - receives tabId and event */ onDragStart: (tabId: string, e: React.DragEvent) => void; /** Stable callback - receives tabId and event */ onDragOver: (tabId: string, e: React.DragEvent) => void; onDragEnd: () => void; /** Stable callback - receives tabId and event */ onDrop: (tabId: string, e: React.DragEvent) => void; isDragging: boolean; isDragOver: boolean; registerRef?: (el: HTMLDivElement | null) => void; } /** * Get color for file extension badge. * Returns a muted color based on file type for visual differentiation. */ function getExtensionColor(extension: string, theme: Theme): { bg: string; text: string } { const ext = extension.toLowerCase(); // TypeScript/JavaScript - blue tones if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) { return { bg: 'rgba(59, 130, 246, 0.3)', text: 'rgba(147, 197, 253, 0.9)' }; } // Markdown/Docs - green tones if (['.md', '.mdx', '.txt', '.rst'].includes(ext)) { return { bg: 'rgba(34, 197, 94, 0.3)', text: 'rgba(134, 239, 172, 0.9)' }; } // JSON/Config - yellow tones if (['.json', '.yaml', '.yml', '.toml', '.ini', '.env'].includes(ext)) { return { bg: 'rgba(234, 179, 8, 0.3)', text: 'rgba(253, 224, 71, 0.9)' }; } // CSS/Styles - purple tones if (['.css', '.scss', '.sass', '.less', '.styl'].includes(ext)) { return { bg: 'rgba(168, 85, 247, 0.3)', text: 'rgba(216, 180, 254, 0.9)' }; } // HTML/Templates - orange tones if (['.html', '.htm', '.xml', '.svg'].includes(ext)) { return { bg: 'rgba(249, 115, 22, 0.3)', text: 'rgba(253, 186, 116, 0.9)' }; } // Default - use theme's dim colors return { bg: theme.colors.border, text: theme.colors.textDim }; } /** * Individual file tab component for file preview tabs. * Similar to AI Tab but with file-specific rendering: * - Shows filename without extension as label * - Displays extension as a colored badge * - Shows pencil icon when tab has unsaved edits * * Wrapped with React.memo to prevent unnecessary re-renders when sibling tabs change. */ const FileTab = memo(function FileTab({ tab, isActive, theme, onSelect, onClose, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, isDragOver, registerRef, }: FileTabProps) { const [isHovered, setIsHovered] = useState(false); const tabRef = useRef(null); // Register ref with parent for scroll-into-view functionality const setTabRef = useCallback( (el: HTMLDivElement | null) => { (tabRef as React.MutableRefObject).current = el; registerRef?.(el); }, [registerRef] ); const handleMouseEnter = () => { setIsHovered(true); }; const handleMouseLeave = () => { setIsHovered(false); }; // Event handlers using stable tabId to avoid inline closure captures const handleMouseDown = useCallback( (e: React.MouseEvent) => { // Middle-click to close if (e.button === 1) { e.preventDefault(); onClose(tab.id); } }, [onClose, tab.id] ); const handleCloseClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onClose(tab.id); }, [onClose, tab.id] ); // Handlers for drag events using stable tabId const handleTabSelect = useCallback(() => { onSelect(tab.id); }, [onSelect, tab.id]); const handleTabDragStart = useCallback( (e: React.DragEvent) => { onDragStart(tab.id, e); }, [onDragStart, tab.id] ); const handleTabDragOver = useCallback( (e: React.DragEvent) => { onDragOver(tab.id, e); }, [onDragOver, tab.id] ); const handleTabDrop = useCallback( (e: React.DragEvent) => { onDrop(tab.id, e); }, [onDrop, tab.id] ); // Get extension badge colors const extensionColors = useMemo( () => getExtensionColor(tab.extension, theme), [tab.extension, theme] ); // Memoize tab styles to avoid creating new object references on every render const tabStyle = useMemo( () => ({ // All tabs have rounded top corners borderTopLeftRadius: '6px', borderTopRightRadius: '6px', // Active tab: bright background matching content area // Inactive tabs: transparent with subtle hover backgroundColor: isActive ? theme.colors.bgMain : isHovered ? 'rgba(255, 255, 255, 0.08)' : 'transparent', // Active tab has visible borders, inactive tabs have no borders (cleaner look) borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderRight: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', // Active tab has no bottom border (connects to content) borderBottom: isActive ? `1px solid ${theme.colors.bgMain}` : '1px solid transparent', // Active tab sits on top of the tab bar's bottom border marginBottom: isActive ? '-1px' : '0', // Slight z-index for active tab to cover border properly zIndex: isActive ? 1 : 0, '--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent', }) as React.CSSProperties, [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent] ); // Check if tab has unsaved edits const hasUnsavedEdits = tab.editContent !== undefined; return (
{/* Unsaved edits indicator - pencil icon */} {hasUnsavedEdits && ( )} {/* Tab name - filename without extension */} {tab.name} {/* Extension badge - small rounded pill */} {tab.extension} {/* Close button - visible on hover or when active */} {(isHovered || isActive) && ( )}
); }); /** * TabBar component for displaying AI session tabs. * Shows tabs for each Claude Code conversation within a Maestro session. * Appears only in AI mode (hidden in terminal mode). */ function TabBarInner({ tabs, activeTabId, theme, onTabSelect, onTabClose, onNewTab, onRequestRename, onTabReorder, onTabStar, onTabMarkUnread, onMergeWith, onSendToAgent, onSummarizeAndContinue, onCopyContext, onExportHtml, onPublishGist, ghCliAvailable, showUnreadOnly: showUnreadOnlyProp, onToggleUnreadFilter, onOpenTabSearch, onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight, // Unified tab system props (Phase 3) unifiedTabs, activeFileTabId, onFileTabSelect, onFileTabClose, onUnifiedTabReorder, }: TabBarProps) { const [draggingTabId, setDraggingTabId] = useState(null); const [dragOverTabId, setDragOverTabId] = useState(null); // Use prop if provided (controlled), otherwise use local state (uncontrolled) const [showUnreadOnlyLocal, setShowUnreadOnlyLocal] = useState(false); const showUnreadOnly = showUnreadOnlyProp ?? showUnreadOnlyLocal; const toggleUnreadFilter = onToggleUnreadFilter ?? (() => setShowUnreadOnlyLocal((prev) => !prev)); const tabBarRef = useRef(null); const tabRefs = useRef>(new Map()); const [isOverflowing, setIsOverflowing] = useState(false); // Center the active tab in the scrollable area when activeTabId changes or filter is toggled useEffect(() => { requestAnimationFrame(() => { const container = tabBarRef.current; const tabElement = container?.querySelector( `[data-tab-id="${activeTabId}"]` ) as HTMLElement | null; if (container && tabElement) { // Calculate scroll position to center the tab const scrollLeft = tabElement.offsetLeft - container.clientWidth / 2 + tabElement.offsetWidth / 2; container.scrollTo({ left: scrollLeft, behavior: 'smooth' }); } }); }, [activeTabId, showUnreadOnly]); // Can always close tabs - closing the last one creates a fresh new tab const canClose = true; // Count unread tabs for the filter toggle tooltip (reserved for future use) const _unreadCount = tabs.filter((t) => t.hasUnread).length; // Filter tabs based on unread filter state // When filter is on, show: unread tabs + active tab + tabs with drafts // The active tab disappears from the filtered list when user navigates away from it const displayedTabs = showUnreadOnly ? tabs.filter((t) => t.hasUnread || t.id === activeTabId || hasDraft(t)) : tabs; // When unifiedTabs is provided, filter it similarly for display // File tabs don't have "unread" state, so they only show in filtered mode if active const displayedUnifiedTabs = useMemo(() => { if (!unifiedTabs) return null; if (!showUnreadOnly) return unifiedTabs; // In filter mode: show AI tabs that are unread/active/have drafts, plus file tabs that are active return unifiedTabs.filter((ut) => { if (ut.type === 'ai') { return ut.data.hasUnread || ut.id === activeTabId || hasDraft(ut.data); } // File tabs: only show if active return ut.id === activeFileTabId; }); }, [unifiedTabs, showUnreadOnly, activeTabId, activeFileTabId]); const handleDragStart = useCallback((tabId: string, e: React.DragEvent) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', tabId); setDraggingTabId(tabId); }, []); const handleDragOver = useCallback( (tabId: string, e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (tabId !== draggingTabId) { setDragOverTabId(tabId); } }, [draggingTabId] ); const handleDragEnd = useCallback(() => { setDraggingTabId(null); setDragOverTabId(null); }, []); const handleDrop = useCallback( (targetTabId: string, e: React.DragEvent) => { e.preventDefault(); const sourceTabId = e.dataTransfer.getData('text/plain'); if (sourceTabId && sourceTabId !== targetTabId) { // When unified tabs are used, prefer onUnifiedTabReorder if (unifiedTabs && onUnifiedTabReorder) { const sourceIndex = unifiedTabs.findIndex((ut) => ut.id === sourceTabId); const targetIndex = unifiedTabs.findIndex((ut) => ut.id === targetTabId); if (sourceIndex !== -1 && targetIndex !== -1) { onUnifiedTabReorder(sourceIndex, targetIndex); } } else if (onTabReorder) { // Fallback to legacy AI-tab-only reorder const sourceIndex = tabs.findIndex((t) => t.id === sourceTabId); const targetIndex = tabs.findIndex((t) => t.id === targetTabId); if (sourceIndex !== -1 && targetIndex !== -1) { onTabReorder(sourceIndex, targetIndex); } } } setDraggingTabId(null); setDragOverTabId(null); }, [tabs, onTabReorder, unifiedTabs, onUnifiedTabReorder] ); const handleRenameRequest = useCallback( (tabId: string) => { // Request rename via modal (window.prompt doesn't work in Electron) if (onRequestRename) { onRequestRename(tabId); } }, [onRequestRename] ); // Check if tabs overflow the container (need sticky + button) useEffect(() => { const checkOverflow = () => { if (tabBarRef.current) { // scrollWidth > clientWidth means content overflows setIsOverflowing(tabBarRef.current.scrollWidth > tabBarRef.current.clientWidth); } }; // Check after DOM renders const timeoutId = setTimeout(checkOverflow, 0); // Re-check on window resize window.addEventListener('resize', checkOverflow); return () => { clearTimeout(timeoutId); window.removeEventListener('resize', checkOverflow); }; }, [tabs.length, displayedTabs.length, unifiedTabs?.length, displayedUnifiedTabs?.length]); const handleMoveToFirst = useCallback( (tabId: string) => { // When unified tabs are used, prefer onUnifiedTabReorder if (unifiedTabs && onUnifiedTabReorder) { const currentIndex = unifiedTabs.findIndex((ut) => ut.id === tabId); if (currentIndex > 0) { onUnifiedTabReorder(currentIndex, 0); } } else if (onTabReorder) { // Fallback to legacy AI-tab-only reorder const currentIndex = tabs.findIndex((t) => t.id === tabId); if (currentIndex > 0) { onTabReorder(currentIndex, 0); } } }, [tabs, onTabReorder, unifiedTabs, onUnifiedTabReorder] ); const handleMoveToLast = useCallback( (tabId: string) => { // When unified tabs are used, prefer onUnifiedTabReorder if (unifiedTabs && onUnifiedTabReorder) { const currentIndex = unifiedTabs.findIndex((ut) => ut.id === tabId); if (currentIndex < unifiedTabs.length - 1) { onUnifiedTabReorder(currentIndex, unifiedTabs.length - 1); } } else if (onTabReorder) { // Fallback to legacy AI-tab-only reorder const currentIndex = tabs.findIndex((t) => t.id === tabId); if (currentIndex < tabs.length - 1) { onTabReorder(currentIndex, tabs.length - 1); } } }, [tabs, onTabReorder, unifiedTabs, onUnifiedTabReorder] ); // Stable callback wrappers that receive tabId from the Tab component // These avoid creating new function references on each render const handleTabStar = useCallback( (tabId: string, starred: boolean) => { onTabStar?.(tabId, starred); }, [onTabStar] ); const handleTabMarkUnread = useCallback( (tabId: string) => { onTabMarkUnread?.(tabId); }, [onTabMarkUnread] ); const handleTabMergeWith = useCallback( (tabId: string) => { onMergeWith?.(tabId); }, [onMergeWith] ); const handleTabSendToAgent = useCallback( (tabId: string) => { onSendToAgent?.(tabId); }, [onSendToAgent] ); const handleTabSummarizeAndContinue = useCallback( (tabId: string) => { onSummarizeAndContinue?.(tabId); }, [onSummarizeAndContinue] ); const handleTabCopyContext = useCallback( (tabId: string) => { onCopyContext?.(tabId); }, [onCopyContext] ); const handleTabExportHtml = useCallback( (tabId: string) => { onExportHtml?.(tabId); }, [onExportHtml] ); const handleTabPublishGist = useCallback( (tabId: string) => { onPublishGist?.(tabId); }, [onPublishGist] ); const handleTabCloseOther = useCallback( (_tabId: string) => { // Close all tabs except the one with this tabId onCloseOtherTabs?.(); }, [onCloseOtherTabs] ); const handleTabCloseLeft = useCallback( (_tabId: string) => { // Close all tabs to the left of this tabId onCloseTabsLeft?.(); }, [onCloseTabsLeft] ); const handleTabCloseRight = useCallback( (_tabId: string) => { // Close all tabs to the right of this tabId onCloseTabsRight?.(); }, [onCloseTabsRight] ); // Stable registerRef callback that manages tab refs const registerTabRef = useCallback((tabId: string, el: HTMLDivElement | null) => { if (el) { tabRefs.current.set(tabId, el); } else { tabRefs.current.delete(tabId); } }, []); return (
{/* Tab search and unread filter - sticky at the beginning with full-height opaque background */}
{/* Tab search button */} {onOpenTabSearch && ( )} {/* Unread filter toggle */}
{/* Empty state when filter is on but no unread tabs */} {showUnreadOnly && (displayedUnifiedTabs ? displayedUnifiedTabs.length === 0 : displayedTabs.length === 0) && (
No unread tabs
)} {/* Tabs with separators between inactive tabs */} {/* When unifiedTabs is provided, render both AI and file tabs from unified list */} {displayedUnifiedTabs ? displayedUnifiedTabs.map((unifiedTab, index) => { // Determine if this tab is active (based on type) const isActive = unifiedTab.type === 'ai' ? unifiedTab.id === activeTabId : unifiedTab.id === activeFileTabId; // Check previous tab's active state for separator logic const prevUnifiedTab = index > 0 ? displayedUnifiedTabs[index - 1] : null; const isPrevActive = prevUnifiedTab ? prevUnifiedTab.type === 'ai' ? prevUnifiedTab.id === activeTabId : prevUnifiedTab.id === activeFileTabId : false; // Get original index in the FULL unified list (not filtered) const originalIndex = unifiedTabs!.findIndex((ut) => ut.id === unifiedTab.id); // Show separator between inactive tabs const showSeparator = index > 0 && !isActive && !isPrevActive; // Position info for move actions const isFirstTab = originalIndex === 0; const isLastTab = originalIndex === unifiedTabs!.length - 1; if (unifiedTab.type === 'ai') { const tab = unifiedTab.data; return ( {showSeparator && (
)} = 5 ? handleTabSummarizeAndContinue : undefined } onCopyContext={ onCopyContext && (tab.logs?.length ?? 0) >= 1 ? handleTabCopyContext : undefined } onExportHtml={onExportHtml ? handleTabExportHtml : undefined} onPublishGist={ onPublishGist && ghCliAvailable && (tab.logs?.length ?? 0) >= 1 ? handleTabPublishGist : undefined } onMoveToFirst={!isFirstTab && onUnifiedTabReorder ? handleMoveToFirst : undefined} onMoveToLast={!isLastTab && onUnifiedTabReorder ? handleMoveToLast : undefined} isFirstTab={isFirstTab} isLastTab={isLastTab} shortcutHint={!showUnreadOnly && originalIndex < 9 ? originalIndex + 1 : null} hasDraft={hasDraft(tab)} registerRef={(el) => registerTabRef(tab.id, el)} onCloseAllTabs={onCloseAllTabs} onCloseOtherTabs={onCloseOtherTabs ? handleTabCloseOther : undefined} onCloseTabsLeft={onCloseTabsLeft ? handleTabCloseLeft : undefined} onCloseTabsRight={onCloseTabsRight ? handleTabCloseRight : undefined} totalTabs={unifiedTabs!.length} tabIndex={originalIndex} /> ); } else { // File tab const fileTab = unifiedTab.data; return ( {showSeparator && (
)} {})} onClose={onFileTabClose || (() => {})} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} onDrop={handleDrop} isDragging={draggingTabId === fileTab.id} isDragOver={dragOverTabId === fileTab.id} registerRef={(el) => registerTabRef(fileTab.id, el)} /> ); } }) : // Fallback: render AI tabs only (legacy mode when unifiedTabs not provided) displayedTabs.map((tab, index) => { const isActive = tab.id === activeTabId; const prevTab = index > 0 ? displayedTabs[index - 1] : null; const isPrevActive = prevTab?.id === activeTabId; // Get original index for shortcut hints (Cmd+1-9) const originalIndex = tabs.findIndex((t) => t.id === tab.id); // Show separator between inactive tabs (not adjacent to active tab) const showSeparator = index > 0 && !isActive && !isPrevActive; // Calculate position info for move actions (within FULL tabs array, not filtered) const isFirstTab = originalIndex === 0; const isLastTab = originalIndex === tabs.length - 1; return ( {showSeparator && (
)} = 5 ? handleTabSummarizeAndContinue : undefined } onCopyContext={ onCopyContext && (tab.logs?.length ?? 0) >= 1 ? handleTabCopyContext : undefined } onExportHtml={onExportHtml ? handleTabExportHtml : undefined} onPublishGist={ onPublishGist && ghCliAvailable && (tab.logs?.length ?? 0) >= 1 ? handleTabPublishGist : undefined } onMoveToFirst={!isFirstTab && onTabReorder ? handleMoveToFirst : undefined} onMoveToLast={!isLastTab && onTabReorder ? handleMoveToLast : undefined} isFirstTab={isFirstTab} isLastTab={isLastTab} shortcutHint={!showUnreadOnly && originalIndex < 9 ? originalIndex + 1 : null} hasDraft={hasDraft(tab)} registerRef={(el) => registerTabRef(tab.id, el)} onCloseAllTabs={onCloseAllTabs} onCloseOtherTabs={onCloseOtherTabs ? handleTabCloseOther : undefined} onCloseTabsLeft={onCloseTabsLeft ? handleTabCloseLeft : undefined} onCloseTabsRight={onCloseTabsRight ? handleTabCloseRight : undefined} totalTabs={tabs.length} tabIndex={originalIndex} /> ); })} {/* New Tab Button - sticky on right when tabs overflow, with full-height opaque background */}
); } export const TabBar = memo(TabBarInner);