diff --git a/archon-ui-main/.dockerignore b/archon-ui-main/.dockerignore
index bbae0365..9e1e2818 100644
--- a/archon-ui-main/.dockerignore
+++ b/archon-ui-main/.dockerignore
@@ -43,6 +43,16 @@ docker-compose.yml
# Tests
coverage
test-results
+tests/
+**/*.test.ts
+**/*.test.tsx
+**/*.spec.ts
+**/*.spec.tsx
+**/__tests__
+**/*.e2e.test.ts
+**/*.integration.test.ts
+vitest.config.ts
+tsconfig.prod.json
# Documentation
README.md
diff --git a/archon-ui-main/__mocks__/lucide-react.tsx b/archon-ui-main/__mocks__/lucide-react.tsx
deleted file mode 100644
index a3553fe1..00000000
--- a/archon-ui-main/__mocks__/lucide-react.tsx
+++ /dev/null
@@ -1,296 +0,0 @@
-import React from 'react'
-import { vi } from 'vitest'
-
-const createMockIcon = (name: string) => {
- const MockIcon = React.forwardRef(({ className, ...props }: any, ref: any) => (
-
- {name}
-
- ))
- MockIcon.displayName = name
- return MockIcon
-}
-
-// Export all icons used in the app
-export const Settings = createMockIcon('Settings')
-export const Check = createMockIcon('Check')
-export const CheckCircle = createMockIcon('CheckCircle')
-export const X = createMockIcon('X')
-export const XCircle = createMockIcon('XCircle')
-export const Eye = createMockIcon('Eye')
-export const EyeOff = createMockIcon('EyeOff')
-export const Save = createMockIcon('Save')
-export const Loader = createMockIcon('Loader')
-export const Loader2 = createMockIcon('Loader2')
-export const RefreshCw = createMockIcon('RefreshCw')
-export const Play = createMockIcon('Play')
-export const Pause = createMockIcon('Pause')
-export const Square = createMockIcon('Square')
-export const FileText = createMockIcon('FileText')
-export const Download = createMockIcon('Download')
-export const Upload = createMockIcon('Upload')
-export const ChevronDown = createMockIcon('ChevronDown')
-export const ChevronUp = createMockIcon('ChevronUp')
-export const ChevronLeft = createMockIcon('ChevronLeft')
-export const ChevronRight = createMockIcon('ChevronRight')
-export const Plus = createMockIcon('Plus')
-export const Minus = createMockIcon('Minus')
-export const Edit = createMockIcon('Edit')
-export const Edit2 = createMockIcon('Edit2')
-export const Edit3 = createMockIcon('Edit3')
-export const Trash = createMockIcon('Trash')
-export const Trash2 = createMockIcon('Trash2')
-export const User = createMockIcon('User')
-export const Users = createMockIcon('Users')
-export const Bot = createMockIcon('Bot')
-export const Database = createMockIcon('Database')
-export const Server = createMockIcon('Server')
-export const Globe = createMockIcon('Globe')
-export const Search = createMockIcon('Search')
-export const Filter = createMockIcon('Filter')
-export const Copy = createMockIcon('Copy')
-export const ExternalLink = createMockIcon('ExternalLink')
-export const Info = createMockIcon('Info')
-export const AlertCircle = createMockIcon('AlertCircle')
-export const AlertTriangle = createMockIcon('AlertTriangle')
-export const Zap = createMockIcon('Zap')
-export const Code = createMockIcon('Code')
-export const Terminal = createMockIcon('Terminal')
-export const Book = createMockIcon('Book')
-export const BookOpen = createMockIcon('BookOpen')
-export const Folder = createMockIcon('Folder')
-export const FolderOpen = createMockIcon('FolderOpen')
-export const File = createMockIcon('File')
-export const Hash = createMockIcon('Hash')
-export const Tag = createMockIcon('Tag')
-export const Clock = createMockIcon('Clock')
-export const Calendar = createMockIcon('Calendar')
-export const MapPin = createMockIcon('MapPin')
-export const Link = createMockIcon('Link')
-export const Mail = createMockIcon('Mail')
-export const Phone = createMockIcon('Phone')
-export const Home = createMockIcon('Home')
-export const Menu = createMockIcon('Menu')
-export const MoreHorizontal = createMockIcon('MoreHorizontal')
-export const MoreVertical = createMockIcon('MoreVertical')
-export const Refresh = createMockIcon('Refresh')
-export const RotateCcw = createMockIcon('RotateCcw')
-export const RotateCw = createMockIcon('RotateCw')
-export const Sun = createMockIcon('Sun')
-export const Moon = createMockIcon('Moon')
-export const Monitor = createMockIcon('Monitor')
-export const Wifi = createMockIcon('Wifi')
-export const WifiOff = createMockIcon('WifiOff')
-export const Volume2 = createMockIcon('Volume2')
-export const VolumeX = createMockIcon('VolumeX')
-export const BarChart = createMockIcon('BarChart')
-export const PieChart = createMockIcon('PieChart')
-export const TrendingUp = createMockIcon('TrendingUp')
-export const TrendingDown = createMockIcon('TrendingDown')
-export const ArrowUp = createMockIcon('ArrowUp')
-export const ArrowDown = createMockIcon('ArrowDown')
-export const ArrowLeft = createMockIcon('ArrowLeft')
-export const ArrowRight = createMockIcon('ArrowRight')
-export const Send = createMockIcon('Send')
-export const MessageSquare = createMockIcon('MessageSquare')
-export const MessageCircle = createMockIcon('MessageCircle')
-export const Heart = createMockIcon('Heart')
-export const Star = createMockIcon('Star')
-export const Bookmark = createMockIcon('Bookmark')
-export const Share = createMockIcon('Share')
-export const Share2 = createMockIcon('Share2')
-export const Maximize = createMockIcon('Maximize')
-export const Minimize = createMockIcon('Minimize')
-export const Expand = createMockIcon('Expand')
-export const Shrink = createMockIcon('Shrink')
-export const Move = createMockIcon('Move')
-export const Shuffle = createMockIcon('Shuffle')
-export const Repeat = createMockIcon('Repeat')
-export const StopCircle = createMockIcon('StopCircle')
-export const SkipBack = createMockIcon('SkipBack')
-export const SkipForward = createMockIcon('SkipForward')
-export const FastForward = createMockIcon('FastForward')
-export const Rewind = createMockIcon('Rewind')
-export const Camera = createMockIcon('Camera')
-export const Image = createMockIcon('Image')
-export const Video = createMockIcon('Video')
-export const Mic = createMockIcon('Mic')
-export const MicOff = createMockIcon('MicOff')
-export const Headphones = createMockIcon('Headphones')
-export const Speaker = createMockIcon('Speaker')
-export const Bell = createMockIcon('Bell')
-export const BellOff = createMockIcon('BellOff')
-export const Shield = createMockIcon('Shield')
-export const ShieldCheck = createMockIcon('ShieldCheck')
-export const ShieldAlert = createMockIcon('ShieldAlert')
-export const Key = createMockIcon('Key')
-export const Lock = createMockIcon('Lock')
-export const Unlock = createMockIcon('Unlock')
-export const LogIn = createMockIcon('LogIn')
-export const LogOut = createMockIcon('LogOut')
-export const UserPlus = createMockIcon('UserPlus')
-export const UserMinus = createMockIcon('UserMinus')
-export const UserCheck = createMockIcon('UserCheck')
-export const UserX = createMockIcon('UserX')
-export const Package = createMockIcon('Package')
-export const Package2 = createMockIcon('Package2')
-export const ShoppingCart = createMockIcon('ShoppingCart')
-export const ShoppingBag = createMockIcon('ShoppingBag')
-export const CreditCard = createMockIcon('CreditCard')
-export const DollarSign = createMockIcon('DollarSign')
-export const Percent = createMockIcon('Percent')
-export const Activity = createMockIcon('Activity')
-export const Cpu = createMockIcon('Cpu')
-export const HardDrive = createMockIcon('HardDrive')
-export const MemoryStick = createMockIcon('MemoryStick')
-export const Smartphone = createMockIcon('Smartphone')
-export const Tablet = createMockIcon('Tablet')
-export const Laptop = createMockIcon('Laptop')
-export const Monitor2 = createMockIcon('Monitor2')
-export const Tv = createMockIcon('Tv')
-export const Watch = createMockIcon('Watch')
-export const Gamepad2 = createMockIcon('Gamepad2')
-export const Mouse = createMockIcon('Mouse')
-export const Keyboard = createMockIcon('Keyboard')
-export const Printer = createMockIcon('Printer')
-export const Scanner = createMockIcon('Scanner')
-export const Webcam = createMockIcon('Webcam')
-export const Bluetooth = createMockIcon('Bluetooth')
-export const Usb = createMockIcon('Usb')
-export const Zap2 = createMockIcon('Zap2')
-export const Battery = createMockIcon('Battery')
-export const BatteryCharging = createMockIcon('BatteryCharging')
-export const Plug = createMockIcon('Plug')
-export const Power = createMockIcon('Power')
-export const PowerOff = createMockIcon('PowerOff')
-export const BarChart2 = createMockIcon('BarChart2')
-export const BarChart3 = createMockIcon('BarChart3')
-export const BarChart4 = createMockIcon('BarChart4')
-export const LineChart = createMockIcon('LineChart')
-export const PieChart2 = createMockIcon('PieChart2')
-export const Layers = createMockIcon('Layers')
-export const Layers2 = createMockIcon('Layers2')
-export const Layers3 = createMockIcon('Layers3')
-export const Grid = createMockIcon('Grid')
-export const Grid2x2 = createMockIcon('Grid2x2')
-export const Grid3x3 = createMockIcon('Grid3x3')
-export const List = createMockIcon('List')
-export const ListChecks = createMockIcon('ListChecks')
-export const ListTodo = createMockIcon('ListTodo')
-export const CheckSquare = createMockIcon('CheckSquare')
-export const Square2 = createMockIcon('Square2')
-export const Circle = createMockIcon('Circle')
-export const CircleCheck = createMockIcon('CircleCheck')
-export const CircleX = createMockIcon('CircleX')
-export const CircleDot = createMockIcon('CircleDot')
-export const Target = createMockIcon('Target')
-export const Focus = createMockIcon('Focus')
-export const Crosshair = createMockIcon('Crosshair')
-export const Locate = createMockIcon('Locate')
-export const LocateFixed = createMockIcon('LocateFixed')
-export const Navigation = createMockIcon('Navigation')
-export const Navigation2 = createMockIcon('Navigation2')
-export const Compass = createMockIcon('Compass')
-export const Map = createMockIcon('Map')
-export const TestTube = createMockIcon('TestTube')
-export const FlaskConical = createMockIcon('FlaskConical')
-export const Bug = createMockIcon('Bug')
-export const GitBranch = createMockIcon('GitBranch')
-export const GitCommit = createMockIcon('GitCommit')
-export const GitMerge = createMockIcon('GitMerge')
-export const GitPullRequest = createMockIcon('GitPullRequest')
-export const Github = createMockIcon('Github')
-export const Gitlab = createMockIcon('Gitlab')
-export const Bitbucket = createMockIcon('Bitbucket')
-export const Network = createMockIcon('Network')
-export const GitGraph = createMockIcon('GitGraph')
-export const ListFilter = createMockIcon('ListFilter')
-export const CheckSquare2 = createMockIcon('CheckSquare2')
-export const CircleSlash2 = createMockIcon('CircleSlash2')
-export const Clock3 = createMockIcon('Clock3')
-export const GitCommitHorizontal = createMockIcon('GitCommitHorizontal')
-export const CalendarDays = createMockIcon('CalendarDays')
-export const Sparkles = createMockIcon('Sparkles')
-export const Layout = createMockIcon('Layout')
-export const Table = createMockIcon('Table')
-export const Columns = createMockIcon('Columns')
-export const GitPullRequestDraft = createMockIcon('GitPullRequestDraft')
-export const BrainCircuit = createMockIcon('BrainCircuit')
-export const Wrench = createMockIcon('Wrench')
-export const PlugZap = createMockIcon('PlugZap')
-export const BoxIcon = createMockIcon('BoxIcon')
-export const Box = createMockIcon('Box')
-export const Brain = createMockIcon('Brain')
-export const LinkIcon = createMockIcon('LinkIcon')
-export const Sparkle = createMockIcon('Sparkle')
-export const FolderTree = createMockIcon('FolderTree')
-export const Lightbulb = createMockIcon('Lightbulb')
-export const Rocket = createMockIcon('Rocket')
-export const Building = createMockIcon('Building')
-export const FileCode = createMockIcon('FileCode')
-export const FileJson = createMockIcon('FileJson')
-export const Braces = createMockIcon('Braces')
-export const Binary = createMockIcon('Binary')
-export const Palette = createMockIcon('Palette')
-export const Paintbrush = createMockIcon('Paintbrush')
-export const Type = createMockIcon('Type')
-export const Heading = createMockIcon('Heading')
-export const AlignLeft = createMockIcon('AlignLeft')
-export const AlignCenter = createMockIcon('AlignCenter')
-export const AlignRight = createMockIcon('AlignRight')
-export const Bold = createMockIcon('Bold')
-export const Italic = createMockIcon('Italic')
-export const Underline = createMockIcon('Underline')
-export const Strikethrough = createMockIcon('Strikethrough')
-export const FileCheck = createMockIcon('FileCheck')
-export const FileX = createMockIcon('FileX')
-export const FilePlus = createMockIcon('FilePlus')
-export const FileMinus = createMockIcon('FileMinus')
-export const FolderPlus = createMockIcon('FolderPlus')
-export const FolderMinus = createMockIcon('FolderMinus')
-export const FolderCheck = createMockIcon('FolderCheck')
-export const FolderX = createMockIcon('FolderX')
-export const startMCPServer = createMockIcon('startMCPServer')
-export const Pin = createMockIcon('Pin')
-export const CheckCircle2 = createMockIcon('CheckCircle2')
-export const Clipboard = createMockIcon('Clipboard')
-export const LayoutGrid = createMockIcon('LayoutGrid')
-export const Pencil = createMockIcon('Pencil')
-export const MousePointer = createMockIcon('MousePointer')
-export const GripVertical = createMockIcon('GripVertical')
-export const History = createMockIcon('History')
-export const PlusCircle = createMockIcon('PlusCircle')
-export const MinusCircle = createMockIcon('MinusCircle')
-export const ChevronDownIcon = createMockIcon('ChevronDownIcon')
-export const FileIcon = createMockIcon('FileIcon')
-export const AlertCircleIcon = createMockIcon('AlertCircleIcon')
-export const Clock4 = createMockIcon('Clock4')
-export const XIcon = createMockIcon('XIcon')
-export const CheckIcon = createMockIcon('CheckIcon')
-export const TrashIcon = createMockIcon('TrashIcon')
-export const EyeIcon = createMockIcon('EyeIcon')
-export const EditIcon = createMockIcon('EditIcon')
-export const DownloadIcon = createMockIcon('DownloadIcon')
-export const RefreshIcon = createMockIcon('RefreshIcon')
-export const SearchIcon = createMockIcon('SearchIcon')
-export const FilterIcon = createMockIcon('FilterIcon')
-export const PlusIcon = createMockIcon('PlusIcon')
-export const FolderIcon = createMockIcon('FolderIcon')
-export const FileTextIcon = createMockIcon('FileTextIcon')
-export const BookOpenIcon = createMockIcon('BookOpenIcon')
-export const DatabaseIcon = createMockIcon('DatabaseIcon')
-export const GlobeIcon = createMockIcon('GlobeIcon')
-export const TagIcon = createMockIcon('TagIcon')
-export const CalendarIcon = createMockIcon('CalendarIcon')
-export const ClockIcon = createMockIcon('ClockIcon')
-export const UserIcon = createMockIcon('UserIcon')
-export const SettingsIcon = createMockIcon('SettingsIcon')
-export const InfoIcon = createMockIcon('InfoIcon')
-export const WarningIcon = createMockIcon('WarningIcon')
-export const ErrorIcon = createMockIcon('ErrorIcon')
\ No newline at end of file
diff --git a/archon-ui-main/biome.json b/archon-ui-main/biome.json
index 90e476bf..2461476d 100644
--- a/archon-ui-main/biome.json
+++ b/archon-ui-main/biome.json
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"files": {
- "includes": ["src/features/**"]
+ "includes": ["src/features/**", "src/components/layout/**"]
},
"formatter": {
"enabled": true,
diff --git a/archon-ui-main/src/App.tsx b/archon-ui-main/src/App.tsx
index 0eb27606..2a0cdc22 100644
--- a/archon-ui-main/src/App.tsx
+++ b/archon-ui-main/src/App.tsx
@@ -6,7 +6,7 @@ import { KnowledgeBasePage } from './pages/KnowledgeBasePage';
import { SettingsPage } from './pages/SettingsPage';
import { MCPPage } from './pages/MCPPage';
import { OnboardingPage } from './pages/OnboardingPage';
-import { MainLayout } from './components/layouts/MainLayout';
+import { MainLayout } from './components/layout/MainLayout';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ToastProvider as FeaturesToastProvider } from './features/ui/components/ToastProvider';
diff --git a/archon-ui-main/src/components/layouts/ArchonChatPanel.tsx b/archon-ui-main/src/components/agent-chat/ArchonChatPanel.tsx
similarity index 100%
rename from archon-ui-main/src/components/layouts/ArchonChatPanel.tsx
rename to archon-ui-main/src/components/agent-chat/ArchonChatPanel.tsx
diff --git a/archon-ui-main/src/components/layout/MainLayout.tsx b/archon-ui-main/src/components/layout/MainLayout.tsx
new file mode 100644
index 00000000..da0b2696
--- /dev/null
+++ b/archon-ui-main/src/components/layout/MainLayout.tsx
@@ -0,0 +1,193 @@
+import { AlertCircle, WifiOff } from "lucide-react";
+import type React from "react";
+import { useEffect } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useToast } from "../../features/ui/hooks/useToast";
+import { cn } from "../../lib/utils";
+import { credentialsService } from "../../services/credentialsService";
+import { isLmConfigured } from "../../utils/onboarding";
+
+// TEMPORARY: Import from old components until they're migrated to features
+import { BackendStartupError } from "../BackendStartupError";
+import { useBackendHealth } from "./hooks/useBackendHealth";
+import { Navigation } from "./Navigation";
+
+interface MainLayoutProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+interface BackendStatusProps {
+ isHealthLoading: boolean;
+ isBackendError: boolean;
+ healthData: { ready: boolean } | undefined;
+}
+
+/**
+ * Backend health indicator component
+ */
+function BackendStatus({ isHealthLoading, isBackendError, healthData }: BackendStatusProps) {
+ if (isHealthLoading) {
+ return (
+
+ );
+ }
+
+ if (isBackendError) {
+ return (
+
+
+ Backend Offline
+
+ );
+ }
+
+ if (healthData?.ready === false) {
+ return (
+
+
+
Backend Starting...
+
+ );
+ }
+
+ return null;
+}
+
+/**
+ * Modern main layout using TanStack Query and Radix UI patterns
+ * Uses CSS Grid for layout instead of fixed positioning
+ */
+export function MainLayout({ children, className }: MainLayoutProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { showToast } = useToast();
+
+ // Backend health monitoring with TanStack Query
+ const {
+ data: healthData,
+ isError: isBackendError,
+ error: backendError,
+ isLoading: isHealthLoading,
+ failureCount,
+ } = useBackendHealth();
+
+ // Track if backend has completely failed (for showing BackendStartupError)
+ const backendStartupFailed = isBackendError && failureCount >= 5;
+
+ // TEMPORARY: Handle onboarding redirect using old logic until migrated
+ useEffect(() => {
+ const checkOnboarding = async () => {
+ // Skip if backend failed to start
+ if (backendStartupFailed) {
+ return;
+ }
+
+ // Skip if not ready, already on onboarding, or already dismissed
+ if (!healthData?.ready || location.pathname === "/onboarding") {
+ return;
+ }
+
+ // Check if onboarding was already dismissed
+ if (localStorage.getItem("onboardingDismissed") === "true") {
+ return;
+ }
+
+ try {
+ // Fetch credentials in parallel (using old service temporarily)
+ const [ragCreds, apiKeyCreds] = await Promise.all([
+ credentialsService.getCredentialsByCategory("rag_strategy"),
+ credentialsService.getCredentialsByCategory("api_keys"),
+ ]);
+
+ // Check if LM is configured (using old utility temporarily)
+ const configured = isLmConfigured(ragCreds, apiKeyCreds);
+
+ if (!configured) {
+ // Redirect to onboarding
+ navigate("/onboarding", { replace: true });
+ }
+ } catch (error) {
+ // Log error but don't block app
+ console.error("ONBOARDING_CHECK_FAILED:", error);
+ showToast(`Configuration check failed. You can manually configure in Settings.`, "warning");
+ }
+ };
+
+ checkOnboarding();
+ }, [healthData?.ready, backendStartupFailed, location.pathname, navigate, showToast]);
+
+ // Show backend error toast (once)
+ useEffect(() => {
+ if (isBackendError && backendError) {
+ const errorMessage = backendError instanceof Error ? backendError.message : "Backend connection failed";
+ showToast(`Backend unavailable: ${errorMessage}. Some features may not work.`, "error");
+ }
+ }, [isBackendError, backendError, showToast]);
+
+ return (
+
+ {/* TEMPORARY: Show backend startup error using old component */}
+ {backendStartupFailed &&
}
+
+ {/* Fixed full-page background grid that doesn't scroll */}
+
+
+ {/* Floating Navigation */}
+
+
+
+
+
+ {/* Main Content Area - matches old layout exactly */}
+
+
+ {/* TEMPORARY: Floating Chat Button (disabled) - from old layout */}
+
+
+
+
+ {/* Tooltip */}
+
+
Coming Soon
+
Knowledge Assistant is under development
+
+
+
+
+ );
+}
+
+/**
+ * Layout variant without navigation for special pages
+ */
+export function MinimalLayout({ children, className }: MainLayoutProps) {
+ return (
+
+ {/* Background Grid Effect */}
+
+
+ {/* Centered Content */}
+
{children}
+
+ );
+}
diff --git a/archon-ui-main/src/components/layout/Navigation.tsx b/archon-ui-main/src/components/layout/Navigation.tsx
new file mode 100644
index 00000000..e2f1e806
--- /dev/null
+++ b/archon-ui-main/src/components/layout/Navigation.tsx
@@ -0,0 +1,178 @@
+import { BookOpen, Settings } from "lucide-react";
+import type React from "react";
+import { Link, useLocation } from "react-router-dom";
+// TEMPORARY: Use old SettingsContext until settings are migrated
+import { useSettings } from "../../contexts/SettingsContext";
+import { glassmorphism } from "../../features/ui/primitives/styles";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../../features/ui/primitives/tooltip";
+import { cn } from "../../lib/utils";
+
+interface NavigationItem {
+ path: string;
+ icon: React.ReactNode;
+ label: string;
+ enabled?: boolean;
+}
+
+interface NavigationProps {
+ className?: string;
+}
+
+/**
+ * Modern navigation component using Radix UI patterns
+ * No fixed positioning - parent controls layout
+ */
+export function Navigation({ className }: NavigationProps) {
+ const location = useLocation();
+ const { projectsEnabled } = useSettings();
+
+ // Navigation items configuration
+ const navigationItems: NavigationItem[] = [
+ {
+ path: "/",
+ icon: ,
+ label: "Knowledge Base",
+ enabled: true,
+ },
+ {
+ path: "/mcp",
+ icon: (
+
+
+
+
+ ),
+ label: "MCP Server",
+ enabled: true,
+ },
+ {
+ path: "/settings",
+ icon: ,
+ label: "Settings",
+ enabled: true,
+ },
+ ];
+
+ const isProjectsActive = location.pathname.startsWith("/projects");
+
+ return (
+
+ {/* Logo - Always visible, conditionally clickable for Projects */}
+
+
+ {projectsEnabled ? (
+
+
+ {/* Active state decorations */}
+ {isProjectsActive && (
+ <>
+
+
+ >
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ {projectsEnabled ? "Project Management" : "Projects Disabled"}
+
+
+
+ {/* Separator */}
+
+
+ {/* Navigation Items */}
+
+ {navigationItems.map((item) => {
+ const isActive = location.pathname === item.path;
+ const isEnabled = item.enabled !== false;
+
+ return (
+
+
+ {
+ if (!isEnabled) {
+ e.preventDefault();
+ }
+ }}
+ >
+ {item.icon}
+ {/* Active state decorations with neon line */}
+ {isActive && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts b/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts
new file mode 100644
index 00000000..91bb7fd3
--- /dev/null
+++ b/archon-ui-main/src/components/layout/hooks/useBackendHealth.ts
@@ -0,0 +1,47 @@
+import { useQuery } from "@tanstack/react-query";
+import { callAPIWithETag } from "../../../features/projects/shared/apiWithEtag";
+import type { HealthResponse } from "../types";
+
+/**
+ * Hook to monitor backend health status using TanStack Query
+ * Uses ETag caching for bandwidth reduction (~70% savings per project docs)
+ */
+export function useBackendHealth() {
+ return useQuery({
+ queryKey: ["backend", "health"],
+ queryFn: ({ signal }) => {
+ // Use existing ETag infrastructure with timeout
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+
+ // Chain signals: React Query's signal + our timeout
+ if (signal) {
+ signal.addEventListener('abort', () => controller.abort());
+ }
+
+ return callAPIWithETag("/api/health", {
+ signal: controller.signal,
+ }).finally(() => {
+ clearTimeout(timeoutId);
+ });
+ },
+ // Retry configuration for startup scenarios
+ retry: (failureCount) => {
+ // Keep retrying during startup, up to 5 times
+ if (failureCount < 5) {
+ return true;
+ }
+ return false;
+ },
+ retryDelay: (attemptIndex) => {
+ // Exponential backoff: 1.5s, 2.25s, 3.375s, etc.
+ return Math.min(1500 * 1.5 ** attemptIndex, 10000);
+ },
+ // Refetch every 30 seconds when healthy
+ refetchInterval: 30000,
+ // Keep trying to connect on window focus
+ refetchOnWindowFocus: true,
+ // Consider data fresh for 20 seconds
+ staleTime: 20000,
+ });
+}
diff --git a/archon-ui-main/src/components/layout/index.ts b/archon-ui-main/src/components/layout/index.ts
new file mode 100644
index 00000000..10736d9b
--- /dev/null
+++ b/archon-ui-main/src/components/layout/index.ts
@@ -0,0 +1,3 @@
+export { useBackendHealth } from "./hooks/useBackendHealth";
+export { MainLayout, MinimalLayout } from "./MainLayout";
+export { Navigation } from "./Navigation";
diff --git a/archon-ui-main/src/components/layout/types.ts b/archon-ui-main/src/components/layout/types.ts
new file mode 100644
index 00000000..76ecf973
--- /dev/null
+++ b/archon-ui-main/src/components/layout/types.ts
@@ -0,0 +1,28 @@
+import type React from "react";
+
+export interface NavigationItem {
+ path: string;
+ icon: React.ReactNode;
+ label: string;
+ enabled?: boolean;
+}
+
+export interface HealthResponse {
+ ready: boolean;
+ message?: string;
+ server_status?: string;
+ credentials_status?: string;
+ database_status?: string;
+ uptime?: number;
+}
+
+export interface AppSettings {
+ projectsEnabled: boolean;
+ theme?: "light" | "dark" | "system";
+ // Add other settings as needed
+}
+
+export interface OnboardingCheckResult {
+ shouldShowOnboarding: boolean;
+ reason: "dismissed" | "missing_rag" | "missing_api_key" | null;
+}
diff --git a/archon-ui-main/src/components/layouts/MainLayout.tsx b/archon-ui-main/src/components/layouts/MainLayout.tsx
deleted file mode 100644
index acc9188a..00000000
--- a/archon-ui-main/src/components/layouts/MainLayout.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useNavigate, useLocation } from 'react-router-dom';
-import { SideNavigation } from './SideNavigation';
-import { ArchonChatPanel } from './ArchonChatPanel';
-import { X } from 'lucide-react';
-import { useToast } from '../../contexts/ToastContext';
-import { credentialsService } from '../../services/credentialsService';
-import { isLmConfigured } from '../../utils/onboarding';
-import { BackendStartupError } from '../BackendStartupError';
-/**
- * Props for the MainLayout component
- */
-interface MainLayoutProps {
- children: React.ReactNode;
-}
-/**
- * MainLayout - The main layout component for the application
- *
- * This component provides the overall layout structure including:
- * - Side navigation
- * - Main content area
- * - Knowledge chat panel (slidable)
- */
-export const MainLayout: React.FC = ({
- children
-}) => {
- // State to track if chat panel is open
- const [isChatOpen, setIsChatOpen] = useState(false);
- const { showToast } = useToast();
- const navigate = useNavigate();
- const location = useLocation();
- const [backendReady, setBackendReady] = useState(false);
- const [backendStartupFailed, setBackendStartupFailed] = useState(false);
-
- // Check backend readiness
- useEffect(() => {
-
- const checkBackendHealth = async (retryCount = 0) => {
- const maxRetries = 3; // 3 retries total
- const retryDelay = 1500; // 1.5 seconds between retries
-
- try {
- // Create AbortController for proper timeout handling
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 5000);
-
- // Check if backend is responding with a simple health check
- const response = await fetch(`${credentialsService['baseUrl']}/api/health`, {
- method: 'GET',
- signal: controller.signal
- });
-
- clearTimeout(timeoutId);
-
- if (response.ok) {
- const healthData = await response.json();
- console.log('📋 Backend health check:', healthData);
-
- // Check if backend is truly ready (not just started)
- if (healthData.ready === true) {
- console.log('✅ Backend is fully initialized');
- setBackendReady(true);
- setBackendStartupFailed(false);
- } else {
- // Backend is starting up but not ready yet
- console.log(`🔄 Backend initializing... (attempt ${retryCount + 1}/${maxRetries}):`, healthData.message || 'Loading credentials...');
-
- // Retry with shorter interval during initialization
- if (retryCount < maxRetries) {
- setTimeout(() => {
- checkBackendHealth(retryCount + 1);
- }, retryDelay); // Constant 1.5s retry during initialization
- } else {
- console.warn('Backend initialization taking too long - proceeding anyway');
- // Don't mark as failed yet, just not fully ready
- setBackendReady(false);
- }
- }
- } else {
- throw new Error(`Backend health check failed: ${response.status}`);
- }
- } catch (error) {
- // Handle AbortError separately for timeout
- const errorMessage = error instanceof Error
- ? (error.name === 'AbortError' ? 'Request timeout (5s)' : error.message)
- : 'Unknown error';
- // Only log after first attempt to reduce noise during normal startup
- if (retryCount > 0) {
- console.log(`Backend not ready yet (attempt ${retryCount + 1}/${maxRetries}):`, errorMessage);
- }
-
- // Retry if we haven't exceeded max retries
- if (retryCount < maxRetries) {
- setTimeout(() => {
- checkBackendHealth(retryCount + 1);
- }, retryDelay * Math.pow(1.5, retryCount)); // Exponential backoff for connection errors
- } else {
- console.error('Backend startup failed after maximum retries - showing error message');
- setBackendReady(false);
- setBackendStartupFailed(true);
- }
- }
- };
-
-
- // Start the health check process
- setTimeout(() => {
- checkBackendHealth();
- }, 1000); // Wait 1 second for initial app startup
- }, []); // Empty deps - only run once on mount
-
- // Check for onboarding redirect after backend is ready
- useEffect(() => {
- const checkOnboarding = async () => {
- // Skip if backend failed to start
- if (backendStartupFailed) {
- return;
- }
-
- // Skip if not ready, already on onboarding, or already dismissed
- if (!backendReady || location.pathname === '/onboarding') {
- return;
- }
-
- // Check if onboarding was already dismissed
- if (localStorage.getItem('onboardingDismissed') === 'true') {
- return;
- }
-
- try {
- // Fetch credentials in parallel
- const [ragCreds, apiKeyCreds] = await Promise.all([
- credentialsService.getCredentialsByCategory('rag_strategy'),
- credentialsService.getCredentialsByCategory('api_keys')
- ]);
-
- // Check if LM is configured
- const configured = isLmConfigured(ragCreds, apiKeyCreds);
-
- if (!configured) {
- // Redirect to onboarding
- navigate('/onboarding', { replace: true });
- }
- } catch (error) {
- // Detailed error handling per alpha principles - fail loud but don't block
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
- const errorDetails = {
- context: 'Onboarding configuration check',
- pathname: location.pathname,
- error: errorMessage,
- timestamp: new Date().toISOString()
- };
-
- // Log with full context and stack trace
- console.error('ONBOARDING_CHECK_FAILED:', errorDetails, error);
-
- // Make error visible to user but don't block app functionality
- showToast(
- `Configuration check failed: ${errorMessage}. You can manually configure in Settings.`,
- 'warning'
- );
-
- // Let user continue - onboarding is optional, they can configure manually
- }
- };
-
- checkOnboarding();
- }, [backendReady, backendStartupFailed, location.pathname, navigate, showToast]);
-
- return
- {/* Show backend startup error if backend failed to start */}
- {backendStartupFailed &&
}
-
- {/* Fixed full-page background grid that doesn't scroll */}
-
- {/* Floating Navigation */}
-
-
-
- {/* Main Content Area - no left margin to allow grid to extend full width */}
-
- {/* Floating Chat Button - Only visible when chat is closed */}
- {!isChatOpen && (
-
-
-
-
- {/* Tooltip */}
-
-
Coming Soon
-
Knowledge Assistant is under development
-
-
-
- )}
- {/* Chat Sidebar - Slides in/out from right */}
-
- {/* Close button - Only visible when chat is open */}
- {isChatOpen &&
setIsChatOpen(false)} className="absolute -left-14 bottom-6 z-50 w-12 h-12 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-white/10 to-black/30 dark:from-white/10 dark:to-black/30 from-pink-100/80 to-pink-50/60 border border-pink-200 dark:border-pink-500/30 shadow-[0_0_15px_rgba(236,72,153,0.2)] dark:shadow-[0_0_15px_rgba(236,72,153,0.5)] hover:shadow-[0_0_20px_rgba(236,72,153,0.4)] dark:hover:shadow-[0_0_20px_rgba(236,72,153,0.7)] transition-all duration-300" aria-label="Close Knowledge Assistant">
-
- }
- {/* Knowledge Chat Panel */}
-
-
-
;
-};
diff --git a/archon-ui-main/src/components/layouts/SideNavigation.tsx b/archon-ui-main/src/components/layouts/SideNavigation.tsx
deleted file mode 100644
index f4165032..00000000
--- a/archon-ui-main/src/components/layouts/SideNavigation.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import React, { useState } from 'react';
-import { Link, useLocation } from 'react-router-dom';
-import { BookOpen, HardDrive, Settings } from 'lucide-react';
-import { useSettings } from '../../contexts/SettingsContext';
-/**
- * Interface for navigation items
- */
-export interface NavigationItem {
- path: string;
- icon: React.ReactNode;
- label: string;
-}
-/**
- * Props for the SideNavigation component
- */
-interface SideNavigationProps {
- className?: string;
- 'data-id'?: string;
-}
-/**
- * Tooltip component for navigation items
- */
-const NavTooltip: React.FC<{
- show: boolean;
- label: string;
- position?: 'left' | 'right';
-}> = ({
- show,
- label,
- position = 'right'
-}) => {
- if (!show) return null;
- return ;
-};
-/**
- * SideNavigation - A vertical navigation component
- *
- * This component renders a navigation sidebar with icons and the application logo.
- * It highlights the active route and provides hover effects.
- */
-export const SideNavigation: React.FC = ({
- className = '',
- 'data-id': dataId
-}) => {
- // State to track which tooltip is currently visible
- const [activeTooltip, setActiveTooltip] = useState(null);
- const { projectsEnabled } = useSettings();
-
- // Default navigation items
- const navigationItems: NavigationItem[] = [{
- path: '/',
- icon: ,
- label: 'Knowledge Base'
- }, {
- path: '/mcp',
- icon: ,
- label: 'MCP Server'
- }, {
- path: '/settings',
- icon: ,
- label: 'Settings'
- }];
- // Logo configuration
- const logoSrc = "/logo-neon.png";
- const logoAlt = 'Knowledge Base Logo';
- // Get current location to determine active route
- const location = useLocation();
- const isProjectsActive = location.pathname === '/projects' && projectsEnabled;
-
- const logoClassName = `
- logo-container p-2 relative rounded-lg transition-all duration-300
- ${isProjectsActive ? 'bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20 shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)] transform scale-110' : ''}
- ${projectsEnabled ? 'hover:bg-white/10 dark:hover:bg-white/5 cursor-pointer' : 'opacity-50 cursor-not-allowed'}
- `;
-
- return
- {/* Logo - Conditionally clickable based on Projects enabled */}
- {projectsEnabled ? (
-
setActiveTooltip('logo')}
- onMouseLeave={() => setActiveTooltip(null)}
- >
-
- {/* Active state decorations */}
- {isProjectsActive && <>
-
-
- >}
-
-
- ) : (
-
setActiveTooltip('logo')}
- onMouseLeave={() => setActiveTooltip(null)}
- >
-
-
-
- )}
- {/* Navigation links */}
-
- {navigationItems.map(item => {
- const isActive = location.pathname === item.path;
- return setActiveTooltip(item.path)} onMouseLeave={() => setActiveTooltip(null)} aria-label={item.label}>
- {/* Active state decorations - Modified to place neon line below button with adjusted width */}
- {isActive && <>
-
- {/* Neon line positioned below the button with reduced width to respect curved edges */}
-
- >}
- {item.icon}
- {/* Custom tooltip */}
-
- ;
- })}
-
-
;
-};
\ No newline at end of file
diff --git a/archon-ui-main/src/components/mcp/ClientCard.tsx b/archon-ui-main/src/components/mcp/ClientCard.tsx
deleted file mode 100644
index 6ddbd059..00000000
--- a/archon-ui-main/src/components/mcp/ClientCard.tsx
+++ /dev/null
@@ -1,508 +0,0 @@
-import React, { useEffect, useState, useRef } from 'react';
-import { Server, Activity, Clock, ChevronRight, Hammer, Settings, Trash2, Plug, PlugZap } from 'lucide-react';
-import { Client } from './MCPClients';
-import { mcpClientService } from '../../services/mcpClientService';
-import { useToast } from '../../contexts/ToastContext';
-
-interface ClientCardProps {
- client: Client;
- onSelect: () => void;
- onEdit?: (client: Client) => void;
- onDelete?: (client: Client) => void;
- onConnectionChange?: () => void;
-}
-
-export const ClientCard = ({
- client,
- onSelect,
- onEdit,
- onDelete,
- onConnectionChange
-}: ClientCardProps) => {
- const [isFlipped, setIsFlipped] = useState(false);
- const [isHovered, setIsHovered] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const particlesRef = useRef(null);
- const { showToast } = useToast();
-
- // Special styling for Archon client
- const isArchonClient = client.name.includes('Archon') || client.name.includes('archon');
-
- // Status-based styling
- const statusConfig = {
- online: {
- color: isArchonClient ? 'archon' : 'cyan',
- glow: isArchonClient ? 'shadow-[0_0_25px_rgba(59,130,246,0.7),0_0_15px_rgba(168,85,247,0.5)] dark:shadow-[0_0_35px_rgba(59,130,246,0.8),0_0_20px_rgba(168,85,247,0.7)]' : 'shadow-[0_0_15px_rgba(34,211,238,0.5)] dark:shadow-[0_0_20px_rgba(34,211,238,0.7)]',
- border: isArchonClient ? 'border-blue-400/60 dark:border-blue-500/60' : 'border-cyan-400/50 dark:border-cyan-500/40',
- badge: isArchonClient ? 'bg-blue-500/30 text-blue-400 border-blue-500/40' : 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
- pulse: isArchonClient ? 'bg-blue-400' : 'bg-cyan-400'
- },
- offline: {
- color: 'gray',
- glow: 'shadow-[0_0_15px_rgba(156,163,175,0.3)] dark:shadow-[0_0_15px_rgba(156,163,175,0.4)]',
- border: 'border-gray-400/30 dark:border-gray-600/30',
- badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
- pulse: 'bg-gray-400'
- },
- error: {
- color: 'pink',
- glow: 'shadow-[0_0_15px_rgba(236,72,153,0.5)] dark:shadow-[0_0_20px_rgba(236,72,153,0.7)]',
- border: 'border-pink-400/50 dark:border-pink-500/40',
- badge: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
- pulse: 'bg-pink-400'
- }
- };
-
- // Handle mouse movement for bioluminescent effect
- useEffect(() => {
- if (!isArchonClient || !particlesRef.current) return;
-
- const currentMousePos = { x: 0, y: 0 };
- const glowOrganisms: HTMLDivElement[] = [];
- let isMousePresent = false;
-
- const createBioluminescentOrganism = (targetX: number, targetY: number, delay = 0) => {
- const organism = document.createElement('div');
- organism.className = 'absolute rounded-full pointer-events-none';
-
- const startX = targetX + (Math.random() - 0.5) * 100;
- const startY = targetY + (Math.random() - 0.5) * 100;
- const size = 8 + Math.random() * 12;
-
- organism.style.left = `${startX}px`;
- organism.style.top = `${startY}px`;
- organism.style.width = `${size}px`;
- organism.style.height = `${size}px`;
- organism.style.transform = 'translate(-50%, -50%)';
- organism.style.opacity = '0';
-
- const hues = [180, 200, 220, 240, 260, 280];
- const hue = hues[Math.floor(Math.random() * hues.length)];
-
- organism.style.background = 'transparent';
-
- organism.style.boxShadow = `
- 0 0 ${size * 2}px hsla(${hue}, 90%, 60%, 0.4),
- 0 0 ${size * 4}px hsla(${hue}, 80%, 50%, 0.25),
- 0 0 ${size * 6}px hsla(${hue}, 70%, 40%, 0.15),
- 0 0 ${size * 8}px hsla(${hue}, 60%, 30%, 0.08)
- `;
-
- organism.style.filter = `blur(${2 + Math.random() * 3}px) opacity(0.6)`;
-
- particlesRef.current?.appendChild(organism);
-
- setTimeout(() => {
- const duration = 1200 + Math.random() * 800;
-
- organism.style.transition = `all ${duration}ms cubic-bezier(0.2, 0.0, 0.1, 1)`;
- organism.style.left = `${targetX + (Math.random() - 0.5) * 50}px`;
- organism.style.top = `${targetY + (Math.random() - 0.5) * 50}px`;
- organism.style.opacity = '0.8';
- organism.style.transform = 'translate(-50%, -50%) scale(1.2)';
-
- setTimeout(() => {
- if (!isMousePresent) {
- organism.style.transition = `all 2500ms cubic-bezier(0.6, 0.0, 0.9, 1)`;
- organism.style.left = `${startX + (Math.random() - 0.5) * 300}px`;
- organism.style.top = `${startY + (Math.random() - 0.5) * 300}px`;
- organism.style.opacity = '0';
- organism.style.transform = 'translate(-50%, -50%) scale(0.2)';
- organism.style.filter = `blur(${8 + Math.random() * 5}px) opacity(0.2)`;
- }
- }, duration + 800);
-
- setTimeout(() => {
- if (particlesRef.current?.contains(organism)) {
- particlesRef.current.removeChild(organism);
- const index = glowOrganisms.indexOf(organism);
- if (index > -1) glowOrganisms.splice(index, 1);
- }
- }, duration + 2000);
-
- }, delay);
-
- return organism;
- };
-
- const spawnOrganismsTowardMouse = () => {
- if (!isMousePresent) return;
-
- const count = 3 + Math.random() * 4;
- for (let i = 0; i < count; i++) {
- const organism = createBioluminescentOrganism(
- currentMousePos.x,
- currentMousePos.y,
- i * 100
- );
- glowOrganisms.push(organism);
- }
- };
-
- const handleMouseEnter = () => {
- isMousePresent = true;
- clearInterval(ambientInterval);
- ambientInterval = setInterval(createAmbientGlow, 1500);
- };
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!particlesRef.current) return;
-
- const rect = particlesRef.current.getBoundingClientRect();
- currentMousePos.x = e.clientX - rect.left;
- currentMousePos.y = e.clientY - rect.top;
-
- isMousePresent = true;
-
- if (Math.random() < 0.4) {
- spawnOrganismsTowardMouse();
- }
- };
-
- const handleMouseLeave = () => {
- setTimeout(() => {
- isMousePresent = false;
- clearInterval(ambientInterval);
- }, 800);
- };
-
- const createAmbientGlow = () => {
- if (!particlesRef.current || isMousePresent) return;
-
- const x = Math.random() * particlesRef.current.clientWidth;
- const y = Math.random() * particlesRef.current.clientHeight;
- const organism = createBioluminescentOrganism(x, y);
-
- organism.style.opacity = '0.3';
- organism.style.filter = `blur(${4 + Math.random() * 4}px) opacity(0.4)`;
- organism.style.animation = 'pulse 4s ease-in-out infinite';
- organism.style.transform = 'translate(-50%, -50%) scale(0.8)';
-
- glowOrganisms.push(organism);
- };
-
- let ambientInterval = setInterval(createAmbientGlow, 1500);
-
- const cardElement = particlesRef.current;
- cardElement.addEventListener('mouseenter', handleMouseEnter);
- cardElement.addEventListener('mousemove', handleMouseMove);
- cardElement.addEventListener('mouseleave', handleMouseLeave);
-
- return () => {
- cardElement.removeEventListener('mouseenter', handleMouseEnter);
- cardElement.removeEventListener('mousemove', handleMouseMove);
- cardElement.removeEventListener('mouseleave', handleMouseLeave);
- clearInterval(ambientInterval);
- };
- }, [isArchonClient]);
-
- const currentStatus = statusConfig[client.status];
-
- // Handle card flip
- const toggleFlip = (e: React.MouseEvent) => {
- e.stopPropagation();
- setIsFlipped(!isFlipped);
- };
-
- // Handle edit
- const handleEdit = (e: React.MouseEvent) => {
- e.stopPropagation();
- onEdit?.(client);
- };
-
- // Handle connect/disconnect
- const handleConnect = async (e: React.MouseEvent) => {
- e.stopPropagation();
- setIsConnecting(true);
-
- try {
- if (client.status === 'offline') {
- await mcpClientService.connectClient(client.id);
- showToast(`Connected to ${client.name}`, 'success');
- } else {
- await mcpClientService.disconnectClient(client.id);
- showToast(`Disconnected from ${client.name}`, 'success');
- }
-
- // The parent component should handle refreshing the client list
- // No need to reload the entire page
- onConnectionChange?.();
- } catch (error) {
- showToast(error instanceof Error ? error.message : 'Connection operation failed', 'error');
- } finally {
- setIsConnecting(false);
- }
- };
-
- // Special background for Archon client
- const archonBackground = isArchonClient ? 'bg-gradient-to-b from-white/80 via-blue-50/30 to-white/60 dark:from-white/10 dark:via-blue-900/10 dark:to-black/30' : 'bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30';
-
- return (
- setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- >
-
- {/* Front Side */}
-
- {/* Particle container for Archon client */}
- {isArchonClient && (
-
- )}
-
- {/* Subtle aurora glow effect for Archon client */}
- {isArchonClient && (
-
- )}
-
- {/* Connect/Disconnect button */}
-
- {client.status === 'offline' ? (
-
- ) : (
-
- )}
-
-
- {/* Edit button - moved to be second from right */}
- {onEdit && (
-
-
-
- )}
-
- {/* Delete button - only for non-Archon clients */}
- {!isArchonClient && onDelete && (
-
{
- e.stopPropagation();
- onDelete(client);
- }}
- className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
- title="Delete client"
- >
-
-
- )}
-
- {/* Client info */}
-
- {isArchonClient ? (
-
-
-
-
- ) : (
-
-
-
- )}
-
-
-
- {client.name}
-
-
- {client.ip}
-
-
-
-
-
-
-
- Last seen:
-
- {client.lastSeen}
-
-
-
-
-
Version:
-
- {client.version}
-
-
-
-
- Tools:
-
- {client.tools.length} available
-
-
-
- {/* Error message display */}
- {client.status === 'error' && client.lastError && (
-
-
-
-
-
Last Error:
-
- {client.lastError}
-
-
-
-
- )}
-
-
- {/* Status badge - moved to bottom left */}
-
-
-
-
-
-
- {client.status.charAt(0).toUpperCase() + client.status.slice(1)}
-
-
-
- {/* Tools button - with Hammer icon */}
-
-
-
-
-
- {/* Back Side */}
-
- {/* Subtle aurora glow effect for Archon client */}
- {isArchonClient && (
-
- )}
-
- {/* Connect/Disconnect button - also on back side */}
-
- {client.status === 'offline' ? (
-
- ) : (
-
- )}
-
-
- {/* Edit button - also on back side */}
- {onEdit && (
-
-
-
- )}
-
- {/* Delete button on back side - only for non-Archon clients */}
- {!isArchonClient && onDelete && (
-
{
- e.stopPropagation();
- onDelete(client);
- }}
- className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
- title="Delete client"
- >
-
-
- )}
-
-
-
- Available Tools ({client.tools.length})
-
-
-
- {client.tools.length === 0 ? (
-
-
- {client.status === 'offline'
- ? 'Client offline - tools unavailable'
- : 'No tools discovered'}
-
-
- ) : (
- client.tools.map(tool => (
-
-
-
- {tool.name}
-
-
-
-
- {tool.description}
-
- {tool.parameters.length > 0 && (
-
- {tool.parameters.length} parameter{tool.parameters.length !== 1 ? 's' : ''}
-
- )}
-
- ))
- )}
-
-
- {/* Status badge - also at bottom left on back side */}
-
-
-
-
-
-
- {client.status.charAt(0).toUpperCase() + client.status.slice(1)}
-
-
-
- {/* Flip button - back to front */}
-
-
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/archon-ui-main/src/components/mcp/MCPClients.tsx b/archon-ui-main/src/components/mcp/MCPClients.tsx
deleted file mode 100644
index d780ce62..00000000
--- a/archon-ui-main/src/components/mcp/MCPClients.tsx
+++ /dev/null
@@ -1,858 +0,0 @@
-import React, { useState, memo, useEffect } from 'react';
-import { Plus, Settings, Trash2, X } from 'lucide-react';
-import { ClientCard } from './ClientCard';
-import { ToolTestingPanel } from './ToolTestingPanel';
-import { Button } from '../ui/Button';
-import { mcpClientService, MCPClient, MCPClientConfig } from '../../services/mcpClientService';
-import { useToast } from '../../contexts/ToastContext';
-import { DeleteConfirmModal } from '../common/DeleteConfirmModal';
-
-// Client interface (keeping for backward compatibility)
-export interface Client {
- id: string;
- name: string;
- status: 'online' | 'offline' | 'error';
- ip: string;
- lastSeen: string;
- version: string;
- tools: Tool[];
- region?: string;
- lastError?: string;
-}
-
-// Tool interface
-export interface Tool {
- id: string;
- name: string;
- description: string;
- parameters: ToolParameter[];
-}
-
-// Tool parameter interface
-export interface ToolParameter {
- name: string;
- type: 'string' | 'number' | 'boolean' | 'array';
- required: boolean;
- description?: string;
-}
-
-export const MCPClients = memo(() => {
- const [clients, setClients] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // State for selected client and panel visibility
- const [selectedClient, setSelectedClient] = useState(null);
- const [isPanelOpen, setIsPanelOpen] = useState(false);
- const [isAddClientModalOpen, setIsAddClientModalOpen] = useState(false);
-
- // State for edit drawer
- const [editClient, setEditClient] = useState(null);
- const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false);
-
- const { showToast } = useToast();
-
- // State for delete confirmation modal
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [clientToDelete, setClientToDelete] = useState(null);
-
- // Load clients when component mounts
- useEffect(() => {
- loadAllClients();
-
- // Set up periodic status checks every 10 seconds
- const statusInterval = setInterval(() => {
- // Silently refresh client statuses without loading state
- refreshClientStatuses();
- }, 10000);
-
- return () => clearInterval(statusInterval);
- }, []);
-
- /**
- * Refresh client statuses without showing loading state
- */
- const refreshClientStatuses = async () => {
- try {
- const dbClients = await mcpClientService.getClients();
-
- setClients(prevClients =>
- prevClients.map(client => {
- const dbClient = dbClients.find(db => db.id === client.id);
- if (dbClient) {
- return {
- ...client,
- status: dbClient.status === 'connected' ? 'online' :
- dbClient.status === 'error' ? 'error' : 'offline',
- lastSeen: dbClient.last_seen ? new Date(dbClient.last_seen).toLocaleString() : 'Never',
- lastError: dbClient.last_error || undefined
- };
- }
- return client;
- })
- );
- } catch (error) {
- console.warn('Failed to refresh client statuses:', error);
- }
- };
-
- /**
- * Load all clients: Archon (hardcoded) + real database clients
- */
- const loadAllClients = async () => {
- try {
- setIsLoading(true);
- setError(null);
-
- // Load ALL clients from database (including Archon)
- let dbClients: MCPClient[] = [];
- try {
- dbClients = await mcpClientService.getClients();
- } catch (clientError) {
- console.warn('Failed to load database clients:', clientError);
- dbClients = [];
- }
-
- // Convert database clients to our Client interface and load their tools
- const convertedClients: Client[] = await Promise.all(
- dbClients.map(async (dbClient) => {
- const client = convertDbClientToClient(dbClient);
- // Load tools for connected clients using universal method
- if (client.status === 'online') {
- await loadTools(client);
- }
- return client;
- })
- );
-
- // Set all clients (Archon will be included as a regular client)
- setClients(convertedClients);
- } catch (error) {
- console.error('Failed to load MCP clients:', error);
- setError(error instanceof Error ? error.message : 'Failed to load clients');
- setClients([]);
- } finally {
- setIsLoading(false);
- }
- };
-
- /**
- * Convert database MCP client to our Client interface
- */
- const convertDbClientToClient = (dbClient: MCPClient): Client => {
- // Map database status to our status types
- const statusMap: Record = {
- 'connected': 'online',
- 'disconnected': 'offline',
- 'connecting': 'offline',
- 'error': 'error'
- };
-
- // Extract connection info (Streamable HTTP-only)
- const config = dbClient.connection_config;
- const ip = config.url || 'N/A';
-
- return {
- id: dbClient.id,
- name: dbClient.name,
- status: statusMap[dbClient.status] || 'offline',
- ip,
- lastSeen: dbClient.last_seen ? new Date(dbClient.last_seen).toLocaleString() : 'Never',
- version: config.version || 'Unknown',
- region: config.region || 'Unknown',
- tools: [], // Will be loaded separately
- lastError: dbClient.last_error || undefined
- };
- };
-
- /**
- * Load tools from any MCP client using universal client service
- */
- const loadTools = async (client: Client) => {
- try {
- const toolsResponse = await mcpClientService.getClientTools(client.id);
-
- // Convert client tools to our Tool interface format
- const convertedTools: Tool[] = toolsResponse.tools.map((clientTool: any, index: number) => {
- const parameters: ToolParameter[] = [];
-
- // Extract parameters from tool schema
- if (clientTool.tool_schema?.inputSchema?.properties) {
- const required = clientTool.tool_schema.inputSchema.required || [];
- Object.entries(clientTool.tool_schema.inputSchema.properties).forEach(([name, schema]: [string, any]) => {
- parameters.push({
- name,
- type: schema.type === 'integer' ? 'number' :
- schema.type === 'array' ? 'array' :
- schema.type === 'boolean' ? 'boolean' : 'string',
- required: required.includes(name),
- description: schema.description || `${name} parameter`
- });
- });
- }
-
- return {
- id: `${client.id}-${index}`,
- name: clientTool.tool_name,
- description: clientTool.tool_description || 'No description available',
- parameters
- };
- });
-
- client.tools = convertedTools;
- console.log(`Loaded ${convertedTools.length} tools for client ${client.name}`);
- } catch (error) {
- console.error(`Failed to load tools for client ${client.name}:`, error);
- client.tools = [];
- }
- };
-
- /**
- * Handle adding a new client
- */
- const handleAddClient = async (clientConfig: MCPClientConfig) => {
- try {
- // Create client in database
- const newClient = await mcpClientService.createClient(clientConfig);
-
- // Convert and add to local state
- const convertedClient = convertDbClientToClient(newClient);
-
- // Try to load tools if client is connected
- if (convertedClient.status === 'online') {
- await loadTools(convertedClient);
- }
-
- setClients(prev => [...prev, convertedClient]);
-
- // Close modal
- setIsAddClientModalOpen(false);
-
- console.log('Client added successfully:', newClient.name);
- } catch (error) {
- console.error('Failed to add client:', error);
- setError(error instanceof Error ? error.message : 'Failed to add client');
- throw error; // Re-throw so modal can handle it
- }
- };
-
- // Handle client selection
- const handleSelectClient = async (client: Client) => {
- setSelectedClient(client);
- setIsPanelOpen(true);
-
- // Refresh tools for the selected client if needed
- if (client.tools.length === 0 && client.status === 'online') {
- await loadTools(client);
-
- // Update the client in the list
- setClients(prev => prev.map(c => c.id === client.id ? client : c));
- }
- };
-
- // Handle client editing
- const handleEditClient = (client: Client) => {
- setEditClient(client);
- setIsEditDrawerOpen(true);
- };
-
- // Handle client deletion (triggers confirmation modal)
- const handleDeleteClient = (client: Client) => {
- setClientToDelete(client);
- setShowDeleteConfirm(true);
- };
-
- // Refresh clients list (for after connection state changes)
- const refreshClients = async () => {
- try {
- const dbClients = await mcpClientService.getClients();
- const convertedClients = await Promise.all(
- dbClients.map(async (dbClient) => {
- const client = convertDbClientToClient(dbClient);
- if (client.status === 'online') {
- await loadTools(client);
- }
- return client;
- })
- );
- setClients(convertedClients);
- } catch (error) {
- console.error('Failed to refresh clients:', error);
- setError(error instanceof Error ? error.message : 'Failed to refresh clients');
- }
- };
-
- // Confirm deletion and execute
- const confirmDeleteClient = async () => {
- if (!clientToDelete) return;
-
- try {
- await mcpClientService.deleteClient(clientToDelete.id);
- setClients(prev => prev.filter(c => c.id !== clientToDelete.id));
- showToast(`MCP Client "${clientToDelete.name}" deleted successfully`, 'success');
- } catch (error) {
- console.error('Failed to delete MCP client:', error);
- showToast(error instanceof Error ? error.message : 'Failed to delete MCP client', 'error');
- } finally {
- setShowDeleteConfirm(false);
- setClientToDelete(null);
- }
- };
-
- // Cancel deletion
- const cancelDeleteClient = () => {
- setShowDeleteConfirm(false);
- setClientToDelete(null);
- };
-
- if (isLoading) {
- return (
-
-
-
-
Loading MCP clients...
-
-
- );
- }
-
- return (
-
- {/* Error display */}
- {error && (
-
-
{error}
-
setError(null)}
- className="text-red-500 hover:text-red-600 text-sm mt-2"
- >
- Dismiss
-
-
- )}
-
- {/* Add Client Button */}
-
-
-
MCP Clients
-
- Connect and manage your MCP-enabled applications
-
-
-
setIsAddClientModalOpen(true)}
- variant="primary"
- accentColor="cyan"
- className="shadow-cyan-500/20 shadow-sm"
- >
-
- Add Client
-
-
-
- {/* Client Grid */}
-
-
- {clients.map(client => (
- handleSelectClient(client)}
- onEdit={() => handleEditClient(client)}
- onDelete={() => handleDeleteClient(client)}
- onConnectionChange={refreshClients}
- />
- ))}
-
-
-
- {/* Tool Testing Panel */}
-
setIsPanelOpen(false)}
- />
-
- {/* Add Client Modal */}
- {isAddClientModalOpen && (
- setIsAddClientModalOpen(false)}
- onSubmit={handleAddClient}
- />
- )}
-
- {/* Edit Client Drawer */}
- {isEditDrawerOpen && editClient && (
- {
- setIsEditDrawerOpen(false);
- setEditClient(null);
- }}
- onUpdate={(updatedClient) => {
- // Update the client in state or remove if deleted
- setClients(prev => {
- if (!updatedClient) { // If updatedClient is null, it means deletion
- return prev.filter(c => c.id !== editClient?.id); // Remove the client that was being edited
- }
- return prev.map(c => c.id === updatedClient.id ? updatedClient : c);
- });
- setIsEditDrawerOpen(false);
- setEditClient(null);
- }}
- />
- )}
-
- {/* Delete Confirmation Modal for Clients */}
- {showDeleteConfirm && clientToDelete && (
-
- )}
-
- );
-});
-
-// Add Client Modal Component
-interface AddClientModalProps {
- isOpen: boolean;
- onClose: () => void;
- onSubmit: (config: MCPClientConfig) => Promise;
-}
-
-const AddClientModal: React.FC = ({ isOpen, onClose, onSubmit }) => {
- const [formData, setFormData] = useState({
- name: '',
- url: '',
- auto_connect: true
- });
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [error, setError] = useState(null);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!formData.name.trim()) {
- setError('Client name is required');
- return;
- }
-
- setIsSubmitting(true);
- setError(null);
-
- try {
- // Validate URL
- if (!formData.url.trim()) {
- setError('MCP server URL is required');
- setIsSubmitting(false);
- return;
- }
-
- // Ensure URL is valid
- try {
- const url = new URL(formData.url);
- if (!url.protocol.startsWith('http')) {
- setError('URL must start with http:// or https://');
- setIsSubmitting(false);
- return;
- }
- } catch (e) {
- setError('Invalid URL format');
- setIsSubmitting(false);
- return;
- }
-
- const connection_config = {
- url: formData.url.trim()
- };
-
- const clientConfig: MCPClientConfig = {
- name: formData.name.trim(),
- transport_type: 'http',
- connection_config,
- auto_connect: formData.auto_connect
- };
-
- await onSubmit(clientConfig);
-
- // Reset form on success
- setFormData({
- name: '',
- url: '',
- auto_connect: true
- });
- setError(null);
- } catch (error) {
- setError(error instanceof Error ? error.message : 'Failed to add client');
- } finally {
- setIsSubmitting(false);
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
-
-
- Add New MCP Client
-
-
-
-
-
- );
-};
-
-// Edit Client Drawer Component
-interface EditClientDrawerProps {
- client: Client;
- isOpen: boolean;
- onClose: () => void;
- onUpdate: (client: Client | null) => void; // Allow null to indicate deletion
-}
-
-const EditClientDrawer: React.FC = ({ client, isOpen, onClose, onUpdate }) => {
- const [editFormData, setEditFormData] = useState({
- name: client.name,
- url: '',
- auto_connect: true
- });
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [error, setError] = useState(null);
- const [isConnecting, setIsConnecting] = useState(false);
-
- // State for delete confirmation modal (moved here)
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [clientToDelete, setClientToDelete] = useState(null);
-
- const { showToast } = useToast(); // Initialize useToast here
-
- // Load current client config when drawer opens
- useEffect(() => {
- if (isOpen && client) {
- // Get client config from the API and populate form
- loadClientConfig();
- }
- }, [isOpen, client.id]);
-
- const loadClientConfig = async () => {
- try {
- const dbClient = await mcpClientService.getClient(client.id);
- const config = dbClient.connection_config;
-
- setEditFormData({
- name: dbClient.name,
- url: config.url || '',
- auto_connect: dbClient.auto_connect
- });
- } catch (error) {
- console.error('Failed to load client config:', error);
- setError('Failed to load client configuration');
- }
- };
-
- const handleUpdateSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsSubmitting(true);
- setError(null);
-
- try {
- // Validate URL
- if (!editFormData.url.trim()) {
- setError('MCP server URL is required');
- setIsSubmitting(false);
- return;
- }
-
- // Ensure URL is valid
- try {
- const url = new URL(editFormData.url);
- if (!url.protocol.startsWith('http')) {
- setError('URL must start with http:// or https://');
- setIsSubmitting(false);
- return;
- }
- } catch (e) {
- setError('Invalid URL format');
- setIsSubmitting(false);
- return;
- }
-
- const connection_config = {
- url: editFormData.url.trim()
- };
-
- // Update client via API
- const updatedClient = await mcpClientService.updateClient(client.id, {
- name: editFormData.name,
- transport_type: 'http',
- connection_config,
- auto_connect: editFormData.auto_connect
- });
-
- // Update local state
- const convertedClient = {
- ...client,
- name: updatedClient.name,
- ip: editFormData.url
- };
-
- onUpdate(convertedClient);
- onClose();
- } catch (error) {
- setError(error instanceof Error ? error.message : 'Failed to update client');
- } finally {
- setIsSubmitting(false);
- }
- };
-
- const handleConnect = async () => {
- setIsConnecting(true);
- try {
- await mcpClientService.connectClient(client.id);
- // Reload the client to get updated status
- loadClientConfig();
- } catch (error) {
- setError(error instanceof Error ? error.message : 'Failed to connect');
- } finally {
- setIsConnecting(false);
- }
- };
-
- const handleDisconnect = async () => {
- try {
- await mcpClientService.disconnectClient(client.id);
- // Reload the client to get updated status
- loadClientConfig();
- } catch (error) {
- setError(error instanceof Error ? error.message : 'Failed to disconnect');
- }
- };
-
- const handleDelete = async () => {
- if (confirm(`Are you sure you want to delete "${client.name}"?`)) {
- try {
- await mcpClientService.deleteClient(client.id);
- onClose();
- // Trigger a reload of the clients list
- window.location.reload();
- } catch (error) {
- setError(error instanceof Error ? error.message : 'Failed to delete client');
- }
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
e.stopPropagation()}
- >
-
-
-
-
- Edit Client Configuration
-
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/archon-ui-main/src/components/mcp/ToolTestingPanel.tsx b/archon-ui-main/src/components/mcp/ToolTestingPanel.tsx
deleted file mode 100644
index f1866c84..00000000
--- a/archon-ui-main/src/components/mcp/ToolTestingPanel.tsx
+++ /dev/null
@@ -1,568 +0,0 @@
-import React, { useEffect, useState, useRef } from 'react';
-import { X, Play, ChevronDown, TerminalSquare, Copy, Check, MinusCircle, Maximize2, Minimize2, Hammer, GripHorizontal } from 'lucide-react';
-import { Client, Tool } from './MCPClients';
-import { Button } from '../ui/Button';
-import { mcpClientService } from '../../services/mcpClientService';
-
-interface ToolTestingPanelProps {
- client: Client | null;
- isOpen: boolean;
- onClose: () => void;
-}
-
-interface TerminalLine {
- id: string;
- content: string;
- isTyping: boolean;
- isCommand: boolean;
- isError?: boolean;
- isWarning?: boolean;
-}
-
-export const ToolTestingPanel = ({
- client,
- isOpen,
- onClose
-}: ToolTestingPanelProps) => {
- const [selectedTool, setSelectedTool] = useState(null);
- const [terminalOutput, setTerminalOutput] = useState([{
- id: '1',
- content: '> Tool testing terminal ready',
- isTyping: false,
- isCommand: true
- }]);
- const [paramValues, setParamValues] = useState>({});
- const [isCopied, setIsCopied] = useState(false);
- const [panelHeight, setPanelHeight] = useState(400);
- const [isResizing, setIsResizing] = useState(false);
- const [isMaximized, setIsMaximized] = useState(false);
- const [isExecuting, setIsExecuting] = useState(false);
- const terminalRef = useRef(null);
- const resizeHandleRef = useRef(null);
- const panelRef = useRef(null);
- const previousHeightRef = useRef(400);
-
- // Reset selected tool when client changes
- useEffect(() => {
- if (client && client.tools.length > 0) {
- setSelectedTool(client.tools[0]);
- setParamValues({});
- } else {
- setSelectedTool(null);
- setParamValues({});
- }
- }, [client]);
-
- // Auto-scroll terminal to bottom when output changes
- useEffect(() => {
- if (terminalRef.current) {
- terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
- }
- }, [terminalOutput]);
-
- // Handle resizing functionality
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (isResizing && panelRef.current) {
- const containerHeight = window.innerHeight;
- const mouseY = e.clientY;
- const newHeight = containerHeight - mouseY;
- if (newHeight >= 200 && newHeight <= containerHeight * 0.8) {
- setPanelHeight(newHeight);
- }
- }
- };
-
- const handleMouseUp = () => {
- setIsResizing(false);
- document.body.style.cursor = 'default';
- document.body.style.userSelect = 'auto';
- };
-
- if (isResizing) {
- document.addEventListener('mousemove', handleMouseMove);
- document.addEventListener('mouseup', handleMouseUp);
- document.body.style.cursor = 'ns-resize';
- document.body.style.userSelect = 'none';
- }
-
- return () => {
- document.removeEventListener('mousemove', handleMouseMove);
- document.removeEventListener('mouseup', handleMouseUp);
- };
- }, [isResizing]);
-
- // Handle tool selection
- const handleToolSelect = (tool: Tool) => {
- setSelectedTool(tool);
- setParamValues({});
- };
-
- // Handle parameter value change
- const handleParamChange = (paramName: string, value: string) => {
- setParamValues(prev => ({
- ...prev,
- [paramName]: value
- }));
- };
-
- // Simulate typing animation for terminal output
- const addTypingLine = (content: string, isCommand: boolean = false, isError: boolean = false, isWarning: boolean = false) => {
- const newLineId = Date.now().toString() + Math.random().toString(36).substring(2);
-
- setTerminalOutput(prev => [...prev, {
- id: newLineId,
- content: '',
- isTyping: true,
- isCommand,
- isError,
- isWarning
- }]);
-
- // Simulate typing animation
- let currentText = '';
- const textArray = content.split('');
- const typeInterval = setInterval(() => {
- if (textArray.length > 0) {
- currentText += textArray.shift();
- setTerminalOutput(prev => prev.map(line =>
- line.id === newLineId ? {
- ...line,
- content: currentText
- } : line
- ));
- } else {
- clearInterval(typeInterval);
- setTerminalOutput(prev => prev.map(line =>
- line.id === newLineId ? {
- ...line,
- isTyping: false
- } : line
- ));
- }
- }, 15); // Faster typing
-
- return newLineId;
- };
-
- // Add instant line (no typing effect)
- const addInstantLine = (content: string, isCommand: boolean = false, isError: boolean = false, isWarning: boolean = false) => {
- const newLineId = Date.now().toString() + Math.random().toString(36).substring(2);
-
- setTerminalOutput(prev => [...prev, {
- id: newLineId,
- content,
- isTyping: false,
- isCommand,
- isError,
- isWarning
- }]);
-
- return newLineId;
- };
-
- // Convert parameter values to proper types
- const convertParameterValues = (): Record => {
- if (!selectedTool) return {};
-
- const convertedParams: Record = {};
-
- selectedTool.parameters.forEach(param => {
- const value = paramValues[param.name];
-
- if (value !== undefined && value !== '') {
- try {
- switch (param.type) {
- case 'number':
- convertedParams[param.name] = Number(value);
- if (isNaN(convertedParams[param.name])) {
- throw new Error(`Invalid number: ${value}`);
- }
- break;
- case 'boolean':
- convertedParams[param.name] = value.toLowerCase() === 'true' || value === '1';
- break;
- case 'array':
- // Try to parse as JSON array first, fallback to comma-separated
- try {
- convertedParams[param.name] = JSON.parse(value);
- if (!Array.isArray(convertedParams[param.name])) {
- throw new Error('Not an array');
- }
- } catch {
- convertedParams[param.name] = value.split(',').map(v => v.trim()).filter(v => v);
- }
- break;
- default:
- convertedParams[param.name] = value;
- }
- } catch (error) {
- console.warn(`Parameter conversion error for ${param.name}:`, error);
- convertedParams[param.name] = value; // Fallback to string
- }
- }
- });
-
- return convertedParams;
- };
-
-
-
- // Execute tool using universal MCP client service (works for ALL clients)
- const executeTool = async () => {
- if (!selectedTool || !client) return;
-
- try {
- const convertedParams = convertParameterValues();
-
- addTypingLine(`> Connecting to ${client.name} via MCP protocol...`);
-
- // Call the client tool via MCP service
- const result = await mcpClientService.callClientTool({
- client_id: client.id,
- tool_name: selectedTool.name,
- arguments: convertedParams
- });
-
- setTimeout(() => addTypingLine('> Tool executed successfully'), 300);
-
- // Display the result
- setTimeout(() => {
- if (result) {
- let resultText = '';
-
- if (typeof result === 'object') {
- if (result.content) {
- // Handle MCP content response
- if (Array.isArray(result.content)) {
- resultText = result.content.map((item: any) =>
- item.text || JSON.stringify(item, null, 2)
- ).join('\n');
- } else {
- resultText = result.content.text || JSON.stringify(result.content, null, 2);
- }
- } else {
- resultText = JSON.stringify(result, null, 2);
- }
- } else {
- resultText = String(result);
- }
-
- addInstantLine('> Result:');
- addInstantLine(resultText);
- } else {
- addTypingLine('> No result returned');
- }
-
- addTypingLine('> Completed successfully');
- setIsExecuting(false);
- }, 600);
-
- } catch (error: any) {
- console.error('MCP tool execution failed:', error);
- setTimeout(() => {
- addTypingLine(`> ERROR: Failed to execute tool on ${client.name}`, false, true);
- addTypingLine(`> ${error.message || 'Unknown error occurred'}`, false, true);
- addTypingLine('> Execution failed');
- setIsExecuting(false);
- }, 300);
- }
- };
-
- // Validate required parameters
- const validateParameters = (): string | null => {
- if (!selectedTool) return 'No tool selected';
-
- for (const param of selectedTool.parameters) {
- if (param.required && !paramValues[param.name]) {
- return `Required parameter '${param.name}' is missing`;
- }
- }
-
- return null;
- };
-
- // Handle tool execution
- const executeSelectedTool = () => {
- if (!selectedTool || !client || isExecuting) return;
-
- // Validate required parameters
- const validationError = validateParameters();
- if (validationError) {
- addTypingLine(`> ERROR: ${validationError}`, false, true);
- return;
- }
-
- setIsExecuting(true);
-
- // Add command to terminal
- const params = selectedTool.parameters.map(p => {
- const value = paramValues[p.name];
- return value ? `${p.name}=${value}` : undefined;
- }).filter(Boolean).join(' ');
-
- const command = `> execute ${selectedTool.name} ${params}`;
- addTypingLine(command, true);
-
- // Execute using universal client service for ALL clients
- setTimeout(() => {
- executeTool();
- }, 200);
- };
-
- // Handle copy terminal output
- const copyTerminalOutput = () => {
- const textContent = terminalOutput.map(line => line.content).join('\n');
- navigator.clipboard.writeText(textContent);
- setIsCopied(true);
- setTimeout(() => setIsCopied(false), 2000);
- };
-
- // Handle resize start
- const handleResizeStart = (e: React.MouseEvent) => {
- e.preventDefault();
- setIsResizing(true);
- };
-
- // Handle maximize/minimize
- const toggleMaximize = () => {
- if (isMaximized) {
- setPanelHeight(previousHeightRef.current);
- } else {
- previousHeightRef.current = panelHeight;
- setPanelHeight(window.innerHeight * 0.8);
- }
- setIsMaximized(!isMaximized);
- };
-
- // Clear terminal
- const clearTerminal = () => {
- setTerminalOutput([{
- id: Date.now().toString(),
- content: '> Terminal cleared',
- isTyping: false,
- isCommand: true
- }]);
- };
-
- if (!isOpen || !client) return null;
-
- return (
-
- {/* Resize handle at the top */}
-
-
- {/* Panel with neon effect */}
-
-
-
- {/* Header */}
-
-
-
- {client.name}
-
- {client.ip}
-
-
- {client.tools.length} tools available
-
-
-
-
-
-
-
- {isMaximized ? : }
-
-
-
-
-
-
-
- {/* Content */}
-
- {client.tools.length === 0 ? (
-
-
-
-
No Tools Available
-
- {client.status === 'offline'
- ? 'Client is offline. Tools will be available when connected.'
- : 'No tools discovered for this client.'}
-
-
-
- ) : (
-
- {/* Left column: Tool selection and parameters */}
-
- {/* Tool selection and execute button row */}
-
-
-
- Select Tool
-
-
-
{
- const tool = client.tools.find(t => t.id === e.target.value);
- if (tool) handleToolSelect(tool);
- }}
- >
- {client.tools.map(tool => (
-
- {tool.name}
-
- ))}
-
-
-
-
-
-
-
-
- {isExecuting ? (
-
-
- Executing...
-
- ) : (
- <>
-
- Execute Tool
- >
- )}
-
-
-
-
- {/* Tool description */}
- {selectedTool && (
-
- {selectedTool.description}
-
- )}
-
- {/* Parameters */}
- {selectedTool && selectedTool.parameters.length > 0 && (
-
-
- Parameters
-
-
- {selectedTool.parameters.map(param => (
-
-
- {param.name}
- {param.required && * }
- ({param.type})
-
-
handleParamChange(param.name, e.target.value)}
- className="w-full px-3 py-2 text-sm bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 transition-all duration-200"
- placeholder={param.description || `Enter ${param.name}`}
- />
- {param.description && (
-
- {param.description}
-
- )}
-
- ))}
-
-
- )}
-
-
- {/* Right column: Terminal output */}
-
-
-
-
-
-
- Terminal Output
-
-
-
- {isCopied ?
- :
-
- }
-
-
-
- {terminalOutput.map(line => (
-
- {line.content}
- {line.isTyping && ▌ }
-
- ))}
-
-
-
-
- )}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/components/McpClientList.tsx b/archon-ui-main/src/features/mcp/components/McpClientList.tsx
new file mode 100644
index 00000000..6acee7b9
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/components/McpClientList.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import { cn, glassmorphism, compoundStyles } from '../../ui/primitives';
+import { Monitor, Clock, Activity } from 'lucide-react';
+import { motion } from 'framer-motion';
+import type { McpClient } from '../types';
+
+interface McpClientListProps {
+ clients: McpClient[];
+ className?: string;
+}
+
+const clientIcons: Record = {
+ 'Claude': '🤖',
+ 'Cursor': '💻',
+ 'Windsurf': '🏄',
+ 'Cline': '🔧',
+ 'KiRo': '🚀',
+ 'Augment': '⚡',
+ 'Gemini': '🌐',
+ 'Unknown': '❓'
+};
+
+export const McpClientList: React.FC = ({
+ clients,
+ className
+}) => {
+ const formatDuration = (connectedAt: string): string => {
+ const now = new Date();
+ const connected = new Date(connectedAt);
+ const seconds = Math.floor((now.getTime() - connected.getTime()) / 1000);
+
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+ };
+
+ const formatLastActivity = (lastActivity: string): string => {
+ const now = new Date();
+ const activity = new Date(lastActivity);
+ const seconds = Math.floor((now.getTime() - activity.getTime()) / 1000);
+
+ if (seconds < 5) return 'Active';
+ if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ return 'Idle';
+ };
+
+ if (clients.length === 0) {
+ return (
+
+
+ Coming Soon
+
+
+
Client detection coming soon
+
+ We'll automatically detect when AI assistants connect to the MCP server
+
+
+ );
+ }
+
+ return (
+
+ {clients.map((client, index) => (
+
+
+
{clientIcons[client.client_type] || '❓'}
+
+
{client.client_type}
+
Session: {client.session_id.slice(0, 8)}
+
+
+
+
+
+
+ {formatDuration(client.connected_at)}
+
+
+
+
+
+ {formatLastActivity(client.last_activity)}
+
+
+
+
+ ))}
+
+ );
+};
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/components/McpConfigSection.tsx b/archon-ui-main/src/features/mcp/components/McpConfigSection.tsx
new file mode 100644
index 00000000..1f3cd17b
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/components/McpConfigSection.tsx
@@ -0,0 +1,298 @@
+import { Copy, ExternalLink } from "lucide-react";
+import type React from "react";
+import { useState } from "react";
+import { useToast } from "../../ui/hooks";
+import { Button, cn, glassmorphism, Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/primitives";
+import type { McpServerConfig, McpServerStatus, SupportedIDE } from "../types";
+
+interface McpConfigSectionProps {
+ config?: McpServerConfig;
+ status: McpServerStatus;
+ className?: string;
+}
+
+const ideConfigurations: Record<
+ SupportedIDE,
+ {
+ title: string;
+ steps: string[];
+ configGenerator: (config: McpServerConfig) => string;
+ supportsOneClick?: boolean;
+ }
+> = {
+ claudecode: {
+ title: "Claude Code Configuration",
+ steps: ["Open a terminal and run the following command:", "The connection will be established automatically"],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ name: "archon",
+ transport: "http",
+ url: `http://${config.host}:${config.port}/mcp`,
+ },
+ null,
+ 2,
+ ),
+ },
+ gemini: {
+ title: "Gemini CLI Configuration",
+ steps: [
+ "Locate or create the settings file at ~/.gemini/settings.json",
+ "Add the configuration shown below to the file",
+ "Launch Gemini CLI in your terminal",
+ "Test the connection by typing /mcp to list available tools",
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ httpUrl: `http://${config.host}:${config.port}/mcp`,
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ },
+ cursor: {
+ title: "Cursor Configuration",
+ steps: [
+ "Option A: Use the one-click install button below (recommended)",
+ "Option B: Manually edit ~/.cursor/mcp.json",
+ "Add the configuration shown below",
+ "Restart Cursor for changes to take effect",
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ url: `http://${config.host}:${config.port}/mcp`,
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ supportsOneClick: true,
+ },
+ windsurf: {
+ title: "Windsurf Configuration",
+ steps: [
+ 'Open Windsurf and click the "MCP servers" button (hammer icon)',
+ 'Click "Configure" and then "View raw config"',
+ "Add the configuration shown below to the mcpServers object",
+ 'Click "Refresh" to connect to the server',
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ serverUrl: `http://${config.host}:${config.port}/mcp`,
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ },
+ cline: {
+ title: "Cline Configuration",
+ steps: [
+ "Open VS Code settings (Cmd/Ctrl + ,)",
+ 'Search for "cline.mcpServers"',
+ 'Click "Edit in settings.json"',
+ "Add the configuration shown below",
+ "Restart VS Code for changes to take effect",
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ command: "npx",
+ args: ["mcp-remote", `http://${config.host}:${config.port}/mcp`, "--allow-http"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ },
+ kiro: {
+ title: "Kiro Configuration",
+ steps: [
+ "Open Kiro settings",
+ "Navigate to MCP Servers section",
+ "Add the configuration shown below",
+ "Save and restart Kiro",
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ command: "npx",
+ args: ["mcp-remote", `http://${config.host}:${config.port}/mcp`, "--allow-http"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ },
+ augment: {
+ title: "Augment Configuration",
+ steps: [
+ "Open Augment settings",
+ "Navigate to Extensions > MCP",
+ "Add the configuration shown below",
+ "Reload configuration",
+ ],
+ configGenerator: (config) =>
+ JSON.stringify(
+ {
+ mcpServers: {
+ archon: {
+ url: `http://${config.host}:${config.port}/mcp`,
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ },
+};
+
+export const McpConfigSection: React.FC = ({ config, status, className }) => {
+ const [selectedIDE, setSelectedIDE] = useState("claudecode");
+ const { showToast } = useToast();
+
+ if (status.status !== "running" || !config) {
+ return (
+
+
Start the MCP server to see configuration options
+
+ );
+ }
+
+ const handleCopyConfig = () => {
+ const configText = ideConfigurations[selectedIDE].configGenerator(config);
+ navigator.clipboard.writeText(configText);
+ showToast("Configuration copied to clipboard", "success");
+ };
+
+ const handleCursorOneClick = () => {
+ const httpConfig = {
+ url: `http://${config.host}:${config.port}/mcp`,
+ };
+ const configString = JSON.stringify(httpConfig);
+ const base64Config = btoa(configString);
+ const deeplink = `cursor://anysphere.cursor-deeplink/mcp/install?name=archon&config=${base64Config}`;
+ window.location.href = deeplink;
+ showToast("Opening Cursor with Archon MCP configuration...", "info");
+ };
+
+ const handleClaudeCodeCommand = () => {
+ const command = `claude mcp add --transport http archon http://${config.host}:${config.port}/mcp`;
+ navigator.clipboard.writeText(command);
+ showToast("Command copied to clipboard", "success");
+ };
+
+ const selectedConfig = ideConfigurations[selectedIDE];
+ const configText = selectedConfig.configGenerator(config);
+
+ return (
+
+ {/* Universal MCP Note */}
+
+
+ Note: Archon works with any application that supports MCP. Below are
+ instructions for common tools, but these steps can be adapted for any MCP-compatible client.
+
+
+
+ {/* IDE Selection Tabs */}
+
setSelectedIDE(value as SupportedIDE)}
+ >
+
+ Claude Code
+ Gemini
+ Cursor
+ Windsurf
+ Cline
+ Kiro
+ Augment
+
+
+
+ {/* Configuration Title and Steps */}
+
+
{selectedConfig.title}
+
+ {selectedConfig.steps.map((step) => (
+ {step}
+ ))}
+
+
+
+ {/* Special Commands for Claude Code */}
+ {selectedIDE === "claudecode" && (
+
+
+ claude mcp add --transport http archon http://{config.host}:{config.port}/mcp
+
+
+
+ Copy
+
+
+ )}
+
+ {/* Configuration Display */}
+
+
+ Configuration
+
+
+ Copy
+
+
+
+ {configText}
+
+
+
+ {/* One-Click Install for Cursor */}
+ {selectedIDE === "cursor" && selectedConfig.supportsOneClick && (
+
+
+
+ One-Click Install for Cursor
+
+ Opens Cursor with configuration
+
+ )}
+
+
+
+ );
+};
diff --git a/archon-ui-main/src/features/mcp/components/McpStatusBar.tsx b/archon-ui-main/src/features/mcp/components/McpStatusBar.tsx
new file mode 100644
index 00000000..3ed7a5b1
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/components/McpStatusBar.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { cn, glassmorphism } from '../../ui/primitives';
+import { CheckCircle, AlertCircle, Clock, Server, Users } from 'lucide-react';
+import type { McpServerStatus, McpSessionInfo, McpServerConfig } from '../types';
+
+interface McpStatusBarProps {
+ status: McpServerStatus;
+ sessionInfo?: McpSessionInfo;
+ config?: McpServerConfig;
+ className?: string;
+}
+
+export const McpStatusBar: React.FC = ({
+ status,
+ sessionInfo,
+ config,
+ className
+}) => {
+ const formatUptime = (seconds: number): string => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+
+ if (hours > 24) {
+ const days = Math.floor(hours / 24);
+ return `${days}d ${hours % 24}h ${minutes}m`;
+ }
+ return `${hours}h ${minutes}m ${secs}s`;
+ };
+
+ const getStatusIcon = () => {
+ if (status.status === 'running') {
+ return ;
+ }
+ return ;
+ };
+
+ const getStatusColor = () => {
+ if (status.status === 'running') {
+ return 'text-green-500 shadow-[0_0_10px_rgba(34,197,94,0.5)]';
+ }
+ return 'text-red-500';
+ };
+
+ return (
+
+ {/* Status Indicator */}
+
+ {getStatusIcon()}
+
+ {status.status.toUpperCase()}
+
+
+
+ {/* Separator */}
+
+
+ {/* Uptime */}
+ {status.uptime !== null && (
+ <>
+
+
+ UP
+ {formatUptime(status.uptime)}
+
+
+ >
+ )}
+
+ {/* Server Info */}
+
+
+ MCP
+ 8051
+
+
+ {/* Active Sessions */}
+ {sessionInfo && (
+ <>
+
+
+
+ SESSIONS
+ Coming Soon
+
+ >
+ )}
+
+ {/* Transport Type */}
+
+
+ TRANSPORT
+
+ {config?.transport === 'streamable-http' ? 'HTTP' :
+ config?.transport === 'sse' ? 'SSE' :
+ config?.transport || 'HTTP'}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/components/index.ts b/archon-ui-main/src/features/mcp/components/index.ts
new file mode 100644
index 00000000..c93bd904
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/components/index.ts
@@ -0,0 +1,3 @@
+export * from "./McpStatusBar";
+export * from "./McpClientList";
+export * from "./McpConfigSection";
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/hooks/index.ts b/archon-ui-main/src/features/mcp/hooks/index.ts
new file mode 100644
index 00000000..51935c1d
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./useMcpQueries";
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts b/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts
new file mode 100644
index 00000000..7cdfc1dd
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts
@@ -0,0 +1,60 @@
+import { useQuery } from "@tanstack/react-query";
+import { useSmartPolling } from "../../ui/hooks";
+import { mcpApi } from "../services";
+
+// Query keys factory
+export const mcpKeys = {
+ all: ["mcp"] as const,
+ status: () => [...mcpKeys.all, "status"] as const,
+ config: () => [...mcpKeys.all, "config"] as const,
+ sessions: () => [...mcpKeys.all, "sessions"] as const,
+ clients: () => [...mcpKeys.all, "clients"] as const,
+};
+
+export function useMcpStatus() {
+ const { refetchInterval } = useSmartPolling(5000); // 5 second polling
+
+ return useQuery({
+ queryKey: mcpKeys.status(),
+ queryFn: () => mcpApi.getStatus(),
+ refetchInterval,
+ refetchOnWindowFocus: false,
+ staleTime: 3000,
+ throwOnError: true,
+ });
+}
+
+export function useMcpConfig() {
+ return useQuery({
+ queryKey: mcpKeys.config(),
+ queryFn: () => mcpApi.getConfig(),
+ staleTime: Infinity, // Config rarely changes
+ throwOnError: true,
+ });
+}
+
+export function useMcpClients() {
+ const { refetchInterval } = useSmartPolling(10000); // 10 second polling
+
+ return useQuery({
+ queryKey: mcpKeys.clients(),
+ queryFn: () => mcpApi.getClients(),
+ refetchInterval,
+ refetchOnWindowFocus: false,
+ staleTime: 8000,
+ throwOnError: true,
+ });
+}
+
+export function useMcpSessionInfo() {
+ const { refetchInterval } = useSmartPolling(10000);
+
+ return useQuery({
+ queryKey: mcpKeys.sessions(),
+ queryFn: () => mcpApi.getSessionInfo(),
+ refetchInterval,
+ refetchOnWindowFocus: false,
+ staleTime: 8000,
+ throwOnError: true,
+ });
+}
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/index.ts b/archon-ui-main/src/features/mcp/index.ts
new file mode 100644
index 00000000..cb9b5ee3
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/index.ts
@@ -0,0 +1,6 @@
+export * from "./components";
+export * from "./hooks";
+export * from "./services";
+export * from "./types";
+export { McpView } from "./views/McpView";
+export { McpViewWithBoundary } from "./views/McpViewWithBoundary";
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/services/index.ts b/archon-ui-main/src/features/mcp/services/index.ts
new file mode 100644
index 00000000..f83d01aa
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/services/index.ts
@@ -0,0 +1 @@
+export * from "./mcpApi";
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/services/mcpApi.ts b/archon-ui-main/src/features/mcp/services/mcpApi.ts
new file mode 100644
index 00000000..048172af
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/services/mcpApi.ts
@@ -0,0 +1,54 @@
+import { callAPIWithETag } from "../../projects/shared/apiWithEtag";
+import type {
+ McpServerStatus,
+ McpServerConfig,
+ McpSessionInfo,
+ McpClient
+} from "../types";
+
+export const mcpApi = {
+ async getStatus(): Promise {
+ try {
+ const response =
+ await callAPIWithETag("/api/mcp/status");
+ return response;
+ } catch (error) {
+ console.error("Failed to get MCP status:", error);
+ throw error;
+ }
+ },
+
+ async getConfig(): Promise {
+ try {
+ const response =
+ await callAPIWithETag("/api/mcp/config");
+ return response;
+ } catch (error) {
+ console.error("Failed to get MCP config:", error);
+ throw error;
+ }
+ },
+
+ async getSessionInfo(): Promise {
+ try {
+ const response =
+ await callAPIWithETag("/api/mcp/sessions");
+ return response;
+ } catch (error) {
+ console.error("Failed to get session info:", error);
+ throw error;
+ }
+ },
+
+ async getClients(): Promise {
+ try {
+ const response = await callAPIWithETag<{ clients: McpClient[] }>(
+ "/api/mcp/clients",
+ );
+ return response.clients || [];
+ } catch (error) {
+ console.error("Failed to get MCP clients:", error);
+ throw error;
+ }
+ },
+};
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/types/index.ts b/archon-ui-main/src/features/mcp/types/index.ts
new file mode 100644
index 00000000..a986320c
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/types/index.ts
@@ -0,0 +1 @@
+export * from "./mcp";
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/types/mcp.ts b/archon-ui-main/src/features/mcp/types/mcp.ts
new file mode 100644
index 00000000..96e033c8
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/types/mcp.ts
@@ -0,0 +1,54 @@
+// Core MCP interfaces matching backend schema
+export interface McpServerStatus {
+ status: "running" | "starting" | "stopped" | "stopping";
+ uptime: number | null;
+ logs: string[];
+}
+
+export interface McpServerConfig {
+ transport: string;
+ host: string;
+ port: number;
+ model?: string;
+}
+
+export interface McpClient {
+ session_id: string;
+ client_type:
+ | "Claude"
+ | "Cursor"
+ | "Windsurf"
+ | "Cline"
+ | "KiRo"
+ | "Augment"
+ | "Gemini"
+ | "Unknown";
+ connected_at: string;
+ last_activity: string;
+ status: "active" | "idle";
+}
+
+export interface McpSessionInfo {
+ active_sessions: number;
+ session_timeout: number;
+ server_uptime_seconds?: number;
+ clients?: McpClient[];
+}
+
+// we actually support all ides and mcp clients
+export type SupportedIDE =
+ | "windsurf"
+ | "cursor"
+ | "claudecode"
+ | "cline"
+ | "kiro"
+ | "augment"
+ | "gemini";
+
+export interface IdeConfiguration {
+ ide: SupportedIDE;
+ title: string;
+ steps: string[];
+ config: string;
+ supportsOneClick?: boolean;
+}
\ No newline at end of file
diff --git a/archon-ui-main/src/features/mcp/views/McpView.tsx b/archon-ui-main/src/features/mcp/views/McpView.tsx
new file mode 100644
index 00000000..ddd3d167
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/views/McpView.tsx
@@ -0,0 +1,110 @@
+import { motion } from "framer-motion";
+import { Loader, Server } from "lucide-react";
+import type React from "react";
+import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
+import { McpClientList, McpConfigSection, McpStatusBar } from "../components";
+import { useMcpClients, useMcpConfig, useMcpSessionInfo, useMcpStatus } from "../hooks";
+
+export const McpView: React.FC = () => {
+ const { data: status, isLoading: statusLoading } = useMcpStatus();
+ const { data: config } = useMcpConfig();
+ const { data: clients = [] } = useMcpClients();
+ const { data: sessionInfo } = useMcpSessionInfo();
+
+ // Staggered entrance animation
+ const isVisible = useStaggeredEntrance([1, 2, 3, 4], 0.15);
+
+ // Animation variants
+ const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.15,
+ },
+ },
+ };
+
+ const itemVariants = {
+ hidden: { opacity: 0, y: 20 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.5,
+ ease: "easeOut",
+ },
+ },
+ };
+
+ const titleVariants = {
+ hidden: { opacity: 0, x: -20 },
+ visible: {
+ opacity: 1,
+ x: 0,
+ transition: {
+ duration: 0.6,
+ ease: "easeOut",
+ },
+ },
+ };
+
+ if (statusLoading || !status) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Title with MCP icon */}
+
+
+ MCP icon
+
+
+
+ MCP Status Dashboard
+
+
+ {/* Status Bar */}
+
+
+
+
+ {/* Connected Clients */}
+
+
+
+ Connected Clients
+
+
+
+
+ {/* IDE Configuration */}
+
+ IDE Configuration
+
+
+
+ );
+};
diff --git a/archon-ui-main/src/features/mcp/views/McpViewWithBoundary.tsx b/archon-ui-main/src/features/mcp/views/McpViewWithBoundary.tsx
new file mode 100644
index 00000000..0112a29c
--- /dev/null
+++ b/archon-ui-main/src/features/mcp/views/McpViewWithBoundary.tsx
@@ -0,0 +1,15 @@
+import { QueryErrorResetBoundary } from "@tanstack/react-query";
+import { FeatureErrorBoundary } from "../../ui/components";
+import { McpView } from "./McpView";
+
+export const McpViewWithBoundary = () => {
+ return (
+
+ {({ reset }) => (
+
+
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/archon-ui-main/src/features/projects/components/tests/ProjectCard.test.tsx b/archon-ui-main/src/features/projects/components/tests/ProjectCard.test.tsx
new file mode 100644
index 00000000..e119c749
--- /dev/null
+++ b/archon-ui-main/src/features/projects/components/tests/ProjectCard.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '../../../testing/test-utils';
+import { ProjectCard } from '../ProjectCard';
+import type { Project } from '../../types';
+
+describe('ProjectCard', () => {
+ const mockProject: Project = {
+ id: 'project-1',
+ title: 'Test Project',
+ description: 'Test Description',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ pinned: false,
+ features: [],
+ docs: [],
+ };
+
+ const mockTaskCounts = {
+ todo: 5,
+ doing: 3,
+ review: 2,
+ done: 10,
+ };
+
+ const mockHandlers = {
+ onSelect: vi.fn(),
+ onPin: vi.fn(),
+ onDelete: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should render project title', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Test Project')).toBeInTheDocument();
+ });
+
+ it('should display task counts', () => {
+ render(
+
+ );
+
+ // Task count badges should be visible
+ // Note: Component only shows todo, doing, and done (not review)
+ const fives = screen.getAllByText('5');
+ expect(fives.length).toBeGreaterThan(0); // todo count
+ expect(screen.getByText('10')).toBeInTheDocument(); // done
+ // Doing count might be displayed as 3 or duplicated - implementation detail
+ });
+
+ it('should call onSelect when clicked', () => {
+ render(
+
+ );
+
+ const card = screen.getByRole('listitem');
+ fireEvent.click(card);
+
+ expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockProject);
+ expect(mockHandlers.onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it('should apply selected styles when isSelected is true', () => {
+ const { container } = render(
+
+ );
+
+ const card = container.querySelector('[role="listitem"]');
+ // Check for selected-specific classes
+ expect(card?.className).toContain('scale-[1.02]');
+ expect(card?.className).toContain('border-purple');
+ });
+
+ it('should apply pinned styles when project is pinned', () => {
+ const pinnedProject = { ...mockProject, pinned: true };
+
+ const { container } = render(
+
+ );
+
+ const card = container.querySelector('[role="listitem"]');
+ // Check for pinned-specific classes
+ expect(card?.className).toContain('from-purple');
+ expect(card?.className).toContain('border-purple-500');
+ });
+
+ it('should render aurora glow effect when selected', () => {
+ const { container } = render(
+
+ );
+
+ // Aurora glow div should exist when selected
+ const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
+ expect(glowEffect).toBeInTheDocument();
+ });
+
+ it('should not render aurora glow effect when not selected', () => {
+ const { container } = render(
+
+ );
+
+ // Aurora glow div should not exist when not selected
+ const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
+ expect(glowEffect).not.toBeInTheDocument();
+ });
+
+ it('should show zero task counts correctly', () => {
+ const zeroTaskCounts = {
+ todo: 0,
+ doing: 0,
+ review: 0,
+ done: 0,
+ };
+
+ render(
+
+ );
+
+ // All counts should show 0 (ProjectCard may not show review count)
+ const zeros = screen.getAllByText('0');
+ expect(zeros.length).toBeGreaterThanOrEqual(3); // At least todo, doing, done
+ });
+
+ it('should handle very long project titles', () => {
+ const longTitleProject = {
+ ...mockProject,
+ title: 'This is an extremely long project title that should be truncated properly to avoid breaking the layout of the card component',
+ };
+
+ render(
+
+ );
+
+ const title = screen.getByText(/This is an extremely long project title/);
+ expect(title).toBeInTheDocument();
+ // Title should have line-clamp-2 class
+ expect(title.className).toContain('line-clamp-2');
+ });
+});
\ No newline at end of file
diff --git a/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts b/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts
new file mode 100644
index 00000000..0b90ba95
--- /dev/null
+++ b/archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts
@@ -0,0 +1,208 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { projectKeys, useProjects, useCreateProject, useUpdateProject, useDeleteProject } from '../useProjectQueries';
+import type { Project } from '../../types';
+import React from 'react';
+
+// Mock the services
+vi.mock('../../services', () => ({
+ projectService: {
+ listProjects: vi.fn(),
+ createProject: vi.fn(),
+ updateProject: vi.fn(),
+ deleteProject: vi.fn(),
+ getProjectFeatures: vi.fn(),
+ },
+ taskService: {
+ getTaskCountsForAllProjects: vi.fn(),
+ },
+}));
+
+// Mock the toast hook
+vi.mock('../../../ui/hooks/useToast', () => ({
+ useToast: () => ({
+ showToast: vi.fn(),
+ }),
+}));
+
+// Mock smart polling
+vi.mock('../../../ui/hooks', () => ({
+ useSmartPolling: () => ({
+ refetchInterval: 5000,
+ isPaused: false,
+ }),
+}));
+
+// Test wrapper with QueryClient
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+};
+
+describe('useProjectQueries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('projectKeys', () => {
+ it('should generate correct query keys', () => {
+ expect(projectKeys.all).toEqual(['projects']);
+ expect(projectKeys.lists()).toEqual(['projects', 'list']);
+ expect(projectKeys.detail('123')).toEqual(['projects', 'detail', '123']);
+ expect(projectKeys.tasks('123')).toEqual(['projects', 'detail', '123', 'tasks']);
+ expect(projectKeys.features('123')).toEqual(['projects', 'detail', '123', 'features']);
+ expect(projectKeys.documents('123')).toEqual(['projects', 'detail', '123', 'documents']);
+ });
+ });
+
+ describe('useProjects', () => {
+ it('should fetch projects list', async () => {
+ const mockProjects: Project[] = [
+ {
+ id: '1',
+ title: 'Test Project',
+ description: 'Test Description',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ pinned: false,
+ features: [],
+ docs: [],
+ },
+ ];
+
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.listProjects).mockResolvedValue(mockProjects);
+
+ const { result } = renderHook(() => useProjects(), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(result.current.data).toEqual(mockProjects);
+ });
+
+ expect(projectService.listProjects).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('useCreateProject', () => {
+ it('should optimistically add project and replace with server response', async () => {
+ const newProject: Project = {
+ id: 'real-id',
+ title: 'New Project',
+ description: 'New Description',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ pinned: false,
+ features: [],
+ docs: [],
+ };
+
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.createProject).mockResolvedValue({
+ project: newProject,
+ message: 'Created',
+ });
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useCreateProject(), { wrapper });
+
+ await result.current.mutateAsync({
+ title: 'New Project',
+ description: 'New Description',
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(projectService.createProject).toHaveBeenCalledWith({
+ title: 'New Project',
+ description: 'New Description',
+ });
+ });
+ });
+
+ it('should rollback on error', async () => {
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.createProject).mockRejectedValue(new Error('Network error'));
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useCreateProject(), { wrapper });
+
+ await expect(
+ result.current.mutateAsync({
+ title: 'New Project',
+ description: 'New Description',
+ })
+ ).rejects.toThrow('Network error');
+ });
+ });
+
+ describe('useUpdateProject', () => {
+ it('should handle pinning a project', async () => {
+ const updatedProject: Project = {
+ id: '1',
+ title: 'Test Project',
+ description: 'Test Description',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ pinned: true,
+ features: [],
+ docs: [],
+ };
+
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.updateProject).mockResolvedValue(updatedProject);
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useUpdateProject(), { wrapper });
+
+ await result.current.mutateAsync({
+ projectId: '1',
+ updates: { pinned: true },
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(projectService.updateProject).toHaveBeenCalledWith('1', { pinned: true });
+ });
+ });
+ });
+
+ describe('useDeleteProject', () => {
+ it('should optimistically remove project', async () => {
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.deleteProject).mockResolvedValue(undefined);
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useDeleteProject(), { wrapper });
+
+ await result.current.mutateAsync('project-to-delete');
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(projectService.deleteProject).toHaveBeenCalledWith('project-to-delete');
+ });
+ });
+
+ it('should rollback on delete error', async () => {
+ const { projectService } = await import('../../services');
+ vi.mocked(projectService.deleteProject).mockRejectedValue(new Error('Permission denied'));
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useDeleteProject(), { wrapper });
+
+ await expect(
+ result.current.mutateAsync('project-to-delete')
+ ).rejects.toThrow('Permission denied');
+ });
+ });
+});
\ No newline at end of file
diff --git a/archon-ui-main/src/features/projects/index.ts b/archon-ui-main/src/features/projects/index.ts
index e04f7058..c8e50df7 100644
--- a/archon-ui-main/src/features/projects/index.ts
+++ b/archon-ui-main/src/features/projects/index.ts
@@ -19,3 +19,4 @@ export * from "./hooks";
export * from "./tasks";
// Views
export { ProjectsView } from "./views/ProjectsView";
+export { ProjectsViewWithBoundary } from "./views/ProjectsViewWithBoundary";
diff --git a/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts
new file mode 100644
index 00000000..a9282987
--- /dev/null
+++ b/archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts
@@ -0,0 +1,195 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { taskKeys, useProjectTasks, useCreateTask } from '../useTaskQueries';
+import type { Task } from '../../types';
+import React from 'react';
+
+// Mock the services
+vi.mock('../../services', () => ({
+ taskService: {
+ getTasksByProject: vi.fn(),
+ createTask: vi.fn(),
+ updateTask: vi.fn(),
+ deleteTask: vi.fn(),
+ },
+}));
+
+// Mock the toast hook
+vi.mock('../../../../ui/hooks/useToast', () => ({
+ useToast: () => ({
+ showToast: vi.fn(),
+ }),
+}));
+
+// Mock smart polling
+vi.mock('../../../../ui/hooks', () => ({
+ useSmartPolling: () => ({
+ refetchInterval: 5000,
+ isPaused: false,
+ }),
+}));
+
+// Test wrapper with QueryClient
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(QueryClientProvider, { client: queryClient }, children);
+};
+
+describe('useTaskQueries', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('taskKeys', () => {
+ it('should generate correct query keys', () => {
+ expect(taskKeys.all('project-123')).toEqual(['projects', 'project-123', 'tasks']);
+ });
+ });
+
+ describe('useProjectTasks', () => {
+ it('should fetch tasks for a project', async () => {
+ const mockTasks: Task[] = [
+ {
+ id: 'task-1',
+ project_id: 'project-123',
+ title: 'Test Task',
+ description: 'Test Description',
+ status: 'todo',
+ assignee: 'User',
+ task_order: 100,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
+ ];
+
+ const { taskService } = await import('../../services');
+ vi.mocked(taskService.getTasksByProject).mockResolvedValue(mockTasks);
+
+ const { result } = renderHook(() => useProjectTasks('project-123'), {
+ wrapper: createWrapper(),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(result.current.data).toEqual(mockTasks);
+ });
+
+ expect(taskService.getTasksByProject).toHaveBeenCalledWith('project-123');
+ });
+
+ it('should not fetch tasks when projectId is undefined', () => {
+ const { result } = renderHook(() => useProjectTasks(undefined), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isFetching).toBe(false);
+ expect(result.current.data).toBeUndefined();
+ });
+
+ it('should respect enabled flag', () => {
+ const { result } = renderHook(() => useProjectTasks('project-123', false), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isFetching).toBe(false);
+ expect(result.current.data).toBeUndefined();
+ });
+ });
+
+ describe('useCreateTask', () => {
+ it('should optimistically add task and replace with server response', async () => {
+ const newTask: Task = {
+ id: 'real-task-id',
+ project_id: 'project-123',
+ title: 'New Task',
+ description: 'New Description',
+ status: 'todo',
+ assignee: 'User',
+ task_order: 100,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ };
+
+ const { taskService } = await import('../../services');
+ vi.mocked(taskService.createTask).mockResolvedValue(newTask);
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useCreateTask(), { wrapper });
+
+ await result.current.mutateAsync({
+ project_id: 'project-123',
+ title: 'New Task',
+ description: 'New Description',
+ status: 'todo',
+ assignee: 'User',
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ expect(taskService.createTask).toHaveBeenCalledWith({
+ project_id: 'project-123',
+ title: 'New Task',
+ description: 'New Description',
+ status: 'todo',
+ assignee: 'User',
+ });
+ });
+ });
+
+ it('should provide default values for optional fields', async () => {
+ const newTask: Task = {
+ id: 'real-task-id',
+ project_id: 'project-123',
+ title: 'Minimal Task',
+ description: '',
+ status: 'todo',
+ assignee: 'User',
+ task_order: 100,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ };
+
+ const { taskService } = await import('../../services');
+ vi.mocked(taskService.createTask).mockResolvedValue(newTask);
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useCreateTask(), { wrapper });
+
+ await result.current.mutateAsync({
+ project_id: 'project-123',
+ title: 'Minimal Task',
+ description: '',
+ });
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+ });
+
+ it('should rollback on error', async () => {
+ const { taskService } = await import('../../services');
+ vi.mocked(taskService.createTask).mockRejectedValue(new Error('Network error'));
+
+ const wrapper = createWrapper();
+ const { result } = renderHook(() => useCreateTask(), { wrapper });
+
+ await expect(
+ result.current.mutateAsync({
+ project_id: 'project-123',
+ title: 'Failed Task',
+ description: 'This will fail',
+ })
+ ).rejects.toThrow('Network error');
+ });
+ });
+});
\ No newline at end of file
diff --git a/archon-ui-main/src/features/testing/test-utils.tsx b/archon-ui-main/src/features/testing/test-utils.tsx
new file mode 100644
index 00000000..fbdb1e5b
--- /dev/null
+++ b/archon-ui-main/src/features/testing/test-utils.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { render as rtlRender } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ToastProvider } from '../ui/components/ToastProvider';
+import { TooltipProvider } from '../ui/primitives/tooltip';
+
+/**
+ * Custom render function that wraps components with all necessary providers
+ * This follows the best practice of having a centralized test render utility
+ */
+export function renderWithProviders(
+ ui: React.ReactElement,
+ {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ }),
+ ...renderOptions
+ } = {}
+) {
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
+}
+
+// Re-export everything from React Testing Library
+export * from '@testing-library/react';
+
+// Override the default render with our custom one
+export { renderWithProviders as render };
\ No newline at end of file
diff --git a/archon-ui-main/src/features/ui/hooks/index.ts b/archon-ui-main/src/features/ui/hooks/index.ts
index 98ba18bc..b23209b4 100644
--- a/archon-ui-main/src/features/ui/hooks/index.ts
+++ b/archon-ui-main/src/features/ui/hooks/index.ts
@@ -1,2 +1,3 @@
export * from "./useSmartPolling";
export * from "./useThemeAware";
+export * from "./useToast";
diff --git a/archon-ui-main/src/features/ui/hooks/tests/useSmartPolling.test.ts b/archon-ui-main/src/features/ui/hooks/tests/useSmartPolling.test.ts
new file mode 100644
index 00000000..7c84c40e
--- /dev/null
+++ b/archon-ui-main/src/features/ui/hooks/tests/useSmartPolling.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useSmartPolling } from '../useSmartPolling';
+
+describe('useSmartPolling', () => {
+ beforeEach(() => {
+ // Reset document visibility state
+ Object.defineProperty(document, 'visibilityState', {
+ value: 'visible',
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(document, 'hidden', {
+ value: false,
+ writable: true,
+ configurable: true,
+ });
+ // Mock document.hasFocus
+ document.hasFocus = vi.fn(() => true);
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.clearAllMocks();
+ });
+
+ it('should return the base interval when document is visible and focused', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ expect(result.current.refetchInterval).toBe(5000);
+ expect(result.current.isActive).toBe(true);
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.hasFocus).toBe(true);
+ });
+
+ it('should disable polling when document is hidden', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ // Initially should be active
+ expect(result.current.isActive).toBe(true);
+ expect(result.current.refetchInterval).toBe(5000);
+
+ // Simulate tab becoming hidden
+ act(() => {
+ Object.defineProperty(document, 'hidden', {
+ value: true,
+ writable: true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ // Should be disabled (returns false)
+ expect(result.current.isVisible).toBe(false);
+ expect(result.current.isActive).toBe(false);
+ expect(result.current.refetchInterval).toBe(false);
+ });
+
+ it('should resume polling when document becomes visible again', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ // Make hidden
+ act(() => {
+ Object.defineProperty(document, 'hidden', {
+ value: true,
+ writable: true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ expect(result.current.refetchInterval).toBe(false);
+
+ // Make visible again
+ act(() => {
+ Object.defineProperty(document, 'hidden', {
+ value: false,
+ writable: true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ expect(result.current.isVisible).toBe(true);
+ expect(result.current.isActive).toBe(true);
+ expect(result.current.refetchInterval).toBe(5000);
+ });
+
+ it('should slow down to 60 seconds when window loses focus', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ // Initially focused
+ expect(result.current.refetchInterval).toBe(5000);
+ expect(result.current.hasFocus).toBe(true);
+
+ // Simulate window blur
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+
+ // Should be slowed down to 60 seconds
+ expect(result.current.hasFocus).toBe(false);
+ expect(result.current.isActive).toBe(false);
+ expect(result.current.refetchInterval).toBe(60000);
+ });
+
+ it('should resume normal speed when window regains focus', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ // Blur window
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+
+ expect(result.current.refetchInterval).toBe(60000);
+
+ // Focus window again
+ act(() => {
+ window.dispatchEvent(new Event('focus'));
+ });
+
+ expect(result.current.hasFocus).toBe(true);
+ expect(result.current.isActive).toBe(true);
+ expect(result.current.refetchInterval).toBe(5000);
+ });
+
+ it('should handle different base intervals', () => {
+ const { result: result1 } = renderHook(() => useSmartPolling(1000));
+ const { result: result2 } = renderHook(() => useSmartPolling(10000));
+
+ expect(result1.current.refetchInterval).toBe(1000);
+ expect(result2.current.refetchInterval).toBe(10000);
+
+ // When blurred, both should be 60 seconds
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+
+ expect(result1.current.refetchInterval).toBe(60000);
+ expect(result2.current.refetchInterval).toBe(60000);
+ });
+
+ it('should use default interval of 10000ms when not specified', () => {
+ const { result } = renderHook(() => useSmartPolling());
+
+ expect(result.current.refetchInterval).toBe(10000);
+ });
+
+ it('should cleanup event listeners on unmount', () => {
+ const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
+ const windowRemoveEventListenerSpy = vi.spyOn(window, 'removeEventListener');
+
+ const { unmount } = renderHook(() => useSmartPolling(5000));
+
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
+ expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
+ expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('blur', expect.any(Function));
+
+ removeEventListenerSpy.mockRestore();
+ windowRemoveEventListenerSpy.mockRestore();
+ });
+
+ it('should correctly report isActive state', () => {
+ const { result } = renderHook(() => useSmartPolling(5000));
+
+ // Active when both visible and focused
+ expect(result.current.isActive).toBe(true);
+
+ // Not active when not focused
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ });
+ expect(result.current.isActive).toBe(false);
+
+ // Not active when hidden
+ act(() => {
+ window.dispatchEvent(new Event('focus')); // Focus first
+ Object.defineProperty(document, 'hidden', {
+ value: true,
+ writable: true,
+ configurable: true,
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current.isActive).toBe(false);
+ });
+});
\ No newline at end of file
diff --git a/archon-ui-main/src/hooks/useMCPQueries.ts b/archon-ui-main/src/hooks/useMCPQueries.ts
deleted file mode 100644
index c3185644..00000000
--- a/archon-ui-main/src/hooks/useMCPQueries.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { mcpServerService } from '../services/mcpServerService';
-import { useToast } from '../contexts/ToastContext';
-
-// Query keys
-export const mcpKeys = {
- all: ['mcp'] as const,
- status: () => [...mcpKeys.all, 'status'] as const,
- config: () => [...mcpKeys.all, 'config'] as const,
- tools: () => [...mcpKeys.all, 'tools'] as const,
-};
-
-// Fetch MCP server status
-export function useMCPStatus() {
- return useQuery({
- queryKey: mcpKeys.status(),
- queryFn: () => mcpServerService.getStatus(),
- staleTime: 5 * 60 * 1000, // 5 minutes - status rarely changes
- refetchOnWindowFocus: false,
- });
-}
-
-// Fetch MCP server config
-export function useMCPConfig(enabled = true) {
- return useQuery({
- queryKey: mcpKeys.config(),
- queryFn: () => mcpServerService.getConfiguration(),
- enabled,
- staleTime: Infinity, // Config never changes unless server restarts
- });
-}
-
-// Start server mutation
-export function useStartMCPServer() {
- const queryClient = useQueryClient();
- const { showToast } = useToast();
-
- return useMutation({
- mutationFn: () => mcpServerService.startServer(),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
- queryClient.invalidateQueries({ queryKey: mcpKeys.config() });
- showToast('MCP server started successfully', 'success');
- },
- onError: (error: any) => {
- showToast(error.message || 'Failed to start server', 'error');
- },
- });
-}
-
-// Stop server mutation
-export function useStopMCPServer() {
- const queryClient = useQueryClient();
- const { showToast } = useToast();
-
- return useMutation({
- mutationFn: () => mcpServerService.stopServer(),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: mcpKeys.status() });
- queryClient.removeQueries({ queryKey: mcpKeys.config() });
- showToast('MCP server stopped', 'info');
- },
- onError: (error: any) => {
- showToast(error.message || 'Failed to stop server', 'error');
- },
- });
-}
-
-// List MCP tools
-export function useMCPTools(enabled = true) {
- return useQuery({
- queryKey: mcpKeys.tools(),
- queryFn: () => mcpServerService.listTools(),
- enabled,
- staleTime: Infinity, // Tools don't change during runtime
- });
-}
\ No newline at end of file
diff --git a/archon-ui-main/src/index.css b/archon-ui-main/src/index.css
index 3d7468f7..0dde10c9 100644
--- a/archon-ui-main/src/index.css
+++ b/archon-ui-main/src/index.css
@@ -30,7 +30,7 @@
--blue-accent: 217 91% 60%;
}
.dark {
- /* Dark mode variables - keep exactly as they were */
+ /* Dark mode variables */
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--muted: 240 4% 16%;
@@ -67,119 +67,22 @@
}
}
@layer components {
+ /* Grid pattern for background (actually used in MainLayout) */
.neon-grid {
- @apply bg-[linear-gradient(to_right,#a855f720_1px,transparent_1px),linear-gradient(to_bottom,#a855f720_1px,transparent_1px)] bg-[size:40px_40px];
- @apply dark:bg-[linear-gradient(to_right,#a855f730_1px,transparent_1px),linear-gradient(to_bottom,#a855f730_1px,transparent_1px)];
+ background-image:
+ linear-gradient(to right, #a855f720 1px, transparent 1px),
+ linear-gradient(to bottom, #a855f720 1px, transparent 1px);
+ background-size: 40px 40px;
}
- .neon-divider-h {
- @apply h-[1px] w-full;
- }
- .neon-divider-h.purple {
- @apply bg-purple-500;
- }
- .neon-divider-h.green {
- @apply bg-emerald-500;
- }
- .neon-divider-h.pink {
- @apply bg-pink-500;
- }
- .neon-divider-h.blue {
- @apply bg-blue-500;
- }
- .neon-divider-v {
- @apply w-[1px] h-full;
- }
- .neon-divider-v.purple {
- @apply bg-purple-500;
- }
- .neon-divider-v.green {
- @apply bg-emerald-500;
- }
- .neon-divider-v.pink {
- @apply bg-pink-500;
- }
- .neon-divider-v.blue {
- @apply bg-blue-500;
- }
- .knowledge-item-card {
- @apply relative backdrop-blur-md bg-gradient-to-b from-white/10 to-black/30 border border-purple-500/30 rounded-md p-4 transition-all duration-300;
- @apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-purple-500 before:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)];
- @apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-purple-500/20 after:to-purple-500/5 after:rounded-t-md after:pointer-events-none;
- @apply shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- }
- .knowledge-item-card:hover {
- @apply border-purple-500/70 shadow-[0_15px_40px_-15px_rgba(0,0,0,0.9)] before:shadow-[0_0_25px_8px_rgba(168,85,247,0.8)];
- @apply translate-y-[-2px];
- }
- /* Glassmorphism utility classes */
- .glass {
- /* Light mode (base) styles */
- @apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-gray-200 shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)];
- /* Dark mode overrides */
- @apply dark:bg-gradient-to-b dark:from-white/10 dark:to-black/30 dark:border-zinc-800/50 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- }
- .glass-purple {
- /* Light mode (base) styles */
- @apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-purple-300 shadow-[0_10px_30px_-15px_rgba(168,85,247,0.15)];
- @apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-purple-500 before:shadow-[0_0_10px_2px_rgba(168,85,247,0.4)];
- @apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-purple-100 after:to-white after:rounded-t-md after:pointer-events-none;
- /* Dark mode overrides */
- @apply dark:from-white/10 dark:to-black/30 dark:border-purple-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- @apply dark:before:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)];
- @apply dark:after:from-purple-500/20 dark:after:to-purple-500/5;
- }
- .glass-green {
- /* Light mode (base) styles */
- @apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-emerald-300 shadow-[0_10px_30px_-15px_rgba(16,185,129,0.15)];
- @apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-emerald-500 before:shadow-[0_0_10px_2px_rgba(16,185,129,0.4)];
- @apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-emerald-100 after:to-white after:rounded-t-md after:pointer-events-none;
- /* Dark mode overrides */
- @apply dark:from-white/10 dark:to-black/30 dark:border-emerald-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- @apply dark:before:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)];
- @apply dark:after:from-emerald-500/20 dark:after:to-emerald-500/5;
- }
- .glass-pink {
- /* Light mode (base) styles */
- @apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-pink-300 shadow-[0_10px_30px_-15px_rgba(236,72,153,0.15)];
- @apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-pink-500 before:shadow-[0_0_10px_2px_rgba(236,72,153,0.4)];
- @apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-pink-100 after:to-white after:rounded-t-md after:pointer-events-none;
- /* Dark mode overrides */
- @apply dark:from-white/10 dark:to-black/30 dark:border-pink-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- @apply dark:before:shadow-[0_0_20px_5px_rgba(236,72,153,0.7)];
- @apply dark:after:from-pink-500/20 dark:after:to-pink-500/5;
- }
- .glass-blue {
- /* Light mode (base) styles */
- @apply backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 border border-blue-300 shadow-[0_10px_30px_-15px_rgba(59,130,246,0.15)];
- @apply before:content-[""] before:absolute before:top-0 before:left-0 before:w-full before:h-[2px] before:bg-blue-500 before:shadow-[0_0_10px_2px_rgba(59,130,246,0.4)];
- @apply after:content-[""] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-blue-100 after:to-white after:rounded-t-md after:pointer-events-none;
- /* Dark mode overrides */
- @apply dark:from-white/10 dark:to-black/30 dark:border-blue-500/30 dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)];
- @apply dark:before:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)];
- @apply dark:after:from-blue-500/20 dark:after:to-blue-500/5;
- }
- /* Hide scrollbar but allow scrolling */
- .hide-scrollbar {
- -ms-overflow-style: none; /* IE and Edge */
- scrollbar-width: none; /* Firefox */
- }
- .hide-scrollbar::-webkit-scrollbar {
- display: none; /* Chrome, Safari and Opera */
- }
- /* Card flip animations */
- .flip-card .backface-hidden {
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- }
- .rotate-y-180 {
- transform: rotateY(180deg);
- }
- .transform-style-preserve-3d {
- transform-style: preserve-3d;
- -webkit-transform-style: preserve-3d;
+
+ .dark .neon-grid {
+ background-image:
+ linear-gradient(to right, #a855f730 1px, transparent 1px),
+ linear-gradient(to bottom, #a855f730 1px, transparent 1px);
}
}
-/* Animation delays */
+
+/* Animation delays (checked for usage) */
.animation-delay-150 {
animation-delay: 150ms;
}
@@ -187,105 +90,17 @@
animation-delay: 300ms;
}
-/* Card expansion animation */
-.card-collapsed {
- height: 140px;
- transition: height 0.5s ease-in-out;
-}
-
-.card-expanded {
- height: 280px;
- transition: height 0.5s ease-in-out;
-}
-
-/* Ensure scrollable content in expanded cards */
-.card-expanded .flex-1.overflow-hidden > .absolute {
- /* Removed max-height to allow full scrolling */
-}
-
-/* Screensaver Animations */
-@keyframes pulse {
- 0% {
- transform: scale(0);
- opacity: 1;
- }
- 100% {
- transform: scale(1);
- opacity: 0;
- }
-}
-
-@keyframes float {
- 0%, 100% {
- transform: translateY(0) translateX(0);
- }
- 33% {
- transform: translateY(-30px) translateX(10px);
- }
- 66% {
- transform: translateY(30px) translateX(-10px);
- }
-}
-
-@keyframes breathe {
- 0%, 100% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.05);
- }
-}
-
-@keyframes hologram {
- 0%, 100% {
- opacity: 1;
- transform: rotateY(0deg) scale(1);
- }
- 50% {
- opacity: 0.8;
- transform: rotateY(10deg) scale(1.02);
- }
-}
-
-@keyframes scan {
- 0% {
- transform: translateY(-100%);
- }
- 100% {
- transform: translateY(100%);
- }
-}
-
-@keyframes etherealFloat {
- 0%, 100% {
- transform: translateY(0) scale(1);
- opacity: 0.6;
- }
- 50% {
- transform: translateY(-20px) scale(1.05);
- opacity: 0.8;
- }
-}
-
-@keyframes glow {
- 0%, 100% {
- transform: scale(1);
- opacity: 0.6;
- }
- 50% {
- transform: scale(1.1);
- opacity: 0.8;
- }
-}
-
+/* Pulse glow animation (used in GlassCrawlDepthSelector) */
@keyframes pulse-glow {
0%, 100% {
- box-shadow: 0 0 20px 10px rgba(59, 130, 246, 0.5),
- 0 0 40px 20px rgba(59, 130, 246, 0.3);
+ box-shadow:
+ 0 0 20px 10px hsl(var(--blue-accent) / 0.50),
+ 0 0 40px 20px hsl(var(--blue-accent) / 0.30);
}
50% {
- box-shadow: 0 0 30px 15px rgba(59, 130, 246, 0.7),
- 0 0 60px 30px rgba(59, 130, 246, 0.4);
+ box-shadow:
+ 0 0 30px 15px hsl(var(--blue-accent) / 0.70),
+ 0 0 60px 30px hsl(var(--blue-accent) / 0.40);
}
}
@@ -293,10 +108,16 @@
animation: pulse-glow 2s ease-in-out infinite;
}
-/* Custom scrollbar styles */
+@media (prefers-reduced-motion: reduce) {
+ .animate-pulse-glow {
+ animation: none !important;
+ }
+}
+
+/* Custom scrollbar styles (used in multiple components) */
.custom-scrollbar {
scrollbar-width: thin;
- scrollbar-color: rgba(59, 130, 246, 0.3) transparent;
+ scrollbar-color: hsl(var(--blue-accent) / 0.30) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
@@ -308,27 +129,31 @@
}
.custom-scrollbar::-webkit-scrollbar-thumb {
- background-color: rgba(59, 130, 246, 0.3);
+ background-color: hsl(var(--blue-accent) / 0.30);
border-radius: 4px;
transition: background-color 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background-color: rgba(59, 130, 246, 0.5);
+ background-color: hsl(var(--blue-accent) / 0.50);
+}
+
+.dark .custom-scrollbar {
+ scrollbar-color: hsl(var(--blue-accent) / 0.45) transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
- background-color: rgba(59, 130, 246, 0.4);
+ background-color: hsl(var(--blue-accent) / 0.40);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background-color: rgba(59, 130, 246, 0.6);
+ background-color: hsl(var(--blue-accent) / 0.60);
}
-/* Thin scrollbar styles */
+/* Thin scrollbar styles (used in KanbanColumn and other components) - Tron-themed */
.scrollbar-thin {
scrollbar-width: thin;
- scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+ scrollbar-color: hsl(var(--blue-accent) / 0.40) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
@@ -341,18 +166,47 @@
}
.scrollbar-thin::-webkit-scrollbar-thumb {
- background-color: rgba(156, 163, 175, 0.5);
+ background: linear-gradient(
+ to bottom,
+ hsl(var(--blue-accent) / 0.60),
+ hsl(var(--blue-accent) / 0.30)
+ );
border-radius: 3px;
+ box-shadow: 0 0 3px hsl(var(--blue-accent) / 0.40);
+ transition: background 0.2s ease, box-shadow 0.2s ease;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
- background-color: rgba(156, 163, 175, 0.7);
+ background: linear-gradient(
+ to bottom,
+ hsl(var(--blue-accent) / 0.80),
+ hsl(var(--blue-accent) / 0.50)
+ );
+ box-shadow:
+ 0 0 6px hsl(var(--blue-accent) / 0.60),
+ inset 0 0 3px hsl(var(--blue-accent) / 0.30);
+}
+
+.dark .scrollbar-thin {
+ scrollbar-color: hsl(var(--blue-accent) / 0.50) transparent;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
- background-color: rgba(75, 85, 99, 0.5);
+ background: linear-gradient(
+ to bottom,
+ hsl(var(--blue-accent) / 0.50),
+ hsl(var(--blue-accent) / 0.20)
+ );
+ box-shadow: 0 0 4px hsl(var(--blue-accent) / 0.50);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
- background-color: rgba(75, 85, 99, 0.7);
+ background: linear-gradient(
+ to bottom,
+ hsl(var(--blue-accent) / 0.70),
+ hsl(var(--blue-accent) / 0.40)
+ );
+ box-shadow:
+ 0 0 8px hsl(var(--blue-accent) / 0.70),
+ inset 0 0 3px hsl(var(--blue-accent) / 0.40);
}
\ No newline at end of file
diff --git a/archon-ui-main/src/pages/MCPPage.tsx b/archon-ui-main/src/pages/MCPPage.tsx
index a7513efa..65084e36 100644
--- a/archon-ui-main/src/pages/MCPPage.tsx
+++ b/archon-ui-main/src/pages/MCPPage.tsx
@@ -1,676 +1,5 @@
-import { useState, useEffect, useRef } from 'react';
-import { Play, Square, Copy, Server, AlertCircle, CheckCircle, Loader } from 'lucide-react';
-import { motion } from 'framer-motion';
-import { Card } from '../components/ui/Card';
-import { Button } from '../components/ui/Button';
-import { useStaggeredEntrance } from '../hooks/useStaggeredEntrance';
-import { useToast } from '../contexts/ToastContext';
-import { mcpServerService, ServerStatus, ServerConfig } from '../services/mcpServerService';
-import { IDEGlobalRules } from '../components/settings/IDEGlobalRules';
-// import { MCPClients } from '../components/mcp/MCPClients'; // Commented out - feature not implemented
+import { McpViewWithBoundary } from '../features/mcp';
-// Supported IDE/Agent types
-type SupportedIDE = 'windsurf' | 'cursor' | 'claudecode' | 'cline' | 'kiro' | 'augment' | 'gemini';
-
-/**
- * MCP Dashboard Page Component
- *
- * This is the main dashboard for managing the MCP (Model Context Protocol) server.
- * It provides a comprehensive interface for:
- *
- * 1. Server Control Tab:
- * - Start/stop the MCP server
- * - Monitor server status and uptime
- * - View and copy connection configuration
- *
- * 2. MCP Clients Tab:
- * - Interactive client management interface
- * - Tool discovery and testing
- * - Real-time tool execution
- * - Parameter input and result visualization
- *
- * The page uses a tab-based layout with preserved server functionality
- * and enhanced client management capabilities.
- *
- * @component
- */
export const MCPPage = () => {
- const [serverStatus, setServerStatus] = useState({
- status: 'stopped',
- uptime: null,
- logs: []
- });
- const [config, setConfig] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
- const [isStarting, setIsStarting] = useState(false);
- const [isStopping, setIsStopping] = useState(false);
- const [selectedIDE, setSelectedIDE] = useState('windsurf');
- const statusPollInterval = useRef | null>(null);
- const { showToast } = useToast();
-
- // Tab state for switching between Server Control and Clients
- const [activeTab, setActiveTab] = useState<'server' | 'clients'>('server');
-
- // Use staggered entrance animation
- const { isVisible, containerVariants, itemVariants, titleVariants } = useStaggeredEntrance(
- [1, 2, 3],
- 0.15
- );
-
- // Load initial status and start polling
- useEffect(() => {
- loadStatus();
- loadConfiguration();
-
- // Start polling for status updates every 5 seconds
- statusPollInterval.current = setInterval(loadStatus, 5000);
-
- return () => {
- if (statusPollInterval.current) {
- clearInterval(statusPollInterval.current);
- }
- };
- }, []);
-
-
- // Ensure configuration is loaded when server is running
- useEffect(() => {
- if (serverStatus.status === 'running' && !config) {
- loadConfiguration();
- }
- }, [serverStatus.status]);
-
-
- /**
- * Load the current MCP server status
- * Called on mount and every 5 seconds via polling
- */
- const loadStatus = async () => {
- try {
- const status = await mcpServerService.getStatus();
- setServerStatus(status);
- setIsLoading(false);
- } catch (error) {
- console.error('Failed to load server status:', error);
- setIsLoading(false);
- }
- };
-
- /**
- * Load the MCP server configuration
- * Falls back to default values if database load fails
- */
- const loadConfiguration = async () => {
- try {
- const cfg = await mcpServerService.getConfiguration();
- console.log('Loaded configuration:', cfg);
- setConfig(cfg);
- } catch (error) {
- console.error('Failed to load configuration:', error);
- // Set a default config if loading fails
- // Try to detect port from environment or use default
- const defaultPort = import.meta.env.ARCHON_MCP_PORT || 8051;
- setConfig({
- transport: 'http',
- host: 'localhost',
- port: typeof defaultPort === 'string' ? parseInt(defaultPort) : defaultPort
- });
- }
- };
-
-
- /**
- * Start the MCP server
- */
- const handleStartServer = async () => {
- try {
- setIsStarting(true);
- const response = await mcpServerService.startServer();
- showToast(response.message, 'success');
- // Immediately refresh status
- await loadStatus();
- } catch (error: any) {
- showToast(error.message || 'Failed to start server', 'error');
- } finally {
- setIsStarting(false);
- }
- };
-
- const handleStopServer = async () => {
- try {
- setIsStopping(true);
- const response = await mcpServerService.stopServer();
- showToast(response.message, 'success');
- // Immediately refresh status
- await loadStatus();
- } catch (error: any) {
- showToast(error.message || 'Failed to stop server', 'error');
- } finally {
- setIsStopping(false);
- }
- };
-
-
- const handleCopyConfig = () => {
- if (!config) return;
-
- const configText = getConfigForIDE(selectedIDE);
- navigator.clipboard.writeText(configText);
- showToast('Configuration copied to clipboard', 'success');
- };
-
- const generateCursorDeeplink = () => {
- if (!config) return '';
-
- const httpConfig = {
- url: `http://${config.host}:${config.port}/mcp`
- };
-
- const configString = JSON.stringify(httpConfig);
- const base64Config = btoa(configString);
- return `cursor://anysphere.cursor-deeplink/mcp/install?name=archon&config=${base64Config}`;
- };
-
- const handleCursorOneClick = () => {
- const deeplink = generateCursorDeeplink();
- window.location.href = deeplink;
- showToast('Opening Cursor with Archon MCP configuration...', 'info');
- };
-
-
-
- const getConfigForIDE = (ide: SupportedIDE) => {
- if (!config || !config.host || !config.port) {
- return '// Configuration not available. Please ensure the server is running.';
- }
-
- const mcpUrl = `http://${config.host}:${config.port}/mcp`;
-
- switch(ide) {
- case 'claudecode':
- return JSON.stringify({
- name: "archon",
- transport: "http",
- url: mcpUrl
- }, null, 2);
-
- case 'cline':
- case 'kiro':
- // Cline and Kiro use stdio transport with mcp-remote
- return JSON.stringify({
- mcpServers: {
- archon: {
- command: "npx",
- args: ["mcp-remote", mcpUrl, "--allow-http"]
- }
- }
- }, null, 2);
-
- case 'windsurf':
- return JSON.stringify({
- mcpServers: {
- archon: {
- serverUrl: mcpUrl
- }
- }
- }, null, 2);
-
- case 'cursor':
- case 'augment':
- return JSON.stringify({
- mcpServers: {
- archon: {
- url: mcpUrl
- }
- }
- }, null, 2);
-
- default:
- return '';
- case 'gemini':
- return JSON.stringify({
- mcpServers: {
- archon: {
- httpUrl: mcpUrl
- }
- }
- }, null, 2);
- }
- };
-
- const getIDEInstructions = (ide: SupportedIDE) => {
- switch (ide) {
- case 'windsurf':
- return {
- title: 'Windsurf Configuration',
- steps: [
- '1. Open Windsurf and click the "MCP servers" button (hammer icon)',
- '2. Click "Configure" and then "View raw config"',
- '3. Add the configuration shown below to the mcpServers object',
- '4. Click "Refresh" to connect to the server'
- ]
- };
- case 'cursor':
- return {
- title: 'Cursor Configuration',
- steps: [
- '1. Option A: Use the one-click install button below (recommended)',
- '2. Option B: Manually edit ~/.cursor/mcp.json',
- '3. Add the configuration shown below',
- '4. Restart Cursor for changes to take effect'
- ]
- };
- case 'claudecode':
- return {
- title: 'Claude Code Configuration',
- steps: [
- '1. Open a terminal and run the following command:',
- `2. claude mcp add --transport http archon http://${config?.host}:${config?.port}/mcp`,
- '3. The connection will be established automatically'
- ]
- };
- case 'cline':
- return {
- title: 'Cline Configuration',
- steps: [
- '1. Open VS Code settings (Cmd/Ctrl + ,)',
- '2. Search for "cline.mcpServers"',
- '3. Click "Edit in settings.json"',
- '4. Add the configuration shown below',
- '5. Restart VS Code for changes to take effect'
- ]
- };
- case 'kiro':
- return {
- title: 'Kiro Configuration',
- steps: [
- '1. Open Kiro settings',
- '2. Navigate to MCP Servers section',
- '3. Add the configuration shown below',
- '4. Save and restart Kiro'
- ]
- };
- case 'augment':
- return {
- title: 'Augment Configuration',
- steps: [
- '1. Open Augment settings',
- '2. Navigate to Extensions > MCP',
- '3. Add the configuration shown below',
- '4. Reload configuration'
- ]
- };
- case 'gemini':
- return {
- title: 'Gemini CLI Configuration',
- steps: [
- '1. Locate or create the settings file at ~/.gemini/settings.json',
- '2. Add the configuration shown below to the file',
- '3. Launch Gemini CLI in your terminal',
- '4. Test the connection by typing /mcp to list available tools'
- ]
- };
- default:
- return {
- title: 'Configuration',
- steps: ['Add the configuration to your IDE settings']
- };
- }
- };
-
- const formatUptime = (seconds: number): string => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
- return `${hours}h ${minutes}m ${secs}s`;
- };
-
- const getStatusIcon = () => {
- switch (serverStatus.status) {
- case 'running':
- return ;
- case 'starting':
- case 'stopping':
- return ;
- default:
- return ;
- }
- };
-
- const getStatusColor = () => {
- switch (serverStatus.status) {
- case 'running':
- return 'text-green-500';
- case 'starting':
- case 'stopping':
- return 'text-blue-500';
- default:
- return 'text-red-500';
- }
- };
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- MCP Dashboard
-
-
- {/* Tab Navigation */}
-
-
- setActiveTab('server')}
- className={`pb-3 relative ${
- activeTab === 'server'
- ? 'text-blue-600 dark:text-blue-400 font-medium'
- : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
- }`}
- >
- Server Control
- {activeTab === 'server' && (
-
- )}
-
- {/* TODO: MCP Client feature not implemented - commenting out for now
- setActiveTab('clients')}
- className={`pb-3 relative ${
- activeTab === 'clients'
- ? 'text-cyan-600 dark:text-cyan-400 font-medium'
- : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
- }`}
- >
- MCP Clients
- {activeTab === 'clients' && (
-
- )}
-
- */}
-
-
-
- {/* Server Control Tab */}
- {activeTab === 'server' && (
- <>
- {/* Server Control */}
-
-
- {/* Left Column: Archon MCP Server */}
-
-
-
- Archon MCP Server
-
-
-
- {/* Status Display */}
-
-
- {getStatusIcon()}
-
-
- Status: {serverStatus.status.charAt(0).toUpperCase() + serverStatus.status.slice(1)}
-
- {serverStatus.uptime !== null && (
-
- Uptime: {formatUptime(serverStatus.uptime)}
-
- )}
-
-
-
- {/* Control Buttons */}
-
- {serverStatus.status === 'stopped' ? (
-
- {isStarting ? (
- <>
-
- Starting...
- >
- ) : (
- <>
-
- Start Server
- >
- )}
-
- ) : (
-
- {isStopping ? (
- <>
-
- Stopping...
- >
- ) : (
- <>
-
- Stop Server
- >
- )}
-
- )}
-
-
-
- {/* Connection Details */}
- {serverStatus.status === 'running' && config && (
-
-
-
- IDE Configuration
-
- HTTP Mode
-
-
-
-
- Copy
-
-
-
- {/* Note about universal MCP compatibility */}
-
-
- Note: Archon works with any application that supports MCP.
- Below are instructions for common tools, but these steps can be adapted for any MCP-compatible client.
-
-
-
- {/* IDE Selection Tabs */}
-
-
- setSelectedIDE('claudecode')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'claudecode'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Claude Code
-
- setSelectedIDE('gemini')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'gemini'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Gemini CLI
-
- setSelectedIDE('cursor')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'cursor'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Cursor
-
- setSelectedIDE('windsurf')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'windsurf'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Windsurf
-
- setSelectedIDE('cline')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'cline'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Cline
-
- setSelectedIDE('kiro')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'kiro'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Kiro
-
- setSelectedIDE('augment')}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- selectedIDE === 'augment'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
- } cursor-pointer`}
- >
- Augment
-
-
-
-
- {/* IDE Instructions */}
-
-
- {getIDEInstructions(selectedIDE).title}
-
-
- {getIDEInstructions(selectedIDE).steps.map((step, index) => (
- {step}
- ))}
-
-
-
-
-
- {getConfigForIDE(selectedIDE)}
-
-
- {selectedIDE === 'cursor'
- ? 'Copy this configuration and add it to ~/.cursor/mcp.json'
- : selectedIDE === 'windsurf'
- ? 'Copy this configuration and add it to your Windsurf MCP settings'
- : selectedIDE === 'claudecode'
- ? 'This shows the configuration format for Claude Code'
- : selectedIDE === 'cline'
- ? 'Copy this configuration and add it to VS Code settings.json under "cline.mcpServers"'
- : selectedIDE === 'kiro'
- ? 'Copy this configuration and add it to your Kiro MCP settings'
- : selectedIDE === 'augment'
- ? 'Copy this configuration and add it to your Augment MCP settings'
- : 'Copy this configuration and add it to your IDE settings'
- }
-
-
-
- {/* One-click install button for Cursor */}
- {selectedIDE === 'cursor' && serverStatus.status === 'running' && (
-
-
-
- One-Click Install for Cursor
-
-
- Requires Cursor to be installed and will open a deeplink
-
-
- )}
-
- )}
-
-
-
-
-
- {/* Global Rules Section */}
-
-
-
- Global IDE Rules
-
-
-
- >
- )}
-
- {/* Clients Tab - commented out as feature not implemented
- {activeTab === 'clients' && (
-
-
-
- )}
- */}
-
- );
+ return ;
};
\ No newline at end of file
diff --git a/archon-ui-main/src/pages/ProjectPage.tsx b/archon-ui-main/src/pages/ProjectPage.tsx
index 9ec8dee0..08453b96 100644
--- a/archon-ui-main/src/pages/ProjectPage.tsx
+++ b/archon-ui-main/src/pages/ProjectPage.tsx
@@ -1,10 +1,11 @@
-import { ProjectsView } from '../features/projects';
+import { ProjectsViewWithBoundary } from '../features/projects';
// Minimal wrapper for routing compatibility
// All implementation is in features/projects/views/ProjectsView.tsx
+// Uses ProjectsViewWithBoundary for proper error handling
function ProjectPage(props: any) {
- return ;
+ return ;
}
export { ProjectPage };
\ No newline at end of file
diff --git a/archon-ui-main/src/services/api.ts b/archon-ui-main/src/services/api.ts
index a4902c0e..68214d22 100644
--- a/archon-ui-main/src/services/api.ts
+++ b/archon-ui-main/src/services/api.ts
@@ -1,19 +1,8 @@
/**
- * API service layer for communicating with the MCP server backend.
+ * API service layer for backend communication.
*/
// Types for API responses
-export interface MCPServerResponse {
- success: boolean;
- status: 'starting' | 'running' | 'stopped' | 'error';
- message?: string;
-}
-
-export interface MCPServerStatus {
- status: 'starting' | 'running' | 'stopped' | 'error';
- uptime?: number;
- logs: string[];
-}
export interface CrawlResponse {
success: boolean;
@@ -125,19 +114,6 @@ export async function apiRequest(
}
}
-// MCP Server Management
-export async function startMCPServer(): Promise {
- return retry(() => apiRequest('/mcp/start', { method: 'POST' }));
-}
-
-export async function stopMCPServer(): Promise {
- return retry(() => apiRequest('/mcp/stop', { method: 'POST' }));
-}
-
-export async function getMCPServerStatus(): Promise {
- return retry(() => apiRequest('/mcp/status'));
-}
-
// Crawling Operations
export async function crawlSinglePage(url: string): Promise {
return retry(() => apiRequest('/crawl/single', {
diff --git a/archon-ui-main/src/services/mcpClientService.ts b/archon-ui-main/src/services/mcpClientService.ts
deleted file mode 100644
index 2010c9bf..00000000
--- a/archon-ui-main/src/services/mcpClientService.ts
+++ /dev/null
@@ -1,445 +0,0 @@
-import { z } from 'zod';
-import { getApiUrl } from '../config/api';
-
-// ========================================
-// TYPES & INTERFACES
-// ========================================
-
-export interface MCPClientConfig {
- name: string;
- transport_type: 'http'; // Only Streamable HTTP is supported for MCP clients
- connection_config: {
- url: string; // The Streamable HTTP endpoint URL (e.g., http://localhost:8051/mcp)
- };
- auto_connect?: boolean;
- health_check_interval?: number;
- is_default?: boolean;
-}
-
-export interface MCPClient {
- id: string;
- name: string;
- transport_type: 'http'; // Only Streamable HTTP is supported
- connection_config: {
- url: string;
- };
- status: 'connected' | 'disconnected' | 'connecting' | 'error';
- auto_connect: boolean;
- health_check_interval: number;
- last_seen: string | null;
- last_error: string | null;
- is_default: boolean;
- created_at: string;
- updated_at: string;
-}
-
-export interface MCPClientTool {
- id: string;
- client_id: string;
- tool_name: string;
- tool_description: string | null;
- tool_schema: Record;
- discovered_at: string;
-}
-
-export interface ToolCallRequest {
- client_id: string;
- tool_name: string;
- arguments: Record;
-}
-
-export interface ClientStatus {
- client_id: string;
- status: string;
- last_seen: string | null;
- last_error: string | null;
- is_active: boolean;
-}
-
-export interface ToolsResponse {
- client_id: string;
- tools: MCPClientTool[];
- count: number;
-}
-
-export interface AllToolsResponse {
- archon_tools: MCPTool[];
- client_tools: { client: MCPClient; tools: MCPClientTool[] }[];
- total_count: number;
-}
-
-// Zod schemas for MCP protocol
-const MCPParameterSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- type: z.string().optional(),
-});
-
-const MCPToolSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- inputSchema: z.object({
- type: z.literal('object'),
- properties: z.record(z.any()).optional(),
- required: z.array(z.string()).optional(),
- }).optional(),
-});
-
-export type MCPTool = z.infer;
-export type MCPParameter = z.infer;
-
-import { getApiUrl } from '../config/api';
-
-/**
- * MCP Client Service - Universal MCP client that connects to any MCP servers
- * This service communicates with the standalone Python MCP client service
- */
-class MCPClientService {
- private baseUrl = getApiUrl();
-
- // ========================================
- // CLIENT MANAGEMENT
- // ========================================
-
- /**
- * Get all configured MCP clients
- */
- async getClients(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/`);
-
- if (!response.ok) {
- throw new Error('Failed to get MCP clients');
- }
-
- return response.json();
- }
-
- /**
- * Create a new MCP client
- */
- async createClient(config: MCPClientConfig): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to create MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Get a specific MCP client
- */
- async getClient(clientId: string): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Update an MCP client
- */
- async updateClient(clientId: string, updates: Partial): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(updates)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to update MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Delete an MCP client
- */
- async deleteClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to delete MCP client');
- }
-
- return response.json();
- }
-
- // ========================================
- // CONNECTION MANAGEMENT
- // ========================================
-
- /**
- * Connect to an MCP client
- */
- async connectClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/connect`, {
- method: 'POST'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to connect to MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Disconnect from an MCP client
- */
- async disconnectClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/disconnect`, {
- method: 'POST'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to disconnect from MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Get client status and health
- */
- async getClientStatus(clientId: string): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/status`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get client status');
- }
-
- return response.json();
- }
-
- /**
- * Test a client configuration before saving
- */
- async testClientConfig(config: MCPClientConfig): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/test-config`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to test client configuration');
- }
-
- return response.json();
- }
-
- // ========================================
- // TOOL DISCOVERY & EXECUTION
- // ========================================
-
- /**
- * Get tools from a specific client
- */
- async getClientTools(clientId: string): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get client tools');
- }
-
- return response.json();
- }
-
- /**
- * Call a tool on a specific client
- */
- async callClientTool(request: ToolCallRequest): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/call`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(request)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to call client tool');
- }
-
- return response.json();
- }
-
- /**
- * Get tools from all connected clients (including Archon via MCP client)
- */
- async getAllAvailableTools(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/all`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get all available tools');
- }
-
- return response.json();
- }
-
- /**
- * Discover tools from a specific client (force refresh)
- */
- async discoverClientTools(clientId: string): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools/discover`, {
- method: 'POST'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to discover client tools');
- }
-
- return response.json();
- }
-
- // ========================================
- // CONVENIENCE METHODS
- // ========================================
-
- /**
- * Connect to multiple clients at once
- */
- async connectMultipleClients(clientIds: string[]): Promise> {
- const results = await Promise.allSettled(
- clientIds.map(async (clientId) => {
- try {
- const result = await this.connectClient(clientId);
- return { clientId, ...result };
- } catch (error) {
- return {
- clientId,
- success: false,
- message: error instanceof Error ? error.message : 'Unknown error'
- };
- }
- })
- );
-
- return results.map((result, index) =>
- result.status === 'fulfilled'
- ? result.value
- : { clientId: clientIds[index], success: false, message: result.reason?.message || 'Failed to connect' }
- );
- }
-
- /**
- * Get status for all clients
- */
- async getAllClientStatuses(): Promise> {
- const clients = await this.getClients();
-
- const statuses = await Promise.allSettled(
- clients.map(async (client) => {
- try {
- const status = await this.getClientStatus(client.id);
- return { client, status };
- } catch (error) {
- return {
- client,
- status: {
- client_id: client.id,
- status: 'error',
- last_seen: null,
- last_error: error instanceof Error ? error.message : 'Unknown error',
- is_active: false
- }
- };
- }
- })
- );
-
- return statuses.map((result) =>
- result.status === 'fulfilled' ? result.value : result.reason
- );
- }
-
- /**
- * Auto-connect to all clients marked with auto_connect
- */
- async autoConnectClients(): Promise> {
- const clients = await this.getClients();
- const autoConnectClients = clients.filter(client => client.auto_connect);
-
- if (autoConnectClients.length === 0) {
- return [];
- }
-
- return this.connectMultipleClients(autoConnectClients.map(c => c.id));
- }
-
- // ========================================
- // ARCHON INTEGRATION HELPERS
- // ========================================
-
- /**
- * Create Archon MCP client using Streamable HTTP transport
- */
- async createArchonClient(): Promise {
- // Require ARCHON_MCP_PORT to be set
- const mcpPort = import.meta.env.ARCHON_MCP_PORT;
- if (!mcpPort) {
- throw new Error(
- 'ARCHON_MCP_PORT environment variable is required. ' +
- 'Please set it in your environment variables. ' +
- 'Default value: 8051'
- );
- }
-
- // Get the host from the API URL
- const apiUrl = getApiUrl();
- const url = new URL(apiUrl || `http://${window.location.hostname}:${mcpPort}`);
- const mcpUrl = `${url.protocol}//${url.hostname}:${mcpPort}/mcp`;
-
- const archonConfig: MCPClientConfig = {
- name: 'Archon',
- transport_type: 'http',
- connection_config: {
- url: mcpUrl
- },
- auto_connect: true,
- health_check_interval: 30,
- is_default: true
- };
-
- return this.createClient(archonConfig);
- }
-
- /**
- * Get the default Archon client (or create if doesn't exist)
- */
- async getOrCreateArchonClient(): Promise {
- const clients = await this.getClients();
- const archonClient = clients.find(client => client.is_default || client.name === 'Archon');
-
- if (archonClient) {
- return archonClient;
- }
-
- return this.createArchonClient();
- }
-}
-
-export const mcpClientService = new MCPClientService();
\ No newline at end of file
diff --git a/archon-ui-main/src/services/mcpServerService.ts b/archon-ui-main/src/services/mcpServerService.ts
deleted file mode 100644
index 4fe7c40e..00000000
--- a/archon-ui-main/src/services/mcpServerService.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { z } from 'zod';
-
-export interface ServerStatus {
- status: 'running' | 'starting' | 'stopped' | 'stopping';
- uptime: number | null;
- logs: string[];
-}
-
-export interface ServerResponse {
- success: boolean;
- status: string;
- message: string;
-}
-
-export interface ServerConfig {
- transport: string;
- host: string;
- port: number;
- model?: string;
-}
-
-// Zod schemas for MCP protocol
-const MCPParameterSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- type: z.string().optional(),
-});
-
-const MCPToolSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- inputSchema: z.object({
- type: z.literal('object'),
- properties: z.record(z.any()).optional(),
- required: z.array(z.string()).optional(),
- }).optional(),
-});
-
-const MCPToolsListResponseSchema = z.object({
- tools: z.array(MCPToolSchema),
-});
-
-const MCPResponseSchema = z.object({
- jsonrpc: z.literal('2.0'),
- id: z.union([z.string(), z.number()]),
- result: z.any().optional(),
- error: z.object({
- code: z.number(),
- message: z.string(),
- data: z.any().optional(),
- }).optional(),
-});
-
-export type MCPTool = z.infer;
-export type MCPParameter = z.infer;
-
-
-/**
- * MCP Server Service - Handles the Archon MCP server lifecycle via FastAPI
- */
-class MCPServerService {
- private baseUrl = ''; // Use relative URL to go through Vite proxy
-
- // ========================================
- // SERVER MANAGEMENT
- // ========================================
-
- async startServer(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to start MCP server');
- }
-
- return response.json();
- }
-
- async stopServer(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/stop`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to stop MCP server');
- }
-
- return response.json();
- }
-
- async getStatus(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/status`);
-
- if (!response.ok) {
- throw new Error('Failed to get server status');
- }
-
- return response.json();
- }
-
- async getConfiguration(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/config`);
-
- if (!response.ok) {
- // Return default config if endpoint doesn't exist yet
- return {
- transport: 'sse',
- host: 'localhost',
- port: 8051
- };
- }
-
- return response.json();
- }
-
- async updateConfiguration(config: Partial): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/config`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to update configuration');
- }
-
- return response.json();
- }
-
-
- // ========================================
- // LEGACY ARCHON TOOL ACCESS (For backward compatibility)
- // ========================================
-
- /**
- * Make an MCP call to the running Archon server via SSE
- */
- private async makeMCPCall(method: string, params?: any): Promise {
- const status = await this.getStatus();
- if (status.status !== 'running') {
- throw new Error('MCP server is not running');
- }
-
- const config = await this.getConfiguration();
- const mcpUrl = `http://${config.host}:${config.port}/${config.transport}`;
-
- // Generate unique request ID
- const id = Math.random().toString(36).substring(2);
-
- const mcpRequest = {
- jsonrpc: '2.0',
- id,
- method,
- params: params || {}
- };
-
- try {
- const response = await fetch(mcpUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(mcpRequest)
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const mcpResponse = await response.json();
-
- // Validate MCP response format
- const validatedResponse = MCPResponseSchema.parse(mcpResponse);
-
- if (validatedResponse.error) {
- throw new Error(`MCP Error: ${validatedResponse.error.message}`);
- }
-
- return validatedResponse.result;
- } catch (error) {
- console.error('MCP call failed:', error);
- throw error;
- }
- }
-
- /**
- * Get available tools from the running Archon MCP server
- * @deprecated Use mcpClientService for tool discovery instead
- */
- async getAvailableTools(): Promise {
- try {
- console.log('Attempting direct MCP tools/list call to Archon server...');
- const result = await this.makeMCPCall('tools/list');
- const validatedResult = MCPToolsListResponseSchema.parse(result);
- console.log('Successfully retrieved tools from Archon server:', validatedResult.tools.length);
- return validatedResult.tools;
- } catch (mcpError) {
- console.warn('Direct MCP call to Archon server failed:', mcpError);
- throw new Error(`Failed to retrieve tools from Archon server: ${mcpError instanceof Error ? mcpError.message : mcpError}`);
- }
- }
-
- /**
- * Call a specific tool on the Archon MCP server
- * @deprecated Use mcpClientService for tool calls instead
- */
- async callTool(name: string, arguments_: Record): Promise {
- try {
- const result = await this.makeMCPCall('tools/call', {
- name,
- arguments: arguments_
- });
- return result;
- } catch (error) {
- console.error(`Failed to call Archon MCP tool ${name}:`, error);
- throw error;
- }
- }
-}
-
-export const mcpServerService = new MCPServerService();
-
-/**
- * Legacy function - use mcpServerService.getAvailableTools() instead
- * @deprecated Use mcpServerService.getAvailableTools() or mcpClientService instead
- */
-export const getMCPTools = async () => {
- console.warn('getMCPTools is deprecated. Use mcpServerService.getAvailableTools() or mcpClientService instead.');
- return mcpServerService.getAvailableTools();
-};
\ No newline at end of file
diff --git a/archon-ui-main/src/services/mcpService.ts b/archon-ui-main/src/services/mcpService.ts
deleted file mode 100644
index 81d7a69d..00000000
--- a/archon-ui-main/src/services/mcpService.ts
+++ /dev/null
@@ -1,580 +0,0 @@
-import { z } from 'zod';
-
-export interface ServerStatus {
- status: 'running' | 'starting' | 'stopped' | 'stopping';
- uptime: number | null;
- logs: string[];
-}
-
-export interface ServerResponse {
- success: boolean;
- status: string;
- message: string;
-}
-
-export interface LogEntry {
- timestamp: string;
- level: string;
- message: string;
-}
-
-export interface ServerConfig {
- transport: string;
- host: string;
- port: number;
- model?: string;
-}
-
-// Multi-client interfaces
-export interface MCPClientConfig {
- name: string;
- transport_type: 'sse' | 'stdio' | 'docker' | 'npx';
- connection_config: Record;
- auto_connect?: boolean;
- health_check_interval?: number;
- is_default?: boolean;
-}
-
-export interface MCPClient {
- id: string;
- name: string;
- transport_type: 'sse' | 'stdio' | 'docker' | 'npx';
- connection_config: Record;
- status: 'connected' | 'disconnected' | 'connecting' | 'error';
- auto_connect: boolean;
- health_check_interval: number;
- last_seen: string | null;
- last_error: string | null;
- is_default: boolean;
- created_at: string;
- updated_at: string;
-}
-
-export interface MCPClientTool {
- id: string;
- client_id: string;
- tool_name: string;
- tool_description: string | null;
- tool_schema: Record;
- discovered_at: string;
-}
-
-export interface ToolCallRequest {
- client_id: string;
- tool_name: string;
- arguments: Record;
-}
-
-interface StreamLogOptions {
- autoReconnect?: boolean;
- reconnectDelay?: number;
-}
-
-// Zod schemas for MCP protocol
-const MCPParameterSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- required: z.boolean().optional(),
- type: z.string().optional(),
-});
-
-const MCPToolSchema = z.object({
- name: z.string(),
- description: z.string().optional(),
- inputSchema: z.object({
- type: z.literal('object'),
- properties: z.record(z.any()).optional(),
- required: z.array(z.string()).optional(),
- }).optional(),
-});
-
-const MCPToolsListResponseSchema = z.object({
- tools: z.array(MCPToolSchema),
-});
-
-const MCPResponseSchema = z.object({
- jsonrpc: z.literal('2.0'),
- id: z.union([z.string(), z.number()]),
- result: z.any().optional(),
- error: z.object({
- code: z.number(),
- message: z.string(),
- data: z.any().optional(),
- }).optional(),
-});
-
-export type MCPTool = z.infer;
-export type MCPParameter = z.infer;
-
-
-class MCPService {
- private baseUrl = ''; // Use relative URL to go through Vite proxy
-
- // ========================================
- // SERVER MANAGEMENT (Original functionality)
- // ========================================
-
- async startServer(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to start MCP server');
- }
-
- return response.json();
- }
-
- async stopServer(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/stop`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to stop MCP server');
- }
-
- return response.json();
- }
-
- async getStatus(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/status`);
-
- if (!response.ok) {
- throw new Error('Failed to get server status');
- }
-
- return response.json();
- }
-
- async getConfiguration(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/config`);
-
- if (!response.ok) {
- // Return default config if endpoint doesn't exist yet
- return {
- transport: 'sse',
- host: 'localhost',
- port: 8051
- };
- }
-
- return response.json();
- }
-
- async updateConfiguration(config: Partial): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/config`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || 'Failed to update configuration');
- }
-
- return response.json();
- }
-
- async getLogs(options: { limit?: number } = {}): Promise {
- const params = new URLSearchParams();
- if (options.limit) {
- params.append('limit', options.limit.toString());
- }
-
- const response = await fetch(`${this.baseUrl}/api/mcp/logs?${params}`);
-
- if (!response.ok) {
- throw new Error('Failed to fetch logs');
- }
-
- const data = await response.json();
- return data.logs || [];
- }
-
- async clearLogs(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/logs`, {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- throw new Error('Failed to clear logs');
- }
-
- return response.json();
- }
-
-
- // ========================================
- // CLIENT MANAGEMENT (New functionality)
- // ========================================
-
- /**
- * Get all configured MCP clients
- */
- async getClients(): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/`);
-
- if (!response.ok) {
- throw new Error('Failed to get MCP clients');
- }
-
- return response.json();
- }
-
- /**
- * Create a new MCP client
- */
- async createClient(config: MCPClientConfig): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to create MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Get a specific MCP client
- */
- async getClient(clientId: string): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Update an MCP client
- */
- async updateClient(clientId: string, updates: Partial): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(updates)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to update MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Delete an MCP client
- */
- async deleteClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}`, {
- method: 'DELETE'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to delete MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Connect to an MCP client
- */
- async connectClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/connect`, {
- method: 'POST'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to connect to MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Disconnect from an MCP client
- */
- async disconnectClient(clientId: string): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/disconnect`, {
- method: 'POST'
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to disconnect from MCP client');
- }
-
- return response.json();
- }
-
- /**
- * Get client status and health
- */
- async getClientStatus(clientId: string): Promise<{
- client_id: string;
- status: string;
- last_seen: string | null;
- last_error: string | null;
- is_active: boolean;
- }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/status`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get client status');
- }
-
- return response.json();
- }
-
- /**
- * Get tools from a specific client
- */
- async getClientTools(clientId: string): Promise<{
- client_id: string;
- tools: MCPClientTool[];
- count: number;
- }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/${clientId}/tools`);
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to get client tools');
- }
-
- return response.json();
- }
-
- /**
- * Test a client configuration before saving
- */
- async testClientConfig(config: MCPClientConfig): Promise<{ success: boolean; message: string }> {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/test-config`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to test client configuration');
- }
-
- return response.json();
- }
-
- /**
- * Call a tool on a specific client
- */
- async callClientTool(request: ToolCallRequest): Promise {
- const response = await fetch(`${this.baseUrl}/api/mcp/clients/tools/call`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(request)
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Failed to call client tool');
- }
-
- return response.json();
- }
-
- // ========================================
- // LEGACY TOOL FUNCTIONALITY (Updated for multi-client)
- // ========================================
-
- /**
- * Make an MCP call to the running server via SSE
- */
- private async makeMCPCall(method: string, params?: any): Promise {
- const status = await this.getStatus();
- if (status.status !== 'running') {
- throw new Error('MCP server is not running');
- }
-
- const config = await this.getConfiguration();
- const mcpUrl = `http://${config.host}:${config.port}/mcp`;
-
- // Generate unique request ID
- const id = Math.random().toString(36).substring(2);
-
- const mcpRequest = {
- jsonrpc: '2.0',
- id,
- method,
- params: params || {}
- };
-
- try {
- const response = await fetch(mcpUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(mcpRequest)
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const mcpResponse = await response.json();
-
- // Validate MCP response format
- const validatedResponse = MCPResponseSchema.parse(mcpResponse);
-
- if (validatedResponse.error) {
- throw new Error(`MCP Error: ${validatedResponse.error.message}`);
- }
-
- return validatedResponse.result;
- } catch (error) {
- console.error('MCP call failed:', error);
- throw error;
- }
- }
-
- /**
- * Get available tools from the running MCP server (legacy - for Archon default client)
- */
- async getAvailableTools(): Promise {
- try {
- // Skip the broken backend endpoint and try direct MCP protocol call
- console.log('Attempting direct MCP tools/list call...');
- const result = await this.makeMCPCall('tools/list');
- const validatedResult = MCPToolsListResponseSchema.parse(result);
- console.log('Successfully retrieved tools via MCP protocol:', validatedResult.tools.length);
- return validatedResult.tools;
- } catch (mcpError) {
- console.warn('Direct MCP call failed, falling back to backend endpoint:', mcpError);
-
- // Fallback to backend endpoint (which returns debug placeholder)
- try {
- const response = await fetch(`${this.baseUrl}/api/mcp/tools`);
-
- if (response.ok) {
- const data = await response.json();
- console.log('Backend endpoint returned:', data);
-
- // If we only get the debug placeholder, return empty array with warning
- if (data.tools.length === 1 && data.tools[0].name === 'debug_placeholder') {
- console.warn('Backend returned debug placeholder - MCP tool introspection is not working');
- // Return empty array instead of the placeholder
- return [];
- }
-
- // Convert the backend format to MCP tool format
- const tools: MCPTool[] = data.tools.map((tool: any) => ({
- name: tool.name,
- description: tool.description,
- inputSchema: {
- type: 'object' as const,
- properties: tool.parameters.reduce((props: any, param: any) => {
- props[param.name] = {
- type: param.type,
- description: param.description
- };
- return props;
- }, {}),
- required: tool.parameters.filter((p: any) => p.required).map((p: any) => p.name)
- }
- }));
- return tools;
- }
- throw new Error('Backend endpoint failed');
- } catch (backendError) {
- console.error('Both MCP protocol and backend endpoint failed:', { mcpError, backendError });
- throw new Error(`Failed to retrieve tools: MCP protocol failed (${mcpError instanceof Error ? mcpError.message : mcpError}), backend also failed (${backendError instanceof Error ? backendError.message : backendError})`);
- }
- }
- }
-
- /**
- * Call a specific MCP tool (legacy - for Archon default client)
- */
- async callTool(name: string, arguments_: Record): Promise {
- try {
- const result = await this.makeMCPCall('tools/call', {
- name,
- arguments: arguments_
- });
- return result;
- } catch (error) {
- console.error(`Failed to call MCP tool ${name}:`, error);
- throw error;
- }
- }
-
- /**
- * Get aggregated tools from all connected clients
- */
- async getAllAvailableTools(): Promise<{
- archon_tools: MCPTool[];
- client_tools: { client: MCPClient; tools: MCPClientTool[] }[];
- total_count: number;
- }> {
- try {
- // Get Archon tools (default client)
- const archonTools = await this.getAvailableTools();
-
- // Get all clients and their tools
- const clients = await this.getClients();
- const clientTools = await Promise.all(
- clients
- .filter(client => client.status === 'connected' && !client.is_default)
- .map(async (client) => {
- try {
- const toolsData = await this.getClientTools(client.id);
- return { client, tools: toolsData.tools };
- } catch {
- return { client, tools: [] };
- }
- })
- );
-
- const totalCount = archonTools.length + clientTools.reduce((sum, ct) => sum + ct.tools.length, 0);
-
- return {
- archon_tools: archonTools,
- client_tools: clientTools,
- total_count: totalCount
- };
- } catch (error) {
- console.error('Failed to get all available tools:', error);
- throw error;
- }
- }
-}
-
-export const mcpService = new MCPService();
-
-/**
- * Legacy function - replaced by mcpService.getAvailableTools()
- * @deprecated Use mcpService.getAvailableTools() instead
- */
-export const getMCPTools = async () => {
- console.warn('getMCPTools is deprecated. Use mcpService.getAvailableTools() instead.');
- return mcpService.getAvailableTools();
-};
\ No newline at end of file
diff --git a/archon-ui-main/test/components.test.tsx b/archon-ui-main/test/components.test.tsx
deleted file mode 100644
index d38d15f5..00000000
--- a/archon-ui-main/test/components.test.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { describe, test, expect, vi } from 'vitest'
-import React from 'react'
-
-describe('Component Tests', () => {
- test('button component works', () => {
- const onClick = vi.fn()
- const MockButton = ({ children, ...props }: any) => (
- {children}
- )
-
- render(Click me )
-
- const button = screen.getByRole('button')
- fireEvent.click(button)
- expect(onClick).toHaveBeenCalledTimes(1)
- })
-
- test('input component works', () => {
- const MockInput = () => {
- const [value, setValue] = React.useState('')
- return (
- setValue(e.target.value)}
- placeholder="Test input"
- />
- )
- }
-
- render( )
- const input = screen.getByPlaceholderText('Test input')
-
- fireEvent.change(input, { target: { value: 'test' } })
- expect((input as HTMLInputElement).value).toBe('test')
- })
-
- test('modal component works', () => {
- const MockModal = () => {
- const [isOpen, setIsOpen] = React.useState(false)
- return (
-
-
setIsOpen(true)}>Open Modal
- {isOpen && (
-
-
Modal Title
- setIsOpen(false)}>Close
-
- )}
-
- )
- }
-
- render( )
-
- // Modal not visible initially
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
-
- // Open modal
- fireEvent.click(screen.getByText('Open Modal'))
- expect(screen.getByRole('dialog')).toBeInTheDocument()
-
- // Close modal
- fireEvent.click(screen.getByText('Close'))
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
- })
-
- test('progress bar component works', () => {
- const MockProgressBar = ({ value, max }: { value: number; max: number }) => (
-
-
Progress: {Math.round((value / max) * 100)}%
-
Bar
-
- )
-
- const { rerender } = render( )
- expect(screen.getByText('Progress: 0%')).toBeInTheDocument()
-
- rerender( )
- expect(screen.getByText('Progress: 50%')).toBeInTheDocument()
-
- rerender( )
- expect(screen.getByText('Progress: 100%')).toBeInTheDocument()
- })
-
- test('tooltip component works', () => {
- const MockTooltip = ({ children, tooltip }: any) => {
- const [show, setShow] = React.useState(false)
- return (
-
-
setShow(true)}
- onMouseLeave={() => setShow(false)}
- >
- {children}
-
- {show &&
{tooltip}
}
-
- )
- }
-
- render(Hover me )
-
- const button = screen.getByText('Hover me')
- expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
-
- fireEvent.mouseEnter(button)
- expect(screen.getByRole('tooltip')).toBeInTheDocument()
-
- fireEvent.mouseLeave(button)
- expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
- })
-
- test('accordion component works', () => {
- const MockAccordion = () => {
- const [expanded, setExpanded] = React.useState(false)
- return (
-
-
setExpanded(!expanded)}>
- Section 1 {expanded ? '−' : '+'}
-
- {expanded &&
Section content
}
-
- )
- }
-
- render( )
-
- expect(screen.queryByText('Section content')).not.toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Section 1 +'))
- expect(screen.getByText('Section content')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Section 1 −'))
- expect(screen.queryByText('Section content')).not.toBeInTheDocument()
- })
-
- test('table sorting works', () => {
- const MockTable = () => {
- const [data, setData] = React.useState([
- { name: 'Alice', age: 30 },
- { name: 'Bob', age: 25 },
- { name: 'Charlie', age: 35 }
- ])
-
- const sortByName = () => {
- setData([...data].sort((a, b) => a.name.localeCompare(b.name)))
- }
-
- return (
-
-
-
-
- Name
-
- Age
-
-
-
- {data.map((row, index) => (
-
- {row.name}
- {row.age}
-
- ))}
-
-
- )
- }
-
- render( )
-
- const cells = screen.getAllByRole('cell')
- expect(cells[0]).toHaveTextContent('Alice')
-
- fireEvent.click(screen.getByText('Name'))
-
- // After sorting, Alice should still be first (already sorted)
- const sortedCells = screen.getAllByRole('cell')
- expect(sortedCells[0]).toHaveTextContent('Alice')
- })
-
- test('pagination works', () => {
- const MockPagination = () => {
- const [page, setPage] = React.useState(1)
- return (
-
-
Page {page}
-
setPage(page - 1)}
- disabled={page === 1}
- >
- Previous
-
-
setPage(page + 1)}>
- Next
-
-
- )
- }
-
- render( )
-
- expect(screen.getByText('Page 1')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Next'))
- expect(screen.getByText('Page 2')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Previous'))
- expect(screen.getByText('Page 1')).toBeInTheDocument()
- })
-
- test('form validation works', () => {
- const MockForm = () => {
- const [email, setEmail] = React.useState('')
- const [error, setError] = React.useState('')
-
- const validate = (value: string) => {
- if (!value) {
- setError('Email is required')
- } else if (!value.includes('@')) {
- setError('Invalid email format')
- } else {
- setError('')
- }
- }
-
- return (
-
-
{
- setEmail(e.target.value)
- validate(e.target.value)
- }}
- />
- {error &&
{error}
}
-
- )
- }
-
- render( )
-
- const input = screen.getByPlaceholderText('Email')
-
- fireEvent.change(input, { target: { value: 'invalid' } })
- expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
-
- fireEvent.change(input, { target: { value: 'valid@email.com' } })
- expect(screen.queryByRole('alert')).not.toBeInTheDocument()
- })
-
- test('search filtering works', () => {
- const MockSearch = () => {
- const [query, setQuery] = React.useState('')
- const items = ['Apple', 'Banana', 'Cherry', 'Date']
- const filtered = items.filter(item =>
- item.toLowerCase().includes(query.toLowerCase())
- )
-
- return (
-
-
setQuery(e.target.value)}
- />
-
- {filtered.map((item, index) => (
- {item}
- ))}
-
-
- )
- }
-
- render( )
-
- // All items visible initially
- expect(screen.getByText('Apple')).toBeInTheDocument()
- expect(screen.getByText('Banana')).toBeInTheDocument()
-
- // Filter items
- const input = screen.getByPlaceholderText('Search items')
- fireEvent.change(input, { target: { value: 'a' } })
-
- expect(screen.getByText('Apple')).toBeInTheDocument()
- expect(screen.getByText('Banana')).toBeInTheDocument()
- expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/components/common/DeleteConfirmModal.test.tsx b/archon-ui-main/test/components/common/DeleteConfirmModal.test.tsx
deleted file mode 100644
index cef0203e..00000000
--- a/archon-ui-main/test/components/common/DeleteConfirmModal.test.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { describe, it, expect, vi } from 'vitest';
-import { DeleteConfirmModal } from '../../../src/components/common/DeleteConfirmModal';
-
-describe('DeleteConfirmModal', () => {
- const defaultProps = {
- itemName: 'Test Item',
- onConfirm: vi.fn(),
- onCancel: vi.fn(),
- type: 'task' as const,
- };
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('renders with correct title and message for task type', () => {
- render( );
-
- expect(screen.getByText('Delete Task')).toBeInTheDocument();
- expect(screen.getByText(/Are you sure you want to delete the "Test Item" task/)).toBeInTheDocument();
- });
-
- it('renders with correct title and message for project type', () => {
- render( );
-
- expect(screen.getByText('Delete Project')).toBeInTheDocument();
- expect(screen.getByText(/Are you sure you want to delete the "Test Item" project/)).toBeInTheDocument();
- });
-
- it('renders with correct title and message for client type', () => {
- render( );
-
- expect(screen.getByText('Delete MCP Client')).toBeInTheDocument();
- expect(screen.getByText(/Are you sure you want to delete the "Test Item" client/)).toBeInTheDocument();
- });
-
- it('calls onConfirm when Delete button is clicked', () => {
- render( );
-
- fireEvent.click(screen.getByText('Delete'));
-
- expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
- });
-
- it('calls onCancel when Cancel button is clicked', () => {
- render( );
-
- fireEvent.click(screen.getByText('Cancel'));
-
- expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
- });
-
- it('calls onCancel when Escape key is pressed', () => {
- render( );
-
- fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
-
- expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
- });
-
- it('calls onCancel when backdrop is clicked', () => {
- render( );
-
- // Click the backdrop
- const backdrop = screen.getByTestId('modal-backdrop');
- fireEvent.click(backdrop);
-
- expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
- });
-
- it('does not call onCancel when modal content is clicked', () => {
- render( );
-
- // Click the modal dialog itself
- fireEvent.click(screen.getByRole('dialog'));
-
- expect(defaultProps.onCancel).not.toHaveBeenCalled();
- });
-
- it('has proper accessibility attributes', () => {
- render( );
-
- const dialog = screen.getByRole('dialog');
- expect(dialog).toHaveAttribute('aria-modal', 'true');
- expect(dialog).toHaveAttribute('aria-labelledby');
- expect(dialog).toHaveAttribute('aria-describedby');
- });
-
- it('focuses Cancel button by default', () => {
- render( );
-
- const cancelButton = screen.getByText('Cancel');
- expect(cancelButton).toHaveFocus();
- });
-
- it('has proper button types', () => {
- render( );
-
- const cancelButton = screen.getByText('Cancel');
- const deleteButton = screen.getByText('Delete');
-
- expect(cancelButton).toHaveAttribute('type', 'button');
- expect(deleteButton).toHaveAttribute('type', 'button');
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx b/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx
deleted file mode 100644
index 64cb4f8b..00000000
--- a/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { describe, test, expect, vi, beforeEach } from 'vitest'
-import React from 'react'
-
-// Mock the dependencies
-vi.mock('../../../src/contexts/ToastContext', () => ({
- useToast: () => ({
- showToast: vi.fn()
- })
-}))
-
-vi.mock('../../../src/services/projectService', () => ({
- projectService: {
- getProjectDocuments: vi.fn().mockResolvedValue([]),
- deleteDocument: vi.fn().mockResolvedValue(undefined),
- updateDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Updated' }),
- getDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Document 1' })
- }
-}))
-
-vi.mock('../../../src/services/knowledgeBaseService', () => ({
- knowledgeBaseService: {
- getItems: vi.fn().mockResolvedValue([])
- }
-}))
-
-// Create a minimal DocsTab component for testing
-const DocsTabTest = () => {
- const [documents, setDocuments] = React.useState([
- {
- id: 'doc-1',
- title: 'Document 1',
- content: { type: 'prp' },
- document_type: 'prp',
- updated_at: '2025-07-30T12:00:00Z'
- },
- {
- id: 'doc-2',
- title: 'Document 2',
- content: { type: 'technical' },
- document_type: 'technical',
- updated_at: '2025-07-30T13:00:00Z'
- },
- {
- id: 'doc-3',
- title: 'Document 3',
- content: { type: 'business' },
- document_type: 'business',
- updated_at: '2025-07-30T14:00:00Z'
- }
- ])
-
- const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
- const { showToast } = { showToast: vi.fn() }
-
- return (
-
-
- {documents.map(doc => (
-
setSelectedDocument(doc)}
- >
-
{doc.document_type}
-
{doc.title}
- {selectedDocument?.id !== doc.id && (
-
{
- e.stopPropagation()
- if (confirm(`Delete "${doc.title}"?`)) {
- setDocuments(prev => prev.filter(d => d.id !== doc.id))
- if (selectedDocument?.id === doc.id) {
- setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
- }
- showToast('Document deleted', 'success')
- }
- }}
- >
- Delete
-
- )}
-
- ))}
-
console.log('New document')}
- >
- New Document
-
-
- {selectedDocument && (
-
- Selected: {selectedDocument.title}
-
- )}
-
- )
-}
-
-describe('DocsTab Document Cards Integration', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- test('renders all document cards', () => {
- render( )
-
- expect(screen.getByTestId('document-card-doc-1')).toBeInTheDocument()
- expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
- expect(screen.getByTestId('document-card-doc-3')).toBeInTheDocument()
- expect(screen.getByTestId('new-document-card')).toBeInTheDocument()
- })
-
- test('shows active state on selected document', () => {
- render( )
-
- const doc1 = screen.getByTestId('document-card-doc-1')
- expect(doc1.className).toContain('border-blue-500')
-
- const doc2 = screen.getByTestId('document-card-doc-2')
- expect(doc2.className).not.toContain('border-blue-500')
- })
-
- test('switches between documents', () => {
- render( )
-
- // Initially doc-1 is selected
- expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
-
- // Click on doc-2
- fireEvent.click(screen.getByTestId('document-card-doc-2'))
-
- // Now doc-2 should be selected
- expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
-
- // Check active states
- expect(screen.getByTestId('document-card-doc-1').className).not.toContain('border-blue-500')
- expect(screen.getByTestId('document-card-doc-2').className).toContain('border-blue-500')
- })
-
- test('deletes document with confirmation', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
- render( )
-
- // Click delete on doc-2
- const deleteButton = screen.getByTestId('delete-doc-2')
- fireEvent.click(deleteButton)
-
- expect(confirmSpy).toHaveBeenCalledWith('Delete "Document 2"?')
-
- // Document should be removed
- expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
-
- confirmSpy.mockRestore()
- })
-
- test('cancels delete when user declines', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
-
- render( )
-
- // Click delete on doc-2
- const deleteButton = screen.getByTestId('delete-doc-2')
- fireEvent.click(deleteButton)
-
- // Document should still be there
- expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
-
- confirmSpy.mockRestore()
- })
-
- test('selects next document when deleting active document', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
- render( )
-
- // doc-1 is initially selected
- expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
-
- // Switch to doc-2
- fireEvent.click(screen.getByTestId('document-card-doc-2'))
- expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
-
- // Switch to doc-1 to delete a non-selected document
- fireEvent.click(screen.getByTestId('document-card-doc-1'))
-
- // Delete doc-2 (not currently selected - it should have delete button)
- const deleteButton = screen.getByTestId('delete-doc-2')
- fireEvent.click(deleteButton)
-
- // Should automatically select another document
- expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document')
- expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
-
- confirmSpy.mockRestore()
- })
-
- test('does not show delete button on active card', () => {
- render( )
-
- // doc-1 is active, should not have delete button
- expect(screen.queryByTestId('delete-doc-1')).not.toBeInTheDocument()
-
- // doc-2 is not active, should have delete button
- expect(screen.getByTestId('delete-doc-2')).toBeInTheDocument()
- })
-
- test('horizontal scroll container has correct classes', () => {
- const { container } = render( )
-
- const scrollContainer = container.querySelector('.overflow-x-auto')
- expect(scrollContainer).toBeInTheDocument()
- expect(scrollContainer?.className).toContain('scrollbar-thin')
- expect(scrollContainer?.className).toContain('scrollbar-thumb-gray-300')
- })
-
- test('document cards maintain fixed width', () => {
- render( )
-
- const cards = screen.getAllByTestId(/document-card-doc-/)
- cards.forEach(card => {
- expect(card.className).toContain('flex-shrink-0')
- expect(card.className).toContain('w-48')
- })
- })
-})
-
-describe('DocsTab Document API Integration', () => {
- test('calls deleteDocument API when deleting a document', async () => {
- const { projectService } = await import('../../../src/services/projectService')
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
- // Create a test component that uses the actual deletion logic
- const DocsTabWithAPI = () => {
- const [documents, setDocuments] = React.useState([
- { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
- { id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
- ])
- const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
- const project = { id: 'proj-123', title: 'Test Project' }
- const { showToast } = { showToast: vi.fn() }
-
- const handleDelete = async (docId: string) => {
- try {
- // This mirrors the actual DocsTab deletion logic
- await projectService.deleteDocument(project.id, docId)
- setDocuments(prev => prev.filter(d => d.id !== docId))
- if (selectedDocument?.id === docId) {
- setSelectedDocument(documents.find(d => d.id !== docId) || null)
- }
- showToast('Document deleted', 'success')
- } catch (error) {
- console.error('Failed to delete document:', error)
- showToast('Failed to delete document', 'error')
- }
- }
-
- return (
-
- {documents.map(doc => (
-
- {doc.title}
- {
- if (confirm(`Delete "${doc.title}"?`)) {
- handleDelete(doc.id)
- }
- }}
- >
- Delete
-
-
- ))}
-
- )
- }
-
- render( )
-
- // Click delete button
- fireEvent.click(screen.getByTestId('delete-doc-2'))
-
- // Wait for async operations
- await waitFor(() => {
- expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-2')
- })
-
- // Verify document is removed from UI
- expect(screen.queryByTestId('doc-doc-2')).not.toBeInTheDocument()
-
- confirmSpy.mockRestore()
- })
-
- test('handles deletion API errors gracefully', async () => {
- const { projectService } = await import('../../../src/services/projectService')
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
-
- // Make deleteDocument reject
- projectService.deleteDocument = vi.fn().mockRejectedValue(new Error('API Error'))
-
- const DocsTabWithError = () => {
- const [documents, setDocuments] = React.useState([
- { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }
- ])
- const project = { id: 'proj-123', title: 'Test Project' }
- const showToast = vi.fn()
-
- const handleDelete = async (docId: string) => {
- try {
- await projectService.deleteDocument(project.id, docId)
- setDocuments(prev => prev.filter(d => d.id !== docId))
- showToast('Document deleted', 'success')
- } catch (error) {
- console.error('Failed to delete document:', error)
- showToast('Failed to delete document', 'error')
- }
- }
-
- return (
-
- {documents.map(doc => (
-
- {
- if (confirm(`Delete "${doc.title}"?`)) {
- handleDelete(doc.id)
- }
- }}
- >
- Delete
-
-
- ))}
-
-
- )
- }
-
- render( )
-
- // Click delete button
- fireEvent.click(screen.getByTestId('delete-doc-1'))
-
- // Wait for async operations
- await waitFor(() => {
- expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-1')
- })
-
- // Document should still be in UI due to error
- expect(screen.getByTestId('doc-doc-1')).toBeInTheDocument()
-
- // Error should be logged
- expect(consoleSpy).toHaveBeenCalledWith('Failed to delete document:', expect.any(Error))
-
- confirmSpy.mockRestore()
- consoleSpy.mockRestore()
- })
-
- test('deletion persists after page refresh', async () => {
- const { projectService } = await import('../../../src/services/projectService')
-
- // Simulate documents before deletion
- let mockDocuments = [
- { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
- { id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
- ]
-
- // First render - before deletion
- const { rerender } = render({mockDocuments.length}
)
- expect(screen.getByTestId('docs-count')).toHaveTextContent('2')
-
- // Mock deleteDocument to also update the mock data
- projectService.deleteDocument = vi.fn().mockImplementation(async (projectId, docId) => {
- mockDocuments = mockDocuments.filter(d => d.id !== docId)
- return Promise.resolve()
- })
-
- // Mock the list function to return current state
- projectService.listProjectDocuments = vi.fn().mockImplementation(async () => {
- return mockDocuments
- })
-
- // Perform deletion
- await projectService.deleteDocument('proj-123', 'doc-2')
-
- // Simulate page refresh by re-fetching documents
- const refreshedDocs = await projectService.listProjectDocuments('proj-123')
-
- // Re-render with refreshed data
- rerender({refreshedDocs.length}
)
-
- // Should only have 1 document after refresh
- expect(screen.getByTestId('docs-count')).toHaveTextContent('1')
- expect(refreshedDocs).toHaveLength(1)
- expect(refreshedDocs[0].id).toBe('doc-1')
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/components/project-tasks/DocumentCard.test.tsx b/archon-ui-main/test/components/project-tasks/DocumentCard.test.tsx
deleted file mode 100644
index 08a4906b..00000000
--- a/archon-ui-main/test/components/project-tasks/DocumentCard.test.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { describe, test, expect, vi } from 'vitest'
-import React from 'react'
-import { DocumentCard, NewDocumentCard } from '../../../src/components/project-tasks/DocumentCard'
-import type { ProjectDoc } from '../../../src/components/project-tasks/DocumentCard'
-
-describe('DocumentCard', () => {
- const mockDocument: ProjectDoc = {
- id: 'doc-1',
- title: 'Test Document',
- content: { test: 'content' },
- document_type: 'prp',
- updated_at: '2025-07-30T12:00:00Z',
- }
-
- const mockHandlers = {
- onSelect: vi.fn(),
- onDelete: vi.fn(),
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- test('renders document card with correct content', () => {
- render(
-
- )
-
- expect(screen.getByText('Test Document')).toBeInTheDocument()
- expect(screen.getByText('prp')).toBeInTheDocument()
- expect(screen.getByText('7/30/2025')).toBeInTheDocument()
- })
-
- test('shows correct icon and color for different document types', () => {
- const documentTypes = [
- { type: 'prp', expectedClass: 'text-blue-600' },
- { type: 'technical', expectedClass: 'text-green-600' },
- { type: 'business', expectedClass: 'text-purple-600' },
- { type: 'meeting_notes', expectedClass: 'text-orange-600' },
- ]
-
- documentTypes.forEach(({ type, expectedClass }) => {
- const { container, rerender } = render(
-
- )
-
- const badge = container.querySelector(`.${expectedClass}`)
- expect(badge).toBeInTheDocument()
- })
- })
-
- test('applies active styles when selected', () => {
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
- expect(card.className).toContain('border-blue-500')
- expect(card.className).toContain('scale-105')
- })
-
- test('calls onSelect when clicked', () => {
- render(
-
- )
-
- const card = screen.getByText('Test Document').closest('div')
- fireEvent.click(card!)
-
- expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockDocument)
- })
-
- test('shows delete button on hover', () => {
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
-
- // Delete button should not be visible initially
- expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
-
- // Hover over the card
- fireEvent.mouseEnter(card)
-
- // Delete button should now be visible
- expect(screen.getByLabelText('Delete Test Document')).toBeInTheDocument()
-
- // Mouse leave
- fireEvent.mouseLeave(card)
-
- // Delete button should be hidden again
- expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
- })
-
- test('does not show delete button on active card', () => {
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
- fireEvent.mouseEnter(card)
-
- expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
- })
-
- test('confirms before deleting', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
- fireEvent.mouseEnter(card)
-
- const deleteButton = screen.getByLabelText('Delete Test Document')
- fireEvent.click(deleteButton)
-
- expect(confirmSpy).toHaveBeenCalledWith('Delete "Test Document"?')
- expect(mockHandlers.onDelete).toHaveBeenCalledWith('doc-1')
-
- confirmSpy.mockRestore()
- })
-
- test('cancels delete when user declines', () => {
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
-
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
- fireEvent.mouseEnter(card)
-
- const deleteButton = screen.getByLabelText('Delete Test Document')
- fireEvent.click(deleteButton)
-
- expect(confirmSpy).toHaveBeenCalled()
- expect(mockHandlers.onDelete).not.toHaveBeenCalled()
-
- confirmSpy.mockRestore()
- })
-
- test('applies dark mode styles correctly', () => {
- const { container } = render(
-
- )
-
- const card = container.firstChild as HTMLElement
- expect(card.className).toContain('dark:')
- })
-})
-
-describe('NewDocumentCard', () => {
- test('renders new document card', () => {
- const onClick = vi.fn()
- render( )
-
- expect(screen.getByText('New Document')).toBeInTheDocument()
- })
-
- test('calls onClick when clicked', () => {
- const onClick = vi.fn()
- render( )
-
- const card = screen.getByText('New Document').closest('div')
- fireEvent.click(card!)
-
- expect(onClick).toHaveBeenCalledTimes(1)
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/components/project-tasks/MilkdownEditor.test.tsx b/archon-ui-main/test/components/project-tasks/MilkdownEditor.test.tsx
deleted file mode 100644
index 0fe48778..00000000
--- a/archon-ui-main/test/components/project-tasks/MilkdownEditor.test.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import { describe, test, expect } from 'vitest'
-
-// Test the PRP to Markdown conversion logic
-describe('MilkdownEditor PRP Conversion', () => {
- // Helper function to format values (extracted from component)
- const formatValue = (value: any, indent = ''): string => {
- if (Array.isArray(value)) {
- return value.map(item => `${indent}- ${formatValue(item, indent + ' ')}`).join('\n') + '\n'
- }
-
- if (typeof value === 'object' && value !== null) {
- let result = ''
- Object.entries(value).forEach(([key, val]) => {
- const formattedKey = key.replace(/_/g, ' ')
- .split(' ')
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ')
-
- if (typeof val === 'string' || typeof val === 'number') {
- result += `${indent}**${formattedKey}:** ${val}\n\n`
- } else {
- result += `${indent}### ${formattedKey}\n\n${formatValue(val, indent)}`
- }
- })
- return result
- }
-
- return String(value)
- }
-
- // Simplified version of convertPRPToMarkdown for testing
- const convertPRPToMarkdown = (content: any, docTitle = 'Test Doc'): string => {
- let markdown = `# ${content.title || docTitle}\n\n`
-
- // Metadata section
- if (content.version || content.author || content.date || content.status) {
- markdown += `## Metadata\n\n`
- if (content.version) markdown += `- **Version:** ${content.version}\n`
- if (content.author) markdown += `- **Author:** ${content.author}\n`
- if (content.date) markdown += `- **Date:** ${content.date}\n`
- if (content.status) markdown += `- **Status:** ${content.status}\n`
- markdown += '\n'
- }
-
- // Goal section
- if (content.goal) {
- markdown += `## Goal\n\n${content.goal}\n\n`
- }
-
- // Why section
- if (content.why) {
- markdown += `## Why\n\n`
- if (Array.isArray(content.why)) {
- content.why.forEach(item => markdown += `- ${item}\n`)
- } else {
- markdown += `${content.why}\n`
- }
- markdown += '\n'
- }
-
- // What section
- if (content.what) {
- markdown += `## What\n\n`
- if (typeof content.what === 'string') {
- markdown += `${content.what}\n\n`
- } else if (content.what.description) {
- markdown += `${content.what.description}\n\n`
-
- if (content.what.success_criteria) {
- markdown += `### Success Criteria\n\n`
- content.what.success_criteria.forEach((criterion: string) => {
- markdown += `- [ ] ${criterion}\n`
- })
- markdown += '\n'
- }
- }
- }
-
- // Handle all other sections dynamically
- const handledKeys = [
- 'title', 'version', 'author', 'date', 'status', 'goal', 'why', 'what',
- 'document_type'
- ]
-
- Object.entries(content).forEach(([key, value]) => {
- if (!handledKeys.includes(key) && value) {
- const sectionTitle = key.replace(/_/g, ' ')
- .split(' ')
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ')
-
- markdown += `## ${sectionTitle}\n\n`
- markdown += formatValue(value)
- markdown += '\n'
- }
- })
-
- return markdown
- }
-
- test('converts basic PRP structure to markdown', () => {
- const prp = {
- title: 'Test PRP',
- version: '1.0',
- author: 'Test Author',
- date: '2025-07-30',
- status: 'draft',
- goal: 'Test goal'
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('# Test PRP')
- expect(markdown).toContain('## Metadata')
- expect(markdown).toContain('- **Version:** 1.0')
- expect(markdown).toContain('- **Author:** Test Author')
- expect(markdown).toContain('- **Date:** 2025-07-30')
- expect(markdown).toContain('- **Status:** draft')
- expect(markdown).toContain('## Goal\n\nTest goal')
- })
-
- test('handles array why section', () => {
- const prp = {
- title: 'Test PRP',
- why: ['Reason 1', 'Reason 2', 'Reason 3']
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('## Why')
- expect(markdown).toContain('- Reason 1')
- expect(markdown).toContain('- Reason 2')
- expect(markdown).toContain('- Reason 3')
- })
-
- test('handles string why section', () => {
- const prp = {
- title: 'Test PRP',
- why: 'Single reason for the change'
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('## Why')
- expect(markdown).toContain('Single reason for the change')
- })
-
- test('handles complex what section with success criteria', () => {
- const prp = {
- title: 'Test PRP',
- what: {
- description: 'Main description of what we are building',
- success_criteria: [
- 'Criterion 1',
- 'Criterion 2',
- 'Criterion 3'
- ]
- }
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('## What')
- expect(markdown).toContain('Main description of what we are building')
- expect(markdown).toContain('### Success Criteria')
- expect(markdown).toContain('- [ ] Criterion 1')
- expect(markdown).toContain('- [ ] Criterion 2')
- expect(markdown).toContain('- [ ] Criterion 3')
- })
-
- test('handles dynamic sections', () => {
- const prp = {
- title: 'Test PRP',
- user_personas: {
- developer: {
- name: 'Developer Dan',
- goals: ['Write clean code', 'Ship features fast']
- }
- },
- technical_requirements: {
- frontend: 'React 18',
- backend: 'FastAPI',
- database: 'PostgreSQL'
- }
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('## User Personas')
- expect(markdown).toContain('### Developer')
- expect(markdown).toContain('**Name:** Developer Dan')
- expect(markdown).toContain('## Technical Requirements')
- expect(markdown).toContain('**Frontend:** React 18')
- expect(markdown).toContain('**Backend:** FastAPI')
- })
-
- test('formats nested objects correctly', () => {
- const value = {
- level1: {
- level2: {
- level3: 'Deep value'
- }
- }
- }
-
- const formatted = formatValue(value)
-
- expect(formatted).toContain('### Level1')
- expect(formatted).toContain('### Level2')
- expect(formatted).toContain('**Level3:** Deep value')
- })
-
- test('formats arrays correctly', () => {
- const value = ['Item 1', 'Item 2', { nested: 'Nested item' }]
-
- const formatted = formatValue(value)
-
- expect(formatted).toContain('- Item 1')
- expect(formatted).toContain('- Item 2')
- expect(formatted).toContain('**Nested:** Nested item')
- })
-
- test('handles empty content', () => {
- const prp = {}
-
- const markdown = convertPRPToMarkdown(prp, 'Default Title')
-
- expect(markdown).toBe('# Default Title\n\n')
- })
-
- test('skips null and undefined values', () => {
- const prp = {
- title: 'Test PRP',
- null_field: null,
- undefined_field: undefined,
- empty_string: '',
- valid_field: 'Valid content'
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).not.toContain('Null Field')
- expect(markdown).not.toContain('Undefined Field')
- expect(markdown).not.toContain('Empty String')
- expect(markdown).toContain('## Valid Field')
- expect(markdown).toContain('Valid content')
- })
-
- test('converts snake_case to Title Case', () => {
- const prp = {
- title: 'Test PRP',
- user_journey_mapping: 'Content',
- api_endpoint_design: 'More content'
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('## User Journey Mapping')
- expect(markdown).toContain('## Api Endpoint Design')
- })
-
- test('preserves markdown formatting in content', () => {
- const prp = {
- title: 'Test PRP',
- description: '**Bold text** and *italic text* with `code`'
- }
-
- const markdown = convertPRPToMarkdown(prp)
-
- expect(markdown).toContain('**Bold text** and *italic text* with `code`')
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/components/project-tasks/TasksTab.dragdrop.test.tsx b/archon-ui-main/test/components/project-tasks/TasksTab.dragdrop.test.tsx
deleted file mode 100644
index 8f90811f..00000000
--- a/archon-ui-main/test/components/project-tasks/TasksTab.dragdrop.test.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { readFileSync } from 'fs';
-import { join } from 'path';
-
-describe('TasksTab Drag and Drop Integration', () => {
- it('should properly manage movingTaskIds during drag operations', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check that moveTask adds task to movingTaskIds
- expect(fileContent).toContain('setMovingTaskIds(prev => new Set([...prev, taskId]))');
-
- // Check that moveTask removes task from movingTaskIds in finally block
- expect(fileContent).toContain('finally {');
- expect(fileContent).toMatch(/finally\s*{\s*\/\/\s*Remove from loading set\s*setMovingTaskIds/);
-
- // Check that the cleanup happens even on error
- const moveTaskMatch = fileContent.match(/const moveTask[\s\S]*?\n{2}\};/);
- expect(moveTaskMatch).toBeTruthy();
- if (moveTaskMatch) {
- const moveTaskFunction = moveTaskMatch[0];
- expect(moveTaskFunction).toContain('try {');
- expect(moveTaskFunction).toContain('catch (error)');
- expect(moveTaskFunction).toContain('finally {');
- }
- });
-
- it('should pass movingTaskIds to TaskBoardView', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check that movingTaskIds is passed to TaskBoardView
- expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
- });
-
- it('should handle task completion through moveTask', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check that completeTask calls moveTask
- expect(fileContent).toMatch(/completeTask.*moveTask\(taskId, 'done'\)/s);
- });
-
- it('should have optimistic updates in moveTask', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check for optimistic update comment and implementation
- expect(fileContent).toContain('// Optimistically update UI for immediate feedback');
- expect(fileContent).toContain('setTasks(prev => prev.map(task =>');
- });
-
- it('should revert on error as indicated by comment', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check for revert comment
- expect(fileContent).toContain('// Revert optimistic update - polling will sync correct state');
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/components/prp/PRPViewer.test.tsx b/archon-ui-main/test/components/prp/PRPViewer.test.tsx
deleted file mode 100644
index 1112fe1a..00000000
--- a/archon-ui-main/test/components/prp/PRPViewer.test.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { describe, test, expect, vi } from 'vitest'
-import React from 'react'
-import { PRPViewer } from '../../../src/components/prp/PRPViewer'
-import type { PRPContent } from '../../../src/components/prp/types/prp.types'
-
-describe('PRPViewer', () => {
- const mockContent: PRPContent = {
- title: 'Test PRP',
- version: '1.0',
- author: 'Test Author',
- date: '2025-07-30',
- status: 'draft',
- goal: 'Test goal with [Image #1] placeholder',
- why: 'Test reason with [Image #2] reference',
- what: {
- description: 'Test description with [Image #3] and [Image #4]',
- success_criteria: ['Criterion 1', 'Criterion 2 with [Image #5]']
- },
- context: {
- background: 'Background with [Image #6]',
- objectives: ['Objective 1', 'Objective 2']
- }
- }
-
- test('renders without [Image #N] placeholders', () => {
- render( )
-
- // Check that [Image #N] placeholders are replaced
- expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
-
- // Check that content is present
- expect(screen.getByText(/Test goal/)).toBeInTheDocument()
- expect(screen.getByText(/Test reason/)).toBeInTheDocument()
- expect(screen.getByText(/Test description/)).toBeInTheDocument()
- })
-
- test('processes nested content with image placeholders', () => {
- const { container } = render( )
-
- // Check that the content has been processed
- const htmlContent = container.innerHTML
-
- // Should not contain raw [Image #N] text
- expect(htmlContent).not.toMatch(/\[Image #\d+\]/)
-
- // Should contain processed markdown image syntax
- expect(htmlContent).toContain('Image 1')
- expect(htmlContent).toContain('Image 2')
- })
-
- test('renders metadata section correctly', () => {
- render( )
-
- expect(screen.getByText('Test PRP')).toBeInTheDocument()
- expect(screen.getByText('1.0')).toBeInTheDocument()
- expect(screen.getByText('Test Author')).toBeInTheDocument()
- expect(screen.getByText('draft')).toBeInTheDocument()
- })
-
- test('handles empty content gracefully', () => {
- render( )
-
- // Should render without errors
- expect(screen.getByText(/Metadata/)).toBeInTheDocument()
- })
-
- test('handles null content', () => {
- render( )
-
- expect(screen.getByText('No PRP content available')).toBeInTheDocument()
- })
-
- test('handles string content in objects', () => {
- const stringContent = {
- title: 'String Test',
- description: 'This has [Image #1] in it'
- }
-
- render( )
-
- // Should process the image placeholder
- expect(screen.queryByText(/\[Image #1\]/)).not.toBeInTheDocument()
- expect(screen.getByText(/This has/)).toBeInTheDocument()
- })
-
- test('handles array content with image placeholders', () => {
- const arrayContent = {
- title: 'Array Test',
- items: [
- 'Item 1 with [Image #1]',
- 'Item 2 with [Image #2]',
- { nested: 'Nested with [Image #3]' }
- ]
- }
-
- render( )
-
- // Should process all image placeholders
- expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
- })
-
- test('renders collapsible sections', () => {
- render( )
-
- // Find collapsible sections
- const contextSection = screen.getByText('Context').closest('div')
- expect(contextSection).toBeInTheDocument()
-
- // Should have chevron icon for collapsible sections
- const chevrons = screen.getAllByTestId('chevron-icon')
- expect(chevrons.length).toBeGreaterThan(0)
- })
-
- test('toggles section visibility', () => {
- render( )
-
- // Find a collapsible section header
- const contextHeader = screen.getByText('Context').closest('button')
-
- // The section should be visible initially (defaultOpen for first 5 sections)
- expect(screen.getByText(/Background with/)).toBeInTheDocument()
-
- // Click to collapse
- fireEvent.click(contextHeader!)
-
- // Content should be hidden
- expect(screen.queryByText(/Background with/)).not.toBeInTheDocument()
-
- // Click to expand
- fireEvent.click(contextHeader!)
-
- // Content should be visible again
- expect(screen.getByText(/Background with/)).toBeInTheDocument()
- })
-
- test('applies dark mode styles', () => {
- const { container } = render( )
-
- const viewer = container.querySelector('.prp-viewer')
- expect(viewer?.className).toContain('dark')
- })
-
- test('uses section overrides when provided', () => {
- const CustomSection = ({ data, title }: any) => (
-
-
{title}
-
Custom rendering of: {JSON.stringify(data)}
-
- )
-
- const overrides = {
- context: CustomSection
- }
-
- render( )
-
- expect(screen.getByTestId('custom-section')).toBeInTheDocument()
- expect(screen.getByText(/Custom rendering of/)).toBeInTheDocument()
- })
-
- test('sorts sections by group', () => {
- const complexContent = {
- title: 'Complex PRP',
- // These should be sorted in a specific order
- validation_gates: { test: 'validation' },
- user_personas: { test: 'personas' },
- context: { test: 'context' },
- user_flows: { test: 'flows' },
- success_metrics: { test: 'metrics' }
- }
-
- const { container } = render( )
-
- // Get all section titles in order
- const sectionTitles = Array.from(
- container.querySelectorAll('h3')
- ).map(el => el.textContent)
-
- // Context should come before personas
- const contextIndex = sectionTitles.findIndex(t => t?.includes('Context'))
- const personasIndex = sectionTitles.findIndex(t => t?.includes('Personas'))
-
- expect(contextIndex).toBeLessThan(personasIndex)
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/config/api.test.ts b/archon-ui-main/test/config/api.test.ts
deleted file mode 100644
index f5243961..00000000
--- a/archon-ui-main/test/config/api.test.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-/**
- * Tests for API configuration port requirements
- */
-
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-
-describe('API Configuration', () => {
- let originalEnv: any;
-
- beforeEach(() => {
- // Save original environment
- originalEnv = { ...import.meta.env };
-
- // Clear the module cache to ensure fresh imports
- vi.resetModules();
- });
-
- afterEach(() => {
- // Restore original environment
- Object.keys(import.meta.env).forEach(key => {
- delete (import.meta.env as any)[key];
- });
- Object.assign(import.meta.env, originalEnv);
- });
-
- describe('getApiUrl', () => {
- it('should use VITE_API_URL when provided', async () => {
- // Set VITE_API_URL
- (import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
-
- const { getApiUrl } = await import('../../src/config/api');
- expect(getApiUrl()).toBe('http://custom-api:9999');
- });
-
- it('should return empty string in production mode', async () => {
- // Set production mode
- (import.meta.env as any).PROD = true;
-
- // It should not use VITE_API_URL
- (import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
-
- const { getApiUrl } = await import('../../src/config/api');
- expect(getApiUrl()).toBe('');
- });
-
- it('should use default port 8181 when no port environment variables are set in development', async () => {
- // Development mode without any port variables
- delete (import.meta.env as any).PROD;
- delete (import.meta.env as any).VITE_API_URL;
- delete (import.meta.env as any).VITE_ARCHON_SERVER_PORT;
- delete (import.meta.env as any).VITE_PORT;
- delete (import.meta.env as any).ARCHON_SERVER_PORT;
-
- // Mock window.location
- Object.defineProperty(window, 'location', {
- value: {
- protocol: 'http:',
- hostname: 'localhost'
- },
- writable: true
- });
-
- const { getApiUrl } = await import('../../src/config/api');
-
- expect(getApiUrl()).toBe('http://localhost:8181');
- });
-
- it('should use VITE_ARCHON_SERVER_PORT when set in development', async () => {
- // Development mode with custom port via VITE_ prefix
- delete (import.meta.env as any).PROD;
- delete (import.meta.env as any).VITE_API_URL;
- (import.meta.env as any).VITE_ARCHON_SERVER_PORT = '9191';
-
- // Mock window.location
- Object.defineProperty(window, 'location', {
- value: {
- protocol: 'http:',
- hostname: 'localhost'
- },
- writable: true
- });
-
- const { getApiUrl } = await import('../../src/config/api');
- expect(getApiUrl()).toBe('http://localhost:9191');
- });
-
- it('should use custom port with https protocol', async () => {
- // Development mode with custom port and https via VITE_ prefix
- delete (import.meta.env as any).PROD;
- delete (import.meta.env as any).VITE_API_URL;
- (import.meta.env as any).VITE_ARCHON_SERVER_PORT = '8443';
-
- // Mock window.location with https
- Object.defineProperty(window, 'location', {
- value: {
- protocol: 'https:',
- hostname: 'example.com'
- },
- writable: true
- });
-
- const { getApiUrl } = await import('../../src/config/api');
- expect(getApiUrl()).toBe('https://example.com:8443');
- });
- });
-
-
- describe('Port validation', () => {
- it('should handle various port formats', async () => {
- const testCases = [
- { port: '80', expected: 'http://localhost:80' },
- { port: '443', expected: 'http://localhost:443' },
- { port: '3000', expected: 'http://localhost:3000' },
- { port: '8080', expected: 'http://localhost:8080' },
- { port: '65535', expected: 'http://localhost:65535' },
- ];
-
- for (const { port, expected } of testCases) {
- vi.resetModules();
- delete (import.meta.env as any).PROD;
- delete (import.meta.env as any).VITE_API_URL;
- (import.meta.env as any).VITE_ARCHON_SERVER_PORT = port;
-
- Object.defineProperty(window, 'location', {
- value: {
- protocol: 'http:',
- hostname: 'localhost'
- },
- writable: true
- });
-
- const { getApiUrl } = await import('../../src/config/api');
- expect(getApiUrl()).toBe(expected);
- }
- });
- });
-});
-
-describe('MCP Client Service Configuration', () => {
- let originalEnv: any;
-
- beforeEach(() => {
- originalEnv = { ...import.meta.env };
- vi.resetModules();
- });
-
- afterEach(() => {
- Object.keys(import.meta.env).forEach(key => {
- delete (import.meta.env as any)[key];
- });
- Object.assign(import.meta.env, originalEnv);
- });
-
- it('should throw error when ARCHON_MCP_PORT is not set', async () => {
- delete (import.meta.env as any).ARCHON_MCP_PORT;
-
- const { mcpClientService } = await import('../../src/services/mcpClientService');
-
- await expect(mcpClientService.createArchonClient()).rejects.toThrow('ARCHON_MCP_PORT environment variable is required');
- await expect(mcpClientService.createArchonClient()).rejects.toThrow('Default value: 8051');
- });
-
- it('should use ARCHON_MCP_PORT when set', async () => {
- (import.meta.env as any).ARCHON_MCP_PORT = '9051';
- (import.meta.env as any).ARCHON_SERVER_PORT = '8181';
-
- // Mock window.location
- Object.defineProperty(window, 'location', {
- value: {
- protocol: 'http:',
- hostname: 'localhost'
- },
- writable: true
- });
-
- // Mock the API call
- global.fetch = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({
- id: 'test-id',
- name: 'Archon',
- transport_type: 'http',
- connection_status: 'connected'
- })
- });
-
- const { mcpClientService } = await import('../../src/services/mcpClientService');
-
- try {
- await mcpClientService.createArchonClient();
-
- // Verify the fetch was called with the correct URL
- expect(global.fetch).toHaveBeenCalledWith(
- expect.stringContaining('/api/mcp/clients'),
- expect.objectContaining({
- method: 'POST',
- body: expect.stringContaining('9051')
- })
- );
- } catch (error) {
- // If it fails due to actual API call, that's okay for this test
- // We're mainly testing that it constructs the URL correctly
- expect(error).toBeDefined();
- }
- });
-});
diff --git a/archon-ui-main/test/errors.test.tsx b/archon-ui-main/test/errors.test.tsx
deleted file mode 100644
index 3971f4af..00000000
--- a/archon-ui-main/test/errors.test.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
-import React from 'react'
-import { credentialsService } from '../src/services/credentialsService'
-
-describe('Error Handling Tests', () => {
- test('api error simulation', () => {
- const MockApiComponent = () => {
- const [error, setError] = React.useState('')
- const [loading, setLoading] = React.useState(false)
-
- const fetchData = async () => {
- setLoading(true)
- try {
- // Simulate API error
- throw new Error('Network error')
- } catch (err) {
- setError('Failed to load data')
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
Load Data
- {loading &&
Loading...
}
- {error &&
{error}
}
-
- )
- }
-
- render( )
-
- fireEvent.click(screen.getByText('Load Data'))
- expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data')
- })
-
- test('timeout error simulation', () => {
- const MockTimeoutComponent = () => {
- const [status, setStatus] = React.useState('idle')
-
- const handleTimeout = () => {
- setStatus('loading')
- setTimeout(() => {
- setStatus('timeout')
- }, 100)
- }
-
- return (
-
-
Start Request
- {status === 'loading' &&
Loading...
}
- {status === 'timeout' &&
Request timed out
}
-
- )
- }
-
- render( )
-
- fireEvent.click(screen.getByText('Start Request'))
- expect(screen.getByText('Loading...')).toBeInTheDocument()
-
- // Wait for timeout
- setTimeout(() => {
- expect(screen.getByRole('alert')).toHaveTextContent('Request timed out')
- }, 150)
- })
-
- test('form validation errors', () => {
- const MockFormErrors = () => {
- const [values, setValues] = React.useState({ name: '', email: '' })
- const [errors, setErrors] = React.useState([])
-
- const validate = () => {
- const newErrors: string[] = []
- if (!values.name) newErrors.push('Name is required')
- if (!values.email) newErrors.push('Email is required')
- if (values.email && !values.email.includes('@')) {
- newErrors.push('Invalid email format')
- }
- setErrors(newErrors)
- }
-
- return (
-
-
setValues({ ...values, name: e.target.value })}
- />
-
setValues({ ...values, email: e.target.value })}
- />
-
Submit
- {errors.length > 0 && (
-
- {errors.map((error, index) => (
-
{error}
- ))}
-
- )}
-
- )
- }
-
- render( )
-
- // Submit empty form
- fireEvent.click(screen.getByText('Submit'))
-
- const alert = screen.getByRole('alert')
- expect(alert).toHaveTextContent('Name is required')
- expect(alert).toHaveTextContent('Email is required')
- })
-
- test('connection error recovery', () => {
- const MockConnection = () => {
- const [connected, setConnected] = React.useState(true)
- const [error, setError] = React.useState('')
-
- const handleDisconnect = () => {
- setConnected(false)
- setError('Connection lost')
- }
-
- const handleReconnect = () => {
- setConnected(true)
- setError('')
- }
-
- return (
-
-
Status: {connected ? 'Connected' : 'Disconnected'}
- {error &&
{error}
}
-
Simulate Disconnect
-
Reconnect
-
- )
- }
-
- render( )
-
- expect(screen.getByText('Status: Connected')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Simulate Disconnect'))
- expect(screen.getByText('Status: Disconnected')).toBeInTheDocument()
- expect(screen.getByRole('alert')).toHaveTextContent('Connection lost')
-
- fireEvent.click(screen.getByText('Reconnect'))
- expect(screen.getByText('Status: Connected')).toBeInTheDocument()
- expect(screen.queryByRole('alert')).not.toBeInTheDocument()
- })
-
- test('user friendly error messages', () => {
- const MockErrorMessages = () => {
- const [errorType, setErrorType] = React.useState('')
-
- const getErrorMessage = (type: string) => {
- switch (type) {
- case '401':
- return 'Please log in to continue'
- case '403':
- return "You don't have permission to access this"
- case '404':
- return "We couldn't find what you're looking for"
- case '500':
- return 'Something went wrong on our end'
- default:
- return ''
- }
- }
-
- return (
-
-
setErrorType('401')}>401 Error
-
setErrorType('403')}>403 Error
-
setErrorType('404')}>404 Error
-
setErrorType('500')}>500 Error
- {errorType && (
-
{getErrorMessage(errorType)}
- )}
-
- )
- }
-
- render( )
-
- fireEvent.click(screen.getByText('401 Error'))
- expect(screen.getByRole('alert')).toHaveTextContent('Please log in to continue')
-
- fireEvent.click(screen.getByText('404 Error'))
- expect(screen.getByRole('alert')).toHaveTextContent("We couldn't find what you're looking for")
-
- fireEvent.click(screen.getByText('500 Error'))
- expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong on our end')
- })
-})
-
-describe('CredentialsService Error Handling', () => {
- const originalFetch = global.fetch
-
- beforeEach(() => {
- global.fetch = vi.fn() as any
- })
-
- afterEach(() => {
- global.fetch = originalFetch
- })
-
- test('should handle network errors with context', async () => {
- const mockError = new Error('Network request failed')
- ;(global.fetch as any).mockRejectedValueOnce(mockError)
-
- await expect(credentialsService.createCredential({
- key: 'TEST_KEY',
- value: 'test',
- is_encrypted: false,
- category: 'test'
- })).rejects.toThrow(/Network error while creating credential 'test_key'/)
- })
-
- test('should preserve context in error messages', async () => {
- const mockError = new Error('database error')
- ;(global.fetch as any).mockRejectedValueOnce(mockError)
-
- await expect(credentialsService.updateCredential({
- key: 'OPENAI_API_KEY',
- value: 'sk-test',
- is_encrypted: true,
- category: 'api_keys'
- })).rejects.toThrow(/Updating credential 'OPENAI_API_KEY' failed/)
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/hooks/usePolling.test.ts b/archon-ui-main/test/hooks/usePolling.test.ts
deleted file mode 100644
index f374da70..00000000
--- a/archon-ui-main/test/hooks/usePolling.test.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { renderHook, act, waitFor } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { usePolling } from '../../src/hooks/usePolling';
-
-describe('usePolling Hook - REAL Tests', () => {
- beforeEach(() => {
- vi.useFakeTimers({ shouldAdvanceTime: true });
- // Mock fetch globally
- global.fetch = vi.fn();
- // Reset document visibility state
- Object.defineProperty(document, 'hidden', {
- value: false,
- writable: true,
- configurable: true
- });
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- configurable: true
- });
- });
-
- afterEach(() => {
- vi.clearAllTimers();
- vi.useRealTimers();
- vi.clearAllMocks();
- vi.restoreAllMocks();
- });
-
- it('should poll the endpoint at specified intervals', async () => {
- const mockResponse = { data: 'test' };
- (global.fetch as any).mockResolvedValue({
- ok: true,
- status: 200,
- json: async () => mockResponse,
- headers: new Headers({ 'etag': '"v1"' })
- });
-
- const { result } = renderHook(() =>
- usePolling('/api/test', { interval: 1000 })
- );
-
- // Initially loading
- expect(result.current.isLoading).toBe(true);
- expect(result.current.data).toBeUndefined();
-
- // Wait for first fetch to complete
- await waitFor(() => {
- expect(result.current.data).toEqual(mockResponse);
- expect(result.current.isLoading).toBe(false);
- }, { timeout: 5000 });
-
- expect(global.fetch).toHaveBeenCalledTimes(1);
-
- // Advance timer to trigger second poll
- await act(async () => {
- vi.advanceTimersByTime(1000);
- });
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledTimes(2);
- }, { timeout: 5000 });
-
- // Check ETag header was sent on second request
- const secondCall = (global.fetch as any).mock.calls[1];
- expect(secondCall[1].headers['If-None-Match']).toBe('"v1"');
- }, 15000);
-
- it('should handle 304 Not Modified responses correctly', async () => {
- const initialData = { value: 'initial' };
-
- // First call returns data
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- status: 200,
- json: async () => initialData,
- headers: new Headers({ 'etag': '"v1"' })
- });
-
- const { result } = renderHook(() =>
- usePolling('/api/test', { interval: 1000 })
- );
-
- await waitFor(() => {
- expect(result.current.data).toEqual(initialData);
- expect(result.current.isLoading).toBe(false);
- }, { timeout: 5000 });
-
- // Second call returns 304 Not Modified
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- status: 304,
- json: async () => null,
- headers: new Headers({ 'etag': '"v1"' })
- });
-
- await act(async () => {
- vi.advanceTimersByTime(1000);
- });
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledTimes(2);
- }, { timeout: 5000 });
-
- // Data should remain unchanged after 304
- expect(result.current.data).toEqual(initialData);
- }, 15000);
-
- it('should pause polling when tab becomes inactive', async () => {
- // This test verifies that polling stops when the tab is hidden
- // The hook behavior is complex due to multiple useEffect hooks
- // so we'll just verify the key behavior: no excessive polling when hidden
-
- (global.fetch as any).mockResolvedValue({
- ok: true,
- status: 200,
- json: async () => ({ data: 'test' }),
- headers: new Headers()
- });
-
- const { result } = renderHook(() => usePolling('/api/test', { interval: 1000 }));
-
- // Wait for initial fetch
- await waitFor(() => {
- expect(result.current.data).toEqual({ data: 'test' });
- expect(result.current.isLoading).toBe(false);
- }, { timeout: 5000 });
-
- // Clear the mock to start fresh
- vi.clearAllMocks();
-
- // Simulate tab becoming hidden
- await act(async () => {
- Object.defineProperty(document, 'visibilityState', {
- value: 'hidden',
- writable: true,
- configurable: true
- });
- Object.defineProperty(document, 'hidden', {
- value: true,
- writable: true,
- configurable: true
- });
- document.dispatchEvent(new Event('visibilitychange'));
- });
-
- // Advance timers significantly while hidden
- await act(async () => {
- vi.advanceTimersByTime(5000);
- });
-
- // Should have minimal or no calls while hidden (allowing for edge cases)
- const hiddenCallCount = (global.fetch as any).mock.calls.length;
- expect(hiddenCallCount).toBeLessThanOrEqual(1);
-
- // Simulate tab becoming visible again
- await act(async () => {
- Object.defineProperty(document, 'visibilityState', {
- value: 'visible',
- writable: true,
- configurable: true
- });
- Object.defineProperty(document, 'hidden', {
- value: false,
- writable: true,
- configurable: true
- });
- document.dispatchEvent(new Event('visibilitychange'));
- });
-
- // Should trigger immediate refetch when becoming visible
- await waitFor(() => {
- expect((global.fetch as any).mock.calls.length).toBeGreaterThan(hiddenCallCount);
- }, { timeout: 5000 });
- }, 15000);
-
- it('should handle errors and retry with backoff', async () => {
- // First call fails
- (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
-
- const { result } = renderHook(() =>
- usePolling('/api/test', { interval: 1000 })
- );
-
- await waitFor(() => {
- expect(result.current.error).toBeInstanceOf(Error);
- expect(result.current.error?.message).toBe('Network error');
- expect(result.current.isLoading).toBe(false);
- }, { timeout: 5000 });
-
- expect(global.fetch).toHaveBeenCalledTimes(1);
-
- // Second call succeeds
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- status: 200,
- json: async () => ({ data: 'recovered' }),
- headers: new Headers()
- });
-
- // Advance timer for retry
- await act(async () => {
- vi.advanceTimersByTime(1000);
- });
-
- await waitFor(() => {
- expect(result.current.data).toEqual({ data: 'recovered' });
- expect(result.current.error).toBeNull();
- }, { timeout: 5000 });
- }, 15000);
-
- it('should cleanup on unmount', async () => {
- (global.fetch as any).mockResolvedValue({
- ok: true,
- status: 200,
- json: async () => ({ data: 'test' }),
- headers: new Headers()
- });
-
- const { unmount, result } = renderHook(() =>
- usePolling('/api/test', { interval: 1000 })
- );
-
- // Wait for initial fetch to complete
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledTimes(1);
- expect(result.current.isLoading).toBe(false);
- }, { timeout: 5000 });
-
- // Clear any pending timers before unmount
- vi.clearAllTimers();
-
- unmount();
-
- // Reset mocks to clear call count
- const callCountBeforeAdvance = (global.fetch as any).mock.calls.length;
-
- // Advance timers after unmount
- await act(async () => {
- vi.advanceTimersByTime(5000);
- });
-
- // No additional calls should be made after unmount
- expect((global.fetch as any).mock.calls.length).toBe(callCountBeforeAdvance);
- }, 15000);
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/pages.test.tsx b/archon-ui-main/test/pages.test.tsx
deleted file mode 100644
index bd7111be..00000000
--- a/archon-ui-main/test/pages.test.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { describe, test, expect, vi } from 'vitest'
-import React from 'react'
-import { isLmConfigured } from '../src/utils/onboarding'
-import type { NormalizedCredential } from '../src/utils/onboarding'
-
-// Mock useNavigate for onboarding page test
-vi.mock('react-router-dom', () => ({
- useNavigate: () => vi.fn()
-}))
-
-describe('Page Load Tests', () => {
- test('simple page component renders', () => {
- const MockPage = () => Projects
- render( )
- expect(screen.getByText('Projects')).toBeInTheDocument()
- })
-
- test('knowledge base mock renders', () => {
- const MockKnowledgePage = () => Knowledge Base
- render( )
- expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
- })
-
- test('settings mock renders', () => {
- const MockSettingsPage = () => Settings
- render( )
- expect(screen.getByText('Settings')).toBeInTheDocument()
- })
-
- test('mcp mock renders', () => {
- const MockMCPPage = () => MCP Servers
- render( )
- expect(screen.getByText('MCP Servers')).toBeInTheDocument()
- })
-
- test('tasks mock renders', () => {
- const MockTasksPage = () => (
-
-
Tasks
-
TODO
-
In Progress
-
Done
-
- )
- render( )
- expect(screen.getByText('Tasks')).toBeInTheDocument()
- expect(screen.getByText('TODO')).toBeInTheDocument()
- expect(screen.getByText('In Progress')).toBeInTheDocument()
- expect(screen.getByText('Done')).toBeInTheDocument()
- })
-
- test('onboarding page renders', () => {
- const MockOnboardingPage = () => Welcome to Archon
- render( )
- expect(screen.getByText('Welcome to Archon')).toBeInTheDocument()
- })
-})
-
-describe('Onboarding Detection Tests', () => {
- test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY exists', () => {
- const ragCreds: NormalizedCredential[] = [
- { key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
- ]
- const apiKeyCreds: NormalizedCredential[] = [
- { key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
- ]
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
- })
-
- test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY is encrypted', () => {
- const ragCreds: NormalizedCredential[] = [
- { key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
- ]
- const apiKeyCreds: NormalizedCredential[] = [
- { key: 'OPENAI_API_KEY', is_encrypted: true, encrypted_value: 'encrypted_sk-test123', category: 'api_keys' }
- ]
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
- })
-
- test('isLmConfigured returns false when provider is openai and no OPENAI_API_KEY', () => {
- const ragCreds: NormalizedCredential[] = [
- { key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
- ]
- const apiKeyCreds: NormalizedCredential[] = []
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
- })
-
- test('isLmConfigured returns true when provider is ollama regardless of API keys', () => {
- const ragCreds: NormalizedCredential[] = [
- { key: 'LLM_PROVIDER', value: 'ollama', category: 'rag_strategy' }
- ]
- const apiKeyCreds: NormalizedCredential[] = []
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
- })
-
- test('isLmConfigured returns true when no provider but OPENAI_API_KEY exists', () => {
- const ragCreds: NormalizedCredential[] = []
- const apiKeyCreds: NormalizedCredential[] = [
- { key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
- ]
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
- })
-
- test('isLmConfigured returns false when no provider and no OPENAI_API_KEY', () => {
- const ragCreds: NormalizedCredential[] = []
- const apiKeyCreds: NormalizedCredential[] = []
-
- expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/pages/ProjectPage.performance.test.tsx b/archon-ui-main/test/pages/ProjectPage.performance.test.tsx
deleted file mode 100644
index 4019ee95..00000000
--- a/archon-ui-main/test/pages/ProjectPage.performance.test.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { readFileSync } from 'fs';
-import { join } from 'path';
-
-describe('ProjectPage Performance Optimizations', () => {
- const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
- const projectServicePath = join(process.cwd(), 'src/services/projectService.ts');
-
- it('should use batch API call for task counts instead of N+1 queries', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Verify batch endpoint is being used
- expect(fileContent).toContain('getTaskCountsForAllProjects');
-
- // Verify we're NOT using Promise.allSettled for parallel fetching
- expect(fileContent).not.toContain('Promise.allSettled');
-
- // Verify single batch API call pattern
- expect(fileContent).toContain('await projectService.getTaskCountsForAllProjects()');
- });
-
- it('should have memoized handleProjectSelect to prevent duplicate calls', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check that handleProjectSelect is wrapped with useCallback
- expect(fileContent).toMatch(/const handleProjectSelect = useCallback\(/);
-
- // Check for early return if same project
- expect(fileContent).toContain('if (selectedProject?.id === project.id) return');
-
- // Check dependency array includes selectedProject?.id
- expect(fileContent).toMatch(/\}, \[.*selectedProject\?\.id.*\]\)/);
- });
-
- it('should implement task counts cache with TTL', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check cache ref is defined
- expect(fileContent).toContain('const taskCountsCache = useRef');
-
- // Check cache structure includes timestamp
- expect(fileContent).toContain('timestamp: number');
-
- // Check cache is checked before API call (5-minute TTL = 300000ms)
- expect(fileContent).toContain('(now - taskCountsCache.current.timestamp) < 300000');
-
- // Check cache is updated after successful API call
- expect(fileContent).toContain('taskCountsCache.current = {');
- });
-
- it('should disable polling during project switching and drag operations', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check useTaskPolling enabled parameter includes conditions
- expect(fileContent).toMatch(/enabled:.*!isSwitchingProject.*movingTaskIds\.size === 0/);
-
- // Verify isSwitchingProject state exists
- expect(fileContent).toContain('const [isSwitchingProject, setIsSwitchingProject]');
- });
-
- it('should have debounce utility implemented', () => {
- const debouncePath = join(process.cwd(), 'src/utils/debounce.ts');
- const fileContent = readFileSync(debouncePath, 'utf-8');
-
- // Check debounce function exists
- expect(fileContent).toContain('export function debounce');
-
- // Check it has proper TypeScript types
- expect(fileContent).toContain('T extends (...args: any[]) => any');
-
- // Check timeout clearing logic
- expect(fileContent).toContain('clearTimeout(timeoutId)');
- });
-
- it('should apply debouncing to loadTaskCountsForAllProjects', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check debounce is imported
- expect(fileContent).toContain('import { debounce } from "../utils/debounce"');
-
- // Check debounced version is created
- expect(fileContent).toContain('const debouncedLoadTaskCounts = useMemo');
- expect(fileContent).toContain('debounce((projectIds: string[])');
-
- // Check debounced version is used instead of direct calls
- expect(fileContent).toContain('debouncedLoadTaskCounts(projectIds)');
-
- // Verify 1000ms delay
- expect(fileContent).toContain('}, 1000)');
- });
-
- it('should have batch task counts endpoint in backend service', () => {
- const serviceContent = readFileSync(projectServicePath, 'utf-8');
-
- // Check the service method exists
- expect(serviceContent).toContain('async getTaskCountsForAllProjects()');
-
- // Check it calls the correct endpoint
- expect(serviceContent).toContain('/api/projects/task-counts');
-
- // Check return type
- expect(serviceContent).toContain('Promise>');
- });
-
- it('should not make duplicate API calls on project switch', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check that tasks are cleared immediately on switch
- expect(fileContent).toContain('setTasks([]); // Clear stale tasks immediately');
-
- // Check loading state is managed properly
- expect(fileContent).toContain('setIsSwitchingProject(true)');
- expect(fileContent).toContain('setIsSwitchingProject(false)');
- });
-
- it('should have correct import statements for performance utilities', () => {
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check all necessary React hooks are imported
- expect(fileContent).toContain('useCallback');
- expect(fileContent).toContain('useMemo');
- expect(fileContent).toContain('useRef');
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/pages/ProjectPage.polling.test.tsx b/archon-ui-main/test/pages/ProjectPage.polling.test.tsx
deleted file mode 100644
index 43022150..00000000
--- a/archon-ui-main/test/pages/ProjectPage.polling.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { readFileSync } from 'fs';
-import { join } from 'path';
-
-describe('ProjectPage Polling Conflict Prevention', () => {
- it('should have movingTaskIds check in polling useEffect', () => {
- // Read the actual source file to verify the implementation
- const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check that movingTaskIds state is declared
- expect(fileContent).toContain('const [movingTaskIds, setMovingTaskIds] = useState>(new Set())');
-
- // Check that movingTaskIds is checked before updating tasks
- expect(fileContent).toContain('if (movingTaskIds.size === 0)');
-
- // Check that merge logic is present for non-moving tasks
- expect(fileContent).toContain('if (movingTaskIds.has(task.id))');
- expect(fileContent).toContain('return task; // Preserve local state for moving tasks');
-
- // Check that movingTaskIds is in the dependency array
- expect(fileContent).toMatch(/\}, \[.*movingTaskIds.*\]\)/);
- });
-
- it('should pass movingTaskIds props to TasksTab', () => {
- const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
- const fileContent = readFileSync(projectPagePath, 'utf-8');
-
- // Check that movingTaskIds is passed as prop
- expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
- expect(fileContent).toContain('setMovingTaskIds={setMovingTaskIds}');
- });
-
- it('should have TasksTab accept movingTaskIds props', () => {
- const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
- const fileContent = readFileSync(tasksTabPath, 'utf-8');
-
- // Check that TasksTab accepts the props
- expect(fileContent).toContain('movingTaskIds: Set');
- expect(fileContent).toContain('setMovingTaskIds: (ids: Set) => void');
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/services/projectService.test.ts b/archon-ui-main/test/services/projectService.test.ts
deleted file mode 100644
index 98715954..00000000
--- a/archon-ui-main/test/services/projectService.test.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-/**
- * Unit tests for projectService document CRUD operations
- */
-
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import type { Document } from '../../src/services/projectService';
-
-// Mock fetch globally
-global.fetch = vi.fn();
-
-describe('projectService Document Operations', () => {
- let projectService: any;
-
- beforeEach(async () => {
- // Reset all mocks
- vi.resetAllMocks();
- vi.resetModules();
-
- // Import fresh instance of projectService
- const module = await import('../../src/services/projectService');
- projectService = module.projectService;
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- describe('getDocument', () => {
- const mockDocument: Document = {
- id: 'doc-123',
- project_id: 'proj-456',
- title: 'Test Document',
- content: { type: 'markdown', text: 'Test content' },
- document_type: 'prp',
- metadata: { version: '1.0' },
- tags: ['test', 'sample'],
- author: 'test-user',
- created_at: '2025-08-18T10:00:00Z',
- updated_at: '2025-08-18T10:00:00Z'
- };
-
- it('should successfully fetch a document', async () => {
- // Mock successful response
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ document: mockDocument })
- });
-
- const result = await projectService.getDocument('proj-456', 'doc-123');
-
- expect(result).toEqual(mockDocument);
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs/doc-123',
- expect.objectContaining({
- headers: expect.objectContaining({
- 'Content-Type': 'application/json'
- })
- })
- );
- });
-
- it('should include projectId in error message when fetch fails', async () => {
- // Mock failed response
- (global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 404,
- text: async () => '{"error": "Document not found"}'
- });
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow();
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to get document doc-123 from project proj-456:',
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
-
- it('should handle network errors', async () => {
- // Mock network error
- (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow('Network error');
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to get document doc-123 from project proj-456:',
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
- });
-
- describe('updateDocument', () => {
- const mockUpdatedDocument: Document = {
- id: 'doc-123',
- project_id: 'proj-456',
- title: 'Updated Document',
- content: { type: 'markdown', text: 'Updated content' },
- document_type: 'prp',
- metadata: { version: '2.0' },
- tags: ['updated', 'test'],
- author: 'test-user',
- created_at: '2025-08-18T10:00:00Z',
- updated_at: '2025-08-18T11:00:00Z'
- };
-
- const updates = {
- title: 'Updated Document',
- content: { type: 'markdown', text: 'Updated content' },
- tags: ['updated', 'test']
- };
-
- it('should successfully update a document', async () => {
- // Mock successful response
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ document: mockUpdatedDocument })
- });
-
- const result = await projectService.updateDocument('proj-456', 'doc-123', updates);
-
- expect(result).toEqual(mockUpdatedDocument);
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs/doc-123',
- expect.objectContaining({
- method: 'PUT',
- headers: expect.objectContaining({
- 'Content-Type': 'application/json'
- }),
- body: JSON.stringify(updates)
- })
- );
- });
-
- it('should include projectId in error message when update fails', async () => {
- // Mock failed response
- (global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 400,
- text: async () => '{"error": "Invalid update data"}'
- });
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.updateDocument('proj-456', 'doc-123', updates)).rejects.toThrow();
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to update document doc-123 in project proj-456:',
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
-
- it('should handle partial updates', async () => {
- const partialUpdate = { title: 'Only Title Updated' };
-
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ document: { ...mockUpdatedDocument, title: 'Only Title Updated' } })
- });
-
- const result = await projectService.updateDocument('proj-456', 'doc-123', partialUpdate);
-
- expect(result.title).toBe('Only Title Updated');
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs/doc-123',
- expect.objectContaining({
- body: JSON.stringify(partialUpdate)
- })
- );
- });
- });
-
- describe('deleteDocument', () => {
- it('should successfully delete a document', async () => {
- // Mock successful response
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({})
- });
-
- await expect(projectService.deleteDocument('proj-456', 'doc-123')).resolves.toBeUndefined();
-
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs/doc-123',
- expect.objectContaining({
- method: 'DELETE',
- headers: expect.objectContaining({
- 'Content-Type': 'application/json'
- })
- })
- );
- });
-
- it('should include projectId in error message when deletion fails', async () => {
- // Mock failed response
- (global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 403,
- text: async () => '{"error": "Permission denied"}'
- });
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to delete document doc-123 from project proj-456:',
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
-
- it('should handle 404 errors appropriately', async () => {
- // Mock 404 response
- (global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 404,
- text: async () => '{"error": "Document not found"}'
- });
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
-
- // Verify the error is logged with project context
- expect(consoleSpy).toHaveBeenCalled();
- const errorLog = consoleSpy.mock.calls[0];
- expect(errorLog[0]).toContain('proj-456');
- expect(errorLog[0]).toContain('doc-123');
-
- consoleSpy.mockRestore();
- });
-
- it('should handle network timeouts', async () => {
- // Mock timeout error
- const timeoutError = new Error('Request timeout');
- (global.fetch as any).mockRejectedValueOnce(timeoutError);
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow('Failed to call API');
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to delete document doc-123 from project proj-456:',
- expect.objectContaining({
- message: expect.stringContaining('Request timeout')
- })
- );
-
- consoleSpy.mockRestore();
- });
- });
-
- describe('listProjectDocuments', () => {
- const mockDocuments: Document[] = [
- {
- id: 'doc-1',
- project_id: 'proj-456',
- title: 'Document 1',
- content: { type: 'markdown', text: 'Content 1' },
- document_type: 'prp',
- created_at: '2025-08-18T10:00:00Z',
- updated_at: '2025-08-18T10:00:00Z'
- },
- {
- id: 'doc-2',
- project_id: 'proj-456',
- title: 'Document 2',
- content: { type: 'markdown', text: 'Content 2' },
- document_type: 'spec',
- created_at: '2025-08-18T11:00:00Z',
- updated_at: '2025-08-18T11:00:00Z'
- }
- ];
-
- it('should successfully list all project documents', async () => {
- // Mock successful response
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ documents: mockDocuments })
- });
-
- const result = await projectService.listProjectDocuments('proj-456');
-
- expect(result).toEqual(mockDocuments);
- expect(result).toHaveLength(2);
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs',
- expect.objectContaining({
- headers: expect.objectContaining({
- 'Content-Type': 'application/json'
- })
- })
- );
- });
-
- it('should return empty array when no documents exist', async () => {
- // Mock response with no documents
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ documents: [] })
- });
-
- const result = await projectService.listProjectDocuments('proj-456');
-
- expect(result).toEqual([]);
- expect(result).toHaveLength(0);
- });
-
- it('should handle null documents field gracefully', async () => {
- // Mock response with null documents
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ documents: null })
- });
-
- const result = await projectService.listProjectDocuments('proj-456');
-
- expect(result).toEqual([]);
- });
- });
-
- describe('createDocument', () => {
- const newDocumentData = {
- title: 'New Document',
- content: { type: 'markdown', text: 'New content' },
- document_type: 'prp',
- tags: ['new', 'test']
- };
-
- const mockCreatedDocument: Document = {
- id: 'doc-new',
- project_id: 'proj-456',
- ...newDocumentData,
- author: 'test-user',
- created_at: '2025-08-18T12:00:00Z',
- updated_at: '2025-08-18T12:00:00Z'
- };
-
- it('should successfully create a new document', async () => {
- // Mock successful response
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ document: mockCreatedDocument })
- });
-
- const result = await projectService.createDocument('proj-456', newDocumentData);
-
- expect(result).toEqual(mockCreatedDocument);
- expect(result.id).toBeDefined();
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/projects/proj-456/docs',
- expect.objectContaining({
- method: 'POST',
- headers: expect.objectContaining({
- 'Content-Type': 'application/json'
- }),
- body: JSON.stringify(newDocumentData)
- })
- );
- });
-
- it('should handle validation errors', async () => {
- // Mock validation error response
- (global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 422,
- text: async () => '{"error": "Title is required"}'
- });
-
- const invalidData = { content: 'Missing title' };
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- await expect(projectService.createDocument('proj-456', invalidData)).rejects.toThrow();
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to create document for project proj-456:',
- expect.any(Error)
- );
-
- consoleSpy.mockRestore();
- });
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/test/user_flows.test.tsx b/archon-ui-main/test/user_flows.test.tsx
deleted file mode 100644
index 71e97dfd..00000000
--- a/archon-ui-main/test/user_flows.test.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { describe, test, expect, vi } from 'vitest'
-import React from 'react'
-
-describe('User Flow Tests', () => {
- test('create project flow mock', () => {
- const MockCreateProject = () => {
- const [project, setProject] = React.useState('')
- return (
-
-
Create Project
- setProject(e.target.value)}
- />
- Create
-
- )
- }
-
- render( )
- expect(screen.getByText('Create Project')).toBeInTheDocument()
- expect(screen.getByPlaceholderText('Project title')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
- })
-
- test('search functionality mock', () => {
- const MockSearch = () => {
- const [query, setQuery] = React.useState('')
- return (
-
-
Search
-
setQuery(e.target.value)}
- />
- {query &&
Results for: {query}
}
-
- )
- }
-
- render( )
- const input = screen.getByPlaceholderText('Search knowledge base')
- fireEvent.change(input, { target: { value: 'test query' } })
- expect(screen.getByText('Results for: test query')).toBeInTheDocument()
- })
-
- test('settings toggle mock', () => {
- const MockSettings = () => {
- const [theme, setTheme] = React.useState('light')
- return (
-
-
Settings
- setTheme(theme === 'light' ? 'dark' : 'light')}>
- Theme: {theme}
-
-
- )
- }
-
- render( )
- const button = screen.getByText('Theme: light')
- fireEvent.click(button)
- expect(screen.getByText('Theme: dark')).toBeInTheDocument()
- })
-
- test('file upload mock', () => {
- const MockUpload = () => {
- const [uploaded, setUploaded] = React.useState(false)
- return (
-
-
Upload Documents
-
setUploaded(true)} data-testid="file-input" />
- {uploaded &&
File uploaded successfully
}
-
- )
- }
-
- render( )
- const input = screen.getByTestId('file-input')
- fireEvent.change(input)
- expect(screen.getByText('File uploaded successfully')).toBeInTheDocument()
- })
-
- test('connection status mock', () => {
- const MockConnection = () => {
- const [connected, setConnected] = React.useState(true)
- return (
-
-
Connection Status
-
{connected ? 'Connected' : 'Disconnected'}
-
setConnected(!connected)}>
- Toggle Connection
-
-
- )
- }
-
- render( )
- expect(screen.getByText('Connected')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Toggle Connection'))
- expect(screen.getByText('Disconnected')).toBeInTheDocument()
- })
-
- test('task management mock', () => {
- const MockTasks = () => {
- const [tasks, setTasks] = React.useState(['Task 1', 'Task 2'])
- const addTask = () => setTasks([...tasks, `Task ${tasks.length + 1}`])
-
- return (
-
-
Task Management
-
Add Task
-
- {tasks.map((task, index) => (
- {task}
- ))}
-
-
- )
- }
-
- render( )
- expect(screen.getByText('Task 1')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Add Task'))
- expect(screen.getByText('Task 3')).toBeInTheDocument()
- })
-
- test('navigation mock', () => {
- const MockNav = () => {
- const [currentPage, setCurrentPage] = React.useState('home')
- return (
-
-
- setCurrentPage('projects')}>Projects
- setCurrentPage('settings')}>Settings
-
-
- Current page: {currentPage}
-
-
- )
- }
-
- render( )
- expect(screen.getByText('Current page: home')).toBeInTheDocument()
-
- fireEvent.click(screen.getByText('Projects'))
- expect(screen.getByText('Current page: projects')).toBeInTheDocument()
- })
-
- test('form validation mock', () => {
- const MockForm = () => {
- const [email, setEmail] = React.useState('')
- const [error, setError] = React.useState('')
-
- const handleSubmit = () => {
- if (!email.includes('@')) {
- setError('Invalid email')
- } else {
- setError('')
- }
- }
-
- return (
-
-
Form Validation
-
setEmail(e.target.value)}
- />
-
Submit
- {error &&
{error}
}
-
- )
- }
-
- render( )
- const input = screen.getByPlaceholderText('Email')
-
- fireEvent.change(input, { target: { value: 'invalid' } })
- fireEvent.click(screen.getByText('Submit'))
- expect(screen.getByRole('alert')).toHaveTextContent('Invalid email')
- })
-
- test('theme switching mock', () => {
- const MockTheme = () => {
- const [isDark, setIsDark] = React.useState(false)
- return (
-
-
Theme Test
- setIsDark(!isDark)}>
- Switch to {isDark ? 'Light' : 'Dark'}
-
-
- )
- }
-
- render( )
- const button = screen.getByText('Switch to Dark')
- fireEvent.click(button)
- expect(screen.getByText('Switch to Light')).toBeInTheDocument()
- })
-
- test('data filtering mock', () => {
- const MockFilter = () => {
- const [filter, setFilter] = React.useState('')
- const items = ['Apple', 'Banana', 'Cherry']
- const filtered = items.filter(item =>
- item.toLowerCase().includes(filter.toLowerCase())
- )
-
- return (
-
-
Filter Test
-
setFilter(e.target.value)}
- />
-
- {filtered.map((item, index) => (
- {item}
- ))}
-
-
- )
- }
-
- render( )
- const input = screen.getByPlaceholderText('Filter items')
-
- fireEvent.change(input, { target: { value: 'a' } })
- expect(screen.getByText('Apple')).toBeInTheDocument()
- expect(screen.getByText('Banana')).toBeInTheDocument()
- expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
- })
-})
\ No newline at end of file
diff --git a/archon-ui-main/test/utils/taskOrdering.test.ts b/archon-ui-main/test/utils/taskOrdering.test.ts
deleted file mode 100644
index cb6816fb..00000000
--- a/archon-ui-main/test/utils/taskOrdering.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { calculateTaskOrder, calculateReorderPosition, getDefaultTaskOrder } from '../../src/utils/taskOrdering';
-import { Task } from '../../src/types/project';
-
-// Mock task factory
-const createMockTask = (id: string, task_order: number): Task => ({
- id,
- title: `Task ${id}`,
- description: '',
- status: 'todo',
- assignee: { name: 'Test User', avatar: '' },
- feature: '',
- featureColor: '#3b82f6',
- task_order,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- project_id: 'test-project'
-});
-
-describe('taskOrdering utilities', () => {
- describe('calculateTaskOrder', () => {
- it('does not mutate existingTasks', () => {
- const existingTasks = [createMockTask('1', 200), createMockTask('2', 100)];
- const snapshot = existingTasks.map(t => t.task_order);
- calculateTaskOrder({ position: 'first', existingTasks });
- expect(existingTasks.map(t => t.task_order)).toEqual(snapshot);
- });
-
- it('should return seed value for first task when no existing tasks', () => {
- const result = calculateTaskOrder({
- position: 'first',
- existingTasks: []
- });
- expect(result).toBe(65536);
- });
-
- it('should calculate first position correctly', () => {
- const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
- const result = calculateTaskOrder({
- position: 'first',
- existingTasks
- });
- expect(result).toBe(50); // Math.floor(100 / 2)
- });
-
- it('should calculate last position correctly', () => {
- const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
- const result = calculateTaskOrder({
- position: 'last',
- existingTasks
- });
- expect(result).toBe(1224); // 200 + 1024
- });
-
- it('should calculate between position correctly', () => {
- const result = calculateTaskOrder({
- position: 'between',
- existingTasks: [],
- beforeTaskOrder: 100,
- afterTaskOrder: 200
- });
- expect(result).toBe(150); // Math.floor((100 + 200) / 2)
- });
- });
-
- describe('getDefaultTaskOrder', () => {
- it('should return seed value when no existing tasks', () => {
- const result = getDefaultTaskOrder([]);
- expect(result).toBe(65536);
- });
-
- it('should return first position when existing tasks present', () => {
- const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
- const result = getDefaultTaskOrder(existingTasks);
- expect(result).toBe(50); // Math.floor(100 / 2)
- });
- });
-
- describe('calculateReorderPosition', () => {
- const statusTasks = [
- createMockTask('1', 100),
- createMockTask('2', 200),
- createMockTask('3', 300)
- ];
-
- it('should calculate position for moving to first', () => {
- const result = calculateReorderPosition(statusTasks, 1, 0);
- expect(result).toBeLessThan(statusTasks[0].task_order);
- });
-
- it('should calculate position for moving to last', () => {
- const result = calculateReorderPosition(statusTasks, 0, 2);
- expect(result).toBeGreaterThan(statusTasks[2].task_order);
- });
-
- it('should calculate position for moving down within middle (1 -> 2)', () => {
- const result = calculateReorderPosition(statusTasks, 1, 2);
- // After excluding moving index 1, insert between 300 and end => should be >300 (or handled by "last" path)
- expect(result).toBeGreaterThan(statusTasks[2].task_order);
- });
-
- it('should calculate position for moving up within middle (2 -> 1)', () => {
- const result = calculateReorderPosition(statusTasks, 2, 1);
- // With fixed neighbor calculation, this should work correctly
- expect(result).toBeGreaterThan(statusTasks[0].task_order); // > 100
- expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
- });
-
- it('should calculate position for moving between items', () => {
- const result = calculateReorderPosition(statusTasks, 0, 1);
- // Moving task 0 (order 100) to position 1 should place it before task 1 (order 200)
- // Since we removed the moving task, it should be between start and 200
- expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
- expect(result).toBeGreaterThan(0); // > 0
- });
-
- it('should return integer values only', () => {
- const result1 = calculateReorderPosition(statusTasks, 1, 0);
- const result2 = calculateReorderPosition(statusTasks, 0, 2);
- const result3 = calculateReorderPosition(statusTasks, 2, 1);
-
- expect(Number.isInteger(result1)).toBe(true);
- expect(Number.isInteger(result2)).toBe(true);
- expect(Number.isInteger(result3)).toBe(true);
- });
-
- it('should handle bounds checking correctly', () => {
- // Test with tasks that have equal order values (edge case)
- const equalTasks = [
- createMockTask('1', 100),
- createMockTask('2', 100)
- ];
- const result = calculateReorderPosition(equalTasks, 0, 1);
- expect(Number.isInteger(result)).toBe(true);
- expect(result).toBeGreaterThan(100);
- });
- });
-});
\ No newline at end of file
diff --git a/archon-ui-main/tests/README.md b/archon-ui-main/tests/README.md
new file mode 100644
index 00000000..16fee148
--- /dev/null
+++ b/archon-ui-main/tests/README.md
@@ -0,0 +1,58 @@
+# Test Structure
+
+## Test Organization
+
+We follow a hybrid testing strategy:
+
+### Unit Tests (Colocated)
+Unit tests live next to the code they test in the `src/features` directory:
+```
+src/features/projects/
+├── components/
+│ ├── ProjectCard.tsx
+│ └── ProjectCard.test.tsx
+```
+
+### Integration Tests
+Tests that cross multiple features/systems:
+```
+tests/integration/
+└── api.integration.test.ts
+```
+
+### E2E Tests
+Full user flow tests:
+```
+tests/e2e/
+└── user-flows.e2e.test.ts
+```
+
+## Running Tests
+
+```bash
+# Run all tests
+npm run test
+
+# Run tests in watch mode
+npm run test:watch
+
+# Run with coverage
+npm run test:coverage
+
+# Run specific test file
+npx vitest run src/features/ui/hooks/useSmartPolling.test.ts
+```
+
+## Test Naming Conventions
+
+- **Unit tests**: `ComponentName.test.tsx` or `hookName.test.ts`
+- **Integration tests**: `feature.integration.test.ts`
+- **E2E tests**: `flow-name.e2e.test.ts`
+
+## Test Setup
+
+Global test setup is in `tests/setup.ts` which:
+- Sets environment variables
+- Mocks fetch and localStorage
+- Mocks DOM APIs
+- Mocks external libraries (lucide-react)
\ No newline at end of file
diff --git a/archon-ui-main/test/setup.ts b/archon-ui-main/tests/setup.ts
similarity index 61%
rename from archon-ui-main/test/setup.ts
rename to archon-ui-main/tests/setup.ts
index 54a4ccb5..0fddd2b4 100644
--- a/archon-ui-main/test/setup.ts
+++ b/archon-ui-main/tests/setup.ts
@@ -35,17 +35,28 @@ Object.defineProperty(window, 'localStorage', {
Element.prototype.scrollIntoView = vi.fn()
window.HTMLElement.prototype.scrollIntoView = vi.fn()
-// Mock lucide-react icons - create a proxy that returns icon name for any icon
-vi.mock('lucide-react', () => {
- return new Proxy({}, {
- get: (target, prop) => {
- if (typeof prop === 'string') {
- return () => prop
- }
- return undefined
- }
- })
-})
+// Mock lucide-react icons - simple implementation
+vi.mock('lucide-react', () => ({
+ Trash2: () => 'Trash2',
+ X: () => 'X',
+ AlertCircle: () => 'AlertCircle',
+ Loader2: () => 'Loader2',
+ BookOpen: () => 'BookOpen',
+ Settings: () => 'Settings',
+ WifiOff: () => 'WifiOff',
+ ChevronDown: () => 'ChevronDown',
+ ChevronRight: () => 'ChevronRight',
+ Plus: () => 'Plus',
+ Search: () => 'Search',
+ Activity: () => 'Activity',
+ CheckCircle2: () => 'CheckCircle2',
+ ListTodo: () => 'ListTodo',
+ MoreHorizontal: () => 'MoreHorizontal',
+ Pin: () => 'Pin',
+ PinOff: () => 'PinOff',
+ Clipboard: () => 'Clipboard',
+ // Add more icons as needed
+}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
diff --git a/archon-ui-main/tsconfig.json b/archon-ui-main/tsconfig.json
index a7d46b8c..6db9f331 100644
--- a/archon-ui-main/tsconfig.json
+++ b/archon-ui-main/tsconfig.json
@@ -23,6 +23,6 @@
/* Path mapping */
"paths": { "@/*": ["./src/*"] }
},
- "include": ["src", "test"],
+ "include": ["src", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/archon-ui-main/tsconfig.prod.json b/archon-ui-main/tsconfig.prod.json
new file mode 100644
index 00000000..0a95c788
--- /dev/null
+++ b/archon-ui-main/tsconfig.prod.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": [
+ "**/*.test.ts",
+ "**/*.test.tsx",
+ "**/*.spec.ts",
+ "**/*.spec.tsx",
+ "**/__tests__/**",
+ "**/tests/**",
+ "src/features/testing/**",
+ "test/**",
+ "tests/**",
+ "coverage/**"
+ ]
+}
\ No newline at end of file
diff --git a/archon-ui-main/vite.config.ts b/archon-ui-main/vite.config.ts
index 9a986523..52c2be86 100644
--- a/archon-ui-main/vite.config.ts
+++ b/archon-ui-main/vite.config.ts
@@ -321,15 +321,18 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
test: {
globals: true,
environment: 'jsdom',
- setupFiles: './test/setup.ts',
+ setupFiles: './tests/setup.ts',
css: true,
+ include: [
+ 'src/**/*.{test,spec}.{ts,tsx}', // Tests colocated in features
+ 'tests/**/*.{test,spec}.{ts,tsx}' // Tests in tests directory
+ ],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
- '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
- '**/*.test.{ts,tsx}',
+ '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'
],
env: {
VITE_HOST: host,
@@ -340,7 +343,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
- 'test/',
+ 'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData.ts',
diff --git a/archon-ui-main/vitest.config.ts b/archon-ui-main/vitest.config.ts
index c27ee106..51e20e1c 100644
--- a/archon-ui-main/vitest.config.ts
+++ b/archon-ui-main/vitest.config.ts
@@ -8,10 +8,12 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
- setupFiles: './test/setup.ts',
+ setupFiles: './tests/setup.ts',
include: [
- 'test/**/*.test.{ts,tsx}',
- 'test/**/*.spec.{ts,tsx}'
+ 'src/**/*.test.{ts,tsx}', // Colocated tests in features
+ 'src/**/*.spec.{ts,tsx}',
+ 'tests/**/*.test.{ts,tsx}', // Tests in tests directory
+ 'tests/**/*.spec.{ts,tsx}'
],
exclude: ['node_modules', 'dist', '.git', '.cache', 'test.backup', '*.backup/**', 'test-backups'],
reporters: ['dot', 'json'],
@@ -35,7 +37,7 @@ export default defineConfig({
reportOnFailure: true, // Generate coverage reports even when tests fail
exclude: [
'node_modules/',
- 'test/',
+ 'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData.ts',
diff --git a/docs/docs/agent-chat.mdx b/docs/docs/agent-chat.mdx
index 3615b402..492877b9 100644
--- a/docs/docs/agent-chat.mdx
+++ b/docs/docs/agent-chat.mdx
@@ -45,7 +45,7 @@ graph LR
```tsx
-import { ArchonChatPanel } from '@/components/layouts/ArchonChatPanel';
+import { ArchonChatPanel } from '@/components/agent-chat/ArchonChatPanel';
function App() {
return (
diff --git a/python/src/server/api_routes/mcp_api.py b/python/src/server/api_routes/mcp_api.py
index df1f4741..6d32e355 100644
--- a/python/src/server/api_routes/mcp_api.py
+++ b/python/src/server/api_routes/mcp_api.py
@@ -1,552 +1,89 @@
"""
MCP API endpoints for Archon
-Handles:
-- MCP server lifecycle (start/stop/status)
-- MCP server configuration management
-- Tool discovery and testing
+Provides status and configuration endpoints for the MCP service.
+The MCP container is managed by docker-compose, not by this API.
"""
-import asyncio
-import time
-from collections import deque
-from datetime import datetime
+import os
from typing import Any
import docker
-from docker.errors import APIError, NotFound
+from docker.errors import NotFound
from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel
# Import unified logging
-from ..config.logfire_config import api_logger, mcp_logger, safe_set_attribute, safe_span
-from ..utils import get_supabase_client
+from ..config.logfire_config import api_logger, safe_set_attribute, safe_span
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
-class ServerConfig(BaseModel):
- transport: str = "sse"
- host: str = "localhost"
- port: int = 8051
+def get_container_status() -> dict[str, Any]:
+ """Get simple MCP container status without Docker management."""
+ docker_client = None
+ try:
+ docker_client = docker.from_env()
+ container = docker_client.containers.get("archon-mcp")
+ # Get container status
+ container_status = container.status
-class ServerResponse(BaseModel):
- success: bool
- message: str
- status: str | None = None
- pid: int | None = None
-
-
-class LogEntry(BaseModel):
- timestamp: str
- level: str
- message: str
-
-
-class MCPServerManager:
- """Manages the MCP Docker container lifecycle."""
-
- def __init__(self):
- self.container_name = None # Will be resolved dynamically
- self.docker_client = None
- self.container = None
- self.status: str = "stopped"
- self.start_time: float | None = None
- self.logs: deque = deque(maxlen=1000) # Keep last 1000 log entries for internal use
- self.log_reader_task: asyncio.Task | None = None
- self._operation_lock = asyncio.Lock() # Prevent concurrent start/stop operations
- self._last_operation_time = 0
- self._min_operation_interval = 2.0 # Minimum 2 seconds between operations
- self._initialize_docker_client()
-
- def _resolve_container(self):
- """Simple container resolution - just use fixed name."""
- if not self.docker_client:
- return None
-
- try:
- # Simple: Just look for the fixed container name
- container = self.docker_client.containers.get("archon-mcp")
- self.container_name = "archon-mcp"
- mcp_logger.info("Found MCP container")
- return container
- except NotFound:
- mcp_logger.warning("MCP container not found - is it running?")
- self.container_name = "archon-mcp"
- return None
-
- def _initialize_docker_client(self):
- """Initialize Docker client and get container reference."""
- try:
- self.docker_client = docker.from_env()
- self.container = self._resolve_container()
- if not self.container:
- mcp_logger.warning("MCP container not found during initialization")
- except Exception as e:
- mcp_logger.error(f"Failed to initialize Docker client: {str(e)}")
- self.docker_client = None
-
- def _get_container_status(self) -> str:
- """Get the current status of the MCP container."""
- if not self.docker_client:
- return "docker_unavailable"
-
- try:
- if self.container:
- self.container.reload() # Refresh container info
- else:
- # Try to resolve container again if we don't have it
- self.container = self._resolve_container()
- if not self.container:
- return "not_found"
-
- return self.container.status
- except NotFound:
- # Try to resolve again in case container was recreated
- self.container = self._resolve_container()
- if self.container:
- return self.container.status
- return "not_found"
- except Exception as e:
- mcp_logger.error(f"Error getting container status: {str(e)}")
- return "error"
-
- def _is_log_reader_active(self) -> bool:
- """Check if the log reader task is active."""
- return self.log_reader_task is not None and not self.log_reader_task.done()
-
- async def _ensure_log_reader_running(self):
- """Ensure the log reader task is running if container is active."""
- if not self.container:
- return
-
- # Cancel existing task if any
- if self.log_reader_task:
- self.log_reader_task.cancel()
- try:
- await self.log_reader_task
- except asyncio.CancelledError:
- pass
-
- # Start new log reader task
- self.log_reader_task = asyncio.create_task(self._read_container_logs())
- self._add_log("INFO", "Connected to MCP container logs")
- mcp_logger.info(f"Started log reader for already-running container: {self.container_name}")
-
- async def start_server(self) -> dict[str, Any]:
- """Start the MCP Docker container."""
- async with self._operation_lock:
- # Check throttling
- current_time = time.time()
- if current_time - self._last_operation_time < self._min_operation_interval:
- wait_time = self._min_operation_interval - (
- current_time - self._last_operation_time
- )
- mcp_logger.warning(f"Start operation throttled, please wait {wait_time:.1f}s")
- return {
- "success": False,
- "status": self.status,
- "message": f"Please wait {wait_time:.1f}s before starting server again",
- }
-
- with safe_span("mcp_server_start") as span:
- safe_set_attribute(span, "action", "start_server")
-
- if not self.docker_client:
- mcp_logger.error("Docker client not available")
- return {
- "success": False,
- "status": "docker_unavailable",
- "message": "Docker is not available. Is Docker socket mounted?",
- }
-
- # Check current container status
- container_status = self._get_container_status()
-
- if container_status == "not_found":
- mcp_logger.error(f"Container {self.container_name} not found")
- return {
- "success": False,
- "status": "not_found",
- "message": f"MCP container {self.container_name} not found. Run docker-compose up -d archon-mcp",
- }
-
- if container_status == "running":
- mcp_logger.warning("MCP server start attempted while already running")
- return {
- "success": False,
- "status": "running",
- "message": "MCP server is already running",
- }
-
- try:
- # Start the container
- self.container.start()
- self.status = "starting"
- self.start_time = time.time()
- self._last_operation_time = time.time()
- self._add_log("INFO", "MCP container starting...")
- mcp_logger.info(f"Starting MCP container: {self.container_name}")
- safe_set_attribute(span, "container_id", self.container.id)
-
- # Start reading logs from the container
- if self.log_reader_task:
- self.log_reader_task.cancel()
- self.log_reader_task = asyncio.create_task(self._read_container_logs())
-
- # Give it a moment to start
- await asyncio.sleep(2)
-
- # Check if container is running
- self.container.reload()
- if self.container.status == "running":
- self.status = "running"
- self._add_log("INFO", "MCP container started successfully")
- mcp_logger.info(
- f"MCP container started successfully - container_id={self.container.id}"
- )
- safe_set_attribute(span, "success", True)
- safe_set_attribute(span, "status", "running")
- return {
- "success": True,
- "status": self.status,
- "message": "MCP server started successfully",
- "container_id": self.container.id[:12],
- }
- else:
- self.status = "failed"
- self._add_log(
- "ERROR", f"MCP container failed to start. Status: {self.container.status}"
- )
- mcp_logger.error(
- f"MCP container failed to start - status: {self.container.status}"
- )
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "status", self.container.status)
- return {
- "success": False,
- "status": self.status,
- "message": f"MCP container failed to start. Status: {self.container.status}",
- }
-
- except APIError as e:
- self.status = "failed"
- self._add_log("ERROR", f"Docker API error: {str(e)}")
- mcp_logger.error(f"Docker API error during MCP startup - error={str(e)}")
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- return {
- "success": False,
- "status": self.status,
- "message": f"Docker API error: {str(e)}",
- }
- except Exception as e:
- self.status = "failed"
- self._add_log("ERROR", f"Failed to start MCP server: {str(e)}")
- mcp_logger.error(
- f"Exception during MCP server startup - error={str(e)}, error_type={type(e).__name__}"
- )
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- return {
- "success": False,
- "status": self.status,
- "message": f"Failed to start MCP server: {str(e)}",
- }
-
- async def stop_server(self) -> dict[str, Any]:
- """Stop the MCP Docker container."""
- async with self._operation_lock:
- # Check throttling
- current_time = time.time()
- if current_time - self._last_operation_time < self._min_operation_interval:
- wait_time = self._min_operation_interval - (
- current_time - self._last_operation_time
- )
- mcp_logger.warning(f"Stop operation throttled, please wait {wait_time:.1f}s")
- return {
- "success": False,
- "status": self.status,
- "message": f"Please wait {wait_time:.1f}s before stopping server again",
- }
-
- with safe_span("mcp_server_stop") as span:
- safe_set_attribute(span, "action", "stop_server")
-
- if not self.docker_client:
- mcp_logger.error("Docker client not available")
- return {
- "success": False,
- "status": "docker_unavailable",
- "message": "Docker is not available",
- }
-
- # Check current container status
- container_status = self._get_container_status()
-
- if container_status not in ["running", "restarting"]:
- mcp_logger.warning(
- f"MCP server stop attempted when not running. Status: {container_status}"
- )
- return {
- "success": False,
- "status": container_status,
- "message": f"MCP server is not running (status: {container_status})",
- }
-
- try:
- self.status = "stopping"
- self._add_log("INFO", "Stopping MCP container...")
- mcp_logger.info(f"Stopping MCP container: {self.container_name}")
- safe_set_attribute(span, "container_id", self.container.id)
-
- # Cancel log reading task
- if self.log_reader_task:
- self.log_reader_task.cancel()
- try:
- await self.log_reader_task
- except asyncio.CancelledError:
- pass
-
- # Stop the container with timeout
- await asyncio.get_event_loop().run_in_executor(
- None,
- lambda: self.container.stop(timeout=10), # 10 second timeout
- )
-
- self.status = "stopped"
- self.start_time = None
- self._last_operation_time = time.time()
- self._add_log("INFO", "MCP container stopped")
- mcp_logger.info("MCP container stopped successfully")
- safe_set_attribute(span, "success", True)
- safe_set_attribute(span, "status", "stopped")
-
- return {
- "success": True,
- "status": self.status,
- "message": "MCP server stopped successfully",
- }
-
- except APIError as e:
- self._add_log("ERROR", f"Docker API error: {str(e)}")
- mcp_logger.error(f"Docker API error during MCP stop - error={str(e)}")
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- return {
- "success": False,
- "status": self.status,
- "message": f"Docker API error: {str(e)}",
- }
- except Exception as e:
- self._add_log("ERROR", f"Error stopping MCP server: {str(e)}")
- mcp_logger.error(
- f"Exception during MCP server stop - error={str(e)}, error_type={type(e).__name__}"
- )
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- return {
- "success": False,
- "status": self.status,
- "message": f"Error stopping MCP server: {str(e)}",
- }
-
- def get_status(self) -> dict[str, Any]:
- """Get the current server status."""
- # Update status based on actual container state
- container_status = self._get_container_status()
-
- # Map Docker statuses to our statuses
- status_map = {
- "running": "running",
- "restarting": "restarting",
- "paused": "paused",
- "exited": "stopped",
- "dead": "stopped",
- "created": "stopped",
- "removing": "stopping",
- "not_found": "not_found",
- "docker_unavailable": "docker_unavailable",
- "error": "error",
- }
-
- self.status = status_map.get(container_status, "unknown")
-
- # If container is running but log reader isn't active, start it
- if self.status == "running" and not self._is_log_reader_active():
- asyncio.create_task(self._ensure_log_reader_running())
-
- uptime = None
- if self.status == "running" and self.start_time:
- uptime = int(time.time() - self.start_time)
- elif self.status == "running" and self.container:
+ # Map Docker statuses to simple statuses
+ if container_status == "running":
+ status = "running"
# Try to get uptime from container info
try:
- self.container.reload()
- started_at = self.container.attrs["State"]["StartedAt"]
- # Parse ISO format datetime
from datetime import datetime
-
+ started_at = container.attrs["State"]["StartedAt"]
started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
uptime = int((datetime.now(started_time.tzinfo) - started_time).total_seconds())
except Exception:
- pass
-
- # Convert log entries to strings for backward compatibility
- recent_logs = []
- for log in list(self.logs)[-10:]:
- if isinstance(log, dict):
- recent_logs.append(f"[{log['level']}] {log['message']}")
- else:
- recent_logs.append(str(log))
+ uptime = None
+ else:
+ status = "stopped"
+ uptime = None
return {
- "status": self.status,
+ "status": status,
"uptime": uptime,
- "logs": recent_logs,
- "container_status": container_status, # Include raw Docker status
+ "logs": [], # No log streaming anymore
+ "container_status": container_status
}
- def _add_log(self, level: str, message: str):
- """Add a log entry for internal tracking."""
- log_entry = {
- "timestamp": datetime.utcnow().isoformat() + "Z",
- "level": level,
- "message": message,
+ except NotFound:
+ return {
+ "status": "not_found",
+ "uptime": None,
+ "logs": [],
+ "container_status": "not_found",
+ "message": "MCP container not found. Run: docker compose up -d archon-mcp"
}
- self.logs.append(log_entry)
-
-
- async def _read_container_logs(self):
- """Read logs from Docker container."""
- if not self.container:
- return
-
- try:
- # Stream logs from container
- log_generator = self.container.logs(stream=True, follow=True, tail=100)
-
- while True:
- try:
- log_line = await asyncio.get_event_loop().run_in_executor(
- None, next, log_generator, None
- )
-
- if log_line is None:
- break
-
- # Decode bytes to string
- if isinstance(log_line, bytes):
- log_line = log_line.decode("utf-8").strip()
-
- if log_line:
- level, message = self._parse_log_line(log_line)
- self._add_log(level, message)
-
- except StopIteration:
- break
- except Exception as e:
- self._add_log("ERROR", f"Log reading error: {str(e)}")
- break
-
- except asyncio.CancelledError:
- pass
- except APIError as e:
- if "container not found" not in str(e).lower():
- self._add_log("ERROR", f"Docker API error reading logs: {str(e)}")
- except Exception as e:
- self._add_log("ERROR", f"Error reading container logs: {str(e)}")
- finally:
- # Check if container stopped
+ except Exception as e:
+ api_logger.error("Failed to get container status", exc_info=True)
+ return {
+ "status": "error",
+ "uptime": None,
+ "logs": [],
+ "container_status": "error",
+ "error": str(e)
+ }
+ finally:
+ if docker_client is not None:
try:
- self.container.reload()
- if self.container.status not in ["running", "restarting"]:
- self._add_log(
- "INFO", f"MCP container stopped with status: {self.container.status}"
- )
+ docker_client.close()
except Exception:
pass
- def _parse_log_line(self, line: str) -> tuple[str, str]:
- """Parse a log line to extract level and message."""
- line = line.strip()
- if not line:
- return "INFO", ""
-
- # Try to extract log level from common formats
- if line.startswith("[") and "]" in line:
- end_bracket = line.find("]")
- potential_level = line[1:end_bracket].upper()
- if potential_level in ["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"]:
- return potential_level, line[end_bracket + 1 :].strip()
-
- # Check for common log level indicators
- line_lower = line.lower()
- if any(word in line_lower for word in ["error", "exception", "failed", "critical"]):
- return "ERROR", line
- elif any(word in line_lower for word in ["warning", "warn"]):
- return "WARNING", line
- elif any(word in line_lower for word in ["debug"]):
- return "DEBUG", line
- else:
- return "INFO", line
-
-
-
-# Global MCP manager instance
-mcp_manager = MCPServerManager()
-
-
-@router.post("/start", response_model=ServerResponse)
-async def start_server():
- """Start the MCP server."""
- with safe_span("api_mcp_start") as span:
- safe_set_attribute(span, "endpoint", "/mcp/start")
- safe_set_attribute(span, "method", "POST")
-
- try:
- result = await mcp_manager.start_server()
- api_logger.info(
- "MCP server start API called - success=%s", result.get("success", False)
- )
- safe_set_attribute(span, "success", result.get("success", False))
- return result
- except Exception as e:
- api_logger.error("MCP server start API failed - error=%s", str(e))
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- raise HTTPException(status_code=500, detail=str(e))
-
-
-@router.post("/stop", response_model=ServerResponse)
-async def stop_server():
- """Stop the MCP server."""
- with safe_span("api_mcp_stop") as span:
- safe_set_attribute(span, "endpoint", "/mcp/stop")
- safe_set_attribute(span, "method", "POST")
-
- try:
- result = await mcp_manager.stop_server()
- api_logger.info(f"MCP server stop API called - success={result.get('success', False)}")
- safe_set_attribute(span, "success", result.get("success", False))
- return result
- except Exception as e:
- api_logger.error(f"MCP server stop API failed - error={str(e)}")
- safe_set_attribute(span, "success", False)
- safe_set_attribute(span, "error", str(e))
- raise HTTPException(status_code=500, detail=str(e))
-
@router.get("/status")
async def get_status():
"""Get MCP server status."""
with safe_span("api_mcp_status") as span:
- safe_set_attribute(span, "endpoint", "/mcp/status")
+ safe_set_attribute(span, "endpoint", "/api/mcp/status")
safe_set_attribute(span, "method", "GET")
try:
- status = mcp_manager.get_status()
+ status = get_container_status()
api_logger.debug(f"MCP server status checked - status={status.get('status')}")
safe_set_attribute(span, "status", status.get("status"))
safe_set_attribute(span, "uptime", status.get("uptime"))
@@ -557,7 +94,6 @@ async def get_status():
raise HTTPException(status_code=500, detail=str(e))
-
@router.get("/config")
async def get_mcp_config():
"""Get MCP server configuration."""
@@ -569,18 +105,16 @@ async def get_mcp_config():
api_logger.info("Getting MCP server configuration")
# Get actual MCP port from environment or use default
- import os
-
mcp_port = int(os.getenv("ARCHON_MCP_PORT", "8051"))
- # Configuration for SSE-only mode with actual port
+ # Configuration for streamable-http mode with actual port
config = {
"host": "localhost",
"port": mcp_port,
- "transport": "sse",
+ "transport": "streamable-http",
}
- # Get only model choice from database
+ # Get only model choice from database (simplified)
try:
from ..services.credential_service import credential_service
@@ -588,172 +122,87 @@ async def get_mcp_config():
"MODEL_CHOICE", "gpt-4o-mini"
)
config["model_choice"] = model_choice
- config["use_contextual_embeddings"] = (
- await credential_service.get_credential("USE_CONTEXTUAL_EMBEDDINGS", "false")
- ).lower() == "true"
- config["use_hybrid_search"] = (
- await credential_service.get_credential("USE_HYBRID_SEARCH", "false")
- ).lower() == "true"
- config["use_agentic_rag"] = (
- await credential_service.get_credential("USE_AGENTIC_RAG", "false")
- ).lower() == "true"
- config["use_reranking"] = (
- await credential_service.get_credential("USE_RERANKING", "false")
- ).lower() == "true"
except Exception:
# Fallback to default model
config["model_choice"] = "gpt-4o-mini"
- config["use_contextual_embeddings"] = False
- config["use_hybrid_search"] = False
- config["use_agentic_rag"] = False
- config["use_reranking"] = False
- api_logger.info("MCP configuration (SSE-only mode)")
+ api_logger.info("MCP configuration (streamable-http mode)")
safe_set_attribute(span, "host", config["host"])
safe_set_attribute(span, "port", config["port"])
- safe_set_attribute(span, "transport", "sse")
+ safe_set_attribute(span, "transport", "streamable-http")
safe_set_attribute(span, "model_choice", config.get("model_choice", "gpt-4o-mini"))
return config
except Exception as e:
- api_logger.error("Failed to get MCP configuration", error=str(e))
+ api_logger.error("Failed to get MCP configuration", exc_info=True)
safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail={"error": str(e)})
-@router.post("/config")
-async def save_configuration(config: ServerConfig):
- """Save MCP server configuration."""
- with safe_span("api_save_mcp_config") as span:
- safe_set_attribute(span, "endpoint", "/api/mcp/config")
- safe_set_attribute(span, "method", "POST")
- safe_set_attribute(span, "transport", config.transport)
- safe_set_attribute(span, "host", config.host)
- safe_set_attribute(span, "port", config.port)
-
- try:
- api_logger.info(
- f"Saving MCP server configuration | transport={config.transport} | host={config.host} | port={config.port}"
- )
- supabase_client = get_supabase_client()
-
- config_json = config.model_dump_json()
-
- # Save MCP config using credential service
- from ..services.credential_service import credential_service
-
- success = await credential_service.set_credential(
- "mcp_config",
- config_json,
- category="mcp",
- description="MCP server configuration settings",
- )
-
- if success:
- api_logger.info("MCP configuration saved successfully")
- safe_set_attribute(span, "operation", "save")
- else:
- raise Exception("Failed to save MCP configuration")
-
- safe_set_attribute(span, "success", True)
- return {"success": True, "message": "Configuration saved"}
-
- except Exception as e:
- api_logger.error(f"Failed to save MCP configuration | error={str(e)}")
- safe_set_attribute(span, "error", str(e))
- raise HTTPException(status_code=500, detail={"error": str(e)})
-
-
-
-@router.get("/tools")
-async def get_mcp_tools():
- """Get available MCP tools by querying the running MCP server's registered tools."""
- with safe_span("api_get_mcp_tools") as span:
- safe_set_attribute(span, "endpoint", "/api/mcp/tools")
+@router.get("/clients")
+async def get_mcp_clients():
+ """Get connected MCP clients with type detection."""
+ with safe_span("api_mcp_clients") as span:
+ safe_set_attribute(span, "endpoint", "/api/mcp/clients")
safe_set_attribute(span, "method", "GET")
try:
- api_logger.info("Getting MCP tools from registered server instance")
-
- # Check if server is running
- server_status = mcp_manager.get_status()
- is_running = server_status.get("status") == "running"
- safe_set_attribute(span, "server_running", is_running)
-
- if not is_running:
- api_logger.warning("MCP server not running when requesting tools")
- return {
- "tools": [],
- "count": 0,
- "server_running": False,
- "source": "server_not_running",
- "message": "MCP server is not running. Start the server to see available tools.",
- }
-
- # SIMPLE DEBUG: Just check if we can see any tools at all
- try:
- # Try to inspect the process to see what tools exist
- api_logger.info("Debugging: Attempting to check MCP server tools")
-
- # For now, just return the known modules info since server is registering them
- # This will at least show the UI that tools exist while we debug the real issue
- if is_running:
- return {
- "tools": [
- {
- "name": "debug_placeholder",
- "description": "MCP server is running and modules are registered, but tool introspection is not working yet",
- "module": "debug",
- "parameters": [],
- }
- ],
- "count": 1,
- "server_running": True,
- "source": "debug_placeholder",
- "message": "MCP server is running with 3 modules registered. Tool introspection needs to be fixed.",
- }
- else:
- return {
- "tools": [],
- "count": 0,
- "server_running": False,
- "source": "server_not_running",
- "message": "MCP server is not running. Start the server to see available tools.",
- }
-
- except Exception as e:
- api_logger.error("Failed to debug MCP server tools", error=str(e))
-
- return {
- "tools": [],
- "count": 0,
- "server_running": is_running,
- "source": "debug_error",
- "message": f"Debug failed: {str(e)}",
- }
-
- except Exception as e:
- api_logger.error("Failed to get MCP tools", error=str(e))
- safe_set_attribute(span, "error", str(e))
- safe_set_attribute(span, "source", "general_error")
+ # TODO: Implement real client detection in the future
+ # For now, return empty array as expected by frontend
+ api_logger.debug("Getting MCP clients - returning empty array")
return {
- "tools": [],
- "count": 0,
- "server_running": False,
- "source": "general_error",
- "message": f"Error retrieving MCP tools: {str(e)}",
+ "clients": [],
+ "total": 0
}
+ except Exception as e:
+ api_logger.error(f"Failed to get MCP clients - error={str(e)}")
+ safe_set_attribute(span, "error", str(e))
+ return {
+ "clients": [],
+ "total": 0,
+ "error": str(e)
+ }
+
+
+@router.get("/sessions")
+async def get_mcp_sessions():
+ """Get MCP session information."""
+ with safe_span("api_mcp_sessions") as span:
+ safe_set_attribute(span, "endpoint", "/api/mcp/sessions")
+ safe_set_attribute(span, "method", "GET")
+
+ try:
+ # Basic session info for now
+ status = get_container_status()
+
+ session_info = {
+ "active_sessions": 0, # TODO: Implement real session tracking
+ "session_timeout": 3600, # 1 hour default
+ }
+
+ # Add uptime if server is running
+ if status.get("status") == "running" and status.get("uptime"):
+ session_info["server_uptime_seconds"] = status["uptime"]
+
+ api_logger.debug(f"MCP session info - sessions={session_info.get('active_sessions')}")
+ safe_set_attribute(span, "active_sessions", session_info.get("active_sessions"))
+
+ return session_info
+ except Exception as e:
+ api_logger.error(f"Failed to get MCP sessions - error={str(e)}")
+ safe_set_attribute(span, "error", str(e))
+ raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
async def mcp_health():
- """Health check for MCP API."""
+ """Health check for MCP API - used by bug report service and tests."""
with safe_span("api_mcp_health") as span:
safe_set_attribute(span, "endpoint", "/api/mcp/health")
safe_set_attribute(span, "method", "GET")
- # Removed health check logging to reduce console noise
+ # Simple health check - no logging to reduce noise
result = {"status": "healthy", "service": "mcp"}
safe_set_attribute(span, "status", "healthy")