feat: TanStack Query Migration Phase 2 - Cleanup and Test Reorganization (#588)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wirasm
2025-09-06 13:43:53 +03:00
committed by GitHub
parent cadda22d22
commit 1a78a8e287
67 changed files with 2392 additions and 8803 deletions

View File

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

View File

@@ -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) => (
<span
ref={ref}
className={className}
data-testid={`${name.toLowerCase()}-icon`}
data-lucide={name}
{...props}
>
{name}
</span>
))
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')

View File

@@ -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,

View File

@@ -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';

View File

@@ -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 (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 text-sm">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
<span>Connecting...</span>
</div>
);
}
if (isBackendError) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 text-sm">
<WifiOff className="w-4 h-4" />
<span>Backend Offline</span>
</div>
);
}
if (healthData?.ready === false) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 text-sm">
<AlertCircle className="w-4 h-4" />
<span>Backend Starting...</span>
</div>
);
}
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 (
<div className={cn("relative min-h-screen bg-white dark:bg-black overflow-hidden", className)}>
{/* TEMPORARY: Show backend startup error using old component */}
{backendStartupFailed && <BackendStartupError />}
{/* Fixed full-page background grid that doesn't scroll */}
<div className="fixed inset-0 neon-grid pointer-events-none z-0" />
{/* Floating Navigation */}
<div className="fixed left-6 top-1/2 -translate-y-1/2 z-50 flex flex-col gap-4">
<Navigation />
<BackendStatus isHealthLoading={isHealthLoading} isBackendError={isBackendError} healthData={healthData} />
</div>
{/* Main Content Area - matches old layout exactly */}
<div className="relative flex-1 pl-[100px] z-10">
<div className="container mx-auto px-8 relative">
<div className="min-h-screen pt-8 pb-16">{children}</div>
</div>
</div>
{/* TEMPORARY: Floating Chat Button (disabled) - from old layout */}
<div className="fixed bottom-6 right-6 z-50 group">
<button
type="button"
disabled
className="w-14 h-14 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-gray-100/80 to-gray-50/60 dark:from-gray-700/30 dark:to-gray-800/30 shadow-[0_0_10px_rgba(156,163,175,0.3)] dark:shadow-[0_0_10px_rgba(156,163,175,0.3)] cursor-not-allowed opacity-60 overflow-hidden border border-gray-300 dark:border-gray-600"
aria-label="Knowledge Assistant - Coming Soon"
>
<img src="/logo-neon.png" alt="Archon" className="w-7 h-7 grayscale opacity-50" />
</button>
{/* Tooltip */}
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
<div className="font-medium">Coming Soon</div>
<div className="text-xs text-gray-300">Knowledge Assistant is under development</div>
<div className="absolute bottom-0 right-6 transform translate-y-1/2 rotate-45 w-2 h-2 bg-gray-800 dark:bg-gray-900"></div>
</div>
</div>
</div>
);
}
/**
* Layout variant without navigation for special pages
*/
export function MinimalLayout({ children, className }: MainLayoutProps) {
return (
<div className={cn("min-h-screen bg-white dark:bg-black", "flex items-center justify-center", className)}>
{/* Background Grid Effect */}
<div
className="absolute inset-0 pointer-events-none opacity-50"
style={{
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px)`,
backgroundSize: "50px 50px",
}}
/>
{/* Centered Content */}
<div className="relative w-full max-w-4xl px-6">{children}</div>
</div>
);
}

View File

@@ -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: <BookOpen className="h-5 w-5" />,
label: "Knowledge Base",
enabled: true,
},
{
path: "/mcp",
icon: (
<svg
fill="currentColor"
fillRule="evenodd"
height="20"
width="20"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="MCP Server Icon"
>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
),
label: "MCP Server",
enabled: true,
},
{
path: "/settings",
icon: <Settings className="h-5 w-5" />,
label: "Settings",
enabled: true,
},
];
const isProjectsActive = location.pathname.startsWith("/projects");
return (
<nav
className={cn(
"flex flex-col items-center gap-6 py-6 px-3",
"rounded-xl w-[72px]",
// Using glassmorphism from primitives
glassmorphism.background.subtle,
"border border-gray-200 dark:border-zinc-800/50",
"shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)]",
className,
)}
>
{/* Logo - Always visible, conditionally clickable for Projects */}
<Tooltip>
<TooltipTrigger asChild>
{projectsEnabled ? (
<Link
to="/projects"
className={cn(
"relative p-2 rounded-lg transition-all duration-300",
"flex items-center justify-center",
"hover:bg-white/10 dark:hover:bg-white/5",
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",
],
)}
>
<img
src="/logo-neon.png"
alt="Archon"
className={cn(
"w-8 h-8 transition-all duration-300",
isProjectsActive && "filter drop-shadow-[0_0_8px_rgba(59,130,246,0.7)]",
)}
/>
{/* Active state decorations */}
{isProjectsActive && (
<>
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30" />
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]" />
</>
)}
</Link>
) : (
<div className="p-2 rounded-lg opacity-50 cursor-not-allowed">
<img src="/logo-neon.png" alt="Archon" className="w-8 h-8 grayscale" />
</div>
)}
</TooltipTrigger>
<TooltipContent>
<p>{projectsEnabled ? "Project Management" : "Projects Disabled"}</p>
</TooltipContent>
</Tooltip>
{/* Separator */}
<div className="w-8 h-px bg-gradient-to-r from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
{/* Navigation Items */}
<nav className="flex flex-col gap-4">
{navigationItems.map((item) => {
const isActive = location.pathname === item.path;
const isEnabled = item.enabled !== false;
return (
<Tooltip key={item.path}>
<TooltipTrigger asChild>
<Link
to={isEnabled ? item.path : "#"}
className={cn(
"relative p-3 rounded-lg transition-all duration-300",
"flex items-center justify-center",
isActive
? [
"bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20",
"text-blue-600 dark:text-blue-400",
"shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)]",
]
: [
"text-gray-500 dark:text-zinc-500",
"hover:text-blue-600 dark:hover:text-blue-400",
"hover:bg-white/10 dark:hover:bg-white/5",
],
!isEnabled && "opacity-50 cursor-not-allowed pointer-events-none",
)}
onClick={(e) => {
if (!isEnabled) {
e.preventDefault();
}
}}
>
{item.icon}
{/* Active state decorations with neon line */}
{isActive && (
<>
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30" />
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]" />
</>
)}
</Link>
</TooltipTrigger>
<TooltipContent>
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</nav>
</nav>
);
}

View File

@@ -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<HealthResponse>({
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<HealthResponse>("/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,
});
}

View File

@@ -0,0 +1,3 @@
export { useBackendHealth } from "./hooks/useBackendHealth";
export { MainLayout, MinimalLayout } from "./MainLayout";
export { Navigation } from "./Navigation";

View File

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

View File

@@ -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<MainLayoutProps> = ({
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 <div className="relative min-h-screen bg-white dark:bg-black overflow-hidden">
{/* Show backend startup error if backend failed to start */}
{backendStartupFailed && <BackendStartupError />}
{/* Fixed full-page background grid that doesn't scroll */}
<div className="fixed inset-0 neon-grid pointer-events-none z-0"></div>
{/* Floating Navigation */}
<div className="fixed left-6 top-1/2 -translate-y-1/2 z-50">
<SideNavigation />
</div>
{/* Main Content Area - no left margin to allow grid to extend full width */}
<div className="relative flex-1 pl-[100px] z-10">
<div className="container mx-auto px-8 relative">
<div className="min-h-screen pt-8 pb-16">{children}</div>
</div>
</div>
{/* Floating Chat Button - Only visible when chat is closed */}
{!isChatOpen && (
<div className="fixed bottom-6 right-6 z-50 group">
<button
disabled
className="w-14 h-14 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-gray-100/80 to-gray-50/60 dark:from-gray-700/30 dark:to-gray-800/30 shadow-[0_0_10px_rgba(156,163,175,0.3)] dark:shadow-[0_0_10px_rgba(156,163,175,0.3)] cursor-not-allowed opacity-60 overflow-hidden border border-gray-300 dark:border-gray-600"
aria-label="Knowledge Assistant - Coming Soon">
<img src="/logo-neon.png" alt="Archon" className="w-7 h-7 grayscale opacity-50" />
</button>
{/* Tooltip */}
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
<div className="font-medium">Coming Soon</div>
<div className="text-xs text-gray-300">Knowledge Assistant is under development</div>
<div className="absolute bottom-0 right-6 transform translate-y-1/2 rotate-45 w-2 h-2 bg-gray-800 dark:bg-gray-900"></div>
</div>
</div>
)}
{/* Chat Sidebar - Slides in/out from right */}
<div className="fixed top-0 right-0 h-full z-40 transition-transform duration-300 ease-in-out transform" style={{
transform: isChatOpen ? 'translateX(0)' : 'translateX(100%)'
}}>
{/* Close button - Only visible when chat is open */}
{isChatOpen && <button onClick={() => setIsChatOpen(false)} className="absolute -left-14 bottom-6 z-50 w-12 h-12 rounded-full flex items-center justify-center backdrop-blur-md bg-gradient-to-b from-white/10 to-black/30 dark:from-white/10 dark:to-black/30 from-pink-100/80 to-pink-50/60 border border-pink-200 dark:border-pink-500/30 shadow-[0_0_15px_rgba(236,72,153,0.2)] dark:shadow-[0_0_15px_rgba(236,72,153,0.5)] hover:shadow-[0_0_20px_rgba(236,72,153,0.4)] dark:hover:shadow-[0_0_20px_rgba(236,72,153,0.7)] transition-all duration-300" aria-label="Close Knowledge Assistant">
<X className="w-5 h-5 text-pink-500" />
</button>}
{/* Knowledge Chat Panel */}
<ArchonChatPanel data-id="archon-chat" />
</div>
</div>;
};

View File

@@ -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 <div className={`absolute ${position === 'right' ? 'left-full ml-2' : 'right-full mr-2'} top-1/2 -translate-y-1/2 px-2 py-1 rounded bg-black/80 text-white text-xs whitespace-nowrap z-50`} style={{
pointerEvents: 'none'
}}>
{label}
<div className={`absolute top-1/2 -translate-y-1/2 ${position === 'right' ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} border-4 ${position === 'right' ? 'border-r-black/80 border-transparent' : 'border-l-black/80 border-transparent'}`}></div>
</div>;
};
/**
* 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<SideNavigationProps> = ({
className = '',
'data-id': dataId
}) => {
// State to track which tooltip is currently visible
const [activeTooltip, setActiveTooltip] = useState<string | null>(null);
const { projectsEnabled } = useSettings();
// Default navigation items
const navigationItems: NavigationItem[] = [{
path: '/',
icon: <BookOpen className="h-5 w-5" />,
label: 'Knowledge Base'
}, {
path: '/mcp',
icon: <svg fill="currentColor" fillRule="evenodd" height="20" width="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>,
label: 'MCP Server'
}, {
path: '/settings',
icon: <Settings className="h-5 w-5" />,
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 <div data-id={dataId} className={`flex flex-col items-center gap-6 py-6 px-3 rounded-xl backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-zinc-800/50 shadow-[0_10px_30px_-15px_rgba(0,0,0,0.1)] dark:shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)] ${className}`}>
{/* Logo - Conditionally clickable based on Projects enabled */}
{projectsEnabled ? (
<Link
to="/projects"
className={logoClassName}
onMouseEnter={() => setActiveTooltip('logo')}
onMouseLeave={() => setActiveTooltip(null)}
>
<img src={logoSrc} alt={logoAlt} className={`w-8 h-8 transition-all duration-300 ${isProjectsActive ? 'filter drop-shadow-[0_0_8px_rgba(59,130,246,0.7)]' : ''}`} />
{/* Active state decorations */}
{isProjectsActive && <>
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30"></span>
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]"></span>
</>}
<NavTooltip show={activeTooltip === 'logo'} label="Project Management" />
</Link>
) : (
<div
className={logoClassName}
onMouseEnter={() => setActiveTooltip('logo')}
onMouseLeave={() => setActiveTooltip(null)}
>
<img src={logoSrc} alt={logoAlt} className="w-8 h-8 transition-all duration-300" />
<NavTooltip show={activeTooltip === 'logo'} label="Projects Disabled" />
</div>
)}
{/* Navigation links */}
<nav className="flex flex-col gap-4">
{navigationItems.map(item => {
const isActive = location.pathname === item.path;
return <Link key={item.path} to={item.path} className={`
relative p-3 rounded-lg flex items-center justify-center
transition-all duration-300
${isActive ? 'bg-gradient-to-b from-white/20 to-white/5 dark:from-white/10 dark:to-black/20 text-blue-600 dark:text-blue-400 shadow-[0_5px_15px_-5px_rgba(59,130,246,0.3)] dark:shadow-[0_5px_15px_-5px_rgba(59,130,246,0.5)]' : 'text-gray-500 dark:text-zinc-500 hover:text-blue-600 dark:hover:text-blue-400'}
`} onMouseEnter={() => setActiveTooltip(item.path)} onMouseLeave={() => setActiveTooltip(null)} aria-label={item.label}>
{/* Active state decorations - Modified to place neon line below button with adjusted width */}
{isActive && <>
<span className="absolute inset-0 rounded-lg border border-blue-300 dark:border-blue-500/30"></span>
{/* Neon line positioned below the button with reduced width to respect curved edges */}
<span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-blue-500 shadow-[0_0_10px_2px_rgba(59,130,246,0.4)] dark:shadow-[0_0_20px_5px_rgba(59,130,246,0.7)]"></span>
</>}
{item.icon}
{/* Custom tooltip */}
<NavTooltip show={activeTooltip === item.path} label={item.label} />
</Link>;
})}
</nav>
</div>;
};

View File

@@ -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<HTMLDivElement>(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 (
<div
className={`flip-card h-[220px] cursor-pointer ${isArchonClient ? 'order-first' : ''}`}
style={{ perspective: '1500px' }}
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={`relative w-full h-full transition-all duration-500 transform-style-preserve-3d ${isFlipped ? 'rotate-y-180' : ''} ${isHovered && !isFlipped ? 'hover-lift' : ''}`}>
{/* Front Side */}
<div
className={`absolute w-full h-full backface-hidden backdrop-blur-md ${archonBackground} rounded-xl p-5 ${currentStatus.border} ${currentStatus.glow} transition-all duration-300 ${isArchonClient ? 'archon-card-border overflow-hidden' : ''}`}
ref={isArchonClient ? particlesRef : undefined}
>
{/* Particle container for Archon client */}
{isArchonClient && (
<div className="absolute inset-0 rounded-xl overflow-hidden pointer-events-none">
<div className="particles-container"></div>
</div>
)}
{/* Subtle aurora glow effect for Archon client */}
{isArchonClient && (
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-20">
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(59,130,246,0.8)_0%,rgba(168,85,247,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
</div>
)}
{/* Connect/Disconnect button */}
<button
onClick={handleConnect}
disabled={isConnecting}
className={`absolute top-3 right-3 p-1.5 rounded-full ${
client.status === 'offline'
? 'bg-green-200/50 dark:bg-green-900/50 hover:bg-green-300/50 dark:hover:bg-green-800/50'
: 'bg-orange-200/50 dark:bg-orange-900/50 hover:bg-orange-300/50 dark:hover:bg-orange-800/50'
} transition-colors transform hover:scale-110 transition-transform duration-200 z-20 ${isConnecting ? 'animate-pulse' : ''}`}
title={client.status === 'offline' ? 'Connect client' : 'Disconnect client'}
>
{client.status === 'offline' ? (
<Plug className="w-4 h-4 text-green-600 dark:text-green-400" />
) : (
<PlugZap className="w-4 h-4 text-orange-600 dark:text-orange-400" />
)}
</button>
{/* Edit button - moved to be second from right */}
{onEdit && (
<button
onClick={handleEdit}
className={`absolute top-3 right-12 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-20`}
title="Edit client configuration"
>
<Settings className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
</button>
)}
{/* Delete button - only for non-Archon clients */}
{!isArchonClient && onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(client);
}}
className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
title="Delete client"
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
{/* Client info */}
<div className="flex items-start">
{isArchonClient ? (
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-500/20 to-purple-500/20 mr-3 relative pulse-soft">
<img src="/logo-neon.png" alt="Archon" className="w-6 h-6 drop-shadow-[0_0_8px_rgba(59,130,246,0.8)] animate-glow-pulse" />
<div className="absolute inset-0 rounded-lg bg-blue-500/10 animate-pulse opacity-60"></div>
</div>
) : (
<div className={`p-3 rounded-lg bg-${currentStatus.color}-500/10 text-${currentStatus.color}-400 mr-3 pulse-soft`}>
<Server className="w-6 h-6" />
</div>
)}
<div>
<h3 className={`font-bold text-gray-800 dark:text-white text-lg ${isArchonClient ? 'bg-gradient-to-r from-blue-400 to-purple-500 text-transparent bg-clip-text animate-text-shimmer' : ''}`}>
{client.name}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{client.ip}
</p>
</div>
</div>
<div className="mt-5 space-y-2">
<div className="flex items-center text-sm">
<Clock className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-gray-700 dark:text-gray-300">Last seen:</span>
<span className="text-gray-600 dark:text-gray-400 ml-auto">
{client.lastSeen}
</span>
</div>
<div className="flex items-center text-sm">
<Activity className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-gray-700 dark:text-gray-300">Version:</span>
<span className={`text-gray-600 dark:text-gray-400 ml-auto ${isArchonClient ? 'font-medium text-blue-600 dark:text-blue-400' : ''}`}>
{client.version}
</span>
</div>
<div className="flex items-center text-sm">
<Hammer className="w-4 h-4 text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-gray-700 dark:text-gray-300">Tools:</span>
<span className={`text-gray-600 dark:text-gray-400 ml-auto ${isArchonClient ? 'font-medium text-blue-600 dark:text-blue-400' : ''}`}>
{client.tools.length} available
</span>
</div>
{/* Error message display */}
{client.status === 'error' && client.lastError && (
<div className="mt-3 p-2 bg-red-50/80 dark:bg-red-900/20 border border-red-200 dark:border-red-800/40 rounded-md">
<div className="flex items-start">
<div className="w-3 h-3 rounded-full bg-red-400 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="text-xs font-medium text-red-700 dark:text-red-300 mb-1">Last Error:</p>
<p className="text-xs text-red-600 dark:text-red-400 break-words">
{client.lastError}
</p>
</div>
</div>
</div>
)}
</div>
{/* Status badge - moved to bottom left */}
<div className="absolute bottom-4 left-4">
<div className={`px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5 border ${currentStatus.badge}`}>
<div className="relative flex h-2 w-2">
<span className={`animate-ping-slow absolute inline-flex h-full w-full rounded-full ${currentStatus.pulse} opacity-75`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${currentStatus.pulse}`}></span>
</div>
{client.status.charAt(0).toUpperCase() + client.status.slice(1)}
</div>
</div>
{/* Tools button - with Hammer icon */}
<button
onClick={toggleFlip}
className={`absolute bottom-4 right-4 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-10`}
title="View available tools"
>
<Hammer className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
</button>
</div>
{/* Back Side */}
<div className={`absolute w-full h-full backface-hidden backdrop-blur-md ${archonBackground} rounded-xl p-5 rotate-y-180 ${currentStatus.border} ${currentStatus.glow} transition-all duration-300 ${isArchonClient ? 'archon-card-border' : ''}`}>
{/* Subtle aurora glow effect for Archon client */}
{isArchonClient && (
<div className="absolute inset-0 rounded-xl overflow-hidden opacity-20">
<div className="absolute -inset-[100px] bg-[radial-gradient(circle,rgba(59,130,246,0.8)_0%,rgba(168,85,247,0.6)_40%,transparent_70%)] blur-3xl animate-[pulse_8s_ease-in-out_infinite]"></div>
</div>
)}
{/* Connect/Disconnect button - also on back side */}
<button
onClick={handleConnect}
disabled={isConnecting}
className={`absolute top-3 right-3 p-1.5 rounded-full ${
client.status === 'offline'
? 'bg-green-200/50 dark:bg-green-900/50 hover:bg-green-300/50 dark:hover:bg-green-800/50'
: 'bg-orange-200/50 dark:bg-orange-900/50 hover:bg-orange-300/50 dark:hover:bg-orange-800/50'
} transition-colors transform hover:scale-110 transition-transform duration-200 z-20 ${isConnecting ? 'animate-pulse' : ''}`}
title={client.status === 'offline' ? 'Connect client' : 'Disconnect client'}
>
{client.status === 'offline' ? (
<Plug className="w-4 h-4 text-green-600 dark:text-green-400" />
) : (
<PlugZap className="w-4 h-4 text-orange-600 dark:text-orange-400" />
)}
</button>
{/* Edit button - also on back side */}
{onEdit && (
<button
onClick={handleEdit}
className={`absolute top-3 right-12 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-20`}
title="Edit client configuration"
>
<Settings className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
</button>
)}
{/* Delete button on back side - only for non-Archon clients */}
{!isArchonClient && onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(client);
}}
className="absolute top-3 right-[84px] p-1.5 rounded-full bg-red-200/50 dark:bg-red-900/50 hover:bg-red-300/50 dark:hover:bg-red-800/50 transition-colors transform hover:scale-110 transition-transform duration-200 z-20"
title="Delete client"
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
<h3 className={`font-bold text-gray-800 dark:text-white mb-3 flex items-center ${isArchonClient ? 'bg-gradient-to-r from-blue-400 to-purple-500 text-transparent bg-clip-text animate-text-shimmer' : ''}`}>
<Hammer className={`w-4 h-4 mr-2 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
Available Tools ({client.tools.length})
</h3>
<div className="space-y-2 overflow-y-auto max-h-[140px] pr-1 hide-scrollbar">
{client.tools.length === 0 ? (
<div className="text-center py-4">
<p className="text-gray-500 dark:text-gray-400 text-sm">
{client.status === 'offline'
? 'Client offline - tools unavailable'
: 'No tools discovered'}
</p>
</div>
) : (
client.tools.map(tool => (
<div
key={tool.id}
className={`p-2 rounded-md ${isArchonClient ? 'bg-blue-50/50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700/50 hover:border-blue-300 dark:hover:border-blue-600/50' : 'bg-gray-100/50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700/50 hover:border-gray-300 dark:hover:border-gray-600/50'} transition-colors transform hover:translate-x-1 transition-transform duration-200`}
>
<div className="flex items-center justify-between">
<span className={`font-mono text-xs ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-blue-600 dark:text-blue-400'}`}>
{tool.name}
</span>
<ChevronRight className="w-3 h-3 text-gray-400" />
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{tool.description}
</p>
{tool.parameters.length > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{tool.parameters.length} parameter{tool.parameters.length !== 1 ? 's' : ''}
</p>
)}
</div>
))
)}
</div>
{/* Status badge - also at bottom left on back side */}
<div className="absolute bottom-4 left-4">
<div className={`px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5 border ${currentStatus.badge}`}>
<div className="relative flex h-2 w-2">
<span className={`animate-ping-slow absolute inline-flex h-full w-full rounded-full ${currentStatus.pulse} opacity-75`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${currentStatus.pulse}`}></span>
</div>
{client.status.charAt(0).toUpperCase() + client.status.slice(1)}
</div>
</div>
{/* Flip button - back to front */}
<button
onClick={toggleFlip}
className={`absolute bottom-4 right-4 p-1.5 rounded-full ${isArchonClient ? 'bg-blue-200/50 dark:bg-blue-900/50 hover:bg-blue-300/50 dark:hover:bg-blue-800/50' : 'bg-gray-200/50 dark:bg-gray-800/50 hover:bg-gray-300/50 dark:hover:bg-gray-700/50'} transition-colors transform hover:scale-110 transition-transform duration-200 z-10`}
title="Show client details"
>
<Server className={`w-4 h-4 ${isArchonClient ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'}`} />
</button>
</div>
</div>
</div>
);
};

