diff --git a/src/App.tsx b/src/App.tsx index 48a4600..083fa31 100644 --- a/src/App.tsx +++ b/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("welcome"); +function AppContent() { + const [view, setView] = useState("tabs"); + const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab } = useTabState(); const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [sessions, setSessions] = useState([]); const [editingClaudeFile, setEditingClaudeFile] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(null); - const [isClaudeStreaming, setIsClaudeStreaming] = useState(false); const [projectForSettings, setProjectForSettings] = useState(null); - const [previousView, setPreviousView] = useState("welcome"); + const [previousView] = useState("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 ( - { - setSelectedSession(null); - handleViewChange("projects"); - }} - onStreamingChange={(isStreaming, sessionId) => { - setIsClaudeStreaming(isStreaming); - setActiveClaudeSessionId(sessionId); - }} - onProjectSettings={handleProjectSettingsFromPath} - /> +
+ +
+ +
+
); case "usage-dashboard": @@ -449,48 +452,66 @@ function App() { }; return ( - -
- {/* Topbar */} - handleViewChange("editor")} - onSettingsClick={() => handleViewChange("settings")} - onUsageClick={() => handleViewChange("usage-dashboard")} - onMCPClick={() => handleViewChange("mcp")} - onInfoClick={() => setShowNFO(true)} - /> - - {/* Main Content */} -
- {renderContent()} -
- - {/* NFO Credits Modal */} - {showNFO && setShowNFO(false)} />} - - {/* Claude Binary Dialog */} - { - 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 */} - - {toast && ( - setToast(null)} - /> - )} - +
+ {/* Topbar */} + createClaudeMdTab()} + onSettingsClick={() => createSettingsTab()} + onUsageClick={() => createUsageTab()} + onMCPClick={() => createMCPTab()} + onInfoClick={() => setShowNFO(true)} + onAgentsClick={() => setShowAgentsModal(true)} + /> + + {/* Main Content */} +
+ {renderContent()}
+ + {/* NFO Credits Modal */} + {showNFO && setShowNFO(false)} />} + + {/* Agents Modal */} + + + {/* Claude Binary Dialog */} + { + 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 */} + + {toast && ( + setToast(null)} + /> + )} + +
+ ); +} + +/** + * Main App component - Wraps the app with providers + */ +function App() { + return ( + + + + ); } diff --git a/src/components/AgentRunOutputViewer.tsx b/src/components/AgentRunOutputViewer.tsx index 928c265..d83fb93 100644 --- a/src/components/AgentRunOutputViewer.tsx +++ b/src/components/AgentRunOutputViewer.tsx @@ -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(null); const [messages, setMessages] = useState([]); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); 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(`agent-output:${run.id}`, (event) => { + const outputUnlisten = await listen(`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(`agent-error:${run.id}`, (event) => { + const errorUnlisten = await listen(`agent-error:${run!.id}`, (event) => { console.error("[AgentRunOutputViewer] Agent error:", event.payload); setToast({ message: event.payload, type: 'error' }); }); - const completeUnlisten = await listen(`agent-complete:${run.id}`, () => { + const completeUnlisten = await listen(`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(`agent-cancelled:${run.id}`, () => { + const cancelUnlisten = await listen(`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,25 +530,23 @@ export function AgentRunOutputViewer({ return tokens.toString(); }; + if (!run) { + return ( +
+
+
+

Loading agent run...

+
+
+ ); + } + return ( <> - - - - - -
+
+ + +
{renderIcon(run.agent_icon)} @@ -610,17 +627,6 @@ export function AgentRunOutputViewer({ onOpenChange={setCopyPopoverOpen} align="end" /> - {onOpenFullView && ( - - )} )} -
-
- - - {loading ? ( +
+
+ + {loading ? (
@@ -701,10 +699,10 @@ export function AgentRunOutputViewer({
- )} - + )} + - +
{/* Fullscreen Modal */} {isFullscreen && ( @@ -826,4 +824,6 @@ export function AgentRunOutputViewer({ ); -} \ No newline at end of file +} + +export default AgentRunOutputViewer; \ No newline at end of file diff --git a/src/components/AgentRunsList.tsx b/src/components/AgentRunsList.tsx index acb7b18..deb403c 100644 --- a/src/components/AgentRunsList.tsx +++ b/src/components/AgentRunsList.tsx @@ -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 = ({ className, }) => { const [currentPage, setCurrentPage] = useState(1); - const [selectedRun, setSelectedRun] = useState(null); + const { createAgentTab } = useTabState(); // Calculate pagination const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE); @@ -81,9 +81,9 @@ export const AgentRunsList: React.FC = ({ // 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 = ({ )}
- {/* Agent Run Output Viewer Modal */} - {selectedRun && ( - setSelectedRun(null)} - /> - )} ); }; \ No newline at end of file diff --git a/src/components/AgentsModal.tsx b/src/components/AgentsModal.tsx new file mode 100644 index 0000000..a93d2df --- /dev/null +++ b/src/components/AgentsModal.tsx @@ -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 = ({ open, onOpenChange }) => { + const [activeTab, setActiveTab] = useState('agents'); + const [agents, setAgents] = useState([]); + const [runningAgents, setRunningAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [agentToDelete, setAgentToDelete] = useState(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 ; + case 'completed': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + return ( + <> + + + + + + Agent Management + + + Create new agents or manage running agent executions + + + + + + Available Agents + + Running Agents + {runningAgents.length > 0 && ( + + {runningAgents.length} + + )} + + + +
+ + + {/* Action buttons at the top */} +
+ + + + + + + + + From File + + + + From GitHub + + + +
+ {loading ? ( +
+ +
+ ) : agents.length === 0 ? ( +
+ +

No agents available

+

+ Create your first agent to get started +

+ +
+ ) : ( +
+ {agents.map((agent) => ( + +
+
+

+ + {agent.name} +

+ {agent.default_task && ( +

+ {agent.default_task} +

+ )} +
+
+ + + +
+
+
+ ))} +
+ )} +
+
+ + + + {runningAgents.length === 0 ? ( +
+ +

No running agents

+

+ Agent executions will appear here when started +

+
+ ) : ( +
+ + {runningAgents.map((run) => ( + handleOpenAgentRun(run)} + > +
+
+

+ {getStatusIcon(run.status)} + {run.agent_name} +

+

+ {run.task} +

+
+ Started: {formatISOTimestamp(run.created_at)} + + {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + +
+
+ +
+
+ ))} +
+
+ )} +
+
+
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Agent + + Are you sure you want to delete "{agentToDelete?.name}"? This action cannot be undone. + + +
+ + +
+
+
+ + {/* GitHub Agent Browser */} + setShowGitHubBrowser(false)} + onImportSuccess={() => { + setShowGitHubBrowser(false); + loadAgents(); // Refresh the agents list + setToast({ message: "Agent imported successfully", type: "success" }); + }} + /> + + {/* Toast notifications */} + {toast && ( + setToast(null)} + /> + )} + + ); +}; + +export default AgentsModal; \ No newline at end of file diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx new file mode 100644 index 0000000..32b2539 --- /dev/null +++ b/src/components/TabContent.tsx @@ -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 = ({ tab, isActive }) => { + const { updateTab, createChatTab } = useTabState(); + const [projects, setProjects] = React.useState([]); + const [selectedProject, setSelectedProject] = React.useState(null); + const [sessions, setSessions] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(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 ( +
+
+ {/* Header */} +
+

CC Projects

+

+ Browse your Claude Code sessions +

+
+ + {/* Error display */} + {error && ( + + {error} + + )} + + {/* Loading state */} + {loading && ( +
+ +
+ )} + + {/* Content */} + {!loading && ( + + {selectedProject ? ( + + { + // 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 } + })); + }} + /> + + ) : ( + + {/* New session button at the top */} + + + + + {/* Running Claude Sessions */} + + + {/* Project list */} + {projects.length > 0 ? ( + { + // Project settings functionality can be added here if needed + console.log('Project settings clicked for:', project); + }} + loading={loading} + className="animate-fade-in" + /> + ) : ( +
+

+ No projects found in ~/.claude/projects +

+
+ )} +
+ )} +
+ )} +
+
+ ); + + case 'chat': + return ( + { + // Go back to projects view in the same tab + updateTab(tab.id, { + type: 'projects', + title: 'CC Projects', + }); + }} + /> + ); + + case 'agent': + if (!tab.agentRunId) { + return
No agent run ID specified
; + } + return ( + + ); + + + case 'usage': + return {}} />; + + case 'mcp': + return {}} />; + + case 'settings': + return {}} />; + + case 'claude-md': + return {}} />; + + case 'claude-file': + if (!tab.claudeFileId) { + return
No Claude file ID specified
; + } + // Note: We need to get the actual file object for ClaudeFileEditor + // For now, returning a placeholder + return
Claude file editor not yet implemented in tabs
; + + case 'agent-execution': + if (!tab.agentData) { + return
No agent data specified
; + } + return ( + {}} + /> + ); + + case 'create-agent': + return ( + { + // 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
Import agent functionality coming soon...
; + + default: + return
Unknown tab type: {tab.type}
; + } + }; + + return ( + + + +
+ } + > + {renderContent()} + + + ); +}; + +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 ( +
+ + {tabs.map((tab) => ( + + ))} + + + {tabs.length === 0 && ( +
+
+

No tabs open

+

Click the + button to start a new chat

+
+
+ )} +
+ ); +}; + +export default TabContent; diff --git a/src/components/TabManager.tsx b/src/components/TabManager.tsx new file mode 100644 index 0000000..cca89d3 --- /dev/null +++ b/src/components/TabManager.tsx @@ -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 = ({ 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 ; + case 'error': + return ; + default: + return null; + } + }; + + const Icon = getIcon(); + const statusIcon = getStatusIcon(); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => onClick(tab.id)} + whileHover={{ y: -1 }} + whileTap={{ scale: 0.98 }} + > + + + + {tab.title} + + + {statusIcon && ( + + {statusIcon} + + )} + + {tab.hasUnsavedChanges && ( + + )} + + + {(isHovered || isActive) && ( + { + e.stopPropagation(); + onClose(tab.id); + }} + className={cn( + "flex-shrink-0 p-0.5 rounded hover:bg-muted-foreground/20", + "transition-colors duration-150" + )} + > + + + )} + + + ); +}; + +interface TabManagerProps { + className?: string; +} + +export const TabManager: React.FC = ({ className }) => { + const { + tabs, + activeTabId, + createChatTab, + createProjectsTab, + closeTab, + switchToTab, + canAddTab + } = useTabState(); + + const scrollContainerRef = useRef(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 ( +
+ {/* Left scroll button */} + + {showLeftScroll && ( + scrollTabs('left')} + className="p-1 hover:bg-muted rounded-sm" + > + + + + + )} + + + {/* Tabs container */} +
+ + + {tabs.map((tab) => ( + + ))} + + +
+ + {/* Right scroll button */} + + {showRightScroll && ( + scrollTabs('right')} + className="p-1 hover:bg-muted rounded-sm" + > + + + + + )} + + + {/* New tab button */} + + + +
+ ); +}; + +export default TabManager; \ No newline at end of file diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 3305547..8917d7d 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -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 = ({ onUsageClick, onMCPClick, onInfoClick, + onAgentsClick, className, }) => { const [versionStatus, setVersionStatus] = useState(null); @@ -173,6 +178,18 @@ export const Topbar: React.FC = ({ {/* Action Buttons */}
+ {onAgentsClick && ( + + )} +