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:
155
src/App.tsx
155
src/App.tsx
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
|
||||
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
|
||||
import { OutputCacheProvider } from "@/lib/outputCache";
|
||||
import { TabProvider } from "@/contexts/TabContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ProjectList } from "@/components/ProjectList";
|
||||
@@ -13,20 +14,22 @@ import { MarkdownEditor } from "@/components/MarkdownEditor";
|
||||
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
|
||||
import { Settings } from "@/components/Settings";
|
||||
import { CCAgents } from "@/components/CCAgents";
|
||||
import { ClaudeCodeSession } from "@/components/ClaudeCodeSession";
|
||||
import { UsageDashboard } from "@/components/UsageDashboard";
|
||||
import { MCPManager } from "@/components/MCPManager";
|
||||
import { NFOCredits } from "@/components/NFOCredits";
|
||||
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
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 =
|
||||
| "welcome"
|
||||
| "projects"
|
||||
| "editor"
|
||||
| "claude-file-editor"
|
||||
| "claude-code-session"
|
||||
| "settings"
|
||||
| "cc-agents"
|
||||
| "create-agent"
|
||||
@@ -35,27 +38,27 @@ type View =
|
||||
| "agent-run-view"
|
||||
| "mcp"
|
||||
| "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() {
|
||||
const [view, setView] = useState<View>("welcome");
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<View>("tabs");
|
||||
const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab } = useTabState();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showNFO, setShowNFO] = useState(false);
|
||||
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
|
||||
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 [previousView, setPreviousView] = useState<View>("welcome");
|
||||
const [previousView] = useState<View>("welcome");
|
||||
const [showAgentsModal, setShowAgentsModal] = useState(false);
|
||||
|
||||
// Load projects on mount when in projects view
|
||||
useEffect(() => {
|
||||
@@ -67,22 +70,56 @@ function App() {
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
// Listen for Claude session selection events
|
||||
// Keyboard shortcuts for tab navigation
|
||||
useEffect(() => {
|
||||
const handleSessionSelected = (event: CustomEvent) => {
|
||||
const { session } = event.detail;
|
||||
setSelectedSession(session);
|
||||
handleViewChange("claude-code-session");
|
||||
if (view !== "tabs") return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
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 = () => {
|
||||
setShowClaudeBinaryDialog(true);
|
||||
};
|
||||
|
||||
window.addEventListener('claude-session-selected', handleSessionSelected as EventListener);
|
||||
window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('claude-session-selected', handleSessionSelected 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
|
||||
*/
|
||||
const handleNewSession = async () => {
|
||||
handleViewChange("claude-code-session");
|
||||
setSelectedSession(null);
|
||||
handleViewChange("tabs");
|
||||
// The tab system will handle creating a new chat tab
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -158,19 +195,7 @@ function App() {
|
||||
* Handles view changes with navigation protection
|
||||
*/
|
||||
const handleViewChange = (newView: View) => {
|
||||
// Check if we're navigating away from an active Claude session
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// No need for navigation protection with tabs since sessions stay open
|
||||
setView(newView);
|
||||
};
|
||||
|
||||
@@ -182,22 +207,6 @@ function App() {
|
||||
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 = () => {
|
||||
switch (view) {
|
||||
@@ -403,20 +412,14 @@ function App() {
|
||||
/>
|
||||
) : null;
|
||||
|
||||
case "claude-code-session":
|
||||
case "tabs":
|
||||
return (
|
||||
<ClaudeCodeSession
|
||||
session={selectedSession || undefined}
|
||||
onBack={() => {
|
||||
setSelectedSession(null);
|
||||
handleViewChange("projects");
|
||||
}}
|
||||
onStreamingChange={(isStreaming, sessionId) => {
|
||||
setIsClaudeStreaming(isStreaming);
|
||||
setActiveClaudeSessionId(sessionId);
|
||||
}}
|
||||
onProjectSettings={handleProjectSettingsFromPath}
|
||||
/>
|
||||
<div className="h-full flex flex-col">
|
||||
<TabManager className="flex-shrink-0" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TabContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "usage-dashboard":
|
||||
@@ -449,25 +452,31 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<OutputCacheProvider>
|
||||
<div className="h-screen bg-background flex flex-col">
|
||||
{/* Topbar */}
|
||||
<Topbar
|
||||
onClaudeClick={() => handleViewChange("editor")}
|
||||
onSettingsClick={() => handleViewChange("settings")}
|
||||
onUsageClick={() => handleViewChange("usage-dashboard")}
|
||||
onMCPClick={() => handleViewChange("mcp")}
|
||||
onClaudeClick={() => createClaudeMdTab()}
|
||||
onSettingsClick={() => createSettingsTab()}
|
||||
onUsageClick={() => createUsageTab()}
|
||||
onMCPClick={() => createMCPTab()}
|
||||
onInfoClick={() => setShowNFO(true)}
|
||||
onAgentsClick={() => setShowAgentsModal(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* NFO Credits Modal */}
|
||||
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
|
||||
|
||||
{/* Agents Modal */}
|
||||
<AgentsModal
|
||||
open={showAgentsModal}
|
||||
onOpenChange={setShowAgentsModal}
|
||||
/>
|
||||
|
||||
{/* Claude Binary Dialog */}
|
||||
<ClaudeBinaryDialog
|
||||
open={showClaudeBinaryDialog}
|
||||
@@ -491,6 +500,18 @@ function App() {
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main App component - Wraps the app with providers
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
<OutputCacheProvider>
|
||||
<TabProvider>
|
||||
<AppContent />
|
||||
</TabProvider>
|
||||
</OutputCacheProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Copy,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
Clock,
|
||||
Hash,
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
StopCircle
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -28,20 +26,17 @@ import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { formatISOTimestamp } from '@/lib/date-utils';
|
||||
import { AGENT_ICONS } from './CCAgents';
|
||||
import type { ClaudeStreamMessage } from './AgentExecution';
|
||||
import { useTabState } from '@/hooks/useTabState';
|
||||
|
||||
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;
|
||||
/**
|
||||
* Optional callback to open full view
|
||||
*/
|
||||
onOpenFullView?: () => void;
|
||||
tabId: string;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
@@ -58,11 +53,12 @@ interface AgentRunOutputViewerProps {
|
||||
* />
|
||||
*/
|
||||
export function AgentRunOutputViewer({
|
||||
run,
|
||||
onClose,
|
||||
onOpenFullView,
|
||||
agentRunId,
|
||||
tabId,
|
||||
className
|
||||
}: AgentRunOutputViewerProps) {
|
||||
const { updateTabTitle, updateTabStatus } = useTabState();
|
||||
const [run, setRun] = useState<AgentRunWithMetrics | null>(null);
|
||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
|
||||
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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -121,7 +139,7 @@ export function AgentRunOutputViewer({
|
||||
}, [messages, hasUserScrolled, isFullscreen]);
|
||||
|
||||
const loadOutput = async (skipCache = false) => {
|
||||
if (!run.id) return;
|
||||
if (!run?.id) return;
|
||||
|
||||
console.log('[AgentRunOutputViewer] Loading output for run:', {
|
||||
runId: run.id,
|
||||
@@ -244,7 +262,7 @@ export function AgentRunOutputViewer({
|
||||
|
||||
// Set up live event listeners for running sessions
|
||||
const setupLiveEventListeners = async () => {
|
||||
if (!run.id || hasSetupListenersRef.current) return;
|
||||
if (!run?.id || hasSetupListenersRef.current) return;
|
||||
|
||||
try {
|
||||
// Clean up existing listeners
|
||||
@@ -261,7 +279,7 @@ export function AgentRunOutputViewer({
|
||||
}, 100);
|
||||
|
||||
// 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 {
|
||||
// Skip messages during initial load phase
|
||||
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);
|
||||
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' });
|
||||
// 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' });
|
||||
});
|
||||
|
||||
@@ -309,6 +327,7 @@ export function AgentRunOutputViewer({
|
||||
};
|
||||
|
||||
const handleCopyAsMarkdown = async () => {
|
||||
if (!run) return;
|
||||
let markdown = `# Agent Execution: ${run.agent_name}\n\n`;
|
||||
markdown += `**Task:** ${run.task}\n`;
|
||||
markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
|
||||
@@ -372,7 +391,7 @@ export function AgentRunOutputViewer({
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!run.id) {
|
||||
if (!run?.id) {
|
||||
console.error('[AgentRunOutputViewer] No run ID available to stop');
|
||||
return;
|
||||
}
|
||||
@@ -404,11 +423,11 @@ export function AgentRunOutputViewer({
|
||||
};
|
||||
setMessages(prev => [...prev, stopMessage]);
|
||||
|
||||
// Update the run status locally
|
||||
// Optionally refresh the parent component
|
||||
setTimeout(() => {
|
||||
window.location.reload(); // Simple refresh to update the status
|
||||
}, 1000);
|
||||
// Update the tab status
|
||||
updateTabStatus(tabId, 'idle');
|
||||
|
||||
// Refresh the output to get updated status
|
||||
await loadOutput(true);
|
||||
} else {
|
||||
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' });
|
||||
@@ -431,10 +450,10 @@ export function AgentRunOutputViewer({
|
||||
|
||||
// Load output on mount
|
||||
useEffect(() => {
|
||||
if (!run.id) return;
|
||||
if (!run?.id) return;
|
||||
|
||||
// Check cache immediately for instant display
|
||||
const cached = getCachedOutput(run.id);
|
||||
const cached = getCachedOutput(run!.id);
|
||||
if (cached) {
|
||||
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
|
||||
setRawJsonlOutput(cachedJsonlLines);
|
||||
@@ -443,7 +462,7 @@ export function AgentRunOutputViewer({
|
||||
|
||||
// Then load fresh data
|
||||
loadOutput();
|
||||
}, [run.id]);
|
||||
}, [run?.id]);
|
||||
|
||||
const displayableMessages = useMemo(() => {
|
||||
return messages.filter((message) => {
|
||||
@@ -511,23 +530,21 @@ export function AgentRunOutputViewer({
|
||||
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 (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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">
|
||||
<div className={`h-full flex flex-col ${className || ''}`}>
|
||||
<Card className="h-full flex flex-col">
|
||||
<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">
|
||||
@@ -610,17 +627,6 @@ export function AgentRunOutputViewer({
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -656,14 +662,6 @@ export function AgentRunOutputViewer({
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -704,7 +702,7 @@ export function AgentRunOutputViewer({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
{isFullscreen && (
|
||||
@@ -827,3 +825,5 @@ export function AgentRunOutputViewer({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentRunOutputViewer;
|
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { formatISOTimestamp } from "@/lib/date-utils";
|
||||
import type { AgentRunWithMetrics } from "@/lib/api";
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
||||
import { AgentRunOutputViewer } from "./AgentRunOutputViewer";
|
||||
import { useTabState } from "@/hooks/useTabState";
|
||||
|
||||
interface AgentRunsListProps {
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedRun, setSelectedRun] = useState<AgentRunWithMetrics | null>(null);
|
||||
const { createAgentTab } = useTabState();
|
||||
|
||||
// Calculate pagination
|
||||
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 (onRunClick) {
|
||||
onRunClick(run);
|
||||
} else {
|
||||
// Otherwise, open in modal preview
|
||||
setSelectedRun(run);
|
||||
} else if (run.id) {
|
||||
// Otherwise, open in new tab
|
||||
createAgentTab(run.id.toString(), run.agent_name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,13 +196,6 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent Run Output Viewer Modal */}
|
||||
{selectedRun && (
|
||||
<AgentRunOutputViewer
|
||||
run={selectedRun}
|
||||
onClose={() => setSelectedRun(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
441
src/components/AgentsModal.tsx
Normal file
441
src/components/AgentsModal.tsx
Normal 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;
|
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;
|
336
src/components/TabManager.tsx
Normal file
336
src/components/TabManager.tsx
Normal 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;
|
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 { Popover } from "@/components/ui/popover";
|
||||
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
||||
@@ -27,6 +27,10 @@ interface TopbarProps {
|
||||
* Callback when Info is clicked
|
||||
*/
|
||||
onInfoClick: () => void;
|
||||
/**
|
||||
* Callback when Agents is clicked
|
||||
*/
|
||||
onAgentsClick?: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
@@ -50,6 +54,7 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
onUsageClick,
|
||||
onMCPClick,
|
||||
onInfoClick,
|
||||
onAgentsClick,
|
||||
className,
|
||||
}) => {
|
||||
const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null);
|
||||
@@ -173,6 +178,18 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
187
src/contexts/TabContext.tsx
Normal file
187
src/contexts/TabContext.tsx
Normal 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
347
src/hooks/useTabState.ts
Normal 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
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user