View File

@@ -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<Client[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// State for selected client and panel visibility
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [isAddClientModalOpen, setIsAddClientModalOpen] = useState(false);
// State for edit drawer
const [editClient, setEditClient] = useState<Client | null>(null);
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false);
const { showToast } = useToast();
// State for delete confirmation modal
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [clientToDelete, setClientToDelete] = useState<Client | null>(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<string, 'online' | 'offline' | 'error'> = {
'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 (
<div className="relative min-h-[80vh] flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-400 mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Loading MCP clients...</p>
</div>
</div>
);
}
return (
<div className="relative">
{/* Error display */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
<button
onClick={() => setError(null)}
className="text-red-500 hover:text-red-600 text-sm mt-2"
>
Dismiss
</button>
</div>
)}
{/* Add Client Button */}
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">MCP Clients</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Connect and manage your MCP-enabled applications
</p>
</div>
<Button
onClick={() => setIsAddClientModalOpen(true)}
variant="primary"
accentColor="cyan"
className="shadow-cyan-500/20 shadow-sm"
>
<Plus className="w-4 h-4 mr-2" />
Add Client
</Button>
</div>
{/* Client Grid */}
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 relative z-10">
{clients.map(client => (
<ClientCard
key={client.id}
client={client}
onSelect={() => handleSelectClient(client)}
onEdit={() => handleEditClient(client)}
onDelete={() => handleDeleteClient(client)}
onConnectionChange={refreshClients}
/>
))}
</div>
</div>
{/* Tool Testing Panel */}
<ToolTestingPanel
client={selectedClient}
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
/>
{/* Add Client Modal */}
{isAddClientModalOpen && (
<AddClientModal
isOpen={isAddClientModalOpen}
onClose={() => setIsAddClientModalOpen(false)}
onSubmit={handleAddClient}
/>
)}
{/* Edit Client Drawer */}
{isEditDrawerOpen && editClient && (
<EditClientDrawer
client={editClient}
isOpen={isEditDrawerOpen}
onClose={() => {
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 && (
<DeleteConfirmModal
itemName={clientToDelete.name}
onConfirm={confirmDeleteClient}
onCancel={cancelDeleteClient}
type="client"
/>
)}
</div>
);
});
// Add Client Modal Component
interface AddClientModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (config: MCPClientConfig) => Promise<void>;
}
const AddClientModal: React.FC<AddClientModalProps> = ({ isOpen, onClose, onSubmit }) => {
const [formData, setFormData] = useState({
name: '',
url: '',
auto_connect: true
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white/90 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg p-6 w-full max-w-md relative backdrop-blur-lg">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-cyan-400 via-blue-500 to-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.6)]"></div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Add New MCP Client
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Client Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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
/>
</div>
{/* MCP Server URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
MCP Server URL *
</label>
<input
type="text"
value={formData.url}
onChange={(e) => 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
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The HTTP endpoint URL of the MCP server
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<strong>Docker Note:</strong> Use <code>host.docker.internal</code> instead of <code>localhost</code>
to access services running on your host machine
</p>
</div>
{/* Auto Connect */}
<div className="flex items-center">
<input
type="checkbox"
id="auto_connect"
checked={formData.auto_connect}
onChange={(e) => setFormData(prev => ({ ...prev, auto_connect: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="auto_connect" className="text-sm text-gray-700 dark:text-gray-300">
Auto-connect on startup
</label>
</div>
{/* Error message */}
{error && (
<div className="text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
{error}
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-3 mt-6">
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
accentColor="cyan"
disabled={isSubmitting}
>
{isSubmitting ? 'Adding...' : 'Add Client'}
</Button>
</div>
</form>
</div>
</div>
);
};
// 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<EditClientDrawerProps> = ({ client, isOpen, onClose, onUpdate }) => {
const [editFormData, setEditFormData] = useState({
name: client.name,
url: '',
auto_connect: true
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
// State for delete confirmation modal (moved here)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [clientToDelete, setClientToDelete] = useState<Client | null>(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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-end justify-center z-50" onClick={onClose}>
<div
className="bg-white/90 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-t-lg p-6 w-full max-w-2xl relative backdrop-blur-lg animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-cyan-400 via-blue-500 to-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.6)]"></div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
<Settings className="w-5 h-5 mr-2 text-cyan-500" />
Edit Client Configuration
</h3>
<form onSubmit={handleUpdateSubmit} className="space-y-4">
{/* Client Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client Name *
</label>
<input
type="text"
value={editFormData.name}
onChange={(e) => 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
/>
</div>
{/* MCP Server URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
MCP Server URL *
</label>
<input
type="text"
value={editFormData.url}
onChange={(e) => 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
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The HTTP endpoint URL of the MCP server
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<strong>Docker Note:</strong> Use <code>host.docker.internal</code> instead of <code>localhost</code>
to access services running on your host machine
</p>
</div>
{/* Auto Connect */}
<div className="flex items-center">
<input
type="checkbox"
id="edit_auto_connect"
checked={editFormData.auto_connect}
onChange={(e) => setEditFormData(prev => ({ ...prev, auto_connect: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="edit_auto_connect" className="text-sm text-gray-700 dark:text-gray-300">
Auto-connect on startup
</label>
</div>
{/* Error message */}
{error && (
<div className="text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
{error}
</div>
)}
{/* Action Buttons */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Quick Actions</h4>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="ghost"
accentColor="green"
onClick={handleConnect}
disabled={isConnecting || client.status === 'online'}
>
{isConnecting ? 'Connecting...' : client.status === 'online' ? 'Connected' : 'Connect'}
</Button>
<Button
type="button"
variant="ghost"
accentColor="orange"
onClick={handleDisconnect}
disabled={client.status === 'offline'}
>
{client.status === 'offline' ? 'Disconnected' : 'Disconnect'}
</Button>
<Button
type="button"
variant="ghost"
accentColor="pink"
onClick={handleDelete}
>
Delete Client
</Button>
<Button
type="button"
variant="ghost"
accentColor="cyan"
onClick={() => window.open(`/api/mcp/clients/${client.id}/status`, '_blank')}
>
Debug Status
</Button>
</div>
</div>
{/* Form Buttons */}
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
accentColor="cyan"
disabled={isSubmitting}
>
{isSubmitting ? 'Updating...' : 'Update Configuration'}
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -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<Tool | null>(null);
const [terminalOutput, setTerminalOutput] = useState<TerminalLine[]>([{
id: '1',
content: '> Tool testing terminal ready',
isTyping: false,
isCommand: true
}]);
const [paramValues, setParamValues] = useState<Record<string, string>>({});
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<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const previousHeightRef = useRef<number>(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<string, any> => {
if (!selectedTool) return {};
const convertedParams: Record<string, any> = {};
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 (
<div
ref={panelRef}
className={`fixed bottom-0 left-1/2 transform -translate-x-1/2 backdrop-blur-md bg-gradient-to-t from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-t border-gray-200 dark:border-gray-800 transition-all duration-500 ease-in-out z-30 shadow-2xl rounded-t-xl overflow-hidden ${isOpen ? 'translate-y-0' : 'translate-y-full'}`}
style={{
height: `${panelHeight}px`,
width: 'calc(100% - 4rem)',
maxWidth: '1400px'
}}
>
{/* Resize handle at the top */}
<div
ref={resizeHandleRef}
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize group transform -translate-y-1 z-10"
onMouseDown={handleResizeStart}
>
<div className="w-16 h-1 mx-auto bg-gray-300 dark:bg-gray-600 rounded-full group-hover:bg-cyan-400 dark:group-hover:bg-cyan-500 transition-colors"></div>
</div>
{/* Panel with neon effect */}
<div className="relative h-full">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-cyan-500 shadow-[0_0_20px_5px_rgba(34,211,238,0.7),0_0_10px_2px_rgba(34,211,238,1.0)] dark:shadow-[0_0_25px_8px_rgba(34,211,238,0.8),0_0_15px_3px_rgba(34,211,238,1.0)]"></div>
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center">
<span className={`w-2 h-2 rounded-full mr-2 ${
client.status === 'online'
? 'bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]'
: client.status === 'offline'
? 'bg-gray-400'
: 'bg-pink-400 shadow-[0_0_8px_rgba(236,72,153,0.6)]'
}`}></span>
{client.name}
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
{client.ip}
</span>
<span className="ml-3 text-xs text-gray-500 dark:text-gray-400">
{client.tools.length} tools available
</span>
</h3>
<div className="flex items-center gap-2">
<button
onClick={clearTerminal}
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
title="Clear terminal"
>
<TerminalSquare className="w-4 h-4" />
</button>
<button
onClick={toggleMaximize}
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
title={isMaximized ? 'Minimize panel' : 'Maximize panel'}
>
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition-colors"
title="Close panel"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Content */}
<div className="px-6 py-4 h-[calc(100%-73px)] overflow-y-auto">
{client.tools.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Hammer className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No Tools Available</h3>
<p className="text-gray-500 dark:text-gray-400">
{client.status === 'offline'
? 'Client is offline. Tools will be available when connected.'
: 'No tools discovered for this client.'}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left column: Tool selection and parameters */}
<div>
{/* Tool selection and execute button row */}
<div className="flex gap-4 mb-6">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Select Tool
</label>
<div className="relative">
<select
className="w-full bg-white/50 dark:bg-black/50 border border-gray-300 dark:border-gray-700 rounded-md py-2 pl-3 pr-10 text-gray-900 dark:text-white appearance-none focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500"
value={selectedTool?.id || ''}
onChange={e => {
const tool = client.tools.find(t => t.id === e.target.value);
if (tool) handleToolSelect(tool);
}}
>
{client.tools.map(tool => (
<option key={tool.id} value={tool.id}>
{tool.name}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<ChevronDown className="w-4 h-4 text-gray-500" />
</div>
</div>
</div>
<div className="flex items-end">
<Button
variant="primary"
accentColor="cyan"
onClick={executeSelectedTool}
disabled={!selectedTool || isExecuting}
>
{isExecuting ? (
<div className="flex items-center">
<span className="inline-block w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
Executing...
</div>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Tool
</>
)}
</Button>
</div>
</div>
{/* Tool description */}
{selectedTool && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{selectedTool.description}
</p>
)}
{/* Parameters */}
{selectedTool && selectedTool.parameters.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Parameters
</h4>
<div className="space-y-3">
{selectedTool.parameters.map(param => (
<div key={param.name}>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
{param.name}
{param.required && <span className="text-pink-500 ml-1">*</span>}
<span className="text-gray-400 ml-1">({param.type})</span>
</label>
<input
type={param.type === 'number' ? 'number' : 'text'}
value={paramValues[param.name] || ''}
onChange={e => 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 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{param.description}
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right column: Terminal output */}
<div className="flex flex-col h-full">
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden relative border border-gray-800 h-full">
<div className="flex items-center justify-between bg-gray-800 px-3 py-2">
<div className="flex items-center">
<TerminalSquare className="w-4 h-4 text-cyan-400 mr-2" />
<span className="text-xs text-gray-300 font-medium">
Terminal Output
</span>
</div>
<button
onClick={copyTerminalOutput}
className="p-1 rounded hover:bg-gray-700 transition-colors"
title="Copy output"
>
{isCopied ?
<Check className="w-4 h-4 text-green-400" /> :
<Copy className="w-4 h-4 text-gray-400 hover:text-gray-300" />
}
</button>
</div>
<div
ref={terminalRef}
className="p-3 h-[calc(100%-36px)] overflow-y-auto font-mono text-xs text-gray-300 space-y-1"
>
{terminalOutput.map(line => (
<div key={line.id} className={`
${line.isCommand ? 'text-cyan-400' : ''}
${line.isWarning ? 'text-yellow-400' : ''}
${line.isError ? 'text-pink-400' : ''}
${line.isTyping ? 'terminal-typing' : ''}
whitespace-pre-wrap
`}>
{line.content}
{line.isTyping && <span className="terminal-cursor"></span>}
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -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<string, string> = {
'Claude': '🤖',
'Cursor': '💻',
'Windsurf': '🏄',
'Cline': '🔧',
'KiRo': '🚀',
'Augment': '⚡',
'Gemini': '🌐',
'Unknown': '❓'
};
export const McpClientList: React.FC<McpClientListProps> = ({
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 (
<div className={cn(compoundStyles.card, "p-6 text-center rounded-lg relative overflow-hidden", className)}>
<div className="absolute top-3 right-3 px-2 py-1 bg-cyan-500/20 text-cyan-400 text-xs font-semibold rounded-full border border-cyan-500/30">
Coming Soon
</div>
<Monitor className="w-12 h-12 mx-auto mb-3 text-zinc-500" />
<p className="text-zinc-400">Client detection coming soon</p>
<p className="text-sm text-zinc-500 mt-2">
We'll automatically detect when AI assistants connect to the MCP server
</p>
</div>
);
}
return (
<div className={cn("space-y-3", className)}>
{clients.map((client, index) => (
<motion.div
key={client.session_id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className={cn(
"flex items-center justify-between p-4 rounded-lg",
glassmorphism.background.card,
glassmorphism.border.default,
client.status === 'active'
? "border-green-500/50 shadow-[0_0_15px_rgba(34,197,94,0.2)]"
: ""
)}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{clientIcons[client.client_type] || ''}</span>
<div>
<p className="font-medium text-white">{client.client_type}</p>
<p className="text-xs text-zinc-400">Session: {client.session_id.slice(0, 8)}</p>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-zinc-400">{formatDuration(client.connected_at)}</span>
</div>
<div className="flex items-center gap-1">
<Activity className="w-3 h-3 text-green-400" />
<span className={cn(
"text-zinc-400",
client.status === 'active' && "text-green-400"
)}>
{formatLastActivity(client.last_activity)}
</span>
</div>
</div>
</motion.div>
))}
</div>
);
};

View File

@@ -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<McpConfigSectionProps> = ({ config, status, className }) => {
const [selectedIDE, setSelectedIDE] = useState<SupportedIDE>("claudecode");
const { showToast } = useToast();
if (status.status !== "running" || !config) {
return (
<div
className={cn(
"p-6 text-center rounded-lg",
glassmorphism.background.subtle,
glassmorphism.border.default,
className,
)}
>
<p className="text-zinc-400">Start the MCP server to see configuration options</p>
</div>
);
}
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 (
<div className={cn("space-y-6", className)}>
{/* Universal MCP Note */}
<div className={cn("p-3 rounded-lg", glassmorphism.background.blue, glassmorphism.border.blue)}>
<p className="text-sm text-blue-700 dark:text-blue-300">
<span className="font-semibold">Note:</span> 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.
</p>
</div>
{/* IDE Selection Tabs */}
<Tabs
defaultValue="claudecode"
value={selectedIDE}
onValueChange={(value) => setSelectedIDE(value as SupportedIDE)}
>
<TabsList className="grid grid-cols-4 lg:grid-cols-7 w-full">
<TabsTrigger value="claudecode">Claude Code</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
<TabsTrigger value="cursor">Cursor</TabsTrigger>
<TabsTrigger value="windsurf">Windsurf</TabsTrigger>
<TabsTrigger value="cline">Cline</TabsTrigger>
<TabsTrigger value="kiro">Kiro</TabsTrigger>
<TabsTrigger value="augment">Augment</TabsTrigger>
</TabsList>
<TabsContent value={selectedIDE} className="mt-6 space-y-4">
{/* Configuration Title and Steps */}
<div>
<h4 className="text-lg font-semibold text-gray-800 dark:text-white mb-3">{selectedConfig.title}</h4>
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-zinc-400">
{selectedConfig.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
{/* Special Commands for Claude Code */}
{selectedIDE === "claudecode" && (
<div
className={cn(
"p-3 rounded-lg flex items-center justify-between",
glassmorphism.background.subtle,
glassmorphism.border.default,
)}
>
<code className="text-sm font-mono text-cyan-600 dark:text-cyan-400">
claude mcp add --transport http archon http://{config.host}:{config.port}/mcp
</code>
<Button variant="outline" size="sm" onClick={handleClaudeCodeCommand}>
<Copy className="w-3 h-3 mr-1" />
Copy
</Button>
</div>
)}
{/* Configuration Display */}
<div className={cn("relative rounded-lg p-4", glassmorphism.background.subtle, glassmorphism.border.default)}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Configuration</span>
<Button variant="outline" size="sm" onClick={handleCopyConfig}>
<Copy className="w-3 h-3 mr-1" />
Copy
</Button>
</div>
<pre className="text-xs font-mono text-gray-800 dark:text-zinc-200 overflow-x-auto">
<code>{configText}</code>
</pre>
</div>
{/* One-Click Install for Cursor */}
{selectedIDE === "cursor" && selectedConfig.supportsOneClick && (
<div className="flex items-center gap-3">
<Button variant="cyan" onClick={handleCursorOneClick} className="shadow-lg">
<ExternalLink className="w-4 h-4 mr-2" />
One-Click Install for Cursor
</Button>
<span className="text-xs text-zinc-500">Opens Cursor with configuration</span>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -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<McpStatusBarProps> = ({
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 <CheckCircle className="w-4 h-4 text-green-500" />;
}
return <AlertCircle className="w-4 h-4 text-red-500" />;
};
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 (
<div className={cn(
"flex items-center gap-6 px-4 py-2 rounded-lg",
glassmorphism.background.subtle,
glassmorphism.border.default,
"font-mono text-sm",
className
)}>
{/* Status Indicator */}
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className={cn("font-semibold", getStatusColor())}>
{status.status.toUpperCase()}
</span>
</div>
{/* Separator */}
<div className="w-px h-4 bg-zinc-700" />
{/* Uptime */}
{status.uptime !== null && (
<>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-500" />
<span className="text-zinc-400">UP</span>
<span className="text-white">{formatUptime(status.uptime)}</span>
</div>
<div className="w-px h-4 bg-zinc-700" />
</>
)}
{/* Server Info */}
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-cyan-500" />
<span className="text-zinc-400">MCP</span>
<span className="text-white">8051</span>
</div>
{/* Active Sessions */}
{sessionInfo && (
<>
<div className="w-px h-4 bg-zinc-700" />
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-pink-500" />
<span className="text-zinc-400">SESSIONS</span>
<span className="text-cyan-400 text-sm">Coming Soon</span>
</div>
</>
)}
{/* Transport Type */}
<div className="w-px h-4 bg-zinc-700 ml-auto" />
<div className="flex items-center gap-2">
<span className="text-zinc-400">TRANSPORT</span>
<span className="text-cyan-400">
{config?.transport === 'streamable-http' ? 'HTTP' :
config?.transport === 'sse' ? 'SSE' :
config?.transport || 'HTTP'}
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./McpStatusBar";
export * from "./McpClientList";
export * from "./McpConfigSection";

View File

@@ -0,0 +1 @@
export * from "./useMcpQueries";

View File

@@ -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,
});
}

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "./mcpApi";

View File

@@ -0,0 +1,54 @@
import { callAPIWithETag } from "../../projects/shared/apiWithEtag";
import type {
McpServerStatus,
McpServerConfig,
McpSessionInfo,
McpClient
} from "../types";
export const mcpApi = {
async getStatus(): Promise<McpServerStatus> {
try {
const response =
await callAPIWithETag<McpServerStatus>("/api/mcp/status");
return response;
} catch (error) {
console.error("Failed to get MCP status:", error);
throw error;
}
},
async getConfig(): Promise<McpServerConfig> {
try {
const response =
await callAPIWithETag<McpServerConfig>("/api/mcp/config");
return response;
} catch (error) {
console.error("Failed to get MCP config:", error);
throw error;
}
},
async getSessionInfo(): Promise<McpSessionInfo> {
try {
const response =
await callAPIWithETag<McpSessionInfo>("/api/mcp/sessions");
return response;
} catch (error) {
console.error("Failed to get session info:", error);
throw error;
}
},
async getClients(): Promise<McpClient[]> {
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;
}
},
};

View File

@@ -0,0 +1 @@
export * from "./mcp";

View File

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

View File

@@ -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 (
<div className="flex items-center justify-center min-h-[400px]">
<Loader className="animate-spin text-gray-500" size={32} />
</div>
);
}
return (
<motion.div
initial="hidden"
animate={isVisible ? "visible" : "hidden"}
variants={containerVariants}
className="space-y-6"
>
{/* Title with MCP icon */}
<motion.h1
className="text-3xl font-bold text-gray-800 dark:text-white mb-8 flex items-center gap-3"
variants={titleVariants}
>
<svg
fill="currentColor"
fillRule="evenodd"
height="28"
width="28"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="text-pink-500 filter drop-shadow-[0_0_8px_rgba(236,72,153,0.8)]"
aria-label="MCP icon"
>
<title>MCP icon</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
MCP Status Dashboard
</motion.h1>
{/* Status Bar */}
<motion.div variants={itemVariants}>
<McpStatusBar status={status} sessionInfo={sessionInfo} config={config} />
</motion.div>
{/* Connected Clients */}
<motion.div variants={itemVariants}>
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white flex items-center gap-2">
<Server className="w-5 h-5 text-cyan-500" />
Connected Clients
</h2>
<McpClientList clients={clients} />
</motion.div>
{/* IDE Configuration */}
<motion.div variants={itemVariants}>
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-white">IDE Configuration</h2>
<McpConfigSection config={config} status={status} />
</motion.div>
</motion.div>
);
};

View File

@@ -0,0 +1,15 @@
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { FeatureErrorBoundary } from "../../ui/components";
import { McpView } from "./McpView";
export const McpViewWithBoundary = () => {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<FeatureErrorBoundary featureName="MCP Dashboard" onReset={reset}>
<McpView />
</FeatureErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

View File

@@ -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(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
expect(screen.getByText('Test Project')).toBeInTheDocument();
});
it('should display task counts', () => {
render(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// 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(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
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(
<ProjectCard
project={mockProject}
isSelected={true}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
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(
<ProjectCard
project={pinnedProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
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(
<ProjectCard
project={mockProject}
isSelected={true}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// 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(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
// 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(
<ProjectCard
project={mockProject}
isSelected={false}
taskCounts={zeroTaskCounts}
{...mockHandlers}
/>
);
// 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(
<ProjectCard
project={longTitleProject}
isSelected={false}
taskCounts={mockTaskCounts}
{...mockHandlers}
/>
);
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');
});
});

View File

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

View File

@@ -19,3 +19,4 @@ export * from "./hooks";
export * from "./tasks";
// Views
export { ProjectsView } from "./views/ProjectsView";
export { ProjectsViewWithBoundary } from "./views/ProjectsViewWithBoundary";

View File

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

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<ToastProvider>
{children}
</ToastProvider>
</TooltipProvider>
</QueryClientProvider>
);
}
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 };

View File

@@ -1,2 +1,3 @@
export * from "./useSmartPolling";
export * from "./useThemeAware";
export * from "./useToast";

View File

@@ -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);
});
});

View File

@@ -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
});
}

View File

@@ -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);
}

View File

@@ -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<ServerStatus>({
status: 'stopped',
uptime: null,
logs: []
});
const [config, setConfig] = useState<ServerConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [selectedIDE, setSelectedIDE] = useState<SupportedIDE>('windsurf');
const statusPollInterval = useRef<ReturnType<typeof setInterval> | 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 <CheckCircle className="w-5 h-5 text-green-500" />;
case 'starting':
case 'stopping':
return <Loader className="w-5 h-5 text-blue-500 animate-spin" />;
default:
return <AlertCircle className="w-5 h-5 text-red-500" />;
}
};
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 (
<div className="flex items-center justify-center min-h-[400px]">
<Loader className="animate-spin text-gray-500" size={32} />
</div>
);
}
return (
<motion.div
initial="hidden"
animate={isVisible ? 'visible' : 'hidden'}
variants={containerVariants}
>
<motion.h1
className="text-3xl font-bold text-gray-800 dark:text-white mb-8 flex items-center gap-3"
variants={titleVariants}
>
<svg fill="currentColor" fillRule="evenodd" height="28" width="28" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" className="text-pink-500 filter drop-shadow-[0_0_8px_rgba(236,72,153,0.8)]">
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path>
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path>
</svg>
MCP Dashboard
</motion.h1>
{/* Tab Navigation */}
<motion.div className="mb-6 border-b border-gray-200 dark:border-gray-800" variants={itemVariants}>
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('server')}
className={`pb-3 relative ${
activeTab === 'server'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
Server Control
{activeTab === 'server' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 shadow-[0_0_10px_rgba(59,130,246,0.5)]"></span>
)}
</button>
{/* TODO: MCP Client feature not implemented - commenting out for now
<button
onClick={() => setActiveTab('clients')}
className={`pb-3 relative ${
activeTab === 'clients'
? 'text-cyan-600 dark:text-cyan-400 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
MCP Clients
{activeTab === 'clients' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]"></span>
)}
</button>
*/}
</div>
</motion.div>
{/* Server Control Tab */}
{activeTab === 'server' && (
<>
{/* Server Control */}
<motion.div className="grid grid-cols-1 gap-6" variants={itemVariants}>
{/* Left Column: Archon MCP Server */}
<div className="flex flex-col">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4 flex items-center">
<Server className="mr-2 text-blue-500" size={20} />
Archon MCP Server
</h2>
<Card accentColor="blue" className="space-y-6 flex-1">
{/* Status Display */}
<div className="flex items-center justify-between">
<div
className="flex items-center gap-3 cursor-help"
title={import.meta.env.DEV ?
`Debug Info:\nStatus: ${serverStatus.status}\nConfig: ${config ? 'loaded' : 'null'}\n${config ? `Details: ${JSON.stringify(config, null, 2)}` : ''}` :
undefined
}
>
{getStatusIcon()}
<div>
<p className={`font-semibold ${getStatusColor()}`}>
Status: {serverStatus.status.charAt(0).toUpperCase() + serverStatus.status.slice(1)}
</p>
{serverStatus.uptime !== null && (
<p className="text-sm text-gray-600 dark:text-zinc-400">
Uptime: {formatUptime(serverStatus.uptime)}
</p>
)}
</div>
</div>
{/* Control Buttons */}
<div className="flex gap-2 items-center">
{serverStatus.status === 'stopped' ? (
<Button
onClick={handleStartServer}
disabled={isStarting}
variant="primary"
accentColor="green"
className="shadow-emerald-500/20 shadow-sm"
>
{isStarting ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin inline" />
Starting...
</>
) : (
<>
<Play className="w-4 h-4 mr-2 inline" />
Start Server
</>
)}
</Button>
) : (
<Button
onClick={handleStopServer}
disabled={isStopping || serverStatus.status !== 'running'}
variant="primary"
accentColor="pink"
className="shadow-pink-500/20 shadow-sm"
>
{isStopping ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin inline" />
Stopping...
</>
) : (
<>
<Square className="w-4 h-4 mr-2 inline" />
Stop Server
</>
)}
</Button>
)}
</div>
</div>
{/* Connection Details */}
{serverStatus.status === 'running' && config && (
<div className="border-t border-gray-200 dark:border-zinc-800 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700 dark:text-zinc-300">
IDE Configuration
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full">
HTTP Mode
</span>
</h3>
<Button
variant="secondary"
accentColor="blue"
size="sm"
onClick={handleCopyConfig}
>
<Copy className="w-3 h-3 mr-1 inline" />
Copy
</Button>
</div>
{/* Note about universal MCP compatibility */}
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-xs text-blue-700 dark:text-blue-300">
<span className="font-semibold">Note:</span> 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.
</p>
</div>
{/* IDE Selection Tabs */}
<div className="mb-4">
<div className="flex flex-wrap border-b border-gray-200 dark:border-zinc-700 mb-3">
<button
onClick={() => setSelectedIDE('claudecode')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'claudecode'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Claude Code
</button>
<button
onClick={() => setSelectedIDE('gemini')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'gemini'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Gemini CLI
</button>
<button
onClick={() => setSelectedIDE('cursor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'cursor'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Cursor
</button>
<button
onClick={() => setSelectedIDE('windsurf')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'windsurf'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Windsurf
</button>
<button
onClick={() => setSelectedIDE('cline')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'cline'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Cline
</button>
<button
onClick={() => setSelectedIDE('kiro')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'kiro'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Kiro
</button>
<button
onClick={() => setSelectedIDE('augment')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedIDE === 'augment'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-zinc-400 hover:text-gray-700 dark:hover:text-zinc-300'
} cursor-pointer`}
>
Augment
</button>
</div>
</div>
{/* IDE Instructions */}
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
{getIDEInstructions(selectedIDE).title}
</h4>
<ul className="text-sm text-gray-600 dark:text-zinc-400 space-y-1">
{getIDEInstructions(selectedIDE).steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ul>
</div>
<div className="bg-gray-50 dark:bg-black/50 rounded-lg p-4 font-mono text-sm relative">
<pre className="text-gray-600 dark:text-zinc-400 whitespace-pre-wrap">
{getConfigForIDE(selectedIDE)}
</pre>
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-3 font-sans">
{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'
}
</p>
</div>
{/* One-click install button for Cursor */}
{selectedIDE === 'cursor' && serverStatus.status === 'running' && (
<div className="mt-4">
<Button
variant="primary"
accentColor="blue"
onClick={handleCursorOneClick}
className="w-full"
>
<Server className="w-4 h-4 mr-2 inline" />
One-Click Install for Cursor
</Button>
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-2 text-center">
Requires Cursor to be installed and will open a deeplink
</p>
</div>
)}
</div>
)}
</Card>
</div>
</motion.div>
{/* Global Rules Section */}
<motion.div className="mt-6" variants={itemVariants}>
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4 flex items-center">
<Server className="mr-2 text-pink-500" size={20} />
Global IDE Rules
</h2>
<IDEGlobalRules />
</motion.div>
</>
)}
{/* Clients Tab - commented out as feature not implemented
{activeTab === 'clients' && (
<motion.div variants={itemVariants}>
<MCPClients />
</motion.div>
)}
*/}
</motion.div>
);
return <McpViewWithBoundary />;
};

View File

@@ -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 <ProjectsView {...props} />;
return <ProjectsViewWithBoundary {...props} />;
}
export { ProjectPage };

View File

@@ -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<T>(
}
}
// MCP Server Management
export async function startMCPServer(): Promise<MCPServerResponse> {
return retry(() => apiRequest<MCPServerResponse>('/mcp/start', { method: 'POST' }));
}
export async function stopMCPServer(): Promise<MCPServerResponse> {
return retry(() => apiRequest<MCPServerResponse>('/mcp/stop', { method: 'POST' }));
}
export async function getMCPServerStatus(): Promise<MCPServerStatus> {
return retry(() => apiRequest<MCPServerStatus>('/mcp/status'));
}
// Crawling Operations
export async function crawlSinglePage(url: string): Promise<CrawlResponse> {
return retry(() => apiRequest<CrawlResponse>('/crawl/single', {

View File

@@ -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<string, any>;
discovered_at: string;
}
export interface ToolCallRequest {
client_id: string;
tool_name: string;
arguments: Record<string, any>;
}
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<typeof MCPToolSchema>;
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
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<MCPClient[]> {
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<MCPClient> {
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<MCPClient> {
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<MCPClientConfig>): Promise<MCPClient> {
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<ClientStatus> {
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<ToolsResponse> {
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<any> {
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<AllToolsResponse> {
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<ToolsResponse> {
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<Array<{ clientId: string; success: boolean; message: string }>> {
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<Array<{ client: MCPClient; status: ClientStatus }>> {
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<Array<{ clientId: string; success: boolean; message: string }>> {
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<MCPClient> {
// 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<MCPClient> {
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();

View File

@@ -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<typeof MCPToolSchema>;
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
/**
* 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<ServerResponse> {
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<ServerResponse> {
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<ServerStatus> {
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<ServerConfig> {
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<ServerConfig>): Promise<ServerResponse> {
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<any> {
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<MCPTool[]> {
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<string, any>): Promise<any> {
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();
};

View File

@@ -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<string, any>;
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<string, any>;
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<string, any>;
discovered_at: string;
}
export interface ToolCallRequest {
client_id: string;
tool_name: string;
arguments: Record<string, any>;
}
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<typeof MCPToolSchema>;
export type MCPParameter = z.infer<typeof MCPParameterSchema>;
class MCPService {
private baseUrl = ''; // Use relative URL to go through Vite proxy
// ========================================
// SERVER MANAGEMENT (Original functionality)
// ========================================
async startServer(): Promise<ServerResponse> {
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<ServerResponse> {
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<ServerStatus> {
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<ServerConfig> {
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<ServerConfig>): Promise<ServerResponse> {
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<LogEntry[]> {
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<ServerResponse> {
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<MCPClient[]> {
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<MCPClient> {
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<MCPClient> {
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<MCPClientConfig>): Promise<MCPClient> {
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<any> {
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<any> {
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<MCPTool[]> {
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<string, any>): Promise<any> {
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();
};

View File

@@ -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) => (
<button {...props}>{children}</button>
)
render(<MockButton onClick={onClick}>Click me</MockButton>)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(onClick).toHaveBeenCalledTimes(1)
})
test('input component works', () => {
const MockInput = () => {
const [value, setValue] = React.useState('')
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Test input"
/>
)
}
render(<MockInput />)
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 (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div role="dialog">
<h2>Modal Title</h2>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</div>
)
}
render(<MockModal />)
// 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 }) => (
<div>
<div>Progress: {Math.round((value / max) * 100)}%</div>
<div style={{ width: `${(value / max) * 100}%` }}>Bar</div>
</div>
)
const { rerender } = render(<MockProgressBar value={0} max={100} />)
expect(screen.getByText('Progress: 0%')).toBeInTheDocument()
rerender(<MockProgressBar value={50} max={100} />)
expect(screen.getByText('Progress: 50%')).toBeInTheDocument()
rerender(<MockProgressBar value={100} max={100} />)
expect(screen.getByText('Progress: 100%')).toBeInTheDocument()
})
test('tooltip component works', () => {
const MockTooltip = ({ children, tooltip }: any) => {
const [show, setShow] = React.useState(false)
return (
<div>
<button
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
</button>
{show && <div role="tooltip">{tooltip}</div>}
</div>
)
}
render(<MockTooltip tooltip="This is a tooltip">Hover me</MockTooltip>)
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 (
<div>
<button onClick={() => setExpanded(!expanded)}>
Section 1 {expanded ? '' : '+'}
</button>
{expanded && <div>Section content</div>}
</div>
)
}
render(<MockAccordion />)
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 (
<table>
<thead>
<tr>
<th onClick={sortByName} style={{ cursor: 'pointer' }}>
Name
</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={index}>
<td>{row.name}</td>
<td>{row.age}</td>
</tr>
))}
</tbody>
</table>
)
}
render(<MockTable />)
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 (
<div>
<div>Page {page}</div>
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</button>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
)
}
render(<MockPagination />)
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 (
<div>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
validate(e.target.value)
}}
/>
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockForm />)
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 (
<div>
<input
placeholder="Search items"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filtered.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)
}
render(<MockSearch />)
// 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()
})
})

View File

@@ -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(<DeleteConfirmModal {...defaultProps} />);
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(<DeleteConfirmModal {...defaultProps} type="project" />);
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(<DeleteConfirmModal {...defaultProps} type="client" />);
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(<DeleteConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('Delete'));
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when Cancel button is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when Escape key is pressed', () => {
render(<DeleteConfirmModal {...defaultProps} />);
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
});
it('calls onCancel when backdrop is clicked', () => {
render(<DeleteConfirmModal {...defaultProps} />);
// 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(<DeleteConfirmModal {...defaultProps} />);
// Click the modal dialog itself
fireEvent.click(screen.getByRole('dialog'));
expect(defaultProps.onCancel).not.toHaveBeenCalled();
});
it('has proper accessibility attributes', () => {
render(<DeleteConfirmModal {...defaultProps} />);
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(<DeleteConfirmModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toHaveFocus();
});
it('has proper button types', () => {
render(<DeleteConfirmModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
const deleteButton = screen.getByText('Delete');
expect(cancelButton).toHaveAttribute('type', 'button');
expect(deleteButton).toHaveAttribute('type', 'button');
});
});

View File

@@ -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 (
<div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
{documents.map(doc => (
<div
key={doc.id}
data-testid={`document-card-${doc.id}`}
className={`flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer ${
selectedDocument?.id === doc.id ? 'border-2 border-blue-500' : 'border border-gray-200'
}`}
onClick={() => setSelectedDocument(doc)}
>
<div className={`text-xs ${doc.document_type}`}>{doc.document_type}</div>
<h4>{doc.title}</h4>
{selectedDocument?.id !== doc.id && (
<button
data-testid={`delete-${doc.id}`}
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete "${doc.title}"?`)) {
setDocuments(prev => prev.filter(d => d.id !== doc.id))
if (selectedDocument?.id === doc.id) {
setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
}
showToast('Document deleted', 'success')
}
}}
>
Delete
</button>
)}
</div>
))}
<div
data-testid="new-document-card"
className="flex-shrink-0 w-48 h-32 rounded-lg border-2 border-dashed"
onClick={() => console.log('New document')}
>
New Document
</div>
</div>
{selectedDocument && (
<div data-testid="selected-document">
Selected: {selectedDocument.title}
</div>
)}
</div>
)
}
describe('DocsTab Document Cards Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('renders all document cards', () => {
render(<DocsTabTest />)
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(<DocsTabTest />)
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(<DocsTabTest />)
// 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(<DocsTabTest />)
// 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(<DocsTabTest />)
// 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(<DocsTabTest />)
// 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(<DocsTabTest />)
// 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(<DocsTabTest />)
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(<DocsTabTest />)
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 (
<div>
{documents.map(doc => (
<div key={doc.id} data-testid={`doc-${doc.id}`}>
<span>{doc.title}</span>
<button
data-testid={`delete-${doc.id}`}
onClick={() => {
if (confirm(`Delete "${doc.title}"?`)) {
handleDelete(doc.id)
}
}}
>
Delete
</button>
</div>
))}
</div>
)
}
render(<DocsTabWithAPI />)
// 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 (
<div>
{documents.map(doc => (
<div key={doc.id} data-testid={`doc-${doc.id}`}>
<button
data-testid={`delete-${doc.id}`}
onClick={() => {
if (confirm(`Delete "${doc.title}"?`)) {
handleDelete(doc.id)
}
}}
>
Delete
</button>
</div>
))}
<div data-testid="toast-container" />
</div>
)
}
render(<DocsTabWithError />)
// 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(<div data-testid="docs-count">{mockDocuments.length}</div>)
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(<div data-testid="docs-count">{refreshedDocs.length}</div>)
// 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')
})
})

View File

@@ -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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={{ ...mockDocument, document_type: type }}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
const badge = container.querySelector(`.${expectedClass}`)
expect(badge).toBeInTheDocument()
})
})
test('applies active styles when selected', () => {
const { container } = render(
<DocumentCard
document={mockDocument}
isActive={true}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={true}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={false}
/>
)
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(
<DocumentCard
document={mockDocument}
isActive={false}
onSelect={mockHandlers.onSelect}
onDelete={mockHandlers.onDelete}
isDarkMode={true}
/>
)
const card = container.firstChild as HTMLElement
expect(card.className).toContain('dark:')
})
})
describe('NewDocumentCard', () => {
test('renders new document card', () => {
const onClick = vi.fn()
render(<NewDocumentCard onClick={onClick} />)
expect(screen.getByText('New Document')).toBeInTheDocument()
})
test('calls onClick when clicked', () => {
const onClick = vi.fn()
render(<NewDocumentCard onClick={onClick} />)
const card = screen.getByText('New Document').closest('div')
fireEvent.click(card!)
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View File

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

View File

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

View File

@@ -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(<PRPViewer content={mockContent} />)
// 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(<PRPViewer content={mockContent} />)
// 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(<PRPViewer content={mockContent} />)
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(<PRPViewer content={{} as PRPContent} />)
// Should render without errors
expect(screen.getByText(/Metadata/)).toBeInTheDocument()
})
test('handles null content', () => {
render(<PRPViewer content={null as any} />)
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(<PRPViewer content={stringContent as any} />)
// 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(<PRPViewer content={arrayContent as any} />)
// Should process all image placeholders
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
})
test('renders collapsible sections', () => {
render(<PRPViewer content={mockContent} />)
// 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(<PRPViewer content={mockContent} />)
// 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(<PRPViewer content={mockContent} isDarkMode={true} />)
const viewer = container.querySelector('.prp-viewer')
expect(viewer?.className).toContain('dark')
})
test('uses section overrides when provided', () => {
const CustomSection = ({ data, title }: any) => (
<div data-testid="custom-section">
<h3>{title}</h3>
<p>Custom rendering of: {JSON.stringify(data)}</p>
</div>
)
const overrides = {
context: CustomSection
}
render(<PRPViewer content={mockContent} sectionOverrides={overrides} />)
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(<PRPViewer content={complexContent as any} />)
// 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)
})
})

