427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
|
import { X, Plus, MessageSquare, Bot, AlertCircle, Loader2, Folder, BarChart, Server, Settings, FileText } from 'lucide-react';
|
|
import { useTabState } from '@/hooks/useTabState';
|
|
import { Tab, useTabContext } from '@/contexts/TabContext';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTrackEvent } from '@/hooks';
|
|
|
|
interface TabItemProps {
|
|
tab: Tab;
|
|
isActive: boolean;
|
|
onClose: (id: string) => void;
|
|
onClick: (id: string) => void;
|
|
isDragging?: boolean;
|
|
setDraggedTabId?: (id: string | null) => void;
|
|
}
|
|
|
|
const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick, isDragging = false, setDraggedTabId }) => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
const getIcon = () => {
|
|
switch (tab.type) {
|
|
case 'chat':
|
|
return MessageSquare;
|
|
case 'agent':
|
|
return Bot;
|
|
case 'projects':
|
|
return Folder;
|
|
case 'usage':
|
|
return BarChart;
|
|
case 'mcp':
|
|
return Server;
|
|
case 'settings':
|
|
return Settings;
|
|
case 'claude-md':
|
|
case 'claude-file':
|
|
return FileText;
|
|
case 'agent-execution':
|
|
return Bot;
|
|
case 'create-agent':
|
|
return Plus;
|
|
case 'import-agent':
|
|
return Plus;
|
|
default:
|
|
return MessageSquare;
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = () => {
|
|
switch (tab.status) {
|
|
case 'running':
|
|
return <Loader2 className="w-3 h-3 animate-spin" />;
|
|
case 'error':
|
|
return <AlertCircle className="w-3 h-3 text-red-500" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const Icon = getIcon();
|
|
const statusIcon = getStatusIcon();
|
|
|
|
return (
|
|
<Reorder.Item
|
|
value={tab}
|
|
id={tab.id}
|
|
dragListener={true}
|
|
transition={{ duration: 0.1 }} // Snappy reorder animation
|
|
className={cn(
|
|
"relative flex items-center gap-2 text-sm cursor-pointer select-none group",
|
|
"transition-colors duration-100 overflow-hidden border-r border-border/20",
|
|
"before:absolute before:bottom-0 before:left-0 before:right-0 before:h-0.5 before:transition-colors before:duration-100",
|
|
isActive
|
|
? "bg-card text-card-foreground before:bg-primary"
|
|
: "bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground before:bg-transparent",
|
|
isDragging && "bg-card border-primary/50 shadow-sm z-50",
|
|
"min-w-[120px] max-w-[220px] h-8 px-3"
|
|
)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onClick={() => onClick(tab.id)}
|
|
onDragStart={() => setDraggedTabId?.(tab.id)}
|
|
onDragEnd={() => setDraggedTabId?.(null)}
|
|
>
|
|
{/* Tab Icon */}
|
|
<div className="flex-shrink-0">
|
|
<Icon className="w-4 h-4" />
|
|
</div>
|
|
|
|
{/* Tab Title */}
|
|
<span className="flex-1 truncate text-xs font-medium min-w-0">
|
|
{tab.title}
|
|
</span>
|
|
|
|
{/* Status Indicators - always takes up space */}
|
|
<div className="flex items-center gap-1.5 flex-shrink-0 w-6 justify-end">
|
|
{statusIcon && (
|
|
<span className="flex items-center justify-center">
|
|
{statusIcon}
|
|
</span>
|
|
)}
|
|
|
|
{tab.hasUnsavedChanges && !statusIcon && (
|
|
<span
|
|
className="w-1.5 h-1.5 bg-primary rounded-full"
|
|
title="Unsaved changes"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Close Button - Always reserves space */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose(tab.id);
|
|
}}
|
|
className={cn(
|
|
"flex-shrink-0 w-4 h-4 flex items-center justify-center rounded-sm",
|
|
"transition-all duration-100 hover:bg-destructive/20 hover:text-destructive",
|
|
"focus:outline-none focus:ring-1 focus:ring-destructive/50",
|
|
(isHovered || isActive) ? "opacity-100" : "opacity-0"
|
|
)}
|
|
title={`Close ${tab.title}`}
|
|
tabIndex={-1}
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
|
|
</Reorder.Item>
|
|
);
|
|
};
|
|
|
|
interface TabManagerProps {
|
|
className?: string;
|
|
}
|
|
|
|
export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
|
|
const {
|
|
tabs,
|
|
activeTabId,
|
|
createChatTab,
|
|
createProjectsTab,
|
|
closeTab,
|
|
switchToTab,
|
|
updateTab,
|
|
canAddTab
|
|
} = useTabState();
|
|
|
|
// Access reorderTabs from context
|
|
const { reorderTabs } = useTabContext();
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
|
const [showRightScroll, setShowRightScroll] = useState(false);
|
|
const [draggedTabId, setDraggedTabId] = useState<string | null>(null);
|
|
|
|
// Analytics tracking
|
|
const trackEvent = useTrackEvent();
|
|
|
|
// Listen for tab switch events
|
|
useEffect(() => {
|
|
const handleSwitchToTab = (event: CustomEvent) => {
|
|
const { tabId } = event.detail;
|
|
switchToTab(tabId);
|
|
};
|
|
|
|
window.addEventListener('switch-to-tab', handleSwitchToTab as EventListener);
|
|
return () => {
|
|
window.removeEventListener('switch-to-tab', handleSwitchToTab as EventListener);
|
|
};
|
|
}, [switchToTab]);
|
|
|
|
// Listen for keyboard shortcut events
|
|
useEffect(() => {
|
|
const handleCreateTab = () => {
|
|
createChatTab();
|
|
trackEvent.tabCreated('chat');
|
|
};
|
|
|
|
const handleCloseTab = async () => {
|
|
if (activeTabId) {
|
|
const tab = tabs.find(t => t.id === activeTabId);
|
|
if (tab) {
|
|
trackEvent.tabClosed(tab.type);
|
|
}
|
|
await closeTab(activeTabId);
|
|
}
|
|
};
|
|
|
|
const handleNextTab = () => {
|
|
const currentIndex = tabs.findIndex(tab => tab.id === activeTabId);
|
|
const nextIndex = (currentIndex + 1) % tabs.length;
|
|
if (tabs[nextIndex]) {
|
|
switchToTab(tabs[nextIndex].id);
|
|
}
|
|
};
|
|
|
|
const handlePreviousTab = () => {
|
|
const currentIndex = tabs.findIndex(tab => tab.id === activeTabId);
|
|
const previousIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
|
|
if (tabs[previousIndex]) {
|
|
switchToTab(tabs[previousIndex].id);
|
|
}
|
|
};
|
|
|
|
const handleTabByIndex = (event: CustomEvent) => {
|
|
const { index } = event.detail;
|
|
if (tabs[index]) {
|
|
switchToTab(tabs[index].id);
|
|
}
|
|
};
|
|
|
|
const handleOpenSessionTab = (event: CustomEvent) => {
|
|
const { session, projectPath } = event.detail;
|
|
if (session && canAddTab()) {
|
|
// Create a new chat tab with the session data
|
|
const tabId = createChatTab();
|
|
// Update the tab with session data
|
|
setTimeout(() => {
|
|
updateTab(tabId, {
|
|
type: 'chat',
|
|
title: session.project_path.split('/').pop() || 'Session',
|
|
sessionId: session.id,
|
|
sessionData: session,
|
|
initialProjectPath: projectPath || session.project_path,
|
|
});
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('create-chat-tab', handleCreateTab);
|
|
window.addEventListener('close-current-tab', handleCloseTab);
|
|
window.addEventListener('switch-to-next-tab', handleNextTab);
|
|
window.addEventListener('switch-to-previous-tab', handlePreviousTab);
|
|
window.addEventListener('switch-to-tab-by-index', handleTabByIndex as EventListener);
|
|
window.addEventListener('open-session-tab', handleOpenSessionTab as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener('create-chat-tab', handleCreateTab);
|
|
window.removeEventListener('close-current-tab', handleCloseTab);
|
|
window.removeEventListener('switch-to-next-tab', handleNextTab);
|
|
window.removeEventListener('switch-to-previous-tab', handlePreviousTab);
|
|
window.removeEventListener('switch-to-tab-by-index', handleTabByIndex as EventListener);
|
|
window.removeEventListener('open-session-tab', handleOpenSessionTab as EventListener);
|
|
};
|
|
}, [tabs, activeTabId, createChatTab, closeTab, switchToTab, updateTab, canAddTab]);
|
|
|
|
// Check scroll buttons visibility
|
|
const checkScrollButtons = () => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const { scrollLeft, scrollWidth, clientWidth } = container;
|
|
setShowLeftScroll(scrollLeft > 0);
|
|
setShowRightScroll(scrollLeft + clientWidth < scrollWidth - 1);
|
|
};
|
|
|
|
useEffect(() => {
|
|
checkScrollButtons();
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
container.addEventListener('scroll', checkScrollButtons);
|
|
window.addEventListener('resize', checkScrollButtons);
|
|
|
|
return () => {
|
|
container.removeEventListener('scroll', checkScrollButtons);
|
|
window.removeEventListener('resize', checkScrollButtons);
|
|
};
|
|
}, [tabs]);
|
|
|
|
const handleReorder = (newOrder: Tab[]) => {
|
|
// Find the positions that changed
|
|
const oldOrder = tabs.map(tab => tab.id);
|
|
const newOrderIds = newOrder.map(tab => tab.id);
|
|
|
|
// Find what moved
|
|
const movedTabId = newOrderIds.find((id, index) => oldOrder[index] !== id);
|
|
if (!movedTabId) return;
|
|
|
|
const oldIndex = oldOrder.indexOf(movedTabId);
|
|
const newIndex = newOrderIds.indexOf(movedTabId);
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
// Use the context's reorderTabs function
|
|
reorderTabs(oldIndex, newIndex);
|
|
// Track the reorder event
|
|
trackEvent.featureUsed?.('tab_reorder', 'drag_drop', {
|
|
from_index: oldIndex,
|
|
to_index: newIndex
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCloseTab = async (id: string) => {
|
|
const tab = tabs.find(t => t.id === id);
|
|
if (tab) {
|
|
trackEvent.tabClosed(tab.type);
|
|
}
|
|
await closeTab(id);
|
|
};
|
|
|
|
const handleNewTab = () => {
|
|
if (canAddTab()) {
|
|
createProjectsTab();
|
|
trackEvent.tabCreated('projects');
|
|
}
|
|
};
|
|
|
|
const scrollTabs = (direction: 'left' | 'right') => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const scrollAmount = 200;
|
|
const newScrollLeft = direction === 'left'
|
|
? container.scrollLeft - scrollAmount
|
|
: container.scrollLeft + scrollAmount;
|
|
|
|
container.scrollTo({
|
|
left: newScrollLeft,
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex items-stretch bg-muted/15 border-b relative", className)}>
|
|
{/* Left fade gradient */}
|
|
{showLeftScroll && (
|
|
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-muted/15 to-transparent pointer-events-none z-10" />
|
|
)}
|
|
|
|
{/* Left scroll button */}
|
|
<AnimatePresence>
|
|
{showLeftScroll && (
|
|
<motion.button
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => scrollTabs('left')}
|
|
className={cn(
|
|
"p-1.5 hover:bg-muted/80 rounded-sm z-20 ml-1",
|
|
"transition-colors duration-200 flex items-center justify-center",
|
|
"bg-background/98 backdrop-blur-xl backdrop-saturate-[1.8] shadow-sm border border-border/60"
|
|
)}
|
|
title="Scroll tabs left"
|
|
>
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
<path d="M15 18l-6-6 6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Tabs container */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex-1 flex overflow-x-auto scrollbar-hide"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
>
|
|
<Reorder.Group
|
|
axis="x"
|
|
values={tabs}
|
|
onReorder={handleReorder}
|
|
className="flex items-stretch h-8"
|
|
layoutScroll={false}
|
|
>
|
|
{tabs.map((tab) => (
|
|
<TabItem
|
|
key={tab.id}
|
|
tab={tab}
|
|
isActive={tab.id === activeTabId}
|
|
onClose={handleCloseTab}
|
|
onClick={switchToTab}
|
|
isDragging={draggedTabId === tab.id}
|
|
setDraggedTabId={setDraggedTabId}
|
|
/>
|
|
))}
|
|
</Reorder.Group>
|
|
</div>
|
|
|
|
{/* Right fade gradient */}
|
|
{showRightScroll && (
|
|
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-muted/15 to-transparent pointer-events-none z-10" />
|
|
)}
|
|
|
|
{/* Right scroll button */}
|
|
<AnimatePresence>
|
|
{showRightScroll && (
|
|
<motion.button
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => scrollTabs('right')}
|
|
className={cn(
|
|
"p-1.5 hover:bg-muted/80 rounded-sm z-20 mr-1",
|
|
"transition-colors duration-200 flex items-center justify-center",
|
|
"bg-background/98 backdrop-blur-xl backdrop-saturate-[1.8] shadow-sm border border-border/60"
|
|
)}
|
|
title="Scroll tabs right"
|
|
>
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
<path d="M9 18l6-6-6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* New tab button */}
|
|
<button
|
|
onClick={handleNewTab}
|
|
disabled={!canAddTab()}
|
|
className={cn(
|
|
"p-2 mx-2 rounded-md transition-all duration-200 flex items-center justify-center",
|
|
"border border-border/60 bg-background/85 backdrop-blur-xl backdrop-saturate-[1.8]",
|
|
canAddTab()
|
|
? "hover:bg-muted/80 hover:border-border text-muted-foreground hover:text-foreground hover:shadow-sm"
|
|
: "opacity-50 cursor-not-allowed bg-muted/30"
|
|
)}
|
|
title={canAddTab() ? "Browse projects (Ctrl+T)" : `Maximum tabs reached (${tabs.length}/20)`}
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TabManager; |