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:
225
src/App.tsx
225
src/App.tsx
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
|
import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
|
||||||
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
|
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
|
||||||
import { OutputCacheProvider } from "@/lib/outputCache";
|
import { OutputCacheProvider } from "@/lib/outputCache";
|
||||||
|
import { TabProvider } from "@/contexts/TabContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { ProjectList } from "@/components/ProjectList";
|
import { ProjectList } from "@/components/ProjectList";
|
||||||
@@ -13,20 +14,22 @@ import { MarkdownEditor } from "@/components/MarkdownEditor";
|
|||||||
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
|
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
|
||||||
import { Settings } from "@/components/Settings";
|
import { Settings } from "@/components/Settings";
|
||||||
import { CCAgents } from "@/components/CCAgents";
|
import { CCAgents } from "@/components/CCAgents";
|
||||||
import { ClaudeCodeSession } from "@/components/ClaudeCodeSession";
|
|
||||||
import { UsageDashboard } from "@/components/UsageDashboard";
|
import { UsageDashboard } from "@/components/UsageDashboard";
|
||||||
import { MCPManager } from "@/components/MCPManager";
|
import { MCPManager } from "@/components/MCPManager";
|
||||||
import { NFOCredits } from "@/components/NFOCredits";
|
import { NFOCredits } from "@/components/NFOCredits";
|
||||||
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||||
import { ProjectSettings } from '@/components/ProjectSettings';
|
import { ProjectSettings } from '@/components/ProjectSettings';
|
||||||
|
import { TabManager } from "@/components/TabManager";
|
||||||
|
import { TabContent } from "@/components/TabContent";
|
||||||
|
import { AgentsModal } from "@/components/AgentsModal";
|
||||||
|
import { useTabState } from "@/hooks/useTabState";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| "welcome"
|
| "welcome"
|
||||||
| "projects"
|
| "projects"
|
||||||
| "editor"
|
| "editor"
|
||||||
| "claude-file-editor"
|
| "claude-file-editor"
|
||||||
| "claude-code-session"
|
|
||||||
| "settings"
|
| "settings"
|
||||||
| "cc-agents"
|
| "cc-agents"
|
||||||
| "create-agent"
|
| "create-agent"
|
||||||
@@ -35,27 +38,27 @@ type View =
|
|||||||
| "agent-run-view"
|
| "agent-run-view"
|
||||||
| "mcp"
|
| "mcp"
|
||||||
| "usage-dashboard"
|
| "usage-dashboard"
|
||||||
| "project-settings";
|
| "project-settings"
|
||||||
|
| "tabs"; // New view for tab-based interface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main App component - Manages the Claude directory browser UI
|
* AppContent component - Contains the main app logic, wrapped by providers
|
||||||
*/
|
*/
|
||||||
function App() {
|
function AppContent() {
|
||||||
const [view, setView] = useState<View>("welcome");
|
const [view, setView] = useState<View>("tabs");
|
||||||
|
const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab } = useTabState();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
|
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
|
||||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showNFO, setShowNFO] = useState(false);
|
const [showNFO, setShowNFO] = useState(false);
|
||||||
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
|
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
|
||||||
const [activeClaudeSessionId, setActiveClaudeSessionId] = useState<string | null>(null);
|
|
||||||
const [isClaudeStreaming, setIsClaudeStreaming] = useState(false);
|
|
||||||
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
|
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
|
||||||
const [previousView, setPreviousView] = useState<View>("welcome");
|
const [previousView] = useState<View>("welcome");
|
||||||
|
const [showAgentsModal, setShowAgentsModal] = useState(false);
|
||||||
|
|
||||||
// Load projects on mount when in projects view
|
// Load projects on mount when in projects view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,22 +70,56 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [view]);
|
}, [view]);
|
||||||
|
|
||||||
// Listen for Claude session selection events
|
// Keyboard shortcuts for tab navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSessionSelected = (event: CustomEvent) => {
|
if (view !== "tabs") return;
|
||||||
const { session } = event.detail;
|
|
||||||
setSelectedSession(session);
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
handleViewChange("claude-code-session");
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
if (modKey) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 't':
|
||||||
|
e.preventDefault();
|
||||||
|
window.dispatchEvent(new CustomEvent('create-chat-tab'));
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
e.preventDefault();
|
||||||
|
window.dispatchEvent(new CustomEvent('close-current-tab'));
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
window.dispatchEvent(new CustomEvent('switch-to-previous-tab'));
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('switch-to-next-tab'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Handle number keys 1-9
|
||||||
|
if (e.key >= '1' && e.key <= '9') {
|
||||||
|
e.preventDefault();
|
||||||
|
const index = parseInt(e.key) - 1;
|
||||||
|
window.dispatchEvent(new CustomEvent('switch-to-tab-by-index', { detail: { index } }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// Listen for Claude not found events
|
||||||
|
useEffect(() => {
|
||||||
const handleClaudeNotFound = () => {
|
const handleClaudeNotFound = () => {
|
||||||
setShowClaudeBinaryDialog(true);
|
setShowClaudeBinaryDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('claude-session-selected', handleSessionSelected as EventListener);
|
|
||||||
window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener);
|
window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('claude-session-selected', handleSessionSelected as EventListener);
|
|
||||||
window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener);
|
window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -126,8 +163,8 @@ function App() {
|
|||||||
* Opens a new Claude Code session in the interactive UI
|
* Opens a new Claude Code session in the interactive UI
|
||||||
*/
|
*/
|
||||||
const handleNewSession = async () => {
|
const handleNewSession = async () => {
|
||||||
handleViewChange("claude-code-session");
|
handleViewChange("tabs");
|
||||||
setSelectedSession(null);
|
// The tab system will handle creating a new chat tab
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,19 +195,7 @@ function App() {
|
|||||||
* Handles view changes with navigation protection
|
* Handles view changes with navigation protection
|
||||||
*/
|
*/
|
||||||
const handleViewChange = (newView: View) => {
|
const handleViewChange = (newView: View) => {
|
||||||
// Check if we're navigating away from an active Claude session
|
// No need for navigation protection with tabs since sessions stay open
|
||||||
if (view === "claude-code-session" && isClaudeStreaming && activeClaudeSessionId) {
|
|
||||||
const shouldLeave = window.confirm(
|
|
||||||
"Claude is still responding. If you navigate away, Claude will continue running in the background.\n\n" +
|
|
||||||
"You can return to this session from the Projects view.\n\n" +
|
|
||||||
"Do you want to continue?"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!shouldLeave) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setView(newView);
|
setView(newView);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,22 +207,6 @@ function App() {
|
|||||||
handleViewChange("project-settings");
|
handleViewChange("project-settings");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles navigating to hooks configuration from a project path
|
|
||||||
*/
|
|
||||||
const handleProjectSettingsFromPath = (projectPath: string) => {
|
|
||||||
// Create a temporary project object from the path
|
|
||||||
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
||||||
const tempProject: Project = {
|
|
||||||
id: projectId,
|
|
||||||
path: projectPath,
|
|
||||||
sessions: [],
|
|
||||||
created_at: Date.now() / 1000
|
|
||||||
};
|
|
||||||
setProjectForSettings(tempProject);
|
|
||||||
setPreviousView(view);
|
|
||||||
handleViewChange("project-settings");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
@@ -403,20 +412,14 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
case "claude-code-session":
|
case "tabs":
|
||||||
return (
|
return (
|
||||||
<ClaudeCodeSession
|
<div className="h-full flex flex-col">
|
||||||
session={selectedSession || undefined}
|
<TabManager className="flex-shrink-0" />
|
||||||
onBack={() => {
|
<div className="flex-1 overflow-hidden">
|
||||||
setSelectedSession(null);
|
<TabContent />
|
||||||
handleViewChange("projects");
|
</div>
|
||||||
}}
|
</div>
|
||||||
onStreamingChange={(isStreaming, sessionId) => {
|
|
||||||
setIsClaudeStreaming(isStreaming);
|
|
||||||
setActiveClaudeSessionId(sessionId);
|
|
||||||
}}
|
|
||||||
onProjectSettings={handleProjectSettingsFromPath}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case "usage-dashboard":
|
case "usage-dashboard":
|
||||||
@@ -449,48 +452,66 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OutputCacheProvider>
|
<div className="h-screen bg-background flex flex-col">
|
||||||
<div className="h-screen bg-background flex flex-col">
|
{/* Topbar */}
|
||||||
{/* Topbar */}
|
<Topbar
|
||||||
<Topbar
|
onClaudeClick={() => createClaudeMdTab()}
|
||||||
onClaudeClick={() => handleViewChange("editor")}
|
onSettingsClick={() => createSettingsTab()}
|
||||||
onSettingsClick={() => handleViewChange("settings")}
|
onUsageClick={() => createUsageTab()}
|
||||||
onUsageClick={() => handleViewChange("usage-dashboard")}
|
onMCPClick={() => createMCPTab()}
|
||||||
onMCPClick={() => handleViewChange("mcp")}
|
onInfoClick={() => setShowNFO(true)}
|
||||||
onInfoClick={() => setShowNFO(true)}
|
onAgentsClick={() => setShowAgentsModal(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-hidden">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* NFO Credits Modal */}
|
|
||||||
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
|
|
||||||
|
|
||||||
{/* Claude Binary Dialog */}
|
|
||||||
<ClaudeBinaryDialog
|
|
||||||
open={showClaudeBinaryDialog}
|
|
||||||
onOpenChange={setShowClaudeBinaryDialog}
|
|
||||||
onSuccess={() => {
|
|
||||||
setToast({ message: "Claude binary path saved successfully", type: "success" });
|
|
||||||
// Trigger a refresh of the Claude version check
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
onError={(message) => setToast({ message, type: "error" })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Toast Container */}
|
|
||||||
<ToastContainer>
|
|
||||||
{toast && (
|
|
||||||
<Toast
|
|
||||||
message={toast.message}
|
|
||||||
type={toast.type}
|
|
||||||
onDismiss={() => setToast(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ToastContainer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* NFO Credits Modal */}
|
||||||
|
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
|
||||||
|
|
||||||
|
{/* Agents Modal */}
|
||||||
|
<AgentsModal
|
||||||
|
open={showAgentsModal}
|
||||||
|
onOpenChange={setShowAgentsModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Claude Binary Dialog */}
|
||||||
|
<ClaudeBinaryDialog
|
||||||
|
open={showClaudeBinaryDialog}
|
||||||
|
onOpenChange={setShowClaudeBinaryDialog}
|
||||||
|
onSuccess={() => {
|
||||||
|
setToast({ message: "Claude binary path saved successfully", type: "success" });
|
||||||
|
// Trigger a refresh of the Claude version check
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
onError={(message) => setToast({ message, type: "error" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toast Container */}
|
||||||
|
<ToastContainer>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onDismiss={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToastContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App component - Wraps the app with providers
|
||||||
|
*/
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<OutputCacheProvider>
|
||||||
|
<TabProvider>
|
||||||
|
<AppContent />
|
||||||
|
</TabProvider>
|
||||||
</OutputCacheProvider>
|
</OutputCacheProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
X,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Hash,
|
Hash,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
ExternalLink,
|
|
||||||
StopCircle
|
StopCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -28,20 +26,17 @@ import { ErrorBoundary } from './ErrorBoundary';
|
|||||||
import { formatISOTimestamp } from '@/lib/date-utils';
|
import { formatISOTimestamp } from '@/lib/date-utils';
|
||||||
import { AGENT_ICONS } from './CCAgents';
|
import { AGENT_ICONS } from './CCAgents';
|
||||||
import type { ClaudeStreamMessage } from './AgentExecution';
|
import type { ClaudeStreamMessage } from './AgentExecution';
|
||||||
|
import { useTabState } from '@/hooks/useTabState';
|
||||||
|
|
||||||
interface AgentRunOutputViewerProps {
|
interface AgentRunOutputViewerProps {
|
||||||
/**
|
/**
|
||||||
* The agent run to display
|
* The agent run ID to display
|
||||||
*/
|
*/
|
||||||
run: AgentRunWithMetrics;
|
agentRunId: string;
|
||||||
/**
|
/**
|
||||||
* Callback when the viewer is closed
|
* Tab ID for this agent run
|
||||||
*/
|
*/
|
||||||
onClose: () => void;
|
tabId: string;
|
||||||
/**
|
|
||||||
* Optional callback to open full view
|
|
||||||
*/
|
|
||||||
onOpenFullView?: () => void;
|
|
||||||
/**
|
/**
|
||||||
* Optional className for styling
|
* Optional className for styling
|
||||||
*/
|
*/
|
||||||
@@ -58,11 +53,12 @@ interface AgentRunOutputViewerProps {
|
|||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
export function AgentRunOutputViewer({
|
export function AgentRunOutputViewer({
|
||||||
run,
|
agentRunId,
|
||||||
onClose,
|
tabId,
|
||||||
onOpenFullView,
|
|
||||||
className
|
className
|
||||||
}: AgentRunOutputViewerProps) {
|
}: AgentRunOutputViewerProps) {
|
||||||
|
const { updateTabTitle, updateTabStatus } = useTabState();
|
||||||
|
const [run, setRun] = useState<AgentRunWithMetrics | null>(null);
|
||||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||||
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
|
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -103,6 +99,28 @@ export function AgentRunOutputViewer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load agent run on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAgentRun = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const agentRun = await api.getAgentRun(parseInt(agentRunId));
|
||||||
|
setRun(agentRun);
|
||||||
|
updateTabTitle(tabId, `Agent: ${agentRun.agent_name || 'Unknown'}`);
|
||||||
|
updateTabStatus(tabId, agentRun.status === 'running' ? 'running' : agentRun.status === 'failed' ? 'error' : 'complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load agent run:', error);
|
||||||
|
updateTabStatus(tabId, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (agentRunId) {
|
||||||
|
loadAgentRun();
|
||||||
|
}
|
||||||
|
}, [agentRunId, tabId, updateTabTitle, updateTabStatus]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -121,7 +139,7 @@ export function AgentRunOutputViewer({
|
|||||||
}, [messages, hasUserScrolled, isFullscreen]);
|
}, [messages, hasUserScrolled, isFullscreen]);
|
||||||
|
|
||||||
const loadOutput = async (skipCache = false) => {
|
const loadOutput = async (skipCache = false) => {
|
||||||
if (!run.id) return;
|
if (!run?.id) return;
|
||||||
|
|
||||||
console.log('[AgentRunOutputViewer] Loading output for run:', {
|
console.log('[AgentRunOutputViewer] Loading output for run:', {
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
@@ -244,7 +262,7 @@ export function AgentRunOutputViewer({
|
|||||||
|
|
||||||
// Set up live event listeners for running sessions
|
// Set up live event listeners for running sessions
|
||||||
const setupLiveEventListeners = async () => {
|
const setupLiveEventListeners = async () => {
|
||||||
if (!run.id || hasSetupListenersRef.current) return;
|
if (!run?.id || hasSetupListenersRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Clean up existing listeners
|
// Clean up existing listeners
|
||||||
@@ -261,7 +279,7 @@ export function AgentRunOutputViewer({
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Set up live event listeners with run ID isolation
|
// Set up live event listeners with run ID isolation
|
||||||
const outputUnlisten = await listen<string>(`agent-output:${run.id}`, (event) => {
|
const outputUnlisten = await listen<string>(`agent-output:${run!.id}`, (event) => {
|
||||||
try {
|
try {
|
||||||
// Skip messages during initial load phase
|
// Skip messages during initial load phase
|
||||||
if (isInitialLoadRef.current) {
|
if (isInitialLoadRef.current) {
|
||||||
@@ -280,17 +298,17 @@ export function AgentRunOutputViewer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorUnlisten = await listen<string>(`agent-error:${run.id}`, (event) => {
|
const errorUnlisten = await listen<string>(`agent-error:${run!.id}`, (event) => {
|
||||||
console.error("[AgentRunOutputViewer] Agent error:", event.payload);
|
console.error("[AgentRunOutputViewer] Agent error:", event.payload);
|
||||||
setToast({ message: event.payload, type: 'error' });
|
setToast({ message: event.payload, type: 'error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeUnlisten = await listen<boolean>(`agent-complete:${run.id}`, () => {
|
const completeUnlisten = await listen<boolean>(`agent-complete:${run!.id}`, () => {
|
||||||
setToast({ message: 'Agent execution completed', type: 'success' });
|
setToast({ message: 'Agent execution completed', type: 'success' });
|
||||||
// Don't set status here as the parent component should handle it
|
// Don't set status here as the parent component should handle it
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${run.id}`, () => {
|
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${run!.id}`, () => {
|
||||||
setToast({ message: 'Agent execution was cancelled', type: 'error' });
|
setToast({ message: 'Agent execution was cancelled', type: 'error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +327,7 @@ export function AgentRunOutputViewer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyAsMarkdown = async () => {
|
const handleCopyAsMarkdown = async () => {
|
||||||
|
if (!run) return;
|
||||||
let markdown = `# Agent Execution: ${run.agent_name}\n\n`;
|
let markdown = `# Agent Execution: ${run.agent_name}\n\n`;
|
||||||
markdown += `**Task:** ${run.task}\n`;
|
markdown += `**Task:** ${run.task}\n`;
|
||||||
markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
|
markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
|
||||||
@@ -372,7 +391,7 @@ export function AgentRunOutputViewer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
if (!run.id) {
|
if (!run?.id) {
|
||||||
console.error('[AgentRunOutputViewer] No run ID available to stop');
|
console.error('[AgentRunOutputViewer] No run ID available to stop');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -404,11 +423,11 @@ export function AgentRunOutputViewer({
|
|||||||
};
|
};
|
||||||
setMessages(prev => [...prev, stopMessage]);
|
setMessages(prev => [...prev, stopMessage]);
|
||||||
|
|
||||||
// Update the run status locally
|
// Update the tab status
|
||||||
// Optionally refresh the parent component
|
updateTabStatus(tabId, 'idle');
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload(); // Simple refresh to update the status
|
// Refresh the output to get updated status
|
||||||
}, 1000);
|
await loadOutput(true);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`);
|
console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`);
|
||||||
setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' });
|
setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' });
|
||||||
@@ -431,10 +450,10 @@ export function AgentRunOutputViewer({
|
|||||||
|
|
||||||
// Load output on mount
|
// Load output on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!run.id) return;
|
if (!run?.id) return;
|
||||||
|
|
||||||
// Check cache immediately for instant display
|
// Check cache immediately for instant display
|
||||||
const cached = getCachedOutput(run.id);
|
const cached = getCachedOutput(run!.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
|
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
|
||||||
setRawJsonlOutput(cachedJsonlLines);
|
setRawJsonlOutput(cachedJsonlLines);
|
||||||
@@ -443,7 +462,7 @@ export function AgentRunOutputViewer({
|
|||||||
|
|
||||||
// Then load fresh data
|
// Then load fresh data
|
||||||
loadOutput();
|
loadOutput();
|
||||||
}, [run.id]);
|
}, [run?.id]);
|
||||||
|
|
||||||
const displayableMessages = useMemo(() => {
|
const displayableMessages = useMemo(() => {
|
||||||
return messages.filter((message) => {
|
return messages.filter((message) => {
|
||||||
@@ -511,25 +530,23 @@ export function AgentRunOutputViewer({
|
|||||||
return tokens.toString();
|
return tokens.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Loading agent run...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<div className={`h-full flex flex-col ${className || ''}`}>
|
||||||
initial={{ opacity: 0 }}
|
<Card className="h-full flex flex-col">
|
||||||
animate={{ opacity: 1 }}
|
<CardHeader className="pb-3">
|
||||||
exit={{ opacity: 0 }}
|
<div className="flex items-start justify-between gap-4">
|
||||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className={`fixed inset-x-4 top-[10%] bottom-[10%] z-50 max-w-4xl mx-auto ${className}`}
|
|
||||||
>
|
|
||||||
<Card className="h-full flex flex-col shadow-xl">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{renderIcon(run.agent_icon)}
|
{renderIcon(run.agent_icon)}
|
||||||
@@ -610,17 +627,6 @@ export function AgentRunOutputViewer({
|
|||||||
onOpenChange={setCopyPopoverOpen}
|
onOpenChange={setCopyPopoverOpen}
|
||||||
align="end"
|
align="end"
|
||||||
/>
|
/>
|
||||||
{onOpenFullView && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onOpenFullView}
|
|
||||||
title="Open in full view"
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -656,19 +662,11 @@ export function AgentRunOutputViewer({
|
|||||||
<StopCircle className="h-4 w-4" />
|
<StopCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'flex-1'} p-0 overflow-hidden`}>
|
<CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'flex-1'} p-0 overflow-hidden`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
@@ -701,10 +699,10 @@ export function AgentRunOutputViewer({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<div ref={outputEndRef} />
|
<div ref={outputEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Fullscreen Modal */}
|
{/* Fullscreen Modal */}
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
@@ -826,4 +824,6 @@ export function AgentRunOutputViewer({
|
|||||||
</ToastContainer>
|
</ToastContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default AgentRunOutputViewer;
|
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { formatISOTimestamp } from "@/lib/date-utils";
|
import { formatISOTimestamp } from "@/lib/date-utils";
|
||||||
import type { AgentRunWithMetrics } from "@/lib/api";
|
import type { AgentRunWithMetrics } from "@/lib/api";
|
||||||
import { AGENT_ICONS } from "./CCAgents";
|
import { AGENT_ICONS } from "./CCAgents";
|
||||||
import { AgentRunOutputViewer } from "./AgentRunOutputViewer";
|
import { useTabState } from "@/hooks/useTabState";
|
||||||
|
|
||||||
interface AgentRunsListProps {
|
interface AgentRunsListProps {
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +42,7 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
|||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [selectedRun, setSelectedRun] = useState<AgentRunWithMetrics | null>(null);
|
const { createAgentTab } = useTabState();
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE);
|
||||||
@@ -81,9 +81,9 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
|||||||
// If there's a callback, use it (for full-page navigation)
|
// If there's a callback, use it (for full-page navigation)
|
||||||
if (onRunClick) {
|
if (onRunClick) {
|
||||||
onRunClick(run);
|
onRunClick(run);
|
||||||
} else {
|
} else if (run.id) {
|
||||||
// Otherwise, open in modal preview
|
// Otherwise, open in new tab
|
||||||
setSelectedRun(run);
|
createAgentTab(run.id.toString(), run.agent_name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,13 +196,6 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Run Output Viewer Modal */}
|
|
||||||
{selectedRun && (
|
|
||||||
<AgentRunOutputViewer
|
|
||||||
run={selectedRun}
|
|
||||||
onClose={() => setSelectedRun(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
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 React, { useEffect, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info } from "lucide-react";
|
import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover } from "@/components/ui/popover";
|
import { Popover } from "@/components/ui/popover";
|
||||||
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
||||||
@@ -27,6 +27,10 @@ interface TopbarProps {
|
|||||||
* Callback when Info is clicked
|
* Callback when Info is clicked
|
||||||
*/
|
*/
|
||||||
onInfoClick: () => void;
|
onInfoClick: () => void;
|
||||||
|
/**
|
||||||
|
* Callback when Agents is clicked
|
||||||
|
*/
|
||||||
|
onAgentsClick?: () => void;
|
||||||
/**
|
/**
|
||||||
* Optional className for styling
|
* Optional className for styling
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +54,7 @@ export const Topbar: React.FC<TopbarProps> = ({
|
|||||||
onUsageClick,
|
onUsageClick,
|
||||||
onMCPClick,
|
onMCPClick,
|
||||||
onInfoClick,
|
onInfoClick,
|
||||||
|
onAgentsClick,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null);
|
const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null);
|
||||||
@@ -173,6 +178,18 @@ export const Topbar: React.FC<TopbarProps> = ({
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{onAgentsClick && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAgentsClick}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Bot className="mr-2 h-3 w-3" />
|
||||||
|
Agents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
187
src/contexts/TabContext.tsx
Normal file
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