View File

@@ -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();
}
});
});

View File

@@ -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 (
<div>
<button onClick={fetchData}>Load Data</button>
{loading && <div>Loading...</div>}
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockApiComponent />)
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 (
<div>
<button onClick={handleTimeout}>Start Request</button>
{status === 'loading' && <div>Loading...</div>}
{status === 'timeout' && <div role="alert">Request timed out</div>}
</div>
)
}
render(<MockTimeoutComponent />)
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<string[]>([])
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 (
<div>
<input
placeholder="Name"
value={values.name}
onChange={(e) => setValues({ ...values, name: e.target.value })}
/>
<input
placeholder="Email"
value={values.email}
onChange={(e) => setValues({ ...values, email: e.target.value })}
/>
<button onClick={validate}>Submit</button>
{errors.length > 0 && (
<div role="alert">
{errors.map((error, index) => (
<div key={index}>{error}</div>
))}
</div>
)}
</div>
)
}
render(<MockFormErrors />)
// 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 (
<div>
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
{error && <div role="alert">{error}</div>}
<button onClick={handleDisconnect}>Simulate Disconnect</button>
<button onClick={handleReconnect}>Reconnect</button>
</div>
)
}
render(<MockConnection />)
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 (
<div>
<button onClick={() => setErrorType('401')}>401 Error</button>
<button onClick={() => setErrorType('403')}>403 Error</button>
<button onClick={() => setErrorType('404')}>404 Error</button>
<button onClick={() => setErrorType('500')}>500 Error</button>
{errorType && (
<div role="alert">{getErrorMessage(errorType)}</div>
)}
</div>
)
}
render(<MockErrorMessages />)
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/)
})
})

