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:
Vivek R
2025-07-15 14:08:48 +05:30
parent cee71343f5
commit 9887b9d14a
9 changed files with 1952 additions and 187 deletions

View File

@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
import { Plus, Loader2, Bot, FolderCode } from "lucide-react"; import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api"; import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
import { OutputCacheProvider } from "@/lib/outputCache"; import { OutputCacheProvider } from "@/lib/outputCache";
import { TabProvider } from "@/contexts/TabContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { ProjectList } from "@/components/ProjectList"; import { ProjectList } from "@/components/ProjectList";
@@ -13,20 +14,22 @@ import { MarkdownEditor } from "@/components/MarkdownEditor";
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor"; import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
import { Settings } from "@/components/Settings"; import { Settings } from "@/components/Settings";
import { CCAgents } from "@/components/CCAgents"; import { CCAgents } from "@/components/CCAgents";
import { ClaudeCodeSession } from "@/components/ClaudeCodeSession";
import { UsageDashboard } from "@/components/UsageDashboard"; import { UsageDashboard } from "@/components/UsageDashboard";
import { MCPManager } from "@/components/MCPManager"; import { MCPManager } from "@/components/MCPManager";
import { NFOCredits } from "@/components/NFOCredits"; import { NFOCredits } from "@/components/NFOCredits";
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog"; import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
import { Toast, ToastContainer } from "@/components/ui/toast"; import { Toast, ToastContainer } from "@/components/ui/toast";
import { ProjectSettings } from '@/components/ProjectSettings'; import { ProjectSettings } from '@/components/ProjectSettings';
import { TabManager } from "@/components/TabManager";
import { TabContent } from "@/components/TabContent";
import { AgentsModal } from "@/components/AgentsModal";
import { useTabState } from "@/hooks/useTabState";
type View = type View =
| "welcome" | "welcome"
| "projects" | "projects"
| "editor" | "editor"
| "claude-file-editor" | "claude-file-editor"
| "claude-code-session"
| "settings" | "settings"
| "cc-agents" | "cc-agents"
| "create-agent" | "create-agent"
@@ -35,27 +38,27 @@ type View =
| "agent-run-view" | "agent-run-view"
| "mcp" | "mcp"
| "usage-dashboard" | "usage-dashboard"
| "project-settings"; | "project-settings"
| "tabs"; // New view for tab-based interface
/** /**
* Main App component - Manages the Claude directory browser UI * AppContent component - Contains the main app logic, wrapped by providers
*/ */
function App() { function AppContent() {
const [view, setView] = useState<View>("welcome"); const [view, setView] = useState<View>("tabs");
const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab } = useTabState();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null); const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showNFO, setShowNFO] = useState(false); const [showNFO, setShowNFO] = useState(false);
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false); const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
const [activeClaudeSessionId, setActiveClaudeSessionId] = useState<string | null>(null);
const [isClaudeStreaming, setIsClaudeStreaming] = useState(false);
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null); const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
const [previousView, setPreviousView] = useState<View>("welcome"); const [previousView] = useState<View>("welcome");
const [showAgentsModal, setShowAgentsModal] = useState(false);
// Load projects on mount when in projects view // Load projects on mount when in projects view
useEffect(() => { useEffect(() => {
@@ -67,22 +70,56 @@ function App() {
} }
}, [view]); }, [view]);
// Listen for Claude session selection events // Keyboard shortcuts for tab navigation
useEffect(() => { useEffect(() => {
const handleSessionSelected = (event: CustomEvent) => { if (view !== "tabs") return;
const { session } = event.detail;
setSelectedSession(session); const handleKeyDown = (e: KeyboardEvent) => {
handleViewChange("claude-code-session"); const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (modKey) {
switch (e.key) {
case 't':
e.preventDefault();
window.dispatchEvent(new CustomEvent('create-chat-tab'));
break;
case 'w':
e.preventDefault();
window.dispatchEvent(new CustomEvent('close-current-tab'));
break;
case 'Tab':
e.preventDefault();
if (e.shiftKey) {
window.dispatchEvent(new CustomEvent('switch-to-previous-tab'));
} else {
window.dispatchEvent(new CustomEvent('switch-to-next-tab'));
}
break;
default:
// Handle number keys 1-9
if (e.key >= '1' && e.key <= '9') {
e.preventDefault();
const index = parseInt(e.key) - 1;
window.dispatchEvent(new CustomEvent('switch-to-tab-by-index', { detail: { index } }));
}
break;
}
}
}; };
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [view]);
// Listen for Claude not found events
useEffect(() => {
const handleClaudeNotFound = () => { const handleClaudeNotFound = () => {
setShowClaudeBinaryDialog(true); setShowClaudeBinaryDialog(true);
}; };
window.addEventListener('claude-session-selected', handleSessionSelected as EventListener);
window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener); window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener);
return () => { return () => {
window.removeEventListener('claude-session-selected', handleSessionSelected as EventListener);
window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener); window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener);
}; };
}, []); }, []);
@@ -126,8 +163,8 @@ function App() {
* Opens a new Claude Code session in the interactive UI * Opens a new Claude Code session in the interactive UI
*/ */
const handleNewSession = async () => { const handleNewSession = async () => {
handleViewChange("claude-code-session"); handleViewChange("tabs");
setSelectedSession(null); // The tab system will handle creating a new chat tab
}; };
/** /**
@@ -158,19 +195,7 @@ function App() {
* Handles view changes with navigation protection * Handles view changes with navigation protection
*/ */
const handleViewChange = (newView: View) => { const handleViewChange = (newView: View) => {
// Check if we're navigating away from an active Claude session // No need for navigation protection with tabs since sessions stay open
if (view === "claude-code-session" && isClaudeStreaming && activeClaudeSessionId) {
const shouldLeave = window.confirm(
"Claude is still responding. If you navigate away, Claude will continue running in the background.\n\n" +
"You can return to this session from the Projects view.\n\n" +
"Do you want to continue?"
);
if (!shouldLeave) {
return;
}
}
setView(newView); setView(newView);
}; };
@@ -182,22 +207,6 @@ function App() {
handleViewChange("project-settings"); handleViewChange("project-settings");
}; };
/**
* Handles navigating to hooks configuration from a project path
*/
const handleProjectSettingsFromPath = (projectPath: string) => {
// Create a temporary project object from the path
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
const tempProject: Project = {
id: projectId,
path: projectPath,
sessions: [],
created_at: Date.now() / 1000
};
setProjectForSettings(tempProject);
setPreviousView(view);
handleViewChange("project-settings");
};
const renderContent = () => { const renderContent = () => {
switch (view) { switch (view) {
@@ -403,20 +412,14 @@ function App() {
/> />
) : null; ) : null;
case "claude-code-session": case "tabs":
return ( return (
<ClaudeCodeSession <div className="h-full flex flex-col">
session={selectedSession || undefined} <TabManager className="flex-shrink-0" />
onBack={() => { <div className="flex-1 overflow-hidden">
setSelectedSession(null); <TabContent />
handleViewChange("projects"); </div>
}} </div>
onStreamingChange={(isStreaming, sessionId) => {
setIsClaudeStreaming(isStreaming);
setActiveClaudeSessionId(sessionId);
}}
onProjectSettings={handleProjectSettingsFromPath}
/>
); );
case "usage-dashboard": case "usage-dashboard":
@@ -449,48 +452,66 @@ function App() {
}; };
return ( return (
<OutputCacheProvider> <div className="h-screen bg-background flex flex-col">
<div className="h-screen bg-background flex flex-col"> {/* Topbar */}
{/* Topbar */} <Topbar
<Topbar onClaudeClick={() => createClaudeMdTab()}
onClaudeClick={() => handleViewChange("editor")} onSettingsClick={() => createSettingsTab()}
onSettingsClick={() => handleViewChange("settings")} onUsageClick={() => createUsageTab()}
onUsageClick={() => handleViewChange("usage-dashboard")} onMCPClick={() => createMCPTab()}
onMCPClick={() => handleViewChange("mcp")} onInfoClick={() => setShowNFO(true)}
onInfoClick={() => setShowNFO(true)} onAgentsClick={() => setShowAgentsModal(true)}
/> />
{/* Main Content */} {/* Main Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-hidden">
{renderContent()} {renderContent()}
</div>
{/* NFO Credits Modal */}
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
{/* Claude Binary Dialog */}
<ClaudeBinaryDialog
open={showClaudeBinaryDialog}
onOpenChange={setShowClaudeBinaryDialog}
onSuccess={() => {
setToast({ message: "Claude binary path saved successfully", type: "success" });
// Trigger a refresh of the Claude version check
window.location.reload();
}}
onError={(message) => setToast({ message, type: "error" })}
/>
{/* Toast Container */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div> </div>
{/* NFO Credits Modal */}
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
{/* Agents Modal */}
<AgentsModal
open={showAgentsModal}
onOpenChange={setShowAgentsModal}
/>
{/* Claude Binary Dialog */}
<ClaudeBinaryDialog
open={showClaudeBinaryDialog}
onOpenChange={setShowClaudeBinaryDialog}
onSuccess={() => {
setToast({ message: "Claude binary path saved successfully", type: "success" });
// Trigger a refresh of the Claude version check
window.location.reload();
}}
onError={(message) => setToast({ message, type: "error" })}
/>
{/* Toast Container */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
);
}
/**
* Main App component - Wraps the app with providers
*/
function App() {
return (
<OutputCacheProvider>
<TabProvider>
<AppContent />
</TabProvider>
</OutputCacheProvider> </OutputCacheProvider>
); );
} }

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
X,
Maximize2, Maximize2,
Minimize2, Minimize2,
Copy, Copy,
@@ -12,7 +11,6 @@ import {
Clock, Clock,
Hash, Hash,
DollarSign, DollarSign,
ExternalLink,
StopCircle StopCircle
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -28,20 +26,17 @@ import { ErrorBoundary } from './ErrorBoundary';
import { formatISOTimestamp } from '@/lib/date-utils'; import { formatISOTimestamp } from '@/lib/date-utils';
import { AGENT_ICONS } from './CCAgents'; import { AGENT_ICONS } from './CCAgents';
import type { ClaudeStreamMessage } from './AgentExecution'; import type { ClaudeStreamMessage } from './AgentExecution';
import { useTabState } from '@/hooks/useTabState';
interface AgentRunOutputViewerProps { interface AgentRunOutputViewerProps {
/** /**
* The agent run to display * The agent run ID to display
*/ */
run: AgentRunWithMetrics; agentRunId: string;
/** /**
* Callback when the viewer is closed * Tab ID for this agent run
*/ */
onClose: () => void; tabId: string;
/**
* Optional callback to open full view
*/
onOpenFullView?: () => void;
/** /**
* Optional className for styling * Optional className for styling
*/ */
@@ -58,11 +53,12 @@ interface AgentRunOutputViewerProps {
* /> * />
*/ */
export function AgentRunOutputViewer({ export function AgentRunOutputViewer({
run, agentRunId,
onClose, tabId,
onOpenFullView,
className className
}: AgentRunOutputViewerProps) { }: AgentRunOutputViewerProps) {
const { updateTabTitle, updateTabStatus } = useTabState();
const [run, setRun] = useState<AgentRunWithMetrics | null>(null);
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]); const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]); const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -103,6 +99,28 @@ export function AgentRunOutputViewer({
} }
}; };
// Load agent run on mount
useEffect(() => {
const loadAgentRun = async () => {
try {
setLoading(true);
const agentRun = await api.getAgentRun(parseInt(agentRunId));
setRun(agentRun);
updateTabTitle(tabId, `Agent: ${agentRun.agent_name || 'Unknown'}`);
updateTabStatus(tabId, agentRun.status === 'running' ? 'running' : agentRun.status === 'failed' ? 'error' : 'complete');
} catch (error) {
console.error('Failed to load agent run:', error);
updateTabStatus(tabId, 'error');
} finally {
setLoading(false);
}
};
if (agentRunId) {
loadAgentRun();
}
}, [agentRunId, tabId, updateTabTitle, updateTabStatus]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -121,7 +139,7 @@ export function AgentRunOutputViewer({
}, [messages, hasUserScrolled, isFullscreen]); }, [messages, hasUserScrolled, isFullscreen]);
const loadOutput = async (skipCache = false) => { const loadOutput = async (skipCache = false) => {
if (!run.id) return; if (!run?.id) return;
console.log('[AgentRunOutputViewer] Loading output for run:', { console.log('[AgentRunOutputViewer] Loading output for run:', {
runId: run.id, runId: run.id,
@@ -244,7 +262,7 @@ export function AgentRunOutputViewer({
// Set up live event listeners for running sessions // Set up live event listeners for running sessions
const setupLiveEventListeners = async () => { const setupLiveEventListeners = async () => {
if (!run.id || hasSetupListenersRef.current) return; if (!run?.id || hasSetupListenersRef.current) return;
try { try {
// Clean up existing listeners // Clean up existing listeners
@@ -261,7 +279,7 @@ export function AgentRunOutputViewer({
}, 100); }, 100);
// Set up live event listeners with run ID isolation // Set up live event listeners with run ID isolation
const outputUnlisten = await listen<string>(`agent-output:${run.id}`, (event) => { const outputUnlisten = await listen<string>(`agent-output:${run!.id}`, (event) => {
try { try {
// Skip messages during initial load phase // Skip messages during initial load phase
if (isInitialLoadRef.current) { if (isInitialLoadRef.current) {
@@ -280,17 +298,17 @@ export function AgentRunOutputViewer({
} }
}); });
const errorUnlisten = await listen<string>(`agent-error:${run.id}`, (event) => { const errorUnlisten = await listen<string>(`agent-error:${run!.id}`, (event) => {
console.error("[AgentRunOutputViewer] Agent error:", event.payload); console.error("[AgentRunOutputViewer] Agent error:", event.payload);
setToast({ message: event.payload, type: 'error' }); setToast({ message: event.payload, type: 'error' });
}); });
const completeUnlisten = await listen<boolean>(`agent-complete:${run.id}`, () => { const completeUnlisten = await listen<boolean>(`agent-complete:${run!.id}`, () => {
setToast({ message: 'Agent execution completed', type: 'success' }); setToast({ message: 'Agent execution completed', type: 'success' });
// Don't set status here as the parent component should handle it // Don't set status here as the parent component should handle it
}); });
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${run.id}`, () => { const cancelUnlisten = await listen<boolean>(`agent-cancelled:${run!.id}`, () => {
setToast({ message: 'Agent execution was cancelled', type: 'error' }); setToast({ message: 'Agent execution was cancelled', type: 'error' });
}); });
@@ -309,6 +327,7 @@ export function AgentRunOutputViewer({
}; };
const handleCopyAsMarkdown = async () => { const handleCopyAsMarkdown = async () => {
if (!run) return;
let markdown = `# Agent Execution: ${run.agent_name}\n\n`; let markdown = `# Agent Execution: ${run.agent_name}\n\n`;
markdown += `**Task:** ${run.task}\n`; markdown += `**Task:** ${run.task}\n`;
markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
@@ -372,7 +391,7 @@ export function AgentRunOutputViewer({
}; };
const handleStop = async () => { const handleStop = async () => {
if (!run.id) { if (!run?.id) {
console.error('[AgentRunOutputViewer] No run ID available to stop'); console.error('[AgentRunOutputViewer] No run ID available to stop');
return; return;
} }
@@ -404,11 +423,11 @@ export function AgentRunOutputViewer({
}; };
setMessages(prev => [...prev, stopMessage]); setMessages(prev => [...prev, stopMessage]);
// Update the run status locally // Update the tab status
// Optionally refresh the parent component updateTabStatus(tabId, 'idle');
setTimeout(() => {
window.location.reload(); // Simple refresh to update the status // Refresh the output to get updated status
}, 1000); await loadOutput(true);
} else { } else {
console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`); console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`);
setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' }); setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' });
@@ -431,10 +450,10 @@ export function AgentRunOutputViewer({
// Load output on mount // Load output on mount
useEffect(() => { useEffect(() => {
if (!run.id) return; if (!run?.id) return;
// Check cache immediately for instant display // Check cache immediately for instant display
const cached = getCachedOutput(run.id); const cached = getCachedOutput(run!.id);
if (cached) { if (cached) {
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
setRawJsonlOutput(cachedJsonlLines); setRawJsonlOutput(cachedJsonlLines);
@@ -443,7 +462,7 @@ export function AgentRunOutputViewer({
// Then load fresh data // Then load fresh data
loadOutput(); loadOutput();
}, [run.id]); }, [run?.id]);
const displayableMessages = useMemo(() => { const displayableMessages = useMemo(() => {
return messages.filter((message) => { return messages.filter((message) => {
@@ -511,25 +530,23 @@ export function AgentRunOutputViewer({
return tokens.toString(); return tokens.toString();
}; };
if (!run) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading agent run...</p>
</div>
</div>
);
}
return ( return (
<> <>
<motion.div <div className={`h-full flex flex-col ${className || ''}`}>
initial={{ opacity: 0 }} <Card className="h-full flex flex-col">
animate={{ opacity: 1 }} <CardHeader className="pb-3">
exit={{ opacity: 0 }} <div className="flex items-start justify-between gap-4">
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className={`fixed inset-x-4 top-[10%] bottom-[10%] z-50 max-w-4xl mx-auto ${className}`}
>
<Card className="h-full flex flex-col shadow-xl">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0"> <div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5"> <div className="mt-0.5">
{renderIcon(run.agent_icon)} {renderIcon(run.agent_icon)}
@@ -610,17 +627,6 @@ export function AgentRunOutputViewer({
onOpenChange={setCopyPopoverOpen} onOpenChange={setCopyPopoverOpen}
align="end" align="end"
/> />
{onOpenFullView && (
<Button
variant="ghost"
size="sm"
onClick={onOpenFullView}
title="Open in full view"
className="h-8 px-2"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -656,19 +662,11 @@ export function AgentRunOutputViewer({
<StopCircle className="h-4 w-4" /> <StopCircle className="h-4 w-4" />
</Button> </Button>
)} )}
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 px-2"
>
<X className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'flex-1'} p-0 overflow-hidden`}> <CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'flex-1'} p-0 overflow-hidden`}>
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
@@ -701,10 +699,10 @@ export function AgentRunOutputViewer({
</AnimatePresence> </AnimatePresence>
<div ref={outputEndRef} /> <div ref={outputEndRef} />
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </div>
{/* Fullscreen Modal */} {/* Fullscreen Modal */}
{isFullscreen && ( {isFullscreen && (
@@ -827,3 +825,5 @@ export function AgentRunOutputViewer({
</> </>
); );
} }
export default AgentRunOutputViewer;

View File

@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
import { formatISOTimestamp } from "@/lib/date-utils"; import { formatISOTimestamp } from "@/lib/date-utils";
import type { AgentRunWithMetrics } from "@/lib/api"; import type { AgentRunWithMetrics } from "@/lib/api";
import { AGENT_ICONS } from "./CCAgents"; import { AGENT_ICONS } from "./CCAgents";
import { AgentRunOutputViewer } from "./AgentRunOutputViewer"; import { useTabState } from "@/hooks/useTabState";
interface AgentRunsListProps { interface AgentRunsListProps {
/** /**
@@ -42,7 +42,7 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
className, className,
}) => { }) => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [selectedRun, setSelectedRun] = useState<AgentRunWithMetrics | null>(null); const { createAgentTab } = useTabState();
// Calculate pagination // Calculate pagination
const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE);
@@ -81,9 +81,9 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
// If there's a callback, use it (for full-page navigation) // If there's a callback, use it (for full-page navigation)
if (onRunClick) { if (onRunClick) {
onRunClick(run); onRunClick(run);
} else { } else if (run.id) {
// Otherwise, open in modal preview // Otherwise, open in new tab
setSelectedRun(run); createAgentTab(run.id.toString(), run.agent_name);
} }
}; };
@@ -196,13 +196,6 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
)} )}
</div> </div>
{/* Agent Run Output Viewer Modal */}
{selectedRun && (
<AgentRunOutputViewer
run={selectedRun}
onClose={() => setSelectedRun(null)}
/>
)}
</> </>
); );
}; };

View File

@@ -0,0 +1,441 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Bot, Plus, Loader2, Play, Clock, CheckCircle, XCircle, Trash2, Import, ChevronDown, FileJson, Globe, Download } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Toast } from '@/components/ui/toast';
import { api, type Agent, type AgentRunWithMetrics } from '@/lib/api';
import { useTabState } from '@/hooks/useTabState';
import { formatISOTimestamp } from '@/lib/date-utils';
import { open as openDialog, save } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { GitHubAgentBrowser } from '@/components/GitHubAgentBrowser';
interface AgentsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const AgentsModal: React.FC<AgentsModalProps> = ({ open, onOpenChange }) => {
const [activeTab, setActiveTab] = useState('agents');
const [agents, setAgents] = useState<Agent[]>([]);
const [runningAgents, setRunningAgents] = useState<AgentRunWithMetrics[]>([]);
const [loading, setLoading] = useState(true);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showGitHubBrowser, setShowGitHubBrowser] = useState(false);
const { createAgentTab, createCreateAgentTab } = useTabState();
// Load agents when modal opens
useEffect(() => {
if (open) {
loadAgents();
loadRunningAgents();
}
}, [open]);
// Refresh running agents periodically
useEffect(() => {
if (!open) return;
const interval = setInterval(() => {
loadRunningAgents();
}, 3000); // Refresh every 3 seconds
return () => clearInterval(interval);
}, [open]);
const loadAgents = async () => {
try {
setLoading(true);
const agentList = await api.listAgents();
setAgents(agentList);
} catch (error) {
console.error('Failed to load agents:', error);
} finally {
setLoading(false);
}
};
const loadRunningAgents = async () => {
try {
const runs = await api.listRunningAgentSessions();
const agentRuns = runs.map(run => ({
id: run.id,
agent_id: run.agent_id,
agent_name: run.agent_name,
task: run.task,
model: run.model,
status: 'running' as const,
created_at: run.created_at,
project_path: run.project_path,
} as AgentRunWithMetrics));
setRunningAgents(agentRuns);
} catch (error) {
console.error('Failed to load running agents:', error);
}
};
const handleRunAgent = async (agent: Agent) => {
// Create a new agent execution tab
const tabId = `agent-exec-${agent.id}-${Date.now()}`;
// Close modal
onOpenChange(false);
// Dispatch event to open agent execution in the new tab
window.dispatchEvent(new CustomEvent('open-agent-execution', {
detail: { agent, tabId }
}));
};
const handleDeleteAgent = async (agent: Agent) => {
setAgentToDelete(agent);
setShowDeleteDialog(true);
};
const confirmDelete = async () => {
if (!agentToDelete?.id) return;
try {
await api.deleteAgent(agentToDelete.id);
loadAgents(); // Refresh the list
setShowDeleteDialog(false);
setAgentToDelete(null);
} catch (error) {
console.error('Failed to delete agent:', error);
}
};
const handleOpenAgentRun = (run: AgentRunWithMetrics) => {
// Create new tab for this agent run
createAgentTab(run.id!.toString(), run.agent_name);
onOpenChange(false);
};
const handleCreateAgent = () => {
// Close modal and create new tab
onOpenChange(false);
createCreateAgentTab();
};
const handleImportFromFile = async () => {
try {
const filePath = await openDialog({
multiple: false,
filters: [{
name: 'JSON',
extensions: ['json']
}]
});
if (filePath) {
const agent = await api.importAgentFromFile(filePath as string);
loadAgents(); // Refresh list
setToast({ message: `Agent "${agent.name}" imported successfully`, type: "success" });
}
} catch (error) {
console.error('Failed to import agent:', error);
setToast({ message: "Failed to import agent", type: "error" });
}
};
const handleImportFromGitHub = () => {
setShowGitHubBrowser(true);
};
const handleExportAgent = async (agent: Agent) => {
try {
const exportData = await api.exportAgent(agent.id!);
const filePath = await save({
defaultPath: `${agent.name.toLowerCase().replace(/\s+/g, '-')}.json`,
filters: [{
name: 'JSON',
extensions: ['json']
}]
});
if (filePath) {
await invoke('write_file', { path: filePath, content: JSON.stringify(exportData, null, 2) });
setToast({ message: "Agent exported successfully", type: "success" });
}
} catch (error) {
console.error('Failed to export agent:', error);
setToast({ message: "Failed to export agent", type: "error" });
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Loader2 className="w-4 h-4 animate-spin" />;
case 'completed':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'failed':
return <XCircle className="w-4 h-4 text-red-500" />;
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[600px] flex flex-col p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<Bot className="w-5 h-5" />
Agent Management
</DialogTitle>
<DialogDescription>
Create new agents or manage running agent executions
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
<TabsList className="mx-6">
<TabsTrigger value="agents">Available Agents</TabsTrigger>
<TabsTrigger value="running" className="relative">
Running Agents
{runningAgents.length > 0 && (
<Badge variant="secondary" className="ml-2 h-5 px-1.5">
{runningAgents.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="agents" className="h-full m-0">
<ScrollArea className="h-full px-6 pb-6">
{/* Action buttons at the top */}
<div className="flex gap-2 mb-4 pt-4">
<Button onClick={handleCreateAgent} className="flex-1">
<Plus className="w-4 h-4 mr-2" />
Create Agent
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex-1">
<Import className="w-4 h-4 mr-2" />
Import Agent
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleImportFromFile}>
<FileJson className="w-4 h-4 mr-2" />
From File
</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportFromGitHub}>
<Globe className="w-4 h-4 mr-2" />
From GitHub
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Bot className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">No agents available</p>
<p className="text-sm text-muted-foreground mb-4">
Create your first agent to get started
</p>
<Button onClick={() => {
onOpenChange(false);
window.dispatchEvent(new CustomEvent('open-create-agent-tab'));
}}>
<Plus className="w-4 h-4 mr-2" />
Create Agent
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
{agents.map((agent) => (
<motion.div
key={agent.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-medium flex items-center gap-2">
<Bot className="w-4 h-4" />
{agent.name}
</h3>
{agent.default_task && (
<p className="text-sm text-muted-foreground mt-1">
{agent.default_task}
</p>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleExportAgent(agent)}
>
<Download className="w-3 h-3 mr-1" />
Export
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAgent(agent)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3 mr-1" />
Delete
</Button>
<Button
size="sm"
onClick={() => handleRunAgent(agent)}
>
<Play className="w-3 h-3 mr-1" />
Run
</Button>
</div>
</div>
</motion.div>
))}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="running" className="h-full m-0">
<ScrollArea className="h-full px-6 pb-6">
{runningAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Clock className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium mb-2">No running agents</p>
<p className="text-sm text-muted-foreground">
Agent executions will appear here when started
</p>
</div>
) : (
<div className="grid gap-4 py-4">
<AnimatePresence mode="popLayout">
{runningAgents.map((run) => (
<motion.div
key={run.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="p-4 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => handleOpenAgentRun(run)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-medium flex items-center gap-2">
{getStatusIcon(run.status)}
{run.agent_name}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{run.task}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span>Started: {formatISOTimestamp(run.created_at)}</span>
<Badge variant="outline" className="text-xs">
{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
</Badge>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleOpenAgentRun(run);
}}
>
View
</Button>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Agent</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{agentToDelete?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false);
setAgentToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
{/* GitHub Agent Browser */}
<GitHubAgentBrowser
isOpen={showGitHubBrowser}
onClose={() => setShowGitHubBrowser(false)}
onImportSuccess={() => {
setShowGitHubBrowser(false);
loadAgents(); // Refresh the agents list
setToast({ message: "Agent imported successfully", type: "success" });
}}
/>
{/* Toast notifications */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</>
);
};
export default AgentsModal;

View 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;

View File

@@ -0,0 +1,336 @@
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 } from '@/contexts/TabContext';
import { cn } from '@/lib/utils';
interface TabItemProps {
tab: Tab;
isActive: boolean;
onClose: (id: string) => void;
onClick: (id: string) => void;
}
const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick }) => {
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}
className={cn(
"relative flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer select-none",
"border-b-2 transition-all duration-200",
isActive
? "border-blue-500 bg-background text-foreground"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
"min-w-[120px] max-w-[200px]"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onClick(tab.id)}
whileHover={{ y: -1 }}
whileTap={{ scale: 0.98 }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 truncate">
{tab.title}
</span>
{statusIcon && (
<span className="flex-shrink-0">
{statusIcon}
</span>
)}
{tab.hasUnsavedChanges && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
)}
<AnimatePresence>
{(isHovered || isActive) && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
className={cn(
"flex-shrink-0 p-0.5 rounded hover:bg-muted-foreground/20",
"transition-colors duration-150"
)}
>
<X className="w-3 h-3" />
</motion.button>
)}
</AnimatePresence>
</Reorder.Item>
);
};
interface TabManagerProps {
className?: string;
}
export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
const {
tabs,
activeTabId,
createChatTab,
createProjectsTab,
closeTab,
switchToTab,
canAddTab
} = useTabState();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftScroll, setShowLeftScroll] = useState(false);
const [showRightScroll, setShowRightScroll] = useState(false);
// 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();
};
const handleCloseTab = async () => {
if (activeTabId) {
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);
}
};
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);
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);
};
}, [tabs, activeTabId, createChatTab, closeTab, switchToTab]);
// 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[]) => {
// This will be handled by the context when we implement reorderTabs
console.log('Reorder tabs:', newOrder);
};
const handleCloseTab = async (id: string) => {
await closeTab(id);
};
const handleNewTab = () => {
if (canAddTab()) {
createProjectsTab();
}
};
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-center bg-muted/30 border-b", className)}>
{/* Left scroll button */}
<AnimatePresence>
{showLeftScroll && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => scrollTabs('left')}
className="p-1 hover:bg-muted rounded-sm"
>
<svg className="w-4 h-4" 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"
>
<AnimatePresence initial={false}>
{tabs.map((tab) => (
<TabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onClose={handleCloseTab}
onClick={switchToTab}
/>
))}
</AnimatePresence>
</Reorder.Group>
</div>
{/* Right scroll button */}
<AnimatePresence>
{showRightScroll && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => scrollTabs('right')}
className="p-1 hover:bg-muted rounded-sm"
>
<svg className="w-4 h-4" 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 */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleNewTab}
disabled={!canAddTab()}
className={cn(
"p-1.5 mx-2 rounded-sm transition-colors",
canAddTab()
? "hover:bg-muted text-muted-foreground hover:text-foreground"
: "opacity-50 cursor-not-allowed"
)}
title={canAddTab() ? "Browse projects" : "Maximum tabs reached"}
>
<Plus className="w-4 h-4" />
</motion.button>
</div>
);
};
export default TabManager;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info } from "lucide-react"; import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover } from "@/components/ui/popover"; import { Popover } from "@/components/ui/popover";
import { api, type ClaudeVersionStatus } from "@/lib/api"; import { api, type ClaudeVersionStatus } from "@/lib/api";
@@ -27,6 +27,10 @@ interface TopbarProps {
* Callback when Info is clicked * Callback when Info is clicked
*/ */
onInfoClick: () => void; onInfoClick: () => void;
/**
* Callback when Agents is clicked
*/
onAgentsClick?: () => void;
/** /**
* Optional className for styling * Optional className for styling
*/ */
@@ -50,6 +54,7 @@ export const Topbar: React.FC<TopbarProps> = ({
onUsageClick, onUsageClick,
onMCPClick, onMCPClick,
onInfoClick, onInfoClick,
onAgentsClick,
className, className,
}) => { }) => {
const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null); const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null);
@@ -173,6 +178,18 @@ export const Topbar: React.FC<TopbarProps> = ({
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{onAgentsClick && (
<Button
variant="ghost"
size="sm"
onClick={onAgentsClick}
className="text-xs"
>
<Bot className="mr-2 h-3 w-3" />
Agents
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

187
src/contexts/TabContext.tsx Normal file
View File

@@ -0,0 +1,187 @@
import React, { createContext, useState, useContext, useCallback, useEffect } from 'react';
export interface Tab {
id: string;
type: 'chat' | 'agent' | 'projects' | 'usage' | 'mcp' | 'settings' | 'claude-md' | 'claude-file' | 'agent-execution' | 'create-agent' | 'import-agent';
title: string;
sessionId?: string; // for chat tabs
sessionData?: any; // for chat tabs - stores full session object
agentRunId?: string; // for agent tabs
agentData?: any; // for agent-execution tabs
claudeFileId?: string; // for claude-file tabs
initialProjectPath?: string; // for chat tabs
status: 'active' | 'idle' | 'running' | 'complete' | 'error';
hasUnsavedChanges: boolean;
order: number;
icon?: string;
createdAt: Date;
updatedAt: Date;
}
interface TabContextType {
tabs: Tab[];
activeTabId: string | null;
addTab: (tab: Omit<Tab, 'id' | 'order' | 'createdAt' | 'updatedAt'>) => string;
removeTab: (id: string) => void;
updateTab: (id: string, updates: Partial<Tab>) => void;
setActiveTab: (id: string) => void;
reorderTabs: (startIndex: number, endIndex: number) => void;
getTabById: (id: string) => Tab | undefined;
closeAllTabs: () => void;
getTabsByType: (type: 'chat' | 'agent') => Tab[];
}
const TabContext = createContext<TabContextType | undefined>(undefined);
// const STORAGE_KEY = 'claudia_tabs'; // No longer needed - persistence disabled
const MAX_TABS = 20;
export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
// Always start with a fresh CC Projects tab
useEffect(() => {
// Create default projects tab
const defaultTab: Tab = {
id: generateTabId(),
type: 'projects',
title: 'CC Projects',
status: 'idle',
hasUnsavedChanges: false,
order: 0,
createdAt: new Date(),
updatedAt: new Date()
};
setTabs([defaultTab]);
setActiveTabId(defaultTab.id);
}, []);
// Tab persistence disabled - no longer saving to localStorage
// useEffect(() => {
// if (tabs.length > 0) {
// const tabsToSave = tabs.map(tab => ({
// ...tab,
// createdAt: tab.createdAt.toISOString(),
// updatedAt: tab.updatedAt.toISOString()
// }));
// localStorage.setItem(STORAGE_KEY, JSON.stringify(tabsToSave));
// }
// }, [tabs]);
const generateTabId = () => {
return `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
const addTab = useCallback((tabData: Omit<Tab, 'id' | 'order' | 'createdAt' | 'updatedAt'>): string => {
if (tabs.length >= MAX_TABS) {
throw new Error(`Maximum number of tabs (${MAX_TABS}) reached`);
}
const newTab: Tab = {
...tabData,
id: generateTabId(),
order: tabs.length,
createdAt: new Date(),
updatedAt: new Date()
};
setTabs(prevTabs => [...prevTabs, newTab]);
setActiveTabId(newTab.id);
return newTab.id;
}, [tabs.length]);
const removeTab = useCallback((id: string) => {
setTabs(prevTabs => {
const filteredTabs = prevTabs.filter(tab => tab.id !== id);
// Reorder remaining tabs
const reorderedTabs = filteredTabs.map((tab, index) => ({
...tab,
order: index
}));
// Update active tab if necessary
if (activeTabId === id && reorderedTabs.length > 0) {
const removedTabIndex = prevTabs.findIndex(tab => tab.id === id);
const newActiveIndex = Math.min(removedTabIndex, reorderedTabs.length - 1);
setActiveTabId(reorderedTabs[newActiveIndex].id);
} else if (reorderedTabs.length === 0) {
setActiveTabId(null);
}
return reorderedTabs;
});
}, [activeTabId]);
const updateTab = useCallback((id: string, updates: Partial<Tab>) => {
setTabs(prevTabs =>
prevTabs.map(tab =>
tab.id === id
? { ...tab, ...updates, updatedAt: new Date() }
: tab
)
);
}, []);
const setActiveTab = useCallback((id: string) => {
if (tabs.find(tab => tab.id === id)) {
setActiveTabId(id);
}
}, [tabs]);
const reorderTabs = useCallback((startIndex: number, endIndex: number) => {
setTabs(prevTabs => {
const newTabs = [...prevTabs];
const [removed] = newTabs.splice(startIndex, 1);
newTabs.splice(endIndex, 0, removed);
// Update order property
return newTabs.map((tab, index) => ({
...tab,
order: index
}));
});
}, []);
const getTabById = useCallback((id: string): Tab | undefined => {
return tabs.find(tab => tab.id === id);
}, [tabs]);
const closeAllTabs = useCallback(() => {
setTabs([]);
setActiveTabId(null);
// localStorage.removeItem(STORAGE_KEY); // Persistence disabled
}, []);
const getTabsByType = useCallback((type: 'chat' | 'agent'): Tab[] => {
return tabs.filter(tab => tab.type === type);
}, [tabs]);
const value: TabContextType = {
tabs,
activeTabId,
addTab,
removeTab,
updateTab,
setActiveTab,
reorderTabs,
getTabById,
closeAllTabs,
getTabsByType
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
};
export const useTabContext = () => {
const context = useContext(TabContext);
if (!context) {
throw new Error('useTabContext must be used within a TabProvider');
}
return context;
};

347
src/hooks/useTabState.ts Normal file
View File

@@ -0,0 +1,347 @@
import { useCallback, useMemo } from 'react';
import { useTabContext } from '@/contexts/TabContext';
import { Tab } from '@/contexts/TabContext';
interface UseTabStateReturn {
// State
tabs: Tab[];
activeTab: Tab | undefined;
activeTabId: string | null;
tabCount: number;
chatTabCount: number;
agentTabCount: number;
// Operations
createChatTab: (projectId?: string, title?: string) => string;
createAgentTab: (agentRunId: string, agentName: string) => string;
createAgentExecutionTab: (agent: any, tabId: string) => string;
createProjectsTab: () => string | null;
createUsageTab: () => string | null;
createMCPTab: () => string | null;
createSettingsTab: () => string | null;
createClaudeMdTab: () => string | null;
createClaudeFileTab: (fileId: string, fileName: string) => string;
createCreateAgentTab: () => string;
createImportAgentTab: () => string;
closeTab: (id: string, force?: boolean) => Promise<boolean>;
closeCurrentTab: () => Promise<boolean>;
switchToTab: (id: string) => void;
switchToNextTab: () => void;
switchToPreviousTab: () => void;
switchToTabByIndex: (index: number) => void;
updateTab: (id: string, updates: Partial<Tab>) => void;
updateTabTitle: (id: string, title: string) => void;
updateTabStatus: (id: string, status: Tab['status']) => void;
markTabAsChanged: (id: string, hasChanges: boolean) => void;
findTabBySessionId: (sessionId: string) => Tab | undefined;
findTabByAgentRunId: (agentRunId: string) => Tab | undefined;
findTabByType: (type: Tab['type']) => Tab | undefined;
canAddTab: () => boolean;
}
export const useTabState = (): UseTabStateReturn => {
const {
tabs,
activeTabId,
addTab,
removeTab,
updateTab,
setActiveTab,
getTabById,
getTabsByType
} = useTabContext();
const activeTab = useMemo(() =>
activeTabId ? getTabById(activeTabId) : undefined,
[activeTabId, getTabById]
);
const tabCount = tabs.length;
const chatTabCount = useMemo(() => getTabsByType('chat').length, [getTabsByType]);
const agentTabCount = useMemo(() => getTabsByType('agent').length, [getTabsByType]);
const createChatTab = useCallback((projectId?: string, title?: string): string => {
const tabTitle = title || `Chat ${chatTabCount + 1}`;
return addTab({
type: 'chat',
title: tabTitle,
sessionId: projectId,
status: 'idle',
hasUnsavedChanges: false,
icon: 'message-square'
});
}, [addTab, chatTabCount]);
const createAgentTab = useCallback((agentRunId: string, agentName: string): string => {
// Check if tab already exists
const existingTab = tabs.find(tab => tab.agentRunId === agentRunId);
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'agent',
title: agentName,
agentRunId,
status: 'running',
hasUnsavedChanges: false,
icon: 'bot'
});
}, [addTab, tabs, setActiveTab]);
const createProjectsTab = useCallback((): string | null => {
// Check if projects tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'projects');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'projects',
title: 'CC Projects',
status: 'idle',
hasUnsavedChanges: false,
icon: 'folder'
});
}, [addTab, tabs, setActiveTab]);
const createUsageTab = useCallback((): string | null => {
// Check if usage tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'usage');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'usage',
title: 'Usage',
status: 'idle',
hasUnsavedChanges: false,
icon: 'bar-chart'
});
}, [addTab, tabs, setActiveTab]);
const createMCPTab = useCallback((): string | null => {
// Check if MCP tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'mcp');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'mcp',
title: 'MCP Servers',
status: 'idle',
hasUnsavedChanges: false,
icon: 'server'
});
}, [addTab, tabs, setActiveTab]);
const createSettingsTab = useCallback((): string | null => {
// Check if settings tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'settings');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'settings',
title: 'Settings',
status: 'idle',
hasUnsavedChanges: false,
icon: 'settings'
});
}, [addTab, tabs, setActiveTab]);
const createClaudeMdTab = useCallback((): string | null => {
// Check if claude-md tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'claude-md');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'claude-md',
title: 'CLAUDE.md',
status: 'idle',
hasUnsavedChanges: false,
icon: 'file-text'
});
}, [addTab, tabs, setActiveTab]);
const createClaudeFileTab = useCallback((fileId: string, fileName: string): string => {
// Check if tab already exists for this file
const existingTab = tabs.find(tab => tab.type === 'claude-file' && tab.claudeFileId === fileId);
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'claude-file',
title: fileName,
claudeFileId: fileId,
status: 'idle',
hasUnsavedChanges: false,
icon: 'file-text'
});
}, [addTab, tabs, setActiveTab]);
const createAgentExecutionTab = useCallback((agent: any, _tabId: string): string => {
return addTab({
type: 'agent-execution',
title: `Run: ${agent.name}`,
agentData: agent,
status: 'idle',
hasUnsavedChanges: false,
icon: 'bot'
});
}, [addTab]);
const createCreateAgentTab = useCallback((): string => {
// Check if create agent tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'create-agent');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'create-agent',
title: 'Create Agent',
status: 'idle',
hasUnsavedChanges: false,
icon: 'plus'
});
}, [addTab, tabs, setActiveTab]);
const createImportAgentTab = useCallback((): string => {
// Check if import agent tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'import-agent');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'import-agent',
title: 'Import Agent',
status: 'idle',
hasUnsavedChanges: false,
icon: 'import'
});
}, [addTab, tabs, setActiveTab]);
const closeTab = useCallback(async (id: string, force: boolean = false): Promise<boolean> => {
const tab = getTabById(id);
if (!tab) return true;
// Check for unsaved changes
if (!force && tab.hasUnsavedChanges) {
// In a real implementation, you'd show a confirmation dialog here
const confirmed = window.confirm(`Tab "${tab.title}" has unsaved changes. Close anyway?`);
if (!confirmed) return false;
}
removeTab(id);
return true;
}, [getTabById, removeTab]);
const closeCurrentTab = useCallback(async (): Promise<boolean> => {
if (!activeTabId) return true;
return closeTab(activeTabId);
}, [activeTabId, closeTab]);
const switchToNextTab = useCallback(() => {
if (tabs.length === 0) return;
const currentIndex = tabs.findIndex(tab => tab.id === activeTabId);
const nextIndex = (currentIndex + 1) % tabs.length;
setActiveTab(tabs[nextIndex].id);
}, [tabs, activeTabId, setActiveTab]);
const switchToPreviousTab = useCallback(() => {
if (tabs.length === 0) return;
const currentIndex = tabs.findIndex(tab => tab.id === activeTabId);
const previousIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
setActiveTab(tabs[previousIndex].id);
}, [tabs, activeTabId, setActiveTab]);
const switchToTabByIndex = useCallback((index: number) => {
if (index >= 0 && index < tabs.length) {
setActiveTab(tabs[index].id);
}
}, [tabs, setActiveTab]);
const updateTabTitle = useCallback((id: string, title: string) => {
updateTab(id, { title });
}, [updateTab]);
const updateTabStatus = useCallback((id: string, status: Tab['status']) => {
updateTab(id, { status });
}, [updateTab]);
const markTabAsChanged = useCallback((id: string, hasChanges: boolean) => {
updateTab(id, { hasUnsavedChanges: hasChanges });
}, [updateTab]);
const findTabBySessionId = useCallback((sessionId: string): Tab | undefined => {
return tabs.find(tab => tab.type === 'chat' && tab.sessionId === sessionId);
}, [tabs]);
const findTabByAgentRunId = useCallback((agentRunId: string): Tab | undefined => {
return tabs.find(tab => tab.type === 'agent' && tab.agentRunId === agentRunId);
}, [tabs]);
const findTabByType = useCallback((type: Tab['type']): Tab | undefined => {
return tabs.find(tab => tab.type === type);
}, [tabs]);
const canAddTab = useCallback((): boolean => {
return tabs.length < 20; // MAX_TABS from context
}, [tabs.length]);
return {
// State
tabs,
activeTab,
activeTabId,
tabCount,
chatTabCount,
agentTabCount,
// Operations
createChatTab,
createAgentTab,
createAgentExecutionTab,
createProjectsTab,
createUsageTab,
createMCPTab,
createSettingsTab,
createClaudeMdTab,
createClaudeFileTab,
createCreateAgentTab,
createImportAgentTab,
closeTab,
closeCurrentTab,
switchToTab: setActiveTab,
switchToNextTab,
switchToPreviousTab,
switchToTabByIndex,
updateTab,
updateTabTitle,
updateTabStatus,
markTabAsChanged,
findTabBySessionId,
findTabByAgentRunId,
findTabByType,
canAddTab
};
};