feat: implement comprehensive tabbed interface system
- Add TabContext and useTabState for centralized tab management - Create TabManager component with drag-and-drop reordering - Implement TabContent component for dynamic content rendering - Add AgentsModal for enhanced agent management interface - Integrate tab system into main App component - Update existing components to work with new tab architecture - Support multiple tab types: chat, agent, projects, usage, mcp, settings - Add tab status tracking and unsaved changes detection - Implement smooth animations and modern UI interactions
This commit is contained in:
423
src/components/TabContent.tsx
Normal file
423
src/components/TabContent.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { Suspense, lazy, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTabState } from '@/hooks/useTabState';
|
||||
import { Tab } from '@/contexts/TabContext';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
import { api, type Project, type Session, type ClaudeMdFile } from '@/lib/api';
|
||||
import { ProjectList } from '@/components/ProjectList';
|
||||
import { SessionList } from '@/components/SessionList';
|
||||
import { RunningClaudeSessions } from '@/components/RunningClaudeSessions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Lazy load heavy components
|
||||
const ClaudeCodeSession = lazy(() => import('@/components/ClaudeCodeSession').then(m => ({ default: m.ClaudeCodeSession })));
|
||||
const AgentRunOutputViewer = lazy(() => import('@/components/AgentRunOutputViewer'));
|
||||
const AgentExecution = lazy(() => import('@/components/AgentExecution').then(m => ({ default: m.AgentExecution })));
|
||||
const CreateAgent = lazy(() => import('@/components/CreateAgent').then(m => ({ default: m.CreateAgent })));
|
||||
const UsageDashboard = lazy(() => import('@/components/UsageDashboard').then(m => ({ default: m.UsageDashboard })));
|
||||
const MCPManager = lazy(() => import('@/components/MCPManager').then(m => ({ default: m.MCPManager })));
|
||||
const Settings = lazy(() => import('@/components/Settings').then(m => ({ default: m.Settings })));
|
||||
const MarkdownEditor = lazy(() => import('@/components/MarkdownEditor').then(m => ({ default: m.MarkdownEditor })));
|
||||
// const ClaudeFileEditor = lazy(() => import('@/components/ClaudeFileEditor').then(m => ({ default: m.ClaudeFileEditor })));
|
||||
|
||||
// Import non-lazy components for projects view
|
||||
|
||||
interface TabPanelProps {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
|
||||
const { updateTab, createChatTab } = useTabState();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null);
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Load projects when tab becomes active and is of type 'projects'
|
||||
useEffect(() => {
|
||||
if (isActive && tab.type === 'projects') {
|
||||
loadProjects();
|
||||
}
|
||||
}, [isActive, tab.type]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const projectList = await api.listProjects();
|
||||
setProjects(projectList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load projects:", err);
|
||||
setError("Failed to load projects. Please ensure ~/.claude directory exists.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectClick = async (project: Project) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const sessionList = await api.getProjectSessions(project.id);
|
||||
setSessions(sessionList);
|
||||
setSelectedProject(project);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
setError("Failed to load sessions for this project.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedProject(null);
|
||||
setSessions([]);
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
// Create a new chat tab
|
||||
createChatTab();
|
||||
};
|
||||
|
||||
// Panel visibility - hide when not active
|
||||
const panelVisibilityClass = isActive ? "" : "hidden";
|
||||
|
||||
const renderContent = () => {
|
||||
switch (tab.type) {
|
||||
case 'projects':
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="container mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">CC Projects</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Browse your Claude Code sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive max-w-2xl"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!loading && (
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedProject ? (
|
||||
<motion.div
|
||||
key="sessions"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
projectPath={selectedProject.path}
|
||||
onBack={handleBack}
|
||||
onSessionClick={(session) => {
|
||||
// Update tab to show this session
|
||||
updateTab(tab.id, {
|
||||
type: 'chat',
|
||||
title: session.project_path.split('/').pop() || 'Session',
|
||||
sessionId: session.id,
|
||||
sessionData: session, // Store full session object
|
||||
initialProjectPath: session.project_path,
|
||||
});
|
||||
}}
|
||||
onEditClaudeFile={(file: ClaudeMdFile) => {
|
||||
// Open CLAUDE.md file in a new tab
|
||||
window.dispatchEvent(new CustomEvent('open-claude-file', {
|
||||
detail: { file }
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="projects"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* New session button at the top */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<Button
|
||||
onClick={handleNewSession}
|
||||
size="default"
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Claude Code session
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Running Claude Sessions */}
|
||||
<RunningClaudeSessions />
|
||||
|
||||
{/* Project list */}
|
||||
{projects.length > 0 ? (
|
||||
<ProjectList
|
||||
projects={projects}
|
||||
onProjectClick={handleProjectClick}
|
||||
onProjectSettings={(project) => {
|
||||
// Project settings functionality can be added here if needed
|
||||
console.log('Project settings clicked for:', project);
|
||||
}}
|
||||
loading={loading}
|
||||
className="animate-fade-in"
|
||||
/>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No projects found in ~/.claude/projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'chat':
|
||||
return (
|
||||
<ClaudeCodeSession
|
||||
session={tab.sessionData} // Pass the full session object if available
|
||||
initialProjectPath={tab.initialProjectPath || tab.sessionId}
|
||||
onBack={() => {
|
||||
// Go back to projects view in the same tab
|
||||
updateTab(tab.id, {
|
||||
type: 'projects',
|
||||
title: 'CC Projects',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'agent':
|
||||
if (!tab.agentRunId) {
|
||||
return <div className="p-4">No agent run ID specified</div>;
|
||||
}
|
||||
return (
|
||||
<AgentRunOutputViewer
|
||||
agentRunId={tab.agentRunId}
|
||||
tabId={tab.id}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
case 'usage':
|
||||
return <UsageDashboard onBack={() => {}} />;
|
||||
|
||||
case 'mcp':
|
||||
return <MCPManager onBack={() => {}} />;
|
||||
|
||||
case 'settings':
|
||||
return <Settings onBack={() => {}} />;
|
||||
|
||||
case 'claude-md':
|
||||
return <MarkdownEditor onBack={() => {}} />;
|
||||
|
||||
case 'claude-file':
|
||||
if (!tab.claudeFileId) {
|
||||
return <div className="p-4">No Claude file ID specified</div>;
|
||||
}
|
||||
// Note: We need to get the actual file object for ClaudeFileEditor
|
||||
// For now, returning a placeholder
|
||||
return <div className="p-4">Claude file editor not yet implemented in tabs</div>;
|
||||
|
||||
case 'agent-execution':
|
||||
if (!tab.agentData) {
|
||||
return <div className="p-4">No agent data specified</div>;
|
||||
}
|
||||
return (
|
||||
<AgentExecution
|
||||
agent={tab.agentData}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'create-agent':
|
||||
return (
|
||||
<CreateAgent
|
||||
onAgentCreated={() => {
|
||||
// Close this tab after agent is created
|
||||
window.dispatchEvent(new CustomEvent('close-tab', { detail: { tabId: tab.id } }));
|
||||
}}
|
||||
onBack={() => {
|
||||
// Close this tab when back is clicked
|
||||
window.dispatchEvent(new CustomEvent('close-tab', { detail: { tabId: tab.id } }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'import-agent':
|
||||
// TODO: Implement import agent component
|
||||
return <div className="p-4">Import agent functionality coming soon...</div>;
|
||||
|
||||
default:
|
||||
return <div className="p-4">Unknown tab type: {tab.type}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`h-full w-full ${panelVisibilityClass}`}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabContent: React.FC = () => {
|
||||
const { tabs, activeTabId, createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab } = useTabState();
|
||||
|
||||
// Listen for events to open sessions in tabs
|
||||
useEffect(() => {
|
||||
const handleOpenSessionInTab = (event: CustomEvent) => {
|
||||
const { session } = event.detail;
|
||||
|
||||
// Check if tab already exists for this session
|
||||
const existingTab = findTabBySessionId(session.id);
|
||||
if (existingTab) {
|
||||
// Update existing tab with session data and switch to it
|
||||
updateTab(existingTab.id, {
|
||||
sessionData: session,
|
||||
title: session.project_path.split('/').pop() || 'Session'
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } }));
|
||||
} else {
|
||||
// Create new tab for this session
|
||||
const projectName = session.project_path.split('/').pop() || 'Session';
|
||||
const newTabId = createChatTab(session.id, projectName);
|
||||
// Update the new tab with session data
|
||||
updateTab(newTabId, {
|
||||
sessionData: session,
|
||||
initialProjectPath: session.project_path
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenClaudeFile = (event: CustomEvent) => {
|
||||
const { file } = event.detail;
|
||||
createClaudeFileTab(file.id, file.name || 'CLAUDE.md');
|
||||
};
|
||||
|
||||
const handleOpenAgentExecution = (event: CustomEvent) => {
|
||||
const { agent, tabId } = event.detail;
|
||||
createAgentExecutionTab(agent, tabId);
|
||||
};
|
||||
|
||||
const handleOpenCreateAgentTab = () => {
|
||||
createCreateAgentTab();
|
||||
};
|
||||
|
||||
const handleOpenImportAgentTab = () => {
|
||||
createImportAgentTab();
|
||||
};
|
||||
|
||||
const handleCloseTab = (event: CustomEvent) => {
|
||||
const { tabId } = event.detail;
|
||||
closeTab(tabId);
|
||||
};
|
||||
|
||||
const handleClaudeSessionSelected = (event: CustomEvent) => {
|
||||
const { session } = event.detail;
|
||||
// Reuse same logic as handleOpenSessionInTab
|
||||
const existingTab = findTabBySessionId(session.id);
|
||||
if (existingTab) {
|
||||
updateTab(existingTab.id, {
|
||||
sessionData: session,
|
||||
title: session.project_path.split('/').pop() || 'Session',
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } }));
|
||||
} else {
|
||||
const projectName = session.project_path.split('/').pop() || 'Session';
|
||||
const newTabId = createChatTab(session.id, projectName);
|
||||
updateTab(newTabId, {
|
||||
sessionData: session,
|
||||
initialProjectPath: session.project_path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener);
|
||||
window.addEventListener('open-claude-file', handleOpenClaudeFile as EventListener);
|
||||
window.addEventListener('open-agent-execution', handleOpenAgentExecution as EventListener);
|
||||
window.addEventListener('open-create-agent-tab', handleOpenCreateAgentTab);
|
||||
window.addEventListener('open-import-agent-tab', handleOpenImportAgentTab);
|
||||
window.addEventListener('close-tab', handleCloseTab as EventListener);
|
||||
window.addEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener);
|
||||
window.removeEventListener('open-claude-file', handleOpenClaudeFile as EventListener);
|
||||
window.removeEventListener('open-agent-execution', handleOpenAgentExecution as EventListener);
|
||||
window.removeEventListener('open-create-agent-tab', handleOpenCreateAgentTab);
|
||||
window.removeEventListener('open-import-agent-tab', handleOpenImportAgentTab);
|
||||
window.removeEventListener('close-tab', handleCloseTab as EventListener);
|
||||
window.removeEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener);
|
||||
};
|
||||
}, [createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative">
|
||||
<AnimatePresence mode="wait">
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{tabs.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg mb-2">No tabs open</p>
|
||||
<p className="text-sm">Click the + button to start a new chat</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabContent;
|
Reference in New Issue
Block a user