View File

@@ -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);
});

View File

@@ -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 = () => <h1>Projects</h1>
render(<MockPage />)
expect(screen.getByText('Projects')).toBeInTheDocument()
})
test('knowledge base mock renders', () => {
const MockKnowledgePage = () => <h1>Knowledge Base</h1>
render(<MockKnowledgePage />)
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
})
test('settings mock renders', () => {
const MockSettingsPage = () => <h1>Settings</h1>
render(<MockSettingsPage />)
expect(screen.getByText('Settings')).toBeInTheDocument()
})
test('mcp mock renders', () => {
const MockMCPPage = () => <h1>MCP Servers</h1>
render(<MockMCPPage />)
expect(screen.getByText('MCP Servers')).toBeInTheDocument()
})
test('tasks mock renders', () => {
const MockTasksPage = () => (
<div>
<h1>Tasks</h1>
<div>TODO</div>
<div>In Progress</div>
<div>Done</div>
</div>
)
render(<MockTasksPage />)
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 = () => <h1>Welcome to Archon</h1>
render(<MockOnboardingPage />)
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)
})
})

View File

@@ -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<Record<string, TaskCounts>>');
});
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');
});
});

View File

@@ -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<Set<string>>(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<string>');
expect(fileContent).toContain('setMovingTaskIds: (ids: Set<string>) => void');
});
});

