447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import React, { Suspense, lazy, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useTabState } from '@/hooks/useTabState';
|
|
import { useScreenTracking } from '@/hooks/useAnalytics';
|
|
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';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
|
|
// 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 { t } = useTranslation();
|
|
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);
|
|
|
|
// Track screen when tab becomes active
|
|
useScreenTracking(isActive ? tab.type : undefined, isActive ? tab.id : undefined);
|
|
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(t('failedToLoadProjects'));
|
|
} 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(t('failedToLoadSessions'));
|
|
} 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">{t('ccProjects')}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{t('browseClaudeCodeSessions')}
|
|
</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" />
|
|
{t('newClaudeCodeSession')}
|
|
</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">
|
|
{t('noProjectsFound')}
|
|
</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: t('ccProjects'),
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case 'agent':
|
|
if (!tab.agentRunId) {
|
|
return <div className="p-4">{t('messages.noAgentRunIdSpecified')}</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">{t('messages.noClaudeFileIdSpecified')}</div>;
|
|
}
|
|
// Note: We need to get the actual file object for ClaudeFileEditor
|
|
// For now, returning a placeholder
|
|
return <div className="p-4">{t('messages.claudeFileEditorNotImplemented')}</div>;
|
|
|
|
case 'agent-execution':
|
|
if (!tab.agentData) {
|
|
return <div className="p-4">{t('messages.noAgentDataSpecified')}</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">{t('messages.importAgentComingSoon')}</div>;
|
|
|
|
default:
|
|
return <div className="p-4">{t('messages.unknownTabType')}: {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 { t } = useTranslation();
|
|
const { tabs, activeTabId, createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab } = useTabState();
|
|
const [hasInitialized, setHasInitialized] = React.useState(false);
|
|
|
|
// Auto redirect to home when no tabs (but not on initial load)
|
|
useEffect(() => {
|
|
if (hasInitialized && tabs.length === 0) {
|
|
// Dispatch event to switch back to welcome view
|
|
setTimeout(() => {
|
|
window.dispatchEvent(new CustomEvent('switch-to-welcome'));
|
|
}, 100);
|
|
}
|
|
}, [tabs.length, hasInitialized]);
|
|
|
|
// Mark as initialized after first render
|
|
useEffect(() => {
|
|
setHasInitialized(true);
|
|
}, []);
|
|
|
|
// 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">{t('messages.noTabsOpen')}</p>
|
|
<p className="text-sm">{t('messages.clickPlusToStartChat')}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TabContent;
|