Files
Maestro/src/renderer/components/TabBar.tsx
Pedram Amini 495ed0f60f MAESTRO: Update TabBar render loop to iterate over unified tabs
- Add displayedUnifiedTabs computed value with unread filter support
- Update render loop to conditionally render from unified tabs when provided
- Check unified tab type to render either Tab (AI) or FileTab (file)
- Update handleDrop, handleMoveToFirst, handleMoveToLast for unified tabs
- Update overflow check and empty state to consider unified tabs
- Maintain backwards compatibility with legacy AI-only tab rendering
2026-02-02 16:18:39 -06:00

1729 lines
54 KiB
TypeScript

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<ReturnType<typeof setTimeout> | null>(null);
const tabRef = useRef<HTMLDivElement>(null);
// Register ref with parent for scroll-into-view functionality
const setTabRef = useCallback(
(el: HTMLDivElement | null) => {
(tabRef as React.MutableRefObject<HTMLDivElement | null>).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 (
<div
ref={setTabRef}
data-tab-id={tab.id}
className={`
relative flex items-center gap-1.5 px-3 py-1.5 cursor-pointer
transition-all duration-150 select-none
${isDragging ? 'opacity-50' : ''}
${isDragOver ? 'ring-2 ring-inset' : ''}
`}
style={tabStyle}
onClick={handleTabSelect}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
draggable
onDragStart={handleTabDragStart}
onDragOver={handleTabDragOver}
onDragEnd={onDragEnd}
onDrop={handleTabDrop}
>
{/* Busy indicator - pulsing dot for tabs in write mode */}
{tab.state === 'busy' && (
<div
className="w-2 h-2 rounded-full shrink-0 animate-pulse"
style={{ backgroundColor: theme.colors.warning }}
/>
)}
{/* Generating name indicator - spinning loader while tab name is being generated */}
{tab.isGeneratingName && tab.state !== 'busy' && (
<span title="Generating tab name...">
<Loader2
className="w-3 h-3 shrink-0 animate-spin"
style={{ color: theme.colors.textDim }}
/>
</span>
)}
{/* Unread indicator - solid dot for tabs with unread messages (not shown when busy) */}
{tab.state !== 'busy' && tab.hasUnread && (
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: theme.colors.accent }}
title="New messages"
/>
)}
{/* Star indicator for starred sessions - only show if tab has a session ID */}
{tab.starred && tab.agentSessionId && (
<Star className="w-3 h-3 fill-current shrink-0" style={{ color: theme.colors.warning }} />
)}
{/* Draft indicator - pencil icon for tabs with unsent input or staged images */}
{hasDraft && (
<span title="Has draft message">
<Pencil className="w-3 h-3 shrink-0" style={{ color: theme.colors.warning }} />
</span>
)}
{/* Shortcut hint badge - shows tab number for Cmd+1-9 navigation */}
{shortcutHint !== null && shortcutHint !== undefined && (
<span
className="w-4 h-4 flex items-center justify-center rounded text-[10px] font-medium shrink-0 opacity-50"
style={{
backgroundColor: theme.colors.border,
color: theme.colors.textMain,
}}
>
{shortcutHint}
</span>
)}
{/* Tab name - show full name for active tab, truncate inactive tabs */}
<span
className={`text-xs font-medium ${isActive ? 'whitespace-nowrap' : 'truncate max-w-[120px]'}`}
style={{ color: isActive ? theme.colors.textMain : theme.colors.textDim }}
>
{displayName}
</span>
{/* Close button - visible on hover or when active, takes space of busy indicator when not busy */}
{canClose && (isHovered || isActive) && (
<button
onClick={handleCloseClick}
className="p-0.5 rounded hover:bg-white/10 transition-colors shrink-0"
title="Close tab"
>
<X className="w-3 h-3" style={{ color: theme.colors.textDim }} />
</button>
)}
{/* Hover overlay with session info and actions - rendered via portal to escape stacking context */}
{overlayOpen &&
overlayPosition &&
createPortal(
<div
className="fixed z-[100]"
style={{
top: overlayPosition.top,
left: overlayPosition.left,
}}
onClick={(e) => 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 */}
<div
className="shadow-xl overflow-hidden"
style={{
backgroundColor: theme.colors.bgSidebar,
borderLeft: `1px solid ${theme.colors.border}`,
borderRight: `1px solid ${theme.colors.border}`,
borderBottom: `1px solid ${theme.colors.border}`,
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
minWidth: '220px',
}}
>
{/* Header with session name and ID - only show for tabs with sessions */}
{tab.agentSessionId && (
<div
className="border-b"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
}}
>
{/* Session name display */}
{tab.name && (
<div
className="px-3 py-2 text-sm font-medium"
style={{ color: theme.colors.textMain }}
>
{tab.name}
</div>
)}
{/* Session ID display */}
<div
className="px-3 py-2 text-[10px] font-mono"
style={{ color: theme.colors.textDim }}
>
{tab.agentSessionId}
</div>
</div>
)}
{/* Actions */}
<div className="p-1">
{tab.agentSessionId && (
<button
onClick={handleCopySessionId}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
title={`Full ID: ${tab.agentSessionId}`}
>
<Copy className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
{showCopied ? 'Copied!' : 'Copy Session ID'}
</button>
)}
{/* Star button - only show for tabs with established session */}
{tab.agentSessionId && (
<button
onClick={handleStarClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Star
className={`w-3.5 h-3.5 ${tab.starred ? 'fill-current' : ''}`}
style={{ color: tab.starred ? theme.colors.warning : theme.colors.textDim }}
/>
{tab.starred ? 'Unstar Session' : 'Star Session'}
</button>
)}
{/* Rename button - only show for tabs with established session */}
{tab.agentSessionId && (
<button
onClick={handleRenameClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Edit2 className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Rename Tab
</button>
)}
{/* Mark as Unread button - only show for tabs with established session */}
{tab.agentSessionId && (
<button
onClick={handleMarkUnreadClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Mail className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Mark as Unread
</button>
)}
{/* Export as HTML - only show if tab has logs */}
{(tab.logs?.length ?? 0) >= 1 && onExportHtml && (
<button
onClick={handleExportHtmlClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Download className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Export as HTML
</button>
)}
{/* Context Management Section - divider and grouped options */}
{(tab.agentSessionId || (tab.logs?.length ?? 0) >= 1) &&
(onMergeWith || onSendToAgent || onSummarizeAndContinue || onCopyContext) && (
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
)}
{/* Context: Copy to Clipboard */}
{(tab.logs?.length ?? 0) >= 1 && onCopyContext && (
<button
onClick={handleCopyContextClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Clipboard className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Context: Copy to Clipboard
</button>
)}
{/* Context: Compact */}
{(tab.logs?.length ?? 0) >= 5 && onSummarizeAndContinue && (
<button
onClick={handleSummarizeAndContinueClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Minimize2 className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Context: Compact
</button>
)}
{/* Context: Merge Into */}
{tab.agentSessionId && onMergeWith && (
<button
onClick={handleMergeWithClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<GitMerge className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Context: Merge Into
</button>
)}
{/* Context: Send to Agent */}
{tab.agentSessionId && onSendToAgent && (
<button
onClick={handleSendToAgentClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<ArrowRightCircle
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
Context: Send to Agent
</button>
)}
{/* Context: Publish as GitHub Gist - only show if tab has logs and gh CLI is available */}
{(tab.logs?.length ?? 0) >= 1 && onPublishGist && (
<button
onClick={handlePublishGistClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Share2 className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Context: Publish as GitHub Gist
</button>
)}
{/* Tab Move Actions Section - divider and move options */}
{(onMoveToFirst || onMoveToLast) && (
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
)}
{/* Move to First Position - suppressed if already first tab or no handler */}
{onMoveToFirst && (
<button
onClick={handleMoveToFirstClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors hover:bg-white/10"
style={{ color: theme.colors.textMain }}
>
<ChevronsLeft className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Move to First Position
</button>
)}
{/* Move to Last Position - suppressed if already last tab or no handler */}
{onMoveToLast && (
<button
onClick={handleMoveToLastClick}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors hover:bg-white/10"
style={{ color: theme.colors.textMain }}
>
<ChevronsRight
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
Move to Last Position
</button>
)}
{/* Tab Close Actions Section - divider and close options */}
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
{/* Close Tab */}
<button
onClick={handleCloseTabClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={totalTabs === 1}
>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Tab
</button>
{/* Close Other Tabs */}
{onCloseOtherTabs && (
<button
onClick={handleCloseOtherTabsClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={totalTabs === 1}
>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Other Tabs
</button>
)}
{/* Close Tabs to Left */}
{onCloseTabsLeft && (
<button
onClick={handleCloseTabsLeftClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
tabIndex === 0 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={tabIndex === 0}
>
<ChevronsLeft className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Tabs to Left
</button>
)}
{/* Close Tabs to Right */}
{onCloseTabsRight && (
<button
onClick={handleCloseTabsRightClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
tabIndex === (totalTabs ?? 1) - 1
? 'opacity-40 cursor-default'
: 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={tabIndex === (totalTabs ?? 1) - 1}
>
<ChevronsRight
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
Close Tabs to Right
</button>
)}
</div>
</div>
</div>,
document.body
)}
</div>
);
});
/**
* 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<HTMLDivElement>(null);
// Register ref with parent for scroll-into-view functionality
const setTabRef = useCallback(
(el: HTMLDivElement | null) => {
(tabRef as React.MutableRefObject<HTMLDivElement | null>).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 (
<div
ref={setTabRef}
data-tab-id={tab.id}
className={`
relative flex items-center gap-1.5 px-3 py-1.5 cursor-pointer
transition-all duration-150 select-none
${isDragging ? 'opacity-50' : ''}
${isDragOver ? 'ring-2 ring-inset' : ''}
`}
style={tabStyle}
onClick={handleTabSelect}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
draggable
onDragStart={handleTabDragStart}
onDragOver={handleTabDragOver}
onDragEnd={onDragEnd}
onDrop={handleTabDrop}
>
{/* Unsaved edits indicator - pencil icon */}
{hasUnsavedEdits && (
<span title="Has unsaved changes">
<Pencil className="w-3 h-3 shrink-0" style={{ color: theme.colors.warning }} />
</span>
)}
{/* Tab name - filename without extension */}
<span
className={`text-xs font-medium ${isActive ? 'whitespace-nowrap' : 'truncate max-w-[100px]'}`}
style={{ color: isActive ? theme.colors.textMain : theme.colors.textDim }}
>
{tab.name}
</span>
{/* Extension badge - small rounded pill */}
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0"
style={{
backgroundColor: extensionColors.bg,
color: extensionColors.text,
}}
>
{tab.extension}
</span>
{/* Close button - visible on hover or when active */}
{(isHovered || isActive) && (
<button
onClick={handleCloseClick}
className="p-0.5 rounded hover:bg-white/10 transition-colors shrink-0"
title="Close tab"
>
<X className="w-3 h-3" style={{ color: theme.colors.textDim }} />
</button>
)}
</div>
);
});
/**
* 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<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(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<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(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 (
<div
ref={tabBarRef}
className="flex items-end gap-0.5 pt-2 border-b overflow-x-auto overflow-y-hidden no-scrollbar"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border,
}}
>
{/* Tab search and unread filter - sticky at the beginning with full-height opaque background */}
<div
className="sticky left-0 flex items-center shrink-0 pl-2 pr-1 gap-1 self-stretch"
style={{ backgroundColor: theme.colors.bgSidebar, zIndex: 5 }}
>
{/* Tab search button */}
{onOpenTabSearch && (
<button
onClick={onOpenTabSearch}
className="flex items-center justify-center w-6 h-6 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
title="Search tabs (Cmd+Shift+O)"
>
<Search className="w-4 h-4" />
</button>
)}
{/* Unread filter toggle */}
<button
onClick={toggleUnreadFilter}
className="relative flex items-center justify-center w-6 h-6 rounded transition-colors"
style={{
color: showUnreadOnly ? theme.colors.accent : theme.colors.textDim,
opacity: showUnreadOnly ? 1 : 0.5,
}}
title={showUnreadOnly ? 'Showing unread only (Cmd+U)' : 'Filter unread tabs (Cmd+U)'}
>
<Mail className="w-4 h-4" />
{/* Notification dot */}
<div
className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</button>
</div>
{/* Empty state when filter is on but no unread tabs */}
{showUnreadOnly &&
(displayedUnifiedTabs ? displayedUnifiedTabs.length === 0 : displayedTabs.length === 0) && (
<div
className="flex items-center px-3 py-1.5 text-xs italic shrink-0 self-center mb-1"
style={{ color: theme.colors.textDim }}
>
No unread tabs
</div>
)}
{/* 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 (
<React.Fragment key={unifiedTab.id}>
{showSeparator && (
<div
className="w-px h-4 self-center shrink-0"
style={{ backgroundColor: theme.colors.border }}
/>
)}
<Tab
tab={tab}
tabId={tab.id}
isActive={isActive}
theme={theme}
canClose={canClose}
onSelect={onTabSelect}
onClose={onTabClose}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
isDragging={draggingTabId === tab.id}
isDragOver={dragOverTabId === tab.id}
onRename={handleRenameRequest}
onStar={onTabStar && tab.agentSessionId ? handleTabStar : undefined}
onMarkUnread={onTabMarkUnread ? handleTabMarkUnread : undefined}
onMergeWith={onMergeWith && tab.agentSessionId ? handleTabMergeWith : undefined}
onSendToAgent={
onSendToAgent && tab.agentSessionId ? handleTabSendToAgent : undefined
}
onSummarizeAndContinue={
onSummarizeAndContinue && (tab.logs?.length ?? 0) >= 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}
/>
</React.Fragment>
);
} else {
// File tab
const fileTab = unifiedTab.data;
return (
<React.Fragment key={unifiedTab.id}>
{showSeparator && (
<div
className="w-px h-4 self-center shrink-0"
style={{ backgroundColor: theme.colors.border }}
/>
)}
<FileTab
tab={fileTab}
isActive={isActive}
theme={theme}
onSelect={onFileTabSelect || (() => {})}
onClose={onFileTabClose || (() => {})}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
isDragging={draggingTabId === fileTab.id}
isDragOver={dragOverTabId === fileTab.id}
registerRef={(el) => registerTabRef(fileTab.id, el)}
/>
</React.Fragment>
);
}
})
: // 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 (
<React.Fragment key={tab.id}>
{showSeparator && (
<div
className="w-px h-4 self-center shrink-0"
style={{ backgroundColor: theme.colors.border }}
/>
)}
<Tab
tab={tab}
tabId={tab.id}
isActive={isActive}
theme={theme}
canClose={canClose}
onSelect={onTabSelect}
onClose={onTabClose}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
isDragging={draggingTabId === tab.id}
isDragOver={dragOverTabId === tab.id}
onRename={handleRenameRequest}
onStar={onTabStar && tab.agentSessionId ? handleTabStar : undefined}
onMarkUnread={onTabMarkUnread ? handleTabMarkUnread : undefined}
onMergeWith={onMergeWith && tab.agentSessionId ? handleTabMergeWith : undefined}
onSendToAgent={
onSendToAgent && tab.agentSessionId ? handleTabSendToAgent : undefined
}
onSummarizeAndContinue={
onSummarizeAndContinue && (tab.logs?.length ?? 0) >= 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}
/>
</React.Fragment>
);
})}
{/* New Tab Button - sticky on right when tabs overflow, with full-height opaque background */}
<div
className={`flex items-center shrink-0 pl-2 pr-2 self-stretch ${isOverflowing ? 'sticky right-0' : ''}`}
style={{
backgroundColor: theme.colors.bgSidebar,
zIndex: 5,
}}
>
<button
onClick={onNewTab}
className="flex items-center justify-center w-6 h-6 rounded hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
title="New tab (Cmd+T)"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
);
}
export const TabBar = memo(TabBarInner);