Files
claudia/src/components/TabManager.tsx
2025-08-07 12:28:47 +08:00

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;