View File

@@ -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();
});
});
});

View File

@@ -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 (
<div>
<h1>Create Project</h1>
<input
placeholder="Project title"
value={project}
onChange={(e) => setProject(e.target.value)}
/>
<button>Create</button>
</div>
)
}
render(<MockCreateProject />)
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 (
<div>
<h1>Search</h1>
<input
placeholder="Search knowledge base"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query && <div>Results for: {query}</div>}
</div>
)
}
render(<MockSearch />)
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 (
<div>
<h1>Settings</h1>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme: {theme}
</button>
</div>
)
}
render(<MockSettings />)
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 (
<div>
<h1>Upload Documents</h1>
<input type="file" onChange={() => setUploaded(true)} data-testid="file-input" />
{uploaded && <div>File uploaded successfully</div>}
</div>
)
}
render(<MockUpload />)
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 (
<div>
<h1>Connection Status</h1>
<div>{connected ? 'Connected' : 'Disconnected'}</div>
<button onClick={() => setConnected(!connected)}>
Toggle Connection
</button>
</div>
)
}
render(<MockConnection />)
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 (
<div>
<h1>Task Management</h1>
<button onClick={addTask}>Add Task</button>
<ul>
{tasks.map((task, index) => (
<li key={index}>{task}</li>
))}
</ul>
</div>
)
}
render(<MockTasks />)
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 (
<div>
<nav>
<button onClick={() => setCurrentPage('projects')}>Projects</button>
<button onClick={() => setCurrentPage('settings')}>Settings</button>
</nav>
<main>
<h1>Current page: {currentPage}</h1>
</main>
</div>
)
}
render(<MockNav />)
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 (
<div>
<h1>Form Validation</h1>
<input
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button onClick={handleSubmit}>Submit</button>
{error && <div role="alert">{error}</div>}
</div>
)
}
render(<MockForm />)
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 (
<div className={isDark ? 'dark' : 'light'}>
<h1>Theme Test</h1>
<button onClick={() => setIsDark(!isDark)}>
Switch to {isDark ? 'Light' : 'Dark'}
</button>
</div>
)
}
render(<MockTheme />)
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 (
<div>
<h1>Filter Test</h1>
<input
placeholder="Filter items"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul>
{filtered.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)
}
render(<MockFilter />)
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()
})
})

