mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Worktree sessions now inherit parent groupId for consistent grouping everywhere! 🧩 - “Send to Agent” now targets existing sessions, not agent detectors! 🚀 - Context transfer now creates a brand-new tab in target session! 📩 - Transfer flow auto-navigates you to the destination session instantly! ⚡ - Send modal redesigned: searchable session list with idle/busy indicators! 🔎 - Removed agent availability fetch; selection now derives from live sessions! 🧹 - Merge modal simplified: “Open Tabs” search replaces removed Recent view! 🎯 - Merge wording clarified: “Merge Into” with optional context cleaning toggle! 🔀 - Tab menu adds per-tab “Merge Into” and “Send” shortcuts! 🧰 - Quick Actions labels upgraded to consistent “Context:” action naming! ✨
746 lines
26 KiB
TypeScript
746 lines
26 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2 } from 'lucide-react';
|
|
import type { AITab, Theme } 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;
|
|
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;
|
|
showUnreadOnly?: boolean;
|
|
onToggleUnreadFilter?: () => void;
|
|
onOpenTabSearch?: () => void;
|
|
}
|
|
|
|
interface TabProps {
|
|
tab: AITab;
|
|
isActive: boolean;
|
|
theme: Theme;
|
|
canClose: boolean;
|
|
onSelect: () => void;
|
|
onClose: () => void;
|
|
onMiddleClick: () => void;
|
|
onDragStart: (e: React.DragEvent) => void;
|
|
onDragOver: (e: React.DragEvent) => void;
|
|
onDragEnd: () => void;
|
|
onDrop: (e: React.DragEvent) => void;
|
|
isDragging: boolean;
|
|
isDragOver: boolean;
|
|
onRename: () => void;
|
|
onStar?: (starred: boolean) => void;
|
|
onMarkUnread?: () => void;
|
|
/** Handler to open merge session modal with this tab as source */
|
|
onMergeWith?: () => void;
|
|
/** Handler to open send to agent modal with this tab as source */
|
|
onSendToAgent?: () => void;
|
|
/** Handler to summarize and continue in a new tab */
|
|
onSummarizeAndContinue?: () => void;
|
|
shortcutHint?: number | null;
|
|
registerRef?: (el: HTMLDivElement | null) => void;
|
|
hasDraft?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
*/
|
|
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.
|
|
*/
|
|
function Tab({
|
|
tab,
|
|
isActive,
|
|
theme,
|
|
canClose,
|
|
onSelect,
|
|
onClose,
|
|
onMiddleClick,
|
|
onDragStart,
|
|
onDragOver,
|
|
onDragEnd,
|
|
onDrop,
|
|
isDragging,
|
|
isDragOver,
|
|
onRename,
|
|
onStar,
|
|
onMarkUnread,
|
|
onMergeWith,
|
|
onSendToAgent,
|
|
onSummarizeAndContinue,
|
|
shortcutHint,
|
|
registerRef,
|
|
hasDraft
|
|
}: TabProps) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [overlayOpen, setOverlayOpen] = useState(false);
|
|
const [showCopied, setShowCopied] = useState(false);
|
|
const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: 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 for tabs with an established Claude session
|
|
// New/empty tabs don't have a session yet, so star/rename don't apply
|
|
if (!tab.agentSessionId) return;
|
|
|
|
// Open overlay after delay
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
// Calculate position for fixed overlay
|
|
if (tabRef.current) {
|
|
const rect = tabRef.current.getBoundingClientRect();
|
|
setOverlayPosition({ top: rect.bottom + 4, left: rect.left });
|
|
}
|
|
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);
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
// Middle-click to close
|
|
if (e.button === 1 && canClose) {
|
|
e.preventDefault();
|
|
onMiddleClick();
|
|
}
|
|
};
|
|
|
|
const handleCloseClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
};
|
|
|
|
const handleCopySessionId = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (tab.agentSessionId) {
|
|
navigator.clipboard.writeText(tab.agentSessionId);
|
|
setShowCopied(true);
|
|
setTimeout(() => setShowCopied(false), 1500);
|
|
}
|
|
};
|
|
|
|
const handleStarClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onStar?.(!tab.starred);
|
|
};
|
|
|
|
const handleRenameClick = (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();
|
|
setOverlayOpen(false);
|
|
};
|
|
|
|
const handleMarkUnreadClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onMarkUnread?.();
|
|
setOverlayOpen(false);
|
|
};
|
|
|
|
const handleMergeWithClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onMergeWith?.();
|
|
setOverlayOpen(false);
|
|
};
|
|
|
|
const handleSendToAgentClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onSendToAgent?.();
|
|
setOverlayOpen(false);
|
|
};
|
|
|
|
const handleSummarizeAndContinueClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onSummarizeAndContinue?.();
|
|
setOverlayOpen(false);
|
|
};
|
|
|
|
const displayName = getTabDisplayName(tab);
|
|
|
|
// 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={{
|
|
// 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}
|
|
onClick={onSelect}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
draggable
|
|
onDragStart={onDragStart}
|
|
onDragOver={onDragOver}
|
|
onDragEnd={onDragEnd}
|
|
onDrop={onDrop}
|
|
title={tab.name || tab.agentSessionId || 'New tab'}
|
|
>
|
|
{/* 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 }}
|
|
/>
|
|
)}
|
|
|
|
{/* 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] rounded-lg shadow-xl border overflow-hidden"
|
|
style={{
|
|
backgroundColor: theme.colors.bgSidebar,
|
|
borderColor: theme.colors.border,
|
|
minWidth: '220px',
|
|
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);
|
|
}}
|
|
>
|
|
{/* Header with session name and ID */}
|
|
<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 */}
|
|
{tab.agentSessionId && (
|
|
<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>
|
|
)}
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{/* Context Management Section - divider and grouped options */}
|
|
{(tab.agentSessionId || (tab.logs?.length ?? 0) >= 5) && (onMergeWith || onSendToAgent || onSummarizeAndContinue) && (
|
|
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
|
|
)}
|
|
|
|
{/* Context: Summarize */}
|
|
{(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: Summarize
|
|
</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>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</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).
|
|
*/
|
|
export function TabBar({
|
|
tabs,
|
|
activeTabId,
|
|
theme,
|
|
onTabSelect,
|
|
onTabClose,
|
|
onNewTab,
|
|
onRequestRename,
|
|
onTabReorder,
|
|
onTabStar,
|
|
onTabMarkUnread,
|
|
onMergeWith,
|
|
onSendToAgent,
|
|
onSummarizeAndContinue,
|
|
showUnreadOnly: showUnreadOnlyProp,
|
|
onToggleUnreadFilter,
|
|
onOpenTabSearch
|
|
}: 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;
|
|
|
|
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 && onTabReorder) {
|
|
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]);
|
|
|
|
const handleRenameRequest = useCallback((tabId: string) => {
|
|
// Request rename via modal (window.prompt doesn't work in Electron)
|
|
if (onRequestRename) {
|
|
onRequestRename(tabId);
|
|
}
|
|
}, [onRequestRename]);
|
|
|
|
// Count unread tabs for the filter toggle tooltip
|
|
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;
|
|
|
|
// 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]);
|
|
|
|
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 && 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 */}
|
|
{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;
|
|
|
|
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}
|
|
isActive={isActive}
|
|
theme={theme}
|
|
canClose={canClose}
|
|
onSelect={() => onTabSelect(tab.id)}
|
|
onClose={() => onTabClose(tab.id)}
|
|
onMiddleClick={() => canClose && onTabClose(tab.id)}
|
|
onDragStart={(e) => handleDragStart(tab.id, e)}
|
|
onDragOver={(e) => handleDragOver(tab.id, e)}
|
|
onDragEnd={handleDragEnd}
|
|
onDrop={(e) => handleDrop(tab.id, e)}
|
|
isDragging={draggingTabId === tab.id}
|
|
isDragOver={dragOverTabId === tab.id}
|
|
onRename={() => handleRenameRequest(tab.id)}
|
|
onStar={onTabStar && tab.agentSessionId ? (starred) => onTabStar(tab.id, starred) : undefined}
|
|
onMarkUnread={onTabMarkUnread ? () => onTabMarkUnread(tab.id) : undefined}
|
|
onMergeWith={onMergeWith && tab.agentSessionId ? () => onMergeWith(tab.id) : undefined}
|
|
onSendToAgent={onSendToAgent && tab.agentSessionId ? () => onSendToAgent(tab.id) : undefined}
|
|
onSummarizeAndContinue={onSummarizeAndContinue && (tab.logs?.length ?? 0) >= 5 ? () => onSummarizeAndContinue(tab.id) : undefined}
|
|
shortcutHint={!showUnreadOnly && originalIndex < 9 ? originalIndex + 1 : null}
|
|
hasDraft={hasDraft(tab)}
|
|
registerRef={(el) => {
|
|
if (el) {
|
|
tabRefs.current.set(tab.id, el);
|
|
} else {
|
|
tabRefs.current.delete(tab.id);
|
|
}
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|