From 1a78a8e287116eda6aaed88949826b1654a9d7ae Mon Sep 17 00:00:00 2001 From: Wirasm <152263317+Wirasm@users.noreply.github.com> Date: Sat, 6 Sep 2025 13:43:53 +0300 Subject: [PATCH] feat: TanStack Query Migration Phase 2 - Cleanup and Test Reorganization (#588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: migrate layouts to TanStack Query and Radix UI patterns - Created new modern layout components in src/components/layout/ - Migrated from old MainLayout/SideNavigation to new system - Added BackendStatus component with proper separation of concerns - Fixed horizontal scrollbar issues in project list - Renamed old layouts folder to agent-chat for unused chat panel - Added layout directory to Biome configuration - Fixed all linting and TypeScript issues in new layout code - Uses TanStack Query for backend health monitoring - Temporarily imports old settings/credentials until full migration * test: reorganize test infrastructure with colocated tests in subdirectories - Move tests into dedicated tests/ subdirectories within each feature - Create centralized test utilities in src/features/testing/ - Update all import paths to match new structure - Configure tsconfig.prod.json to exclude test files - Remove legacy test files from old test/ directory - All 32 tests passing with proper provider wrapping * fix: use error boundary wrapper for ProjectPage - Export ProjectsViewWithBoundary from projects feature module - Update ProjectPage to use boundary-wrapped version - Provides proper error containment and recovery with TanStack Query integration * cleanup: remove unused MCP client components - Remove ToolTestingPanel, ClientCard, and MCPClients components - These were part of an unimplemented MCP clients feature - Clean up commented import in MCPPage - Preparing for proper MCP feature migration to features directory * cleanup: remove unused mcpService.ts - Remove duplicate/unused mcpService.ts (579 lines) - Keep mcpServerService.ts which is actively used by MCPPage and useMCPQueries - mcpService was never imported or used anywhere in the codebase * cleanup: remove unused mcpClientService and update deprecation comments - Remove mcpClientService.ts (445 lines) - no longer used after removing MCP client components - Update deprecation comments in mcpServerService to remove references to deleted service - This completes the MCP service cleanup * fix: correct test directory exclusion in coverage config Update coverage exclusion from 'test/' to 'tests/' to match actual project structure and ensure proper test file exclusion from coverage. 🤖 Generated with Claude Code Co-Authored-By: Claude * docs: fix ArchonChatPanel import path in agent-chat.mdx Update import from deprecated layouts to agent-chat directory. 🤖 Generated with Claude Code Co-Authored-By: Claude * refactor: improve backend health hook and types - Use existing ETag infrastructure in useBackendHealth for 70% bandwidth reduction - Honor React Query cancellation signals with proper timeout handling - Remove duplicate HealthResponse interface, import from shared types - Add React type import to fix potential strict TypeScript issues 🤖 Generated with Claude Code Co-Authored-By: Claude * fix: remove .d.ts exclusion from production TypeScript config Removing **/*.d.ts exclusion to fix import.meta.env type errors in production builds. The exclusion was preventing src/env.d.ts from being included, breaking ImportMetaEnv interface definitions. 🤖 Generated with Claude Code Co-Authored-By: Claude * feat: implement modern MCP feature architecture - Add new /features/mcp with TanStack Query integration - Components: McpClientList, McpStatusBar, McpConfigSection - Services: mcpApi with ETag caching - Hooks: useMcpStatus, useMcpConfig, useMcpClients, useMcpSessionInfo - Views: McpView with error boundary wrapper - Full TypeScript types for MCP protocol Part of TanStack Query migration phase 2. 🤖 Generated with Claude Code Co-Authored-By: Claude * refactor: complete MCP modernization and cleanup - Remove deprecated mcpServerService.ts (237 lines) - Remove unused useMCPQueries.ts hooks (77 lines) - Simplify MCPPage.tsx to use new feature architecture - Export useSmartPolling from ui/hooks for MCP feature - Add Python MCP API routes for backend integration This completes the MCP migration to TanStack Query with: - ETag caching for 70% bandwidth reduction - Smart polling with visibility awareness - Vertical slice architecture - Full TypeScript type safety 🤖 Generated with Claude Code Co-Authored-By: Claude * fix: correct MCP transport mode display and complete cleanup - Fix backend API to return correct "streamable-http" transport mode - Update frontend to dynamically display transport type from config - Remove unused MCP functions (startMCPServer, stopMCPServer, getMCPServerStatus) - Clean up unused MCPServerResponse interface - Update log messages to show accurate transport mode - Complete aggressive MCP cleanup with 75% code reduction (617 lines removed) Backend changes: - python/src/server/api_routes/mcp_api.py: Fix transport and logs - Reduced from 818 to 201 lines while preserving all functionality Frontend changes: - McpStatusBar: Dynamic transport display based on config - McpView: Pass config to status bar component - api.ts: Remove unused MCP management functions All MCP tools tested and verified working after cleanup. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * simplify MCP API to status-only endpoints - Remove Docker container management functionality - Remove start/stop/restart endpoints - Simplify to status and config endpoints only - Container is now managed entirely via docker-compose * feat: complete MCP feature migration to TanStack Query - Add MCP feature with TanStack Query hooks and services - Create useMcpQueries hook with smart polling for status/config - Implement mcpApi service with streamable-http transport - Add MCP page component with real-time updates - Export MCP hooks from features/ui for global access - Fix logging bug in mcp_api.py (invalid error kwarg) - Update docker command to v2 syntax (docker compose) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: clean up unused CSS and unify Tron-themed scrollbars - Remove 200+ lines of unused CSS classes (62% file size reduction) - Delete unused: glass classes, neon-dividers, card animations, screensaver animations - Remove unused knowledge-item-card and hide-scrollbar styles - Remove unused flip-card and card expansion animations - Update scrollbar-thin to match Tron theme with blue glow effects - Add gradient and glow effects to thin scrollbars for consistency - Keep only actively used styles: neon-grid, scrollbars, animation delays File reduced from 11.2KB to 4.3KB with no visual regressions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: address CodeRabbit CSS review feedback - Fix neon-grid Tailwind @apply with arbitrary values (breaking build) - Convert hardcoded RGBA colors to HSL tokens using --blue-accent - Add prefers-reduced-motion accessibility support - Add Firefox dark mode scrollbar-color support - Optimize transitions to specific properties instead of 'all' 🤖 Generated with Claude Code Co-Authored-By: Claude * fix: properly close Docker client to prevent resource leak - Add finally block to ensure Docker client is closed - Prevents resource leak in get_container_status function - Fix linting issues (whitespace and newline) 🤖 Generated with Claude Code Co-Authored-By: Claude --------- Co-authored-by: Claude --- archon-ui-main/.dockerignore | 10 + archon-ui-main/__mocks__/lucide-react.tsx | 296 ------ archon-ui-main/biome.json | 2 +- archon-ui-main/src/App.tsx | 2 +- .../ArchonChatPanel.tsx | 0 .../src/components/layout/MainLayout.tsx | 193 ++++ .../src/components/layout/Navigation.tsx | 178 ++++ .../layout/hooks/useBackendHealth.ts | 47 + archon-ui-main/src/components/layout/index.ts | 3 + archon-ui-main/src/components/layout/types.ts | 28 + .../src/components/layouts/MainLayout.tsx | 215 ----- .../src/components/layouts/SideNavigation.tsx | 130 --- .../src/components/mcp/ClientCard.tsx | 508 ----------- .../src/components/mcp/MCPClients.tsx | 858 ------------------ .../src/components/mcp/ToolTestingPanel.tsx | 568 ------------ .../features/mcp/components/McpClientList.tsx | 108 +++ .../mcp/components/McpConfigSection.tsx | 298 ++++++ .../features/mcp/components/McpStatusBar.tsx | 107 +++ .../src/features/mcp/components/index.ts | 3 + .../src/features/mcp/hooks/index.ts | 1 + .../src/features/mcp/hooks/useMcpQueries.ts | 60 ++ archon-ui-main/src/features/mcp/index.ts | 6 + .../src/features/mcp/services/index.ts | 1 + .../src/features/mcp/services/mcpApi.ts | 54 ++ .../src/features/mcp/types/index.ts | 1 + archon-ui-main/src/features/mcp/types/mcp.ts | 54 ++ .../src/features/mcp/views/McpView.tsx | 110 +++ .../mcp/views/McpViewWithBoundary.tsx | 15 + .../components/tests/ProjectCard.test.tsx | 189 ++++ .../hooks/tests/useProjectQueries.test.ts | 208 +++++ archon-ui-main/src/features/projects/index.ts | 1 + .../tasks/hooks/tests/useTaskQueries.test.ts | 195 ++++ .../src/features/testing/test-utils.tsx | 42 + archon-ui-main/src/features/ui/hooks/index.ts | 1 + .../ui/hooks/tests/useSmartPolling.test.ts | 189 ++++ archon-ui-main/src/hooks/useMCPQueries.ts | 77 -- archon-ui-main/src/index.css | 288 ++---- archon-ui-main/src/pages/MCPPage.tsx | 675 +------------- archon-ui-main/src/pages/ProjectPage.tsx | 5 +- archon-ui-main/src/services/api.ts | 26 +- .../src/services/mcpClientService.ts | 445 --------- .../src/services/mcpServerService.ts | 237 ----- archon-ui-main/src/services/mcpService.ts | 580 ------------ archon-ui-main/test/components.test.tsx | 294 ------ .../common/DeleteConfirmModal.test.tsx | 107 --- .../DocsTab.integration.test.tsx | 407 --------- .../project-tasks/DocumentCard.test.tsx | 227 ----- .../project-tasks/MilkdownEditor.test.tsx | 272 ------ .../project-tasks/TasksTab.dragdrop.test.tsx | 60 -- .../test/components/prp/PRPViewer.test.tsx | 186 ---- archon-ui-main/test/config/api.test.ts | 206 ----- archon-ui-main/test/errors.test.tsx | 236 ----- archon-ui-main/test/hooks/usePolling.test.ts | 246 ----- archon-ui-main/test/pages.test.tsx | 116 --- .../pages/ProjectPage.performance.test.tsx | 124 --- .../test/pages/ProjectPage.polling.test.tsx | 42 - .../test/services/projectService.test.ts | 393 -------- archon-ui-main/test/user_flows.test.tsx | 243 ----- .../test/utils/taskOrdering.test.ts | 138 --- archon-ui-main/tests/README.md | 58 ++ archon-ui-main/{test => tests}/setup.ts | 33 +- archon-ui-main/tsconfig.json | 2 +- archon-ui-main/tsconfig.prod.json | 15 + archon-ui-main/vite.config.ts | 11 +- archon-ui-main/vitest.config.ts | 10 +- docs/docs/agent-chat.mdx | 2 +- python/src/server/api_routes/mcp_api.py | 753 +++------------ 67 files changed, 2392 insertions(+), 8803 deletions(-) delete mode 100644 archon-ui-main/__mocks__/lucide-react.tsx rename archon-ui-main/src/components/{layouts => agent-chat}/ArchonChatPanel.tsx (100%) create mode 100644 archon-ui-main/src/components/layout/MainLayout.tsx create mode 100644 archon-ui-main/src/components/layout/Navigation.tsx create mode 100644 archon-ui-main/src/components/layout/hooks/useBackendHealth.ts create mode 100644 archon-ui-main/src/components/layout/index.ts create mode 100644 archon-ui-main/src/components/layout/types.ts delete mode 100644 archon-ui-main/src/components/layouts/MainLayout.tsx delete mode 100644 archon-ui-main/src/components/layouts/SideNavigation.tsx delete mode 100644 archon-ui-main/src/components/mcp/ClientCard.tsx delete mode 100644 archon-ui-main/src/components/mcp/MCPClients.tsx delete mode 100644 archon-ui-main/src/components/mcp/ToolTestingPanel.tsx create mode 100644 archon-ui-main/src/features/mcp/components/McpClientList.tsx create mode 100644 archon-ui-main/src/features/mcp/components/McpConfigSection.tsx create mode 100644 archon-ui-main/src/features/mcp/components/McpStatusBar.tsx create mode 100644 archon-ui-main/src/features/mcp/components/index.ts create mode 100644 archon-ui-main/src/features/mcp/hooks/index.ts create mode 100644 archon-ui-main/src/features/mcp/hooks/useMcpQueries.ts create mode 100644 archon-ui-main/src/features/mcp/index.ts create mode 100644 archon-ui-main/src/features/mcp/services/index.ts create mode 100644 archon-ui-main/src/features/mcp/services/mcpApi.ts create mode 100644 archon-ui-main/src/features/mcp/types/index.ts create mode 100644 archon-ui-main/src/features/mcp/types/mcp.ts create mode 100644 archon-ui-main/src/features/mcp/views/McpView.tsx create mode 100644 archon-ui-main/src/features/mcp/views/McpViewWithBoundary.tsx create mode 100644 archon-ui-main/src/features/projects/components/tests/ProjectCard.test.tsx create mode 100644 archon-ui-main/src/features/projects/hooks/tests/useProjectQueries.test.ts create mode 100644 archon-ui-main/src/features/projects/tasks/hooks/tests/useTaskQueries.test.ts create mode 100644 archon-ui-main/src/features/testing/test-utils.tsx create mode 100644 archon-ui-main/src/features/ui/hooks/tests/useSmartPolling.test.ts delete mode 100644 archon-ui-main/src/hooks/useMCPQueries.ts delete mode 100644 archon-ui-main/src/services/mcpClientService.ts delete mode 100644 archon-ui-main/src/services/mcpServerService.ts delete mode 100644 archon-ui-main/src/services/mcpService.ts delete mode 100644 archon-ui-main/test/components.test.tsx delete mode 100644 archon-ui-main/test/components/common/DeleteConfirmModal.test.tsx delete mode 100644 archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx delete mode 100644 archon-ui-main/test/components/project-tasks/DocumentCard.test.tsx delete mode 100644 archon-ui-main/test/components/project-tasks/MilkdownEditor.test.tsx delete mode 100644 archon-ui-main/test/components/project-tasks/TasksTab.dragdrop.test.tsx delete mode 100644 archon-ui-main/test/components/prp/PRPViewer.test.tsx delete mode 100644 archon-ui-main/test/config/api.test.ts delete mode 100644 archon-ui-main/test/errors.test.tsx delete mode 100644 archon-ui-main/test/hooks/usePolling.test.ts delete mode 100644 archon-ui-main/test/pages.test.tsx delete mode 100644 archon-ui-main/test/pages/ProjectPage.performance.test.tsx delete mode 100644 archon-ui-main/test/pages/ProjectPage.polling.test.tsx delete mode 100644 archon-ui-main/test/services/projectService.test.ts delete mode 100644 archon-ui-main/test/user_flows.test.tsx delete mode 100644 archon-ui-main/test/utils/taskOrdering.test.ts create mode 100644 archon-ui-main/tests/README.md rename archon-ui-main/{test => tests}/setup.ts (61%) create mode 100644 archon-ui-main/tsconfig.prod.json 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 ( +
+
+ Connecting... +
+ ); + } + + 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 */} +
+
+
{children}
+
+
+ + {/* 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 ( + + ); +} 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 */} -
-
-
{children}
-
-
- {/* 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 && } - {/* 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
- {label} -
-
; -}; -/** - * 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)} - > - {logoAlt} - {/* Active state decorations */} - {isProjectsActive && <> - - - } - - - ) : ( -
setActiveTooltip('logo')} - onMouseLeave={() => setActiveTooltip(null)} - > - {logoAlt} - -
- )} - {/* Navigation links */} - -
; -}; \ 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 */} - - - {/* Edit button - moved to be second from right */} - {onEdit && ( - - )} - - {/* Delete button - only for non-Archon clients */} - {!isArchonClient && onDelete && ( - - )} - - {/* Client info */} -
- {isArchonClient ? ( -
- Archon -
-
- ) : ( -
- -
- )} - -
-

- {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 */} - - - {/* Edit button - also on back side */} - {onEdit && ( - - )} - - {/* Delete button on back side - only for non-Archon clients */} - {!isArchonClient && onDelete && ( - - )} - -

- - 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}

- -
- )} - - {/* Add Client Button */} -
-
-

MCP Clients

-

- Connect and manage your MCP-enabled applications -

-
- -
- - {/* 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 -

- -
- {/* Client Name */} -
- - setFormData(prev => ({ ...prev, name: e.target.value }))} - className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500" - placeholder="Enter client name" - required - /> -
- - {/* MCP Server URL */} -
- - setFormData(prev => ({ ...prev, url: e.target.value }))} - className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500" - placeholder="http://host.docker.internal:8051/mcp" - required - /> -

- The HTTP endpoint URL of the MCP server -

-

- Docker Note: Use host.docker.internal instead of localhost - to access services running on your host machine -

-
- - {/* Auto Connect */} -
- setFormData(prev => ({ ...prev, auto_connect: e.target.checked }))} - className="mr-2" - /> - -
- - {/* Error message */} - {error && ( -
- {error} -
- )} - - {/* Buttons */} -
- - -
-
-
-
- ); -}; - -// 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 -

- -
- {/* Client Name */} -
- - setEditFormData(prev => ({ ...prev, name: e.target.value }))} - className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500" - required - /> -
- - {/* MCP Server URL */} -
- - setEditFormData(prev => ({ ...prev, url: e.target.value }))} - className="w-full px-3 py-2 bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-cyan-500" - placeholder="http://host.docker.internal:8051/mcp" - required - /> -

- The HTTP endpoint URL of the MCP server -

-

- Docker Note: Use host.docker.internal instead of localhost - to access services running on your host machine -

-
- - {/* Auto Connect */} -
- setEditFormData(prev => ({ ...prev, auto_connect: e.target.checked }))} - className="mr-2" - /> - -
- - {/* Error message */} - {error && ( -
- {error} -
- )} - - {/* Action Buttons */} -
-

Quick Actions

-
- - - - -
-
- - {/* Form Buttons */} -
- - -
-
-
-
- ); -}; \ 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 - -

-
- - - -
-
- - {/* 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 */} -
-
- -
- -
- -
-
-
-
- -
-
- - {/* Tool description */} - {selectedTool && ( -

- {selectedTool.description} -

- )} - - {/* Parameters */} - {selectedTool && selectedTool.parameters.length > 0 && ( -
-

- Parameters -

-
- {selectedTool.parameters.map(param => ( -
- - 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 - -
- -
-
- {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) => ( +
  1. {step}
  2. + ))} +
+
+ + {/* Special Commands for Claude Code */} + {selectedIDE === "claudecode" && ( +
+ + claude mcp add --transport http archon http://{config.host}:{config.port}/mcp + + +
+ )} + + {/* Configuration Display */} +
+
+ Configuration + +
+
+              {configText}
+            
+
+ + {/* One-Click Install for Cursor */} + {selectedIDE === "cursor" && selectedConfig.supportsOneClick && ( +
+ + 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 */} - -
- - {/* TODO: MCP Client feature not implemented - commenting out for now - - */} -
-
- - {/* 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' ? ( - - ) : ( - - )} -
-
- - {/* Connection Details */} - {serverStatus.status === 'running' && config && ( -
-
-

- IDE Configuration - - HTTP Mode - -

- -
- - {/* 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 */} -
-
- - - - - - - -
-
- - {/* 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' && ( -
- -

- 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) => ( - - ) - - 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 ( -
- - {isOpen && ( -
-

Modal Title

- -
- )} -
- ) - } - - 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 ( -
- - {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 ( -
- - {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 ( - - - - - - - - - {data.map((row, index) => ( - - - - - ))} - -
- Name - Age
{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}
- - -
- ) - } - - 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 && ( - - )} -
- ))} -
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} - -
- ))} -
- ) - } - - 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 => ( -
- -
- ))} -
-
- ) - } - - 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 ( -
- - {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 ( -
- - {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 })} - /> - - {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}
} - - -
- ) - } - - 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 ( -
- - - - - {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)} - /> - -
- ) - } - - 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

- -
- ) - } - - 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'}
- -
- ) - } - - 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

- -
    - {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 ( -
- -
-

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)} - /> - - {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

- -
- ) - } - - 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")