View File

@@ -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);
});
});
});

View File

@@ -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)

View File

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

View File

@@ -23,6 +23,6 @@
/* Path mapping */
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "test"],
"include": ["src", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"exclude": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/__tests__/**",
"**/tests/**",
"src/features/testing/**",
"test/**",
"tests/**",
"coverage/**"
]
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -45,7 +45,7 @@ graph LR
<TabItem value="component" label="React Component">
```tsx
import { ArchonChatPanel } from '@/components/layouts/ArchonChatPanel';
import { ArchonChatPanel } from '@/components/agent-chat/ArchonChatPanel';
function App() {
return (

View File

@@ -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
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
def get_container_status() -> dict[str, Any]:
"""Get simple MCP container status without Docker management."""
docker_client = 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
docker_client = docker.from_env()
container = docker_client.containers.get("archon-mcp")
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",
}
# Get container status
container_status = container.status
# Map Docker statuses to simple statuses
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:
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']}")
uptime = None
else:
recent_logs.append(str(log))
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)}")
api_logger.error("Failed to get container status", exc_info=True)
return {
"status": "error",
"uptime": None,
"logs": [],
"container_status": "error",
"error": str(e)
}
finally:
# Check if container stopped
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")
# 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")
# 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.",
"clients": [],
"total": 0
}
# 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))
api_logger.error(f"Failed to get MCP clients - error={str(e)}")
safe_set_attribute(span, "error", str(e))
safe_set_attribute(span, "source", "general_error")
return {
"tools": [],
"count": 0,
"server_running": False,
"source": "general_error",
"message": f"Error retrieving MCP tools: {str(e)}",
"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")