Merge origin/main into bugfix-issue-362

This merge brings in the latest changes from main including:
- HTTP polling architecture (replacing Socket.IO)
- TanStack Query migration
- Document browser functionality
- Latest bug fixes and improvements

Our OpenAI error handling features have been preserved and integrated:
- Authentication error handling with HTTP 401 responses
- API key validation before operations
- Error message sanitization for security
- Comprehensive error handling for quota/rate limits

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
leex279
2025-09-06 21:36:14 +02:00
318 changed files with 23448 additions and 39153 deletions

View File

@@ -0,0 +1,73 @@
---
name: Archon CodeRabbit Helper
description: Analyze CodeRabbit suggestions, assess validity, and provide actionable options with tradeoffs
argument-hint: Paste the CodeRabbit suggestion here
---
# CodeRabbit Review Analysis
**Review:** $ARGUMENTS
## Instructions
Analyze this CodeRabbit suggestion following these steps:
### 1. Deep Analysis
- Understand the technical issue being raised
- Check if it's a real problem or false positive
- Search the codebase for related patterns and context
- Consider project phase (early beta) and architecture
### 2. Context Assessment
- We're in early beta - prioritize simplicity over perfection
- Follow KISS principles and existing codebase patterns
- Avoid premature optimization or over-engineering
- Consider if this affects user experience or is internal only
### 3. Generate Options
Think harder about the problem and potential solutions.
Provide 2-5 practical options with clear tradeoffs
## Response Format
### 📋 Issue Summary
_[One sentence describing what CodeRabbit found]_
### ✅ Is this valid?
_[YES/NO with brief explanation]_
### 🎯 Priority for this PR
_[HIGH/MEDIUM/LOW/SKIP with reasoning]_
### 🔧 Options & Tradeoffs
**Option 1: [Name]**
- What: _[Brief description]_
- Pros: _[Benefits]_
- Cons: _[Drawbacks]_
- Effort: _[Low/Medium/High]_
**Option 2: [Name]**
- What: _[Brief description]_
- Pros: _[Benefits]_
- Cons: _[Drawbacks]_
- Effort: _[Low/Medium/High]_
### 💡 Recommendation
_[Your recommended option with 1-2 sentence justification]_
## User feedback
- When you have presented the review to the user you must ask for their feedback on the suggested changes.
- Ask the user if they wish to discuss any of the options further
- If the user wishes for you to explore further, provide additional options or tradeoffs.
- If the user is ready to implement the recommended option right away

View File

@@ -42,6 +42,12 @@ ARCHON_DOCS_PORT=3838
# If not set, defaults to localhost, 127.0.0.1, ::1, and the HOST value above
VITE_ALLOWED_HOSTS=
# Development Tools
# VITE_SHOW_DEVTOOLS: Show TanStack Query DevTools (for developers only)
# Set to "true" to enable the DevTools panel in bottom right corner
# Defaults to "false" for end users
VITE_SHOW_DEVTOOLS=false
# When enabled, PROD mode will proxy ARCHON_SERVER_PORT through ARCHON_UI_PORT. This exposes both the
# Archon UI and API through a single port. This is useful when deploying Archon behind a reverse
# proxy where you want to expose the frontend on a single external domain.

4
.gitignore vendored
View File

@@ -3,4 +3,8 @@ __pycache__
.serena
.claude/settings.local.json
PRPs/local
PRPs/completed/
/logs/
.zed
tmp/
temp/

388
AGENTS.md Normal file
View File

@@ -0,0 +1,388 @@
# AGENTS.md
## Alpha Development Guidelines
**Local-only deployment** - each user runs their own instance.
### Core Principles
- **No backwards compatibility** - remove deprecated code immediately
- **Detailed errors over graceful failures** - we want to identify and fix issues fast
- **Break things to improve them** - alpha is for rapid iteration
### Error Handling
**Core Principle**: In alpha, we need to intelligently decide when to fail hard and fast to quickly address issues, and when to allow processes to complete in critical services despite failures. Read below carefully and make intelligent decisions on a case-by-case basis.
#### When to Fail Fast and Loud (Let it Crash!)
These errors should stop execution and bubble up immediately: (except for crawling flows)
- **Service startup failures** - If credentials, database, or any service can't initialize, the system should crash with a clear error
- **Missing configuration** - Missing environment variables or invalid settings should stop the system
- **Database connection failures** - Don't hide connection issues, expose them
- **Authentication/authorization failures** - Security errors must be visible and halt the operation
- **Data corruption or validation errors** - Never silently accept bad data, Pydantic should raise
- **Critical dependencies unavailable** - If a required service is down, fail immediately
- **Invalid data that would corrupt state** - Never store zero embeddings, null foreign keys, or malformed JSON
#### When to Complete but Log Detailed Errors
These operations should continue but track and report failures clearly:
- **Batch processing** - When crawling websites or processing documents, complete what you can and report detailed failures for each item
- **Background tasks** - Embedding generation, async jobs should finish the queue but log failures
- **WebSocket events** - Don't crash on a single event failure, log it and continue serving other clients
- **Optional features** - If projects/tasks are disabled, log and skip rather than crash
- **External API calls** - Retry with exponential backoff, then fail with a clear message about what service failed and why
#### Critical Nuance: Never Accept Corrupted Data
When a process should continue despite failures, it must **skip the failed item entirely** rather than storing corrupted data:
**❌ WRONG - Silent Corruption:**
```python
try:
embedding = create_embedding(text)
except Exception as e:
embedding = [0.0] * 1536 # NEVER DO THIS - corrupts database
store_document(doc, embedding)
```
**✅ CORRECT - Skip Failed Items:**
```python
try:
embedding = create_embedding(text)
store_document(doc, embedding) # Only store on success
except Exception as e:
failed_items.append({'doc': doc, 'error': str(e)})
logger.error(f"Skipping document {doc.id}: {e}")
# Continue with next document, don't store anything
```
**✅ CORRECT - Batch Processing with Failure Tracking:**
```python
def process_batch(items):
results = {'succeeded': [], 'failed': []}
for item in items:
try:
result = process_item(item)
results['succeeded'].append(result)
except Exception as e:
results['failed'].append({
'item': item,
'error': str(e),
'traceback': traceback.format_exc()
})
logger.error(f"Failed to process {item.id}: {e}")
# Always return both successes and failures
return results
```
#### Error Message Guidelines
- Include context about what was being attempted when the error occurred
- Preserve full stack traces with `exc_info=True` in Python logging
- Use specific exception types, not generic Exception catching
- Include relevant IDs, URLs, or data that helps debug the issue
- Never return None/null to indicate failure - raise an exception with details
- For batch operations, always report both success count and detailed failure list
### Code Quality
- Remove dead code immediately rather than maintaining it - no backward compatibility or legacy functions
- Prioritize functionality over production-ready patterns
- Focus on user experience and feature completeness
- When updating code, don't reference what is changing (avoid keywords like LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
## Architecture Overview
Archon V2 Alpha is a microservices-based knowledge management system with MCP (Model Context Protocol) integration:
- **Frontend (port 3737)**: React + TypeScript + Vite + TailwindCSS
- **UI Strategy**: Radix UI primitives in `/features`, custom components in legacy `/components`
- **State Management**: TanStack Query for all data fetching in `/features`
- **Styling**: Tron-inspired glassmorphism with Tailwind CSS
- **Main Server (port 8181)**: FastAPI with HTTP polling for updates
- **MCP Server (port 8051)**: Lightweight HTTP-based MCP protocol server
- **Agents Service (port 8052)**: PydanticAI agents for AI/ML operations
- **Database**: Supabase (PostgreSQL + pgvector for embeddings)
## Development Commands
### Frontend (archon-ui-main/)
```bash
npm run dev # Start development server on port 3737
npm run build # Build for production
npm run lint # Run ESLint
npm run test # Run Vitest tests
npm run test:coverage # Run tests with coverage report
```
# Biome Linter Guide for AI Assistants
## Overview
This project uses Biome for linting and formatting the `/src/features` directory. Biome provides fast, machine-readable feedback that AI assistants can use to improve code quality.
## Configuration
Biome is configured in `biome.json`:
- **Scope**: Only checks `/src/features/**` directory
- **Formatting**: 2 spaces, 80 char line width
- **Linting**: Recommended rules enabled
- **Import Organization**: Automatically sorts and groups imports
## AI Assistant Workflow in the new /features directory
1. **Check Issues**: Run `npm run biome:ai` to get JSON output
2. **Parse Output**: Extract error locations and types
3. **Apply Fixes**:
- Run `npm run biome:ai-fix` for auto-fixable issues
- Manually fix remaining issues based on patterns above
4. **Verify**: Run `npm run biome:ai` again to confirm fixes
## JSON Output Format
When using `biome:ai`, the output is structured JSON:
```json
{
"diagnostics": [
{
"file": "path/to/file.tsx",
"line": 10,
"column": 5,
"severity": "error",
"message": "Description of the issue",
"rule": "lint/a11y/useButtonType"
}
]
}
```
### Backend (python/)
```bash
# Using uv package manager
uv sync # Install/update dependencies
uv run pytest # Run tests
uv run python -m src.server.main # Run server locally
# With Docker
docker-compose up --build -d # Start all services
docker-compose logs -f # View logs
docker-compose restart # Restart services
```
### Testing
```bash
# Frontend tests (from archon-ui-main/)
npm run test:coverage:stream # Run with streaming output
npm run test:ui # Run with Vitest UI
# Backend tests (from python/)
uv run pytest tests/test_api_essentials.py -v
uv run pytest tests/test_service_integration.py -v
```
## Key API Endpoints
### Knowledge Base
- `POST /api/knowledge/crawl` - Crawl a website
- `POST /api/knowledge/upload` - Upload documents (PDF, DOCX, MD)
- `GET /api/knowledge/items` - List knowledge items
- `POST /api/knowledge/search` - RAG search
### MCP Integration
- `GET /api/mcp/health` - MCP server status
- `POST /api/mcp/tools/{tool_name}` - Execute MCP tool
- `GET /api/mcp/tools` - List available tools
### Projects & Tasks (when enabled)
- `GET /api/projects` - List all projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}` - Get single project
- `PUT /api/projects/{id}` - Update project
- `DELETE /api/projects/{id}` - Delete project
- `GET /api/projects/{id}/tasks` - Get tasks for project (use this, not getTasks)
- `POST /api/tasks` - Create task
- `PUT /api/tasks/{id}` - Update task
- `DELETE /api/tasks/{id}` - Delete task
## Polling Architecture
### HTTP Polling (replaced Socket.IO)
- **Polling intervals**: 1-2s for active operations, 5-10s for background data
- **ETag caching**: Reduces bandwidth by ~70% via 304 Not Modified responses
- **Smart pausing**: Stops polling when browser tab is inactive
- **Progress endpoints**: `/api/progress/crawl`, `/api/progress/project-creation`
### Key Polling Hooks
- `usePolling` - Generic polling with ETag support
- `useDatabaseMutation` - Optimistic updates with rollback
- `useProjectMutation` - Project-specific operations
## Environment Variables
Required in `.env`:
```bash
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key-here
```
Optional:
```bash
OPENAI_API_KEY=your-openai-key # Can be set via UI
LOGFIRE_TOKEN=your-logfire-token # For observability
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
```
## File Organization
### Frontend Structure
- `src/components/` - Legacy UI components (custom-built)
- `src/features/` - Modern vertical slice architecture with Radix UI
- `ui/primitives/` - Radix UI primitives with Tron glassmorphism
- `projects/` - Project management feature
- `tasks/` - Task management sub-feature
- `src/pages/` - Main application pages
- `src/services/` - API communication and business logic
- `src/hooks/` - Custom React hooks
- `src/contexts/` - React context providers
### UI Libraries
- **Radix UI** (@radix-ui/react-\*) - Unstyled, accessible primitives for `/features`
- **TanStack Query** - Data fetching and caching for `/features`
- **React DnD** - Drag and drop for Kanban boards
- **Tailwind CSS** - Utility-first styling with Tron-inspired glassmorphism
- **Framer Motion** - Animations (minimal usage)
### Theme Management
- **ThemeContext** - Manages light/dark theme state
- **Tailwind dark mode** - Uses `dark:` prefix with selector strategy
- **Automatic switching** - All components respect theme via Tailwind classes
- **Persistent** - Theme choice saved in localStorage
- **Tron aesthetic** - Stronger neon glows in dark mode, subtle in light mode
We're migrating to a vertical slice architecture where each feature is self-contained. Features are organized by domain hierarchy - main features contain their sub-features. For example, tasks are a sub-feature of projects, so they live at `features/projects/tasks/` rather than as separate siblings. Each feature level has its own components, hooks, types, and services folders. This keeps related code together and makes the codebase more maintainable as it scales.
### Backend Structure
- `src/server/` - Main FastAPI application
- `src/server/api_routes/` - API route handlers
- `src/server/services/` - Business logic services
- `src/mcp/` - MCP server implementation
- `src/agents/` - PydanticAI agent implementations
## Database Schema
Key tables in Supabase:
- `sources` - Crawled websites and uploaded documents
- `documents` - Processed document chunks with embeddings
- `projects` - Project management (optional feature)
- `tasks` - Task tracking linked to projects
- `code_examples` - Extracted code snippets
## API Naming Conventions
### Task Status Values
Use database values directly (no UI mapping):
- `todo`, `doing`, `review`, `done`
### Service Method Patterns
- `get[Resource]sByProject(projectId)` - Scoped queries
- `get[Resource](id)` - Single resource
- `create[Resource](data)` - Create operations
- `update[Resource](id, updates)` - Updates
- `delete[Resource](id)` - Soft deletes
### State Naming
- `is[Action]ing` - Loading states (e.g., `isSwitchingProject`)
- `[resource]Error` - Error messages
- `selected[Resource]` - Current selection
## Common Development Tasks
### Add a new API endpoint
1. Create route handler in `python/src/server/api_routes/`
2. Add service logic in `python/src/server/services/`
3. Include router in `python/src/server/main.py`
4. Update frontend service in `archon-ui-main/src/services/`
### Add a new UI component
For **features** directory (preferred for new components):
1. Use Radix UI primitives from `src/features/ui/primitives/`
2. Create component in relevant feature folder under `src/features/`
3. Use TanStack Query for data fetching
4. Apply Tron-inspired glassmorphism styling with Tailwind
For **legacy** components:
1. Create component in `archon-ui-main/src/components/`
2. Add to page in `archon-ui-main/src/pages/`
3. Include any new API calls in services
4. Add tests in `archon-ui-main/test/`
### Debug MCP connection issues
1. Check MCP health: `curl http://localhost:8051/health`
2. View MCP logs: `docker-compose logs archon-mcp`
3. Test tool execution via UI MCP page
4. Verify Supabase connection and credentials
## Code Quality Standards
We enforce code quality through automated linting and type checking:
- **Python 3.12** with 120 character line length
- **Ruff** for linting - checks for errors, warnings, unused imports, and code style
- **Mypy** for type checking - ensures type safety across the codebase
- **Auto-formatting** on save in IDEs to maintain consistent style
- Run `uv run ruff check` and `uv run mypy src/` locally before committing
## MCP Tools Available
When connected to Cursor/Windsurf:
- `archon:perform_rag_query` - Search knowledge base
- `archon:search_code_examples` - Find code snippets
- `archon:manage_project` - Project operations
- `archon:manage_task` - Task management
- `archon:get_available_sources` - List knowledge sources
## Important Notes
- Projects feature is optional - toggle in Settings UI
- All services communicate via HTTP, not gRPC
- HTTP polling handles all updates (Socket.IO removed)
- Frontend uses Vite proxy for API calls in development
- Python backend uses `uv` for dependency management
- Docker Compose handles service orchestration
- we use tanstack query NO PROP DRILLING! refacring in progress!

344
CLAUDE.md
View File

@@ -18,7 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
#### When to Fail Fast and Loud (Let it Crash!)
These errors should stop execution and bubble up immediately:
These errors should stop execution and bubble up immediately: (except for crawling flows)
- **Service startup failures** - If credentials, database, or any service can't initialize, the system should crash with a clear error
- **Missing configuration** - Missing environment variables or invalid settings should stop the system
@@ -102,16 +102,6 @@ def process_batch(items):
- Focus on user experience and feature completeness
- When updating code, don't reference what is changing (avoid keywords like LEGACY, CHANGED, REMOVED), instead focus on comments that document just the functionality of the code
## Architecture Overview
Archon V2 Alpha is a microservices-based knowledge management system with MCP (Model Context Protocol) integration:
- **Frontend (port 3737)**: React + TypeScript + Vite + TailwindCSS
- **Main Server (port 8181)**: FastAPI + Socket.IO for real-time updates
- **MCP Server (port 8051)**: Lightweight HTTP-based MCP protocol server
- **Agents Service (port 8052)**: PydanticAI agents for AI/ML operations
- **Database**: Supabase (PostgreSQL + pgvector for embeddings)
## Development Commands
### Frontend (archon-ui-main/)
@@ -119,112 +109,261 @@ Archon V2 Alpha is a microservices-based knowledge management system with MCP (M
```bash
npm run dev # Start development server on port 3737
npm run build # Build for production
npm run lint # Run ESLint
npm run test # Run Vitest tests
npm run test:coverage # Run tests with coverage report
npm run lint # Run ESLint on legacy code (excludes /features)
npm run lint:files path/to/file.tsx # Lint specific files
# Biome for /src/features directory only
npm run biome # Check features directory
npm run biome:fix # Auto-fix issues
npm run biome:format # Format code (120 char lines)
npm run biome:ai # Machine-readable JSON output for AI
npm run biome:ai-fix # Auto-fix with JSON output
# Testing
npm run test # Run all tests in watch mode
npm run test:ui # Run with Vitest UI interface
npm run test:coverage:stream # Run once with streaming output
vitest run src/features/projects # Test specific directory
# TypeScript
npx tsc --noEmit # Check all TypeScript errors
npx tsc --noEmit 2>&1 | grep "src/features" # Check features only
```
### Backend (python/)
```bash
# Using uv package manager
uv sync # Install/update dependencies
uv run pytest # Run tests
uv run python -m src.server.main # Run server locally
# Using uv package manager (preferred)
uv sync --group all # Install all dependencies
uv run python -m src.server.main # Run server locally on 8181
uv run pytest # Run all tests
uv run pytest tests/test_api_essentials.py -v # Run specific test
uv run ruff check # Run linter
uv run ruff check --fix # Auto-fix linting issues
uv run mypy src/ # Type check
# With Docker
docker-compose up --build -d # Start all services
docker-compose logs -f # View logs
docker-compose restart # Restart services
# Docker operations
docker compose up --build -d # Start all services
docker compose --profile backend up -d # Backend only (for hybrid dev)
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose restart archon-server # Restart after code changes
docker compose down # Stop all services
docker compose down -v # Stop and remove volumes
```
### Testing
### Quick Workflows
```bash
# Frontend tests (from archon-ui-main/)
npm run test:coverage:stream # Run with streaming output
npm run test:ui # Run with Vitest UI
# Hybrid development (recommended) - backend in Docker, frontend local
make dev # Or manually: docker compose --profile backend up -d && cd archon-ui-main && npm run dev
# Backend tests (from python/)
uv run pytest tests/test_api_essentials.py -v
uv run pytest tests/test_service_integration.py -v
# Full Docker mode
make dev-docker # Or: docker compose up --build -d
# Run linters before committing
make lint # Runs both frontend and backend linters
make lint-fe # Frontend only (ESLint + Biome)
make lint-be # Backend only (Ruff + MyPy)
# Testing
make test # Run all tests
make test-fe # Frontend tests only
make test-be # Backend tests only
```
## Key API Endpoints
## Architecture Overview
### Knowledge Base
Archon V2 Alpha is a microservices-based knowledge management system with MCP (Model Context Protocol) integration:
- `POST /api/knowledge/crawl` - Crawl a website
- `POST /api/knowledge/upload` - Upload documents (PDF, DOCX, MD)
- `GET /api/knowledge/items` - List knowledge items
- `POST /api/knowledge/search` - RAG search
### Service Architecture
### MCP Integration
- **Frontend (port 3737)**: React + TypeScript + Vite + TailwindCSS
- **Dual UI Strategy**:
- `/features` - Modern vertical slice with Radix UI primitives + TanStack Query
- `/components` - Legacy custom components (being migrated)
- **State Management**: TanStack Query for all data fetching (no prop drilling)
- **Styling**: Tron-inspired glassmorphism with Tailwind CSS
- **Linting**: Biome for `/features`, ESLint for legacy code
- `GET /api/mcp/health` - MCP server status
- `POST /api/mcp/tools/{tool_name}` - Execute MCP tool
- `GET /api/mcp/tools` - List available tools
- **Main Server (port 8181)**: FastAPI with HTTP polling for updates
- Handles all business logic, database operations, and external API calls
- WebSocket support removed in favor of HTTP polling with ETag caching
### Projects & Tasks (when enabled)
- **MCP Server (port 8051)**: Lightweight HTTP-based MCP protocol server
- Provides tools for AI assistants (Claude, Cursor, Windsurf)
- Exposes knowledge search, task management, and project operations
- `GET /api/projects` - List projects
- `POST /api/projects` - Create project
- `GET /api/projects/{id}/tasks` - Get project tasks
- `POST /api/projects/{id}/tasks` - Create task
- **Agents Service (port 8052)**: PydanticAI agents for AI/ML operations
- Handles complex AI workflows and document processing
## Socket.IO Events
- **Database**: Supabase (PostgreSQL + pgvector for embeddings)
- Cloud or local Supabase both supported
- pgvector for semantic search capabilities
Real-time updates via Socket.IO on port 8181:
### Frontend Architecture Details
- `crawl_progress` - Website crawling progress
- `project_creation_progress` - Project setup progress
- `task_update` - Task status changes
- `knowledge_update` - Knowledge base changes
#### Vertical Slice Architecture (/features)
## Environment Variables
Features are organized by domain hierarchy with self-contained modules:
Required in `.env`:
```bash
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key-here
```
src/features/
├── ui/
│ ├── primitives/ # Radix UI base components
│ ├── hooks/ # Shared UI hooks (useSmartPolling, etc)
│ └── types/ # UI type definitions
├── projects/
│ ├── components/ # Project UI components
│ ├── hooks/ # Project hooks (useProjectQueries, etc)
│ ├── services/ # Project API services
│ ├── types/ # Project type definitions
│ ├── tasks/ # Tasks sub-feature (nested under projects)
│ │ ├── components/
│ │ ├── hooks/ # Task-specific hooks
│ │ ├── services/ # Task API services
│ │ └── types/
│ └── documents/ # Documents sub-feature
│ ├── components/
│ ├── services/
│ └── types/
```
Optional:
#### TanStack Query Patterns
```bash
OPENAI_API_KEY=your-openai-key # Can be set via UI
LOGFIRE_TOKEN=your-logfire-token # For observability
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
All data fetching uses TanStack Query with consistent patterns:
```typescript
// Query keys factory pattern
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
};
// Smart polling with visibility awareness
const { refetchInterval } = useSmartPolling(10000); // Pauses when tab inactive
// Optimistic updates with rollback
useMutation({
onMutate: async (data) => {
await queryClient.cancelQueries(key);
const previous = queryClient.getQueryData(key);
queryClient.setQueryData(key, optimisticData);
return { previous };
},
onError: (err, vars, context) => {
if (context?.previous) {
queryClient.setQueryData(key, context.previous);
}
},
});
```
## File Organization
### Backend Architecture Details
### Frontend Structure
#### Service Layer Pattern
- `src/components/` - Reusable UI components
- `src/pages/` - Main application pages
- `src/services/` - API communication and business logic
- `src/hooks/` - Custom React hooks
- `src/contexts/` - React context providers
```python
# API Route -> Service -> Database
# src/server/api_routes/projects.py
@router.get("/{project_id}")
async def get_project(project_id: str):
return await project_service.get_project(project_id)
### Backend Structure
# src/server/services/project_service.py
async def get_project(project_id: str):
# Business logic here
return await db.fetch_project(project_id)
```
- `src/server/` - Main FastAPI application
- `src/server/api_routes/` - API route handlers
- `src/server/services/` - Business logic services
- `src/mcp/` - MCP server implementation
- `src/agents/` - PydanticAI agent implementations
#### Error Handling Patterns
```python
# Use specific exceptions
class ProjectNotFoundError(Exception): pass
class ValidationError(Exception): pass
# Rich error responses
@app.exception_handler(ProjectNotFoundError)
async def handle_not_found(request, exc):
return JSONResponse(
status_code=404,
content={"detail": str(exc), "type": "not_found"}
)
```
## Polling Architecture
### HTTP Polling (replaced Socket.IO)
- **Polling intervals**: 1-2s for active operations, 5-10s for background data
- **ETag caching**: Reduces bandwidth by ~70% via 304 Not Modified responses
- **Smart pausing**: Stops polling when browser tab is inactive
- **Progress endpoints**: `/api/progress/{id}` for operation tracking
### Key Polling Hooks
- `useSmartPolling` - Adjusts interval based on page visibility/focus
- `useCrawlProgressPolling` - Specialized for crawl progress with auto-cleanup
- `useProjectTasks` - Smart polling for task lists
## Database Schema
Key tables in Supabase:
- `sources` - Crawled websites and uploaded documents
- Stores metadata, crawl status, and configuration
- `documents` - Processed document chunks with embeddings
- Text chunks with vector embeddings for semantic search
- `projects` - Project management (optional feature)
- Contains features array, documents, and metadata
- `tasks` - Task tracking linked to projects
- Status: todo, doing, review, done
- Assignee: User, Archon, AI IDE Agent
- `code_examples` - Extracted code snippets
- Language, summary, and relevance metadata
## API Naming Conventions
### Task Status Values
Use database values directly (no UI mapping):
- `todo`, `doing`, `review`, `done`
### Service Method Patterns
- `get[Resource]sByProject(projectId)` - Scoped queries
- `get[Resource](id)` - Single resource
- `create[Resource](data)` - Create operations
- `update[Resource](id, updates)` - Updates
- `delete[Resource](id)` - Soft deletes
### State Naming
- `is[Action]ing` - Loading states (e.g., `isSwitchingProject`)
- `[resource]Error` - Error messages
- `selected[Resource]` - Current selection
## Environment Variables
Required in `.env`:
```bash
SUPABASE_URL=https://your-project.supabase.co # Or http://host.docker.internal:8000 for local
SUPABASE_SERVICE_KEY=your-service-key-here # Use legacy key format for cloud Supabase
```
Optional:
```bash
LOGFIRE_TOKEN=your-logfire-token # For observability
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
ARCHON_SERVER_PORT=8181 # Server port
ARCHON_MCP_PORT=8051 # MCP server port
ARCHON_UI_PORT=3737 # Frontend port
```
## Common Development Tasks
@@ -233,47 +372,72 @@ Key tables in Supabase:
1. Create route handler in `python/src/server/api_routes/`
2. Add service logic in `python/src/server/services/`
3. Include router in `python/src/server/main.py`
4. Update frontend service in `archon-ui-main/src/services/`
4. Update frontend service in `archon-ui-main/src/features/[feature]/services/`
### Add a new UI component
### Add a new UI component in features directory
1. Create component in `archon-ui-main/src/components/`
2. Add to page in `archon-ui-main/src/pages/`
3. Include any new API calls in services
4. Add tests in `archon-ui-main/test/`
1. Use Radix UI primitives from `src/features/ui/primitives/`
2. Create component in relevant feature folder under `src/features/[feature]/components/`
3. Define types in `src/features/[feature]/types/`
4. Use TanStack Query hook from `src/features/[feature]/hooks/`
5. Apply Tron-inspired glassmorphism styling with Tailwind
### Debug MCP connection issues
1. Check MCP health: `curl http://localhost:8051/health`
2. View MCP logs: `docker-compose logs archon-mcp`
2. View MCP logs: `docker compose logs archon-mcp`
3. Test tool execution via UI MCP page
4. Verify Supabase connection and credentials
### Fix TypeScript/Linting Issues
```bash
# TypeScript errors in features
npx tsc --noEmit 2>&1 | grep "src/features"
# Biome auto-fix for features
npm run biome:fix
# ESLint for legacy code
npm run lint:files src/components/SomeComponent.tsx
```
## Code Quality Standards
We enforce code quality through automated linting and type checking:
### Frontend
- **TypeScript**: Strict mode enabled, no implicit any
- **Biome** for `/src/features/`: 120 char lines, double quotes, trailing commas
- **ESLint** for legacy code: Standard React rules
- **Testing**: Vitest with React Testing Library
### Backend
- **Python 3.12** with 120 character line length
- **Ruff** for linting - checks for errors, warnings, unused imports, and code style
- **Mypy** for type checking - ensures type safety across the codebase
- **Auto-formatting** on save in IDEs to maintain consistent style
- Run `uv run ruff check` and `uv run mypy src/` locally before committing
- **Ruff** for linting - checks for errors, warnings, unused imports
- **Mypy** for type checking - ensures type safety
- **Pytest** for testing with async support
## MCP Tools Available
When connected to Cursor/Windsurf:
When connected to Client/Cursor/Windsurf:
- `archon:perform_rag_query` - Search knowledge base
- `archon:search_code_examples` - Find code snippets
- `archon:manage_project` - Project operations
- `archon:manage_task` - Task management
- `archon:create_project` - Create new project
- `archon:list_projects` - List all projects
- `archon:create_task` - Create task in project
- `archon:list_tasks` - List and filter tasks
- `archon:update_task` - Update task status/details
- `archon:get_available_sources` - List knowledge sources
## Important Notes
- Projects feature is optional - toggle in Settings UI
- All services communicate via HTTP, not gRPC
- Socket.IO handles all real-time updates
- HTTP polling handles all updates
- Frontend uses Vite proxy for API calls in development
- Python backend uses `uv` for dependency management
- Docker Compose handles service orchestration
- TanStack Query for all data fetching - NO PROP DRILLING
- Vertical slice architecture in `/features` - features own their sub-features

View File

@@ -0,0 +1,163 @@
# API Naming Conventions
## Overview
This document defines the naming conventions used throughout the Archon V2 codebase for consistency and clarity.
## Task Status Values
**Database values only - no UI mapping:**
- `todo` - Task is in backlog/todo state
- `doing` - Task is actively being worked on
- `review` - Task is pending review
- `done` - Task is completed
## Service Method Naming
### Project Service (`projectService.ts`)
#### Projects
- `listProjects()` - Get all projects
- `getProject(projectId)` - Get single project by ID
- `createProject(projectData)` - Create new project
- `updateProject(projectId, updates)` - Update project
- `deleteProject(projectId)` - Delete project
#### Tasks
- `getTasksByProject(projectId)` - Get all tasks for a specific project
- `getTask(taskId)` - Get single task by ID
- `createTask(taskData)` - Create new task
- `updateTask(taskId, updates)` - Update task with partial data
- `updateTaskStatus(taskId, status)` - Update only task status
- `updateTaskOrder(taskId, newOrder, newStatus?)` - Update task position/order
- `deleteTask(taskId)` - Delete task (soft delete/archive)
- `getTasksByStatus(status)` - Get all tasks with specific status
#### Documents
- `getDocuments(projectId)` - Get all documents for project
- `getDocument(projectId, docId)` - Get single document
- `createDocument(projectId, documentData)` - Create document
- `updateDocument(projectId, docId, updates)` - Update document
- `deleteDocument(projectId, docId)` - Delete document
#### Versions
- `createVersion(projectId, field, content)` - Create version snapshot
- `listVersions(projectId, fieldName?)` - List version history
- `getVersion(projectId, fieldName, versionNumber)` - Get specific version
- `restoreVersion(projectId, fieldName, versionNumber)` - Restore version
## API Endpoint Patterns
### RESTful Endpoints
```
GET /api/projects - List all projects
POST /api/projects - Create project
GET /api/projects/{project_id} - Get project
PUT /api/projects/{project_id} - Update project
DELETE /api/projects/{project_id} - Delete project
GET /api/projects/{project_id}/tasks - Get project tasks
POST /api/tasks - Create task (project_id in body)
GET /api/tasks/{task_id} - Get task
PUT /api/tasks/{task_id} - Update task
DELETE /api/tasks/{task_id} - Delete task
GET /api/projects/{project_id}/docs - Get project documents
POST /api/projects/{project_id}/docs - Create document
GET /api/projects/{project_id}/docs/{doc_id} - Get document
PUT /api/projects/{project_id}/docs/{doc_id} - Update document
DELETE /api/projects/{project_id}/docs/{doc_id} - Delete document
```
### Progress/Polling Endpoints
```
GET /api/progress/{operation_id} - Generic operation progress
GET /api/knowledge/crawl-progress/{id} - Crawling progress
GET /api/agent-chat/sessions/{id}/messages - Chat messages
```
## Component Naming
### Hooks
- `use[Feature]` - Custom hooks (e.g., `usePolling`, `useProjectMutation`)
- Returns object with: `{ data, isLoading, error, refetch }`
### Services
- `[feature]Service` - Service modules (e.g., `projectService`, `crawlProgressService`)
- Methods return Promises with typed responses
### Components
- `[Feature][Type]` - UI components (e.g., `TaskBoardView`, `EditTaskModal`)
- Props interfaces: `[Component]Props`
## State Variable Naming
### Loading States
- `isLoading[Feature]` - Boolean loading indicators
- `isSwitchingProject` - Specific operation states
- `movingTaskIds` - Set/Array of items being processed
### Error States
- `[feature]Error` - Error message strings
- `taskOperationError` - Specific operation errors
### Data States
- `[feature]s` - Plural for collections (e.g., `tasks`, `projects`)
- `selected[Feature]` - Currently selected item
- `[feature]Data` - Raw data from API
## Type Definitions
### Database Types (from backend)
```typescript
type DatabaseTaskStatus = 'todo' | 'doing' | 'review' | 'done';
type Assignee = 'User' | 'Archon' | 'AI IDE Agent';
```
### Request/Response Types
```typescript
Create[Feature]Request // e.g., CreateTaskRequest
Update[Feature]Request // e.g., UpdateTaskRequest
[Feature]Response // e.g., TaskResponse
```
## Function Naming Patterns
### Event Handlers
- `handle[Event]` - Generic handlers (e.g., `handleProjectSelect`)
- `on[Event]` - Props callbacks (e.g., `onTaskMove`, `onRefresh`)
### Operations
- `load[Feature]` - Fetch data (e.g., `loadTasksForProject`)
- `save[Feature]` - Persist changes (e.g., `saveTask`)
- `delete[Feature]` - Remove items (e.g., `deleteTask`)
- `refresh[Feature]` - Reload data (e.g., `refreshTasks`)
### Formatting/Transformation
- `format[Feature]` - Format for display (e.g., `formatTask`)
- `validate[Feature]` - Validate data (e.g., `validateUpdateTask`)
## Best Practices
### ✅ Do Use
- `getTasksByProject(projectId)` - Clear scope with context
- `status` - Single source of truth from database
- Direct database values everywhere (no mapping)
- Polling with `usePolling` hook for data fetching
- Async/await with proper error handling
- ETag headers for efficient polling
- Loading indicators during operations
## Current Architecture Patterns
### Polling & Data Fetching
- HTTP polling with `usePolling` and `useCrawlProgressPolling` hooks
- ETag-based caching for bandwidth efficiency
- Loading state indicators (`isLoading`, `isSwitchingProject`)
- Error toast notifications for user feedback
- Manual refresh triggers via `refetch()`
- Immediate UI updates followed by API calls
### Service Architecture
- Specialized services for different domains (`projectService`, `crawlProgressService`)
- Direct database value usage (no UI/DB mapping)
- Promise-based async operations
- Typed request/response interfaces

View File

@@ -0,0 +1,481 @@
# Archon Architecture
## Overview
Archon follows a **Vertical Slice Architecture** pattern where features are organized by business capability rather than technical layers. Each module is self-contained with its own API, business logic, and data access, making the system modular, maintainable, and ready for future microservice extraction if needed.
## Core Principles
1. **Feature Cohesion**: All code for a feature lives together
2. **Module Independence**: Modules communicate through well-defined interfaces
3. **Vertical Slices**: Each feature contains its complete stack (API → Service → Repository)
4. **Shared Minimal**: Only truly cross-cutting concerns go in shared
5. **Migration Ready**: Structure supports easy extraction to microservices
## Directory Structure
```
archon/
├── python/
│ ├── src/
│ │ ├── knowledge/ # Knowledge Management Module
│ │ │ ├── __init__.py
│ │ │ ├── main.py # Knowledge module entry point
│ │ │ ├── shared/ # Shared within knowledge context
│ │ │ │ ├── models.py
│ │ │ │ ├── exceptions.py
│ │ │ │ └── utils.py
│ │ │ └── features/ # Knowledge feature slices
│ │ │ ├── crawling/ # Web crawling feature
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Crawl endpoints
│ │ │ │ ├── service.py # Crawling orchestration
│ │ │ │ ├── models.py # Crawl-specific models
│ │ │ │ ├── repository.py # Crawl data storage
│ │ │ │ └── tests/
│ │ │ ├── document_processing/ # Document upload & processing
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Upload endpoints
│ │ │ │ ├── service.py # PDF/DOCX processing
│ │ │ │ ├── extractors.py # Text extraction
│ │ │ │ └── tests/
│ │ │ ├── embeddings/ # Vector embeddings
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Embedding endpoints
│ │ │ │ ├── service.py # OpenAI/local embeddings
│ │ │ │ ├── models.py
│ │ │ │ └── repository.py # Vector storage
│ │ │ ├── search/ # RAG search
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Search endpoints
│ │ │ │ ├── service.py # Search algorithms
│ │ │ │ ├── reranker.py # Result reranking
│ │ │ │ └── tests/
│ │ │ ├── code_extraction/ # Code snippet extraction
│ │ │ │ ├── __init__.py
│ │ │ │ ├── service.py # Code parsing
│ │ │ │ ├── analyzers.py # Language detection
│ │ │ │ └── repository.py
│ │ │ └── source_management/ # Knowledge source CRUD
│ │ │ ├── __init__.py
│ │ │ ├── api.py
│ │ │ ├── service.py
│ │ │ └── repository.py
│ │ │
│ │ ├── projects/ # Project Management Module
│ │ │ ├── __init__.py
│ │ │ ├── main.py # Projects module entry point
│ │ │ ├── shared/ # Shared within projects context
│ │ │ │ ├── database.py # Project DB utilities
│ │ │ │ ├── models.py # Shared project models
│ │ │ │ └── exceptions.py # Project-specific exceptions
│ │ │ └── features/ # Project feature slices
│ │ │ ├── project_management/ # Project CRUD
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Project endpoints
│ │ │ │ ├── service.py # Project business logic
│ │ │ │ ├── models.py # Project models
│ │ │ │ ├── repository.py # Project DB operations
│ │ │ │ └── tests/
│ │ │ ├── task_management/ # Task CRUD
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Task endpoints
│ │ │ │ ├── service.py # Task business logic
│ │ │ │ ├── models.py # Task models
│ │ │ │ ├── repository.py # Task DB operations
│ │ │ │ └── tests/
│ │ │ ├── task_ordering/ # Drag-and-drop reordering
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Reorder endpoints
│ │ │ │ ├── service.py # Reordering algorithm
│ │ │ │ └── tests/
│ │ │ ├── document_management/ # Project documents
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Document endpoints
│ │ │ │ ├── service.py # Document logic
│ │ │ │ ├── models.py
│ │ │ │ └── repository.py
│ │ │ ├── document_versioning/ # Version control
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Version endpoints
│ │ │ │ ├── service.py # Versioning logic
│ │ │ │ ├── models.py # Version models
│ │ │ │ └── repository.py # Version storage
│ │ │ ├── ai_generation/ # AI project creation
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Generate endpoints
│ │ │ │ ├── service.py # AI orchestration
│ │ │ │ ├── agents.py # Agent interactions
│ │ │ │ ├── progress.py # Progress tracking
│ │ │ │ └── prompts.py # Generation prompts
│ │ │ ├── source_linking/ # Link to knowledge base
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api.py # Link endpoints
│ │ │ │ ├── service.py # Linking logic
│ │ │ │ └── repository.py # Junction table ops
│ │ │ └── bulk_operations/ # Batch updates
│ │ │ ├── __init__.py
│ │ │ ├── api.py # Bulk endpoints
│ │ │ ├── service.py # Batch processing
│ │ │ └── tests/
│ │ │
│ │ ├── mcp_server/ # MCP Protocol Server (IDE Integration)
│ │ │ ├── __init__.py
│ │ │ ├── main.py # MCP server entry point
│ │ │ ├── server.py # FastMCP server setup
│ │ │ ├── features/ # MCP tool implementations
│ │ │ │ ├── projects/ # Project tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── project_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ ├── tasks/ # Task tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── task_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ ├── documents/ # Document tools for IDEs
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── document_tools.py
│ │ │ │ │ ├── version_tools.py
│ │ │ │ │ └── tests/
│ │ │ │ └── feature_tools.py # Feature management
│ │ │ ├── modules/ # MCP modules
│ │ │ │ └── archon.py # Main Archon MCP module
│ │ │ └── utils/ # MCP utilities
│ │ │ └── tool_utils.py
│ │ │
│ │ ├── agents/ # AI Agents Module
│ │ │ ├── __init__.py
│ │ │ ├── main.py # Agents module entry point
│ │ │ ├── config.py # Agent configurations
│ │ │ ├── features/ # Agent capabilities
│ │ │ │ ├── document_agent/ # Document processing agent
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── agent.py # PydanticAI agent
│ │ │ │ │ ├── prompts.py # Agent prompts
│ │ │ │ │ └── tools.py # Agent tools
│ │ │ │ ├── code_agent/ # Code analysis agent
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── agent.py
│ │ │ │ │ └── analyzers.py
│ │ │ │ └── project_agent/ # Project creation agent
│ │ │ │ ├── __init__.py
│ │ │ │ ├── agent.py
│ │ │ │ ├── prp_generator.py
│ │ │ │ └── task_generator.py
│ │ │ └── shared/ # Shared agent utilities
│ │ │ ├── base_agent.py
│ │ │ ├── llm_client.py
│ │ │ └── response_models.py
│ │ │
│ │ ├── shared/ # Shared Across All Modules
│ │ │ ├── database/ # Database utilities
│ │ │ │ ├── __init__.py
│ │ │ │ ├── supabase.py # Supabase client
│ │ │ │ ├── migrations.py # DB migrations
│ │ │ │ └── connection_pool.py
│ │ │ ├── auth/ # Authentication
│ │ │ │ ├── __init__.py
│ │ │ │ ├── api_keys.py
│ │ │ │ └── permissions.py
│ │ │ ├── config/ # Configuration
│ │ │ │ ├── __init__.py
│ │ │ │ ├── settings.py # Environment settings
│ │ │ │ └── logfire_config.py # Logging config
│ │ │ ├── middleware/ # HTTP middleware
│ │ │ │ ├── __init__.py
│ │ │ │ ├── cors.py
│ │ │ │ └── error_handler.py
│ │ │ └── utils/ # General utilities
│ │ │ ├── __init__.py
│ │ │ ├── datetime_utils.py
│ │ │ └── json_utils.py
│ │ │
│ │ └── main.py # Application orchestrator
│ │
│ └── tests/ # Integration tests
│ ├── test_api_essentials.py
│ ├── test_service_integration.py
│ └── fixtures/
├── archon-ui-main/ # Frontend Application
│ ├── src/
│ │ ├── pages/ # Page components
│ │ │ ├── KnowledgeBasePage.tsx
│ │ │ ├── ProjectPage.tsx
│ │ │ ├── SettingsPage.tsx
│ │ │ └── MCPPage.tsx
│ │ ├── components/ # Reusable components
│ │ │ ├── knowledge-base/ # Knowledge features
│ │ │ ├── project-tasks/ # Project features
│ │ │ └── ui/ # Shared UI components
│ │ ├── services/ # API services
│ │ │ ├── api.ts # Base API client
│ │ │ ├── knowledgeBaseService.ts
│ │ │ ├── projectService.ts
│ │ │ └── pollingService.ts # New polling utilities
│ │ ├── hooks/ # React hooks
│ │ │ ├── usePolling.ts # Polling hook
│ │ │ ├── useDatabaseMutation.ts # DB-first mutations
│ │ │ └── useAsyncAction.ts
│ │ └── contexts/ # React contexts
│ │ ├── ToastContext.tsx
│ │ └── ThemeContext.tsx
│ │
│ └── tests/ # Frontend tests
├── PRPs/ # Product Requirement Prompts
│ ├── templates/ # PRP templates
│ ├── ai_docs/ # AI context documentation
│ └── *.md # Feature PRPs
├── docs/ # Documentation
│ └── architecture/ # Architecture decisions
└── docker/ # Docker configurations
├── Dockerfile
└── docker-compose.yml
```
## Module Descriptions
### Knowledge Module (`src/knowledge/`)
Core knowledge management functionality including web crawling, document processing, embeddings, and RAG search. This is the heart of Archon's knowledge engine.
**Key Features:**
- Web crawling with JavaScript rendering
- Document upload and text extraction
- Vector embeddings and similarity search
- Code snippet extraction and indexing
- Source management and organization
### Projects Module (`src/projects/`)
Project and task management system with AI-powered project generation. Currently optional via feature flag.
**Key Features:**
- Project CRUD operations
- Task management with drag-and-drop ordering
- Document management with versioning
- AI-powered project generation
- Integration with knowledge base sources
### MCP Server Module (`src/mcp_server/`)
Model Context Protocol server that exposes Archon functionality to IDEs like Cursor and Windsurf.
**Key Features:**
- Tool-based API for IDE integration
- Project and task management tools
- Document operations
- Async operation support
### Agents Module (`src/agents/`)
AI agents powered by PydanticAI for intelligent document processing and project generation.
**Key Features:**
- Document analysis and summarization
- Code understanding and extraction
- Project requirement generation
- Task breakdown and planning
### Shared Module (`src/shared/`)
Cross-cutting concerns shared across all modules. Kept minimal to maintain module independence.
**Key Components:**
- Database connections and utilities
- Authentication and authorization
- Configuration management
- Logging and observability
- Common middleware
## Communication Patterns
### Inter-Module Communication
Modules communicate through:
1. **Direct HTTP API Calls** (current)
- Projects module calls Knowledge module APIs
- Simple and straightforward
- Works well for current scale
2. **Event Bus** (future consideration)
```python
# Example event-driven communication
await event_bus.publish("project.created", {
"project_id": "123",
"created_by": "user"
})
```
3. **Shared Database** (current reality)
- All modules use same Supabase instance
- Direct foreign keys between contexts
- Will need refactoring for true microservices
## Feature Flags
Features can be toggled via environment variables:
```python
# settings.py
PROJECTS_ENABLED = env.bool("PROJECTS_ENABLED", default=False)
TASK_ORDERING_ENABLED = env.bool("TASK_ORDERING_ENABLED", default=True)
AI_GENERATION_ENABLED = env.bool("AI_GENERATION_ENABLED", default=True)
```
## Database Architecture
Currently using a shared Supabase (PostgreSQL) database:
```sql
-- Knowledge context tables
sources
documents
code_examples
-- Projects context tables
archon_projects
archon_tasks
archon_document_versions
-- Cross-context junction tables
archon_project_sources -- Links projects to knowledge
```
## API Structure
Each feature exposes its own API routes:
```
/api/knowledge/
/crawl # Web crawling
/upload # Document upload
/search # RAG search
/sources # Source management
/api/projects/
/projects # Project CRUD
/tasks # Task management
/tasks/reorder # Task ordering
/documents # Document management
/generate # AI generation
```
## Deployment Architecture
### Current mixed
### Future (service modules)
Each module can become its own service:
```yaml
# docker-compose.yml (future)
services:
knowledge:
image: archon-knowledge
ports: ["8001:8000"]
projects:
image: archon-projects
ports: ["8002:8000"]
mcp-server:
image: archon-mcp
ports: ["8051:8051"]
agents:
image: archon-agents
ports: ["8052:8052"]
```
## Migration Path
### Phase 1: Current State (Modules/service)
- All code in one repository
- Shared database
- Single deployment
### Phase 2: Vertical Slices
- Reorganize by feature
- Clear module boundaries
- Feature flags for control
## Development Guidelines
### Adding a New Feature
1. **Identify the Module**: Which bounded context does it belong to?
2. **Create Feature Slice**: New folder under `module/features/`
3. **Implement Vertical Slice**:
- `api.py` - HTTP endpoints
- `service.py` - Business logic
- `models.py` - Data models
- `repository.py` - Data access
- `tests/` - Feature tests
### Testing Strategy
- **Unit Tests**: Each feature has its own tests
- **Integration Tests**: Test module boundaries
- **E2E Tests**: Test complete user flows
### Code Organization Rules
1. **Features are Self-Contained**: All code for a feature lives together
2. **No Cross-Feature Imports**: Use module's shared or API calls
3. **Shared is Minimal**: Only truly cross-cutting concerns
4. **Dependencies Point Inward**: Features → Module Shared → Global Shared
## Technology Stack
### Backend
- **FastAPI**: Web framework
- **Supabase**: Database and auth
- **PydanticAI**: AI agents
- **OpenAI**: Embeddings and LLM
- **Crawl4AI**: Web crawling
### Frontend
- **React**: UI framework
- **TypeScript**: Type safety
- **TailwindCSS**: Styling
- **React Query**: Data fetching
- **Vite**: Build tool
### Infrastructure
- **Docker**: Containerization
- **PostgreSQL**: Database (via Supabase, desire to support any PostgreSQL)
- **pgvector**: Vector storage, Desire to support ChromaDB, Pinecone, Weaviate, etc.
## Future Considerations
### Planned Improvements
1. **Remove Socket.IO**: Replace with polling (in progress)
2. **API Gateway**: Central entry point for all services
3. **Separate Databases**: One per bounded context
### Scalability Path
1. **Vertical Scaling**: Current approach, works for single-user
2. **Horizontal Scaling**: Add load balancer and multiple instances
---
This architecture provides a clear path from the current monolithic application to a more modular approach with vertical slicing, for easy potential to service separation if needed.

View File

@@ -0,0 +1,39 @@
# ETag Implementation
## Current Implementation
Our ETag implementation provides efficient HTTP caching for polling endpoints to reduce bandwidth usage.
### What It Does
- **Generates ETags**: Creates MD5 hashes of JSON response data
- **Checks ETags**: Simple string equality comparison between client's `If-None-Match` header and current data's ETag
- **Returns 304**: When ETags match, returns `304 Not Modified` with no body (saves bandwidth)
### How It Works
1. Server generates ETag from response data using MD5 hash
2. Client sends previous ETag in `If-None-Match` header
3. Server compares ETags:
- **Match**: Returns 304 (no body)
- **No match**: Returns 200 with new data and new ETag
### Example
```python
# Server generates: ETag: "a3c2f1e4b5d6789"
# Client sends: If-None-Match: "a3c2f1e4b5d6789"
# Server returns: 304 Not Modified (no body)
```
## Limitations
Our implementation is simplified and doesn't support full RFC 7232 features:
- ❌ Wildcard (`*`) matching
- ❌ Multiple ETags (`"etag1", "etag2"`)
- ❌ Weak validators (`W/"etag"`)
- ✅ Single ETag comparison only
This works perfectly for our browser-to-API polling use case but may need enhancement for CDN/proxy support.
## Files
- Implementation: `python/src/server/utils/etag_utils.py`
- Tests: `python/tests/server/utils/test_etag_utils.py`
- Used in: Progress API, Projects API polling endpoints

View File

@@ -0,0 +1,194 @@
# Polling Architecture Documentation
## Overview
Archon V2 uses HTTP polling instead of WebSockets for real-time updates. This simplifies the architecture, reduces complexity, and improves maintainability while providing adequate responsiveness for project management tasks.
## Core Components
### 1. usePolling Hook (`archon-ui-main/src/hooks/usePolling.ts`)
Generic polling hook that manages periodic data fetching with smart optimizations.
**Key Features:**
- Configurable polling intervals (default: 3 seconds)
- Automatic pause during browser tab inactivity
- ETag-based caching to reduce bandwidth
- Manual refresh capability
**Usage:**
```typescript
const { data, isLoading, error, refetch } = usePolling('/api/projects', {
interval: 5000,
enabled: true,
onSuccess: (data) => console.log('Projects updated:', data)
});
```
### 2. Specialized Progress Services
Individual services handle specific progress tracking needs:
**CrawlProgressService (`archon-ui-main/src/services/crawlProgressService.ts`)**
- Tracks website crawling operations
- Maps backend status to UI-friendly format
- Includes in-flight request guard to prevent overlapping fetches
- 1-second polling interval during active crawls
**Polling Endpoints:**
- `/api/projects` - Project list updates
- `/api/projects/{project_id}/tasks` - Task list for active project
- `/api/crawl-progress/{progress_id}` - Website crawling progress
- `/api/agent-chat/sessions/{session_id}/messages` - Chat messages
## Backend Support
### ETag Implementation (`python/src/server/utils/etag_utils.py`)
Server-side optimization to reduce unnecessary data transfer.
**How it works:**
1. Server generates ETag hash from response data
2. Client sends `If-None-Match` header with cached ETag
3. Server returns 304 Not Modified if data unchanged
4. Client uses cached data, reducing bandwidth by ~70%
### Progress API (`python/src/server/api_routes/progress_api.py`)
Dedicated endpoints for progress tracking:
- `GET /api/crawl-progress/{progress_id}` - Returns crawling status with ETag support
- Includes completion percentage, current step, and error details
## State Management
### Loading States
Visual feedback during operations:
- `movingTaskIds: Set<string>` - Tracks tasks being moved
- `isSwitchingProject: boolean` - Project transition state
- Loading overlays prevent concurrent operations
## Error Handling
### Retry Strategy
```typescript
retryCount: 3
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000)
```
- Exponential backoff: 1s, 2s, 4s...
- Maximum retry delay: 30 seconds
- Automatic recovery after network issues
### User Feedback
- Toast notifications for errors
- Loading spinners during operations
- Clear error messages with recovery actions
## Performance Optimizations
### 1. Request Deduplication
Prevents multiple components from making identical requests:
```typescript
const cacheKey = `${endpoint}-${JSON.stringify(params)}`;
if (pendingRequests.has(cacheKey)) {
return pendingRequests.get(cacheKey);
}
```
### 2. Smart Polling Intervals
- Active operations: 1-2 second intervals
- Background data: 5-10 second intervals
- Paused when tab inactive (visibility API)
### 3. Selective Updates
Only polls active/relevant data:
- Tasks poll only for selected project
- Progress polls only during active operations
- Chat polls only for open sessions
## Architecture Benefits
### What We Have
- **Simple HTTP polling** - Standard request/response pattern
- **Automatic error recovery** - Built-in retry with exponential backoff
- **ETag caching** - 70% bandwidth reduction via 304 responses
- **Easy debugging** - Standard HTTP requests visible in DevTools
- **No connection limits** - Scales with standard HTTP infrastructure
- **Consolidated polling hooks** - Single pattern for all data fetching
### Trade-offs
- **Latency:** 1-5 second delay vs instant updates
- **Bandwidth:** More requests, but mitigated by ETags
- **Battery:** Slightly higher mobile battery usage
## Developer Guidelines
### Adding New Polling Endpoint
1. **Frontend - Use the usePolling hook:**
```typescript
// In your component or custom hook
const { data, isLoading, error, refetch } = usePolling('/api/new-endpoint', {
interval: 5000,
enabled: true,
staleTime: 2000
});
```
2. **Backend - Add ETag support:**
```python
from ..utils.etag_utils import generate_etag, check_etag
@router.get("/api/new-endpoint")
async def get_data(request: Request):
data = fetch_data()
etag = generate_etag(data)
if check_etag(request, etag):
return Response(status_code=304)
return JSONResponse(
content=data,
headers={"ETag": etag}
)
```
3. **For progress tracking, use useCrawlProgressPolling:**
```typescript
const { data, isLoading } = useCrawlProgressPolling(operationId, {
onSuccess: (data) => {
if (data.status === 'completed') {
// Handle completion
}
}
});
```
### Best Practices
1. **Always provide loading states** - Users should know when data is updating
2. **Handle errors gracefully** - Show toast notifications with clear messages
3. **Respect polling intervals** - Don't poll faster than necessary
4. **Clean up on unmount** - Cancel pending requests when components unmount
5. **Use ETag caching** - Reduce bandwidth with 304 responses
## Testing Polling Behavior
### Manual Testing
1. Open Network tab in DevTools
2. Look for requests with 304 status (cache hits)
3. Verify polling stops when switching tabs
4. Test error recovery by stopping backend
### Debugging Tips
- Check `localStorage` for cached ETags
- Monitor `console.log` for polling lifecycle events
- Use React DevTools to inspect hook states
- Watch for memory leaks in long-running sessions
## Future Improvements
### Planned Enhancements
- WebSocket fallback for critical updates
- Configurable per-user polling rates
- Smart polling based on user activity patterns
- GraphQL subscriptions for selective field updates
### Considered Alternatives
- Server-Sent Events (SSE) - One-way real-time updates
- Long polling - Reduced request frequency
- WebRTC data channels - P2P updates between clients

View File

@@ -0,0 +1,148 @@
# Optimistic Updates Pattern (Future State)
**⚠️ STATUS:** This is not currently implemented. There is a proofofconcept (POC) on the frontend Project page. This document describes the desired future state for handling optimistic updates in a simple, consistent way.
## Mental Model
Think of optimistic updates as "assuming success" - update the UI immediately for instant feedback, then verify with the server. If something goes wrong, revert to the last known good state.
## The Pattern
```typescript
// 1. Save current state (for rollback) — take an immutable snapshot
const previousState = structuredClone(currentState);
// 2. Update UI immediately
setState(newState);
// 3. Call API
try {
const serverState = await api.updateResource(newState);
// Success — use server as the source of truth
setState(serverState);
} catch (error) {
// 4. Rollback on failure
setState(previousState);
showToast("Failed to update. Reverted changes.", "error");
}
```
## Implementation Approach
### Simple Hook Pattern
```typescript
function useOptimistic<T>(initialValue: T, updateFn: (value: T) => Promise<T>) {
const [value, setValue] = useState(initialValue);
const [isUpdating, setIsUpdating] = useState(false);
const previousValueRef = useRef<T>(initialValue);
const opSeqRef = useRef(0); // monotonically increasing op id
const mountedRef = useRef(true); // avoid setState after unmount
useEffect(() => () => { mountedRef.current = false; }, []);
const optimisticUpdate = async (newValue: T) => {
const opId = ++opSeqRef.current;
// Save for rollback
previousValueRef.current = value;
// Update immediately
if (mountedRef.current) setValue(newValue);
if (mountedRef.current) setIsUpdating(true);
try {
const result = await updateFn(newValue);
// Apply only if latest op and still mounted
if (mountedRef.current && opId === opSeqRef.current) {
setValue(result); // Server is source of truth
}
} catch (error) {
// Rollback
if (mountedRef.current && opId === opSeqRef.current) {
setValue(previousValueRef.current);
}
throw error;
} finally {
if (mountedRef.current && opId === opSeqRef.current) {
setIsUpdating(false);
}
}
};
return { value, optimisticUpdate, isUpdating };
}
```
### Usage Example
```typescript
// In a component
const {
value: task,
optimisticUpdate,
isUpdating,
} = useOptimistic(initialTask, (task) =>
projectService.updateTask(task.id, task),
);
// Handle user action
const handleStatusChange = (newStatus: string) => {
optimisticUpdate({ ...task, status: newStatus }).catch((error) =>
showToast("Failed to update task", "error"),
);
};
```
## Key Principles
1. **Keep it simple** — save, update, roll back.
2. **Server is the source of truth** — always use the server response as the final state.
3. **User feedback** — show loading states and clear error messages.
4. **Selective usage** — only where instant feedback matters:
- Draganddrop
- Status changes
- Toggle switches
- Quick edits
## What NOT to Do
- Don't track complex state histories
- Don't try to merge conflicts
- Use with caution for create/delete operations. If used, generate temporary client IDs, reconcile with serverassigned IDs, ensure idempotency, and define clear rollback/error states. Prefer nonoptimistic flows when side effects are complex.
- Don't over-engineer with queues or reconciliation
## When to Implement
Implement optimistic updates when:
- Users complain about UI feeling "slow"
- Drag-and-drop or reordering feels laggy
- Quick actions (like checkbox toggles) feel unresponsive
- Network latency is noticeable (> 200ms)
## Success Metrics
When implemented correctly:
- UI feels instant (< 100ms response)
- Rollbacks are rare (< 1% of updates)
- Error messages are clear
- Users understand what happened when things fail
## Production Considerations
The examples above are simplified for clarity. Production implementations should consider:
1. **Deep cloning**: Use `structuredClone()` or a deep clone utility for complex state
```typescript
const previousState = structuredClone(currentState); // Proper deep clone
```
2. **Race conditions**: Handle out-of-order responses with operation IDs
3. **Unmount safety**: Avoid setState after component unmount
4. **Debouncing**: For rapid updates (e.g., sliders), debounce API calls
5. **Conflict resolution**: For collaborative editing, consider operational transforms
6. **Polling/ETag interplay**: When polling, ignore stale responses (e.g., compare opId or Last-Modified) and rely on ETag/304 to prevent flicker overriding optimistic state.
7. **Idempotency & retries**: Use idempotency keys on write APIs so client retries (or duplicate submits) don't create duplicate effects.
These complexities are why we recommend starting simple and only adding optimistic updates where the UX benefit is clear.

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

@@ -6,28 +6,119 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
ignorePatterns: [
'dist',
'.eslintrc.cjs',
'public',
'__mocks__',
'*.config.js',
'*.config.ts',
'coverage',
'node_modules',
'src/features/**' // Biome handles this directory
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react-refresh'],
rules: {
/**
* LINTING STRATEGY FOR ALPHA DEVELOPMENT:
*
* Development: Warnings don't block local development, allowing rapid iteration
* CI/PR: Run with --max-warnings 0 to treat warnings as errors before merge
*
* Philosophy:
* - Strict typing where it helps AI assistants (Claude Code, Copilot, etc.)
* - Pragmatic flexibility for alpha-stage rapid development
* - Console.log allowed locally but caught in CI
* - Progressive enhancement: stricter rules in /features (new code) vs /components (legacy)
*/
// React Refresh
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['warn', {
// TypeScript - Pragmatic strictness for AI-assisted development
'@typescript-eslint/no-explicit-any': 'warn', // Visible but won't block development
'@typescript-eslint/no-non-null-assertion': 'warn', // Allow when developer is certain
'@typescript-eslint/no-empty-function': 'warn', // Sometimes needed for placeholders
'@typescript-eslint/ban-types': 'error', // Keep strict - prevents real issues
// Help AI assistants understand code intent
'@typescript-eslint/explicit-function-return-type': ['warn', {
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
}],
// Better TypeScript patterns
'@typescript-eslint/prefer-as-const': 'error',
// Variable and import management - strict with escape hatches
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
ignoreRestSiblings: true,
destructuredArrayIgnorePattern: '^_'
}],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-inferrable-types': 'off',
// React hooks - warn to allow intentional omissions during development
'react-hooks/exhaustive-deps': 'warn',
'no-case-declarations': 'off',
'no-constant-condition': 'warn',
'prefer-const': 'warn',
'no-undef': 'off',
// Console usage - warn locally, CI treats as error
'no-console': ['warn', { allow: ['error', 'warn'] }], // console.log caught but not blocking
// General code quality
'prefer-const': 'error',
'no-var': 'error',
'no-constant-condition': 'error',
'no-debugger': 'warn', // Warn in dev, error in CI
'no-alert': 'error',
// Disable rules that conflict with TypeScript
'no-undef': 'off', // TypeScript handles this better
'no-unused-vars': 'off', // Use @typescript-eslint/no-unused-vars instead
},
}
// Override rules for specific file types and directories
overrides: [
{
// Stricter rules for new vertical slice architecture
files: ['src/features/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'error', // No any in new code
'@typescript-eslint/explicit-function-return-type': ['error', {
allowExpressions: true,
allowTypedFunctionExpressions: true,
}],
'no-console': ['error', { allow: ['error', 'warn'] }], // Stricter console usage
}
},
{
// More lenient for legacy components being migrated
files: ['src/components/**/*.{ts,tsx}', 'src/services/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn', // Still visible during migration
'@typescript-eslint/explicit-function-return-type': 'off', // Not required for legacy
'no-console': 'warn', // Warn during migration
}
},
{
// Test files - most lenient but still helpful
files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'test/**/*'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn', // OK in tests but still visible
'@typescript-eslint/no-non-null-assertion': 'off', // Fine in tests
'@typescript-eslint/no-empty-function': 'off', // Mock functions need this
'@typescript-eslint/explicit-function-return-type': 'off',
'no-console': 'off', // Debugging in tests is fine
}
}
]
};

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

41
archon-ui-main/biome.json Normal file
View File

@@ -0,0 +1,41 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"files": {
"includes": ["src/features/**", "src/components/layout/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120,
"bracketSpacing": true,
"attributePosition": "auto"
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on"
}
}
}
}
}

View File

@@ -1,255 +0,0 @@
# Socket & Memoization Patterns
## Quick Reference
### DO:
- ✅ Track optimistic updates to prevent double-renders
- ✅ Memoize socket event handlers with useCallback
- ✅ Check if incoming data actually differs from current state
- ✅ Use debouncing for rapid UI updates (drag & drop)
- ✅ Clean up socket listeners in useEffect cleanup
### DON'T:
- ❌ Update state without checking if data changed
- ❌ Create new handler functions on every render
- ❌ Apply server updates that match pending optimistic updates
- ❌ Forget to handle the "modal open" edge case
## Pattern Examples
### Optimistic Update Pattern
```typescript
import { useOptimisticUpdates } from '../../hooks/useOptimisticUpdates';
const MyComponent = () => {
const { addPendingUpdate, isPendingUpdate } = useOptimisticUpdates<Task>();
const handleLocalUpdate = (task: Task) => {
// Track the optimistic update
addPendingUpdate({
id: task.id,
timestamp: Date.now(),
data: task,
operation: 'update'
});
// Update local state immediately
setTasks(prev => prev.map(t => t.id === task.id ? task : t));
// Persist to server
api.updateTask(task);
};
const handleServerUpdate = useCallback((task: Task) => {
// Skip if this is our own update echoing back
if (isPendingUpdate(task.id, task)) {
console.log('Skipping own optimistic update');
return;
}
// Apply server update
setTasks(prev => prev.map(t => t.id === task.id ? task : t));
}, [isPendingUpdate]);
};
```
### Socket Handler Pattern
```typescript
import { useSocketSubscription } from '../../hooks/useSocketSubscription';
const MyComponent = () => {
// Option 1: Using the hook
useSocketSubscription(
socketService,
'data_updated',
(data) => {
console.log('Data updated:', data);
// Handle update
},
[/* dependencies */]
);
// Option 2: Manual memoization
const handleUpdate = useCallback((message: any) => {
const data = message.data || message;
setItems(prev => {
// Check if data actually changed
const existing = prev.find(item => item.id === data.id);
if (existing && JSON.stringify(existing) === JSON.stringify(data)) {
return prev; // No change, prevent re-render
}
return prev.map(item => item.id === data.id ? data : item);
});
}, []);
useEffect(() => {
socketService.addMessageHandler('update', handleUpdate);
return () => {
socketService.removeMessageHandler('update', handleUpdate);
};
}, [handleUpdate]);
};
```
### Debounced Reordering Pattern
```typescript
const useReordering = () => {
const debouncedPersist = useMemo(
() => debounce(async (items: Item[]) => {
try {
await api.updateOrder(items);
} catch (error) {
console.error('Failed to persist order:', error);
// Rollback or retry logic
}
}, 500),
[]
);
const handleReorder = useCallback((dragIndex: number, dropIndex: number) => {
// Update UI immediately
setItems(prev => {
const newItems = [...prev];
const [draggedItem] = newItems.splice(dragIndex, 1);
newItems.splice(dropIndex, 0, draggedItem);
// Update order numbers
return newItems.map((item, index) => ({
...item,
order: index + 1
}));
});
// Persist changes (debounced)
debouncedPersist(items);
}, [items, debouncedPersist]);
};
```
## WebSocket Service Configuration
### Deduplication
The enhanced WebSocketService now includes automatic deduplication:
```typescript
// Configure deduplication window (default: 100ms)
socketService.setDeduplicationWindow(200); // 200ms window
// Duplicate messages within the window are automatically filtered
```
### Connection Management
```typescript
// Always check connection state before critical operations
if (socketService.isConnected()) {
socketService.send({ type: 'update', data: payload });
}
// Monitor connection state
socketService.addStateChangeHandler((state) => {
if (state === WebSocketState.CONNECTED) {
console.log('Connected - refresh data');
}
});
```
## Common Patterns
### 1. State Equality Checks
Always check if incoming data actually differs from current state:
```typescript
// ❌ BAD - Always triggers re-render
setTasks(prev => prev.map(t => t.id === id ? newTask : t));
// ✅ GOOD - Only updates if changed
setTasks(prev => {
const existing = prev.find(t => t.id === id);
if (existing && deepEqual(existing, newTask)) return prev;
return prev.map(t => t.id === id ? newTask : t);
});
```
### 2. Modal State Handling
Be aware of modal state when applying updates:
```typescript
const handleSocketUpdate = useCallback((data) => {
if (isModalOpen && editingItem?.id === data.id) {
console.warn('Update received while editing - consider skipping or merging');
// Option 1: Skip the update
// Option 2: Merge with current edits
// Option 3: Show conflict resolution UI
}
// Normal update flow
}, [isModalOpen, editingItem]);
```
### 3. Cleanup Pattern
Always clean up socket listeners:
```typescript
useEffect(() => {
const handlers = [
{ event: 'create', handler: handleCreate },
{ event: 'update', handler: handleUpdate },
{ event: 'delete', handler: handleDelete }
];
// Add all handlers
handlers.forEach(({ event, handler }) => {
socket.addMessageHandler(event, handler);
});
// Cleanup
return () => {
handlers.forEach(({ event, handler }) => {
socket.removeMessageHandler(event, handler);
});
};
}, [handleCreate, handleUpdate, handleDelete]);
```
## Performance Tips
1. **Measure First**: Use React DevTools Profiler before optimizing
2. **Batch Updates**: Group related state changes
3. **Debounce Rapid Changes**: Especially for drag & drop operations
4. **Use Stable References**: Memoize callbacks passed to child components
5. **Avoid Deep Equality Checks**: Use optimized comparison for large objects
## Debugging
Enable verbose logging for troubleshooting:
```typescript
// In development
if (process.env.NODE_ENV === 'development') {
console.log('[Socket] Message received:', message);
console.log('[Socket] Deduplication result:', isDuplicate);
console.log('[Optimistic] Pending updates:', pendingUpdates);
}
```
## Migration Guide
To migrate existing components:
1. Import `useOptimisticUpdates` hook
2. Wrap socket handlers with `useCallback`
3. Add optimistic update tracking to local changes
4. Check for pending updates in socket handlers
5. Test with React DevTools Profiler
Remember: The goal is to eliminate unnecessary re-renders while maintaining real-time synchronization across all connected clients.

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,14 @@
"dev": "npx vite",
"build": "npx vite build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:files": "eslint --ext .js,.jsx,.ts,.tsx",
"biome": "biome check",
"biome:fix": "biome check --write",
"biome:format": "biome format --write",
"biome:lint": "biome lint",
"biome:ai": "biome check --reporter=json",
"biome:ai-fix": "biome check --write --reporter=json",
"biome:ci": "biome ci",
"preview": "npx vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
@@ -18,11 +26,17 @@
"seed:projects": "node --loader ts-node/esm ../scripts/seed-project-data.ts"
},
"dependencies": {
"@milkdown/crepe": "^7.5.0",
"@milkdown/kit": "^7.5.0",
"@milkdown/plugin-history": "^7.5.0",
"@milkdown/preset-commonmark": "^7.5.0",
"@xyflow/react": "^12.3.0",
"@mdxeditor/editor": "^3.42.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.85.8",
"@tanstack/react-query-devtools": "^5.85.8",
"clsx": "latest",
"date-fns": "^4.1.0",
"fractional-indexing": "^3.2.0",
@@ -33,25 +47,26 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "latest",
"zod": "^3.25.46"
},
"devDependencies": {
"@biomejs/biome": "2.2.2",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20.19.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"autoprefixer": "latest",
"eslint": "^8.50.0",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.1",
"jsdom": "^24.1.0",

View File

@@ -1,13 +1,17 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
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';
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import { TooltipProvider } from './features/ui/primitives/tooltip';
import { ProjectPage } from './pages/ProjectPage';
import { DisconnectScreenOverlay } from './components/DisconnectScreenOverlay';
import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundaryWithBugReport';
@@ -15,6 +19,28 @@ import { MigrationBanner } from './components/ui/MigrationBanner';
import { serverHealthService } from './services/serverHealthService';
import { useMigrationStatus } from './hooks/useMigrationStatus';
// Create a client with optimized settings for our polling use case
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Keep data fresh for 2 seconds by default
staleTime: 2000,
// Cache data for 5 minutes
gcTime: 5 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Refetch on window focus
refetchOnWindowFocus: true,
// Don't refetch on reconnect by default (we handle this manually)
refetchOnReconnect: false,
},
mutations: {
// Retry mutations once on failure
retry: 1,
},
},
});
const AppRoutes = () => {
const { projectsEnabled } = useSettings();
@@ -25,7 +51,10 @@ const AppRoutes = () => {
<Route path="/settings" element={<SettingsPage />} />
<Route path="/mcp" element={<MCPPage />} />
{projectsEnabled ? (
<Route path="/projects" element={<ProjectPage />} />
<>
<Route path="/projects" element={<ProjectPage />} />
<Route path="/projects/:projectId" element={<ProjectPage />} />
</>
) : (
<Route path="/projects" element={<Navigate to="/" replace />} />
)}
@@ -102,12 +131,21 @@ const AppContent = () => {
export function App() {
return (
<ThemeProvider>
<ToastProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</ToastProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ToastProvider>
<FeaturesToastProvider>
<TooltipProvider>
<SettingsProvider>
<AppContent />
</SettingsProvider>
</TooltipProvider>
</FeaturesToastProvider>
</ToastProvider>
</ThemeProvider>
{import.meta.env.VITE_SHOW_DEVTOOLS === 'true' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}

View File

@@ -1,289 +0,0 @@
import React, { useState } from 'react';
import { Card } from './ui/Card';
import { motion, AnimatePresence } from 'framer-motion';
import {
CheckCircle,
XCircle,
Loader2,
FileText,
ChevronDown,
ChevronUp,
RotateCcw,
Clock,
Bot,
BrainCircuit,
BookOpen,
Database,
AlertCircle
} from 'lucide-react';
import { Button } from './ui/Button';
import { ProjectCreationProgressData } from '../services/projectCreationProgressService';
interface ProjectCreationProgressCardProps {
progressData: ProjectCreationProgressData;
onComplete?: (data: ProjectCreationProgressData) => void;
onError?: (error: string) => void;
onRetry?: () => void;
connectionStatus?: 'connected' | 'connecting' | 'disconnected' | 'error';
}
export const ProjectCreationProgressCard: React.FC<ProjectCreationProgressCardProps> = ({
progressData,
onComplete,
onError,
onRetry,
connectionStatus = 'connected'
}) => {
const [showLogs, setShowLogs] = useState(false);
const [hasCompletedRef] = useState({ value: false });
const [hasErroredRef] = useState({ value: false });
// Handle completion/error events
React.useEffect(() => {
if (progressData.status === 'completed' && onComplete && !hasCompletedRef.value) {
hasCompletedRef.value = true;
onComplete(progressData);
} else if (progressData.status === 'error' && onError && !hasErroredRef.value) {
hasErroredRef.value = true;
onError(progressData.error || 'Project creation failed');
}
}, [progressData.status, onComplete, onError, progressData, hasCompletedRef, hasErroredRef]);
const getStatusIcon = () => {
switch (progressData.status) {
case 'completed':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'initializing_agents':
return <Bot className="w-5 h-5 text-blue-500 animate-pulse" />;
case 'generating_docs':
case 'processing_requirements':
case 'ai_generation':
return <BrainCircuit className="w-5 h-5 text-purple-500 animate-pulse" />;
case 'finalizing_docs':
return <BookOpen className="w-5 h-5 text-indigo-500 animate-pulse" />;
case 'saving_to_database':
return <Database className="w-5 h-5 text-green-500 animate-pulse" />;
default:
return <Loader2 className="w-5 h-5 text-blue-500 animate-spin" />;
}
};
const getStatusColor = () => {
switch (progressData.status) {
case 'completed':
return 'text-green-500';
case 'error':
return 'text-red-500';
case 'initializing_agents':
return 'text-blue-500';
case 'generating_docs':
case 'processing_requirements':
case 'ai_generation':
return 'text-purple-500';
case 'finalizing_docs':
return 'text-indigo-500';
case 'saving_to_database':
return 'text-green-500';
default:
return 'text-blue-500';
}
};
const getStatusText = () => {
switch (progressData.status) {
case 'starting':
return 'Starting project creation...';
case 'initializing_agents':
return 'Initializing AI agents...';
case 'generating_docs':
return 'Generating documentation...';
case 'processing_requirements':
return 'Processing requirements...';
case 'ai_generation':
return 'AI is creating project docs...';
case 'finalizing_docs':
return 'Finalizing documents...';
case 'saving_to_database':
return 'Saving to database...';
case 'completed':
return 'Project created successfully!';
case 'error':
return 'Project creation failed';
default:
return 'Processing...';
}
};
const isActive = progressData.status !== 'completed' && progressData.status !== 'error';
return (
<Card className="p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{getStatusIcon()}
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
Creating Project: {progressData.project?.title || 'New Project'}
</h3>
<p className={`text-sm ${getStatusColor()}`}>
{getStatusText()}
</p>
</div>
</div>
{progressData.eta && isActive && (
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>{progressData.eta}</span>
</div>
)}
</div>
{/* Connection Status Indicator */}
{connectionStatus !== 'connected' && (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-yellow-700 dark:text-yellow-400">
{connectionStatus === 'connecting' && <Loader2 className="w-4 h-4 animate-spin" />}
{connectionStatus === 'disconnected' && <AlertCircle className="w-4 h-4" />}
{connectionStatus === 'error' && <XCircle className="w-4 h-4" />}
<span>
{connectionStatus === 'connecting' && 'Connecting to progress stream...'}
{connectionStatus === 'disconnected' && 'Disconnected from progress stream'}
{connectionStatus === 'error' && 'Connection error - retrying...'}
</span>
</div>
</div>
)}
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
Progress
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{progressData.percentage}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<motion.div
className={`h-2 rounded-full transition-all duration-500 ${
progressData.status === 'error'
? 'bg-red-500'
: progressData.status === 'completed'
? 'bg-green-500'
: 'bg-purple-500'
}`}
initial={{ width: 0 }}
animate={{ width: `${progressData.percentage}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
</div>
{/* Step Information */}
{progressData.step && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Current Step: </span>
<span className="font-medium text-gray-900 dark:text-white">
{progressData.step}
</span>
</div>
</div>
)}
{/* Error Information */}
{progressData.status === 'error' && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="text-sm text-red-700 dark:text-red-400">
<strong>Error:</strong> {progressData.error || 'Project creation failed'}
{progressData.progressId && (
<div className="mt-1 text-xs opacity-75">
Progress ID: {progressData.progressId}
</div>
)}
</div>
</div>
)}
{/* Debug Information - Show when stuck on starting status */}
{progressData.status === 'starting' && progressData.percentage === 0 && connectionStatus === 'connected' && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm text-blue-700 dark:text-blue-400">
<strong>Debug:</strong> Connected to progress stream but no updates received yet.
<div className="mt-1 text-xs opacity-75">
Progress ID: {progressData.progressId}
</div>
<div className="mt-1 text-xs opacity-75">
Check browser console for Socket.IO connection details.
</div>
</div>
</div>
)}
{/* Duration (when completed) */}
{progressData.status === 'completed' && progressData.duration && (
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-sm text-green-700 dark:text-green-400">
<strong>Completed in:</strong> {progressData.duration}
</div>
</div>
)}
{/* Console Logs */}
{progressData.logs && progressData.logs.length > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<button
onClick={() => setShowLogs(!showLogs)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-white transition-colors mb-2"
>
<FileText className="w-4 h-4" />
<span>View Console Output</span>
{showLogs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<AnimatePresence>
{showLogs && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="bg-gray-900 dark:bg-black rounded-md p-3 max-h-32 overflow-y-auto">
<div className="space-y-1 font-mono text-xs">
{progressData.logs.map((log, index) => (
<div key={index} className="text-green-400">
{log}
</div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Action Buttons */}
{progressData.status === 'error' && onRetry && (
<div className="flex justify-end mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
onClick={onRetry}
variant="primary"
accentColor="purple"
className="text-sm"
>
<RotateCcw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
)}
</Card>
);
};

View File

@@ -41,24 +41,14 @@ export const ArchonChatPanel: React.FC<ArchonChatPanelProps> = props => {
const chatPanelRef = useRef<HTMLDivElement>(null);
const sessionIdRef = useRef<string | null>(null);
/**
* Initialize chat session and WebSocket connection
* Initialize chat session and connection
*/
const initializeChat = React.useCallback(async () => {
try {
// Check if WebSocket is enabled
const enableWebSocket = import.meta.env.VITE_ENABLE_WEBSOCKET !== 'false';
if (!enableWebSocket) {
console.warn('WebSocket connection is disabled by environment configuration');
setConnectionError('Agent chat is currently disabled');
setConnectionStatus('offline');
setIsInitialized(true);
return;
}
setConnectionStatus('connecting');
// Add a small delay to prevent WebSocket race conditions on page refresh
await new Promise(resolve => setTimeout(resolve, 500));
// Yield to next frame to avoid initialization race conditions
await new Promise(resolve => requestAnimationFrame(resolve));
// Create a new chat session
try {
@@ -68,68 +58,57 @@ export const ArchonChatPanel: React.FC<ArchonChatPanelProps> = props => {
setSessionId(session_id);
sessionIdRef.current = session_id;
// Subscribe to connection status changes
agentChatService.onStatusChange(session_id, (status) => {
setConnectionStatus(status);
if (status === 'offline') {
setConnectionError('Chat is offline. Please try reconnecting.');
} else if (status === 'online') {
setConnectionError(null);
} else if (status === 'connecting') {
setConnectionError('Reconnecting...');
}
});
// Load initial chat history
try {
const history = await agentChatService.getChatHistory(session_id);
console.log(`[CHAT PANEL] Loaded chat history:`, history);
setMessages(history || []);
} catch (error) {
console.error('Failed to load chat history:', error);
// Initialize with empty messages if history can't be loaded
setMessages([]);
}
// Load session data to get initial messages
const session = await agentChatService.getSession(session_id);
console.log(`[CHAT PANEL] Loaded session:`, session);
console.log(`[CHAT PANEL] Session agent_type: "${session.agent_type}"`);
console.log(`[CHAT PANEL] First message:`, session.messages?.[0]);
setMessages(session.messages || []);
// Connect WebSocket for real-time communication
agentChatService.connectWebSocket(
session_id,
(message: ChatMessage) => {
setMessages(prev => [...prev, message]);
setConnectionError(null); // Clear any previous errors on successful message
setConnectionStatus('online');
},
(typing: boolean) => {
setIsTyping(typing);
},
(chunk: string) => {
// Handle streaming chunks
setStreamingMessage(prev => prev + chunk);
setIsStreaming(true);
},
() => {
// Handle stream completion
setIsStreaming(false);
setStreamingMessage('');
},
(error: Event) => {
console.error('WebSocket error:', error);
// Don't set error message here, let the status handler manage it
},
(event: CloseEvent) => {
console.log('WebSocket closed:', event);
// Don't set error message here, let the status handler manage it
}
);
// Start polling for new messages (will fail gracefully if backend is down)
try {
await agentChatService.streamMessages(
session_id,
(message: ChatMessage) => {
setMessages(prev => [...prev, message]);
setConnectionError(null); // Clear any previous errors on successful message
setConnectionStatus('online');
},
(error: Error) => {
console.error('Message streaming error:', error);
setConnectionStatus('offline');
setConnectionError('Chat service is offline. Messages will not be received.');
}
);
} catch (error) {
console.error('Failed to start message streaming:', error);
// Continue anyway - the chat will work in offline mode
}
setIsInitialized(true);
setConnectionStatus('online');
setConnectionError(null);
} catch (error) {
console.error('Failed to initialize chat session:', error);
setConnectionError('Failed to initialize chat. Server may be offline.');
if (error instanceof Error && error.message.includes('not available')) {
setConnectionError('Agent chat service is disabled. Enable it in docker-compose to use this feature.');
} else {
setConnectionError('Failed to initialize chat. Server may be offline.');
}
setConnectionStatus('offline');
}
} catch (error) {
console.error('Failed to initialize chat:', error);
setConnectionError('Failed to connect to agent. Server may be offline.');
if (error instanceof Error && error.message.includes('not available')) {
setConnectionError('Agent chat service is disabled. Enable it in docker-compose to use this feature.');
} else {
setConnectionError('Failed to connect to agent. Server may be offline.');
}
setConnectionStatus('offline');
}
}, []);
@@ -146,8 +125,8 @@ export const ArchonChatPanel: React.FC<ArchonChatPanelProps> = props => {
return () => {
if (sessionIdRef.current) {
console.log('[CHAT PANEL] Component unmounting, cleaning up session:', sessionIdRef.current);
agentChatService.disconnectWebSocket(sessionIdRef.current);
agentChatService.offStatusChange(sessionIdRef.current);
// Stop streaming messages when component unmounts
agentChatService.stopStreaming(sessionIdRef.current);
}
};
}, []); // Empty deps = only on unmount

View File

@@ -0,0 +1,93 @@
import React, { useId } from 'react';
import { Trash2 } from 'lucide-react';
interface DeleteConfirmModalProps {
itemName: string;
onConfirm: () => void;
onCancel: () => void;
type: "project" | "task" | "client";
}
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
itemName,
onConfirm,
onCancel,
type,
}) => {
const titleId = useId();
const descId = useId();
const TITLES: Record<DeleteConfirmModalProps['type'], string> = {
project: "Delete Project",
task: "Delete Task",
client: "Delete MCP Client",
};
const MESSAGES: Record<DeleteConfirmModalProps['type'], (n: string) => string> = {
project: (n) => `Are you sure you want to delete the "${n}" project? This will also delete all associated tasks and documents and cannot be undone.`,
task: (n) => `Are you sure you want to delete the "${n}" task? This action cannot be undone.`,
client: (n) => `Are you sure you want to delete the "${n}" client? This will permanently remove its configuration and cannot be undone.`,
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
onClick={onCancel}
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
aria-hidden={false}
data-testid="modal-backdrop"
>
<div
className="relative p-6 rounded-md backdrop-blur-md w-full max-w-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)]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px]
before:rounded-t-[4px] before:bg-red-500
before:shadow-[0_0_10px_2px_rgba(239,68,68,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(239,68,68,0.7)]"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
onClick={(e) => e.stopPropagation()}
>
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 id={titleId} className="text-lg font-semibold text-gray-800 dark:text-white">
{TITLES[type]}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
</div>
<p id={descId} className="text-gray-700 dark:text-gray-300 mb-6">
{MESSAGES[type](itemName)}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
autoFocus
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors shadow-lg shadow-red-600/25 hover:shadow-red-700/25"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,407 @@
import { useState } from 'react';
import {
LinkIcon,
Upload,
BoxIcon,
Brain,
Plus
} from 'lucide-react';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Input } from '../ui/Input';
import { Badge } from '../ui/Badge';
import { GlassCrawlDepthSelector } from '../ui/GlassCrawlDepthSelector';
import { useToast } from '../../contexts/ToastContext';
import { knowledgeBaseService } from '../../services/knowledgeBaseService';
import { CrawlProgressData } from '../../types/crawl';
interface AddKnowledgeModalProps {
onClose: () => void;
onSuccess: () => void;
onStartCrawl: (progressId: string, initialData: Partial<CrawlProgressData>) => void;
}
export const AddKnowledgeModal = ({
onClose,
onSuccess,
onStartCrawl
}: AddKnowledgeModalProps) => {
const [method, setMethod] = useState<'url' | 'file'>('url');
const [url, setUrl] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [newTag, setNewTag] = useState('');
const [knowledgeType, setKnowledgeType] = useState<'technical' | 'business'>('technical');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [crawlDepth, setCrawlDepth] = useState(2);
const [showDepthTooltip, setShowDepthTooltip] = useState(false);
const { showToast } = useToast();
// URL validation function
const validateUrl = async (url: string): Promise<{ isValid: boolean; error?: string; formattedUrl?: string }> => {
try {
let formattedUrl = url.trim();
if (!formattedUrl.startsWith('http://') && !formattedUrl.startsWith('https://')) {
formattedUrl = `https://${formattedUrl}`;
}
let urlObj;
try {
urlObj = new URL(formattedUrl);
} catch {
return { isValid: false, error: 'Please enter a valid URL format' };
}
const hostname = urlObj.hostname;
if (!hostname || hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return { isValid: true, formattedUrl };
}
if (!hostname.includes('.')) {
return { isValid: false, error: 'Please enter a valid domain name' };
}
const parts = hostname.split('.');
const tld = parts[parts.length - 1];
if (tld.length < 2) {
return { isValid: false, error: 'Please enter a valid domain with a proper extension' };
}
// Optional DNS check
try {
const response = await fetch(`https://dns.google/resolve?name=${hostname}&type=A`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (response.ok) {
const dnsResult = await response.json();
if (dnsResult.Status === 0 && dnsResult.Answer?.length > 0) {
return { isValid: true, formattedUrl };
} else {
return { isValid: false, error: `Domain "${hostname}" could not be resolved` };
}
}
} catch {
// Allow URL even if DNS check fails
console.warn('DNS check failed, allowing URL anyway');
}
return { isValid: true, formattedUrl };
} catch {
return { isValid: false, error: 'URL validation failed' };
}
};
const handleSubmit = async () => {
try {
setLoading(true);
if (method === 'url') {
if (!url.trim()) {
showToast('Please enter a URL', 'error');
return;
}
showToast('Validating URL...', 'info');
const validation = await validateUrl(url);
if (!validation.isValid) {
showToast(validation.error || 'Invalid URL', 'error');
return;
}
const formattedUrl = validation.formattedUrl!;
setUrl(formattedUrl);
// Detect crawl type based on URL
const crawlType = detectCrawlType(formattedUrl);
const result = await knowledgeBaseService.crawlUrl({
url: formattedUrl,
knowledge_type: knowledgeType,
tags,
max_depth: crawlDepth
});
if ((result as any).progressId) {
onStartCrawl((result as any).progressId, {
status: 'initializing',
progress: 0,
currentStep: 'Starting crawl',
crawlType,
currentUrl: formattedUrl,
originalCrawlParams: {
url: formattedUrl,
knowledge_type: knowledgeType,
tags,
max_depth: crawlDepth
}
});
showToast(`Starting ${crawlType} crawl...`, 'success');
onClose();
} else {
showToast((result as any).message || 'Crawling started', 'success');
onSuccess();
}
} else {
if (!selectedFile) {
showToast('Please select a file', 'error');
return;
}
const result = await knowledgeBaseService.uploadDocument(selectedFile, {
knowledge_type: knowledgeType,
tags
});
if (result.success && result.progressId) {
onStartCrawl(result.progressId, {
currentUrl: `file://${selectedFile.name}`,
progress: 0,
status: 'starting',
uploadType: 'document',
fileName: selectedFile.name,
fileType: selectedFile.type,
originalUploadParams: {
file: selectedFile,
knowledge_type: knowledgeType,
tags
}
});
showToast('Document upload started', 'success');
onClose();
} else {
showToast(result.message || 'Document uploaded', 'success');
onSuccess();
}
}
} catch (error) {
console.error('Failed to add knowledge:', error);
showToast('Failed to add knowledge source', 'error');
} finally {
setLoading(false);
}
};
// Helper to detect crawl type
const detectCrawlType = (url: string): 'sitemap' | 'llms-txt' | 'normal' => {
if (url.includes('sitemap.xml')) return 'sitemap';
if (url.includes('llms') && url.endsWith('.txt')) return 'llms-txt';
return 'normal';
};
return (
<div className="fixed inset-0 bg-gray-500/50 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl relative before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-[1px] before:bg-green-500 p-8">
<h2 className="text-xl font-bold text-gray-800 dark:text-white mb-8">
Add Knowledge Source
</h2>
{/* Knowledge Type Selection */}
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Knowledge Type
</label>
<div className="flex gap-4">
<label className={`
flex-1 p-4 rounded-md border cursor-pointer transition flex items-center justify-center gap-2
${knowledgeType === 'technical'
? 'border-blue-500 text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-blue-300 dark:hover:border-blue-500/30'}
`}>
<input
type="radio"
name="knowledgeType"
value="technical"
checked={knowledgeType === 'technical'}
onChange={() => setKnowledgeType('technical')}
className="sr-only"
/>
<BoxIcon className="w-5 h-5" />
<span>Technical/Coding</span>
</label>
<label className={`
flex-1 p-4 rounded-md border cursor-pointer transition flex items-center justify-center gap-2
${knowledgeType === 'business'
? 'border-purple-500 text-purple-600 dark:text-purple-500 bg-purple-50 dark:bg-purple-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-purple-300 dark:hover:border-purple-500/30'}
`}>
<input
type="radio"
name="knowledgeType"
value="business"
checked={knowledgeType === 'business'}
onChange={() => setKnowledgeType('business')}
className="sr-only"
/>
<Brain className="w-5 h-5" />
<span>Business/Project</span>
</label>
</div>
</div>
{/* Source Type Selection */}
<div className="flex gap-4 mb-6">
<button
onClick={() => setMethod('url')}
className={`flex-1 p-4 rounded-md border transition flex items-center justify-center gap-2
${method === 'url'
? 'border-blue-500 text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-blue-300 dark:hover:border-blue-500/30'}`}
>
<LinkIcon className="w-4 h-4" />
<span>URL / Website</span>
</button>
<button
onClick={() => setMethod('file')}
className={`flex-1 p-4 rounded-md border transition flex items-center justify-center gap-2
${method === 'file'
? 'border-pink-500 text-pink-600 dark:text-pink-500 bg-pink-50 dark:bg-pink-500/5'
: 'border-gray-200 dark:border-zinc-900 text-gray-500 dark:text-zinc-400 hover:border-pink-300 dark:hover:border-pink-500/30'}`}
>
<Upload className="w-4 h-4" />
<span>Upload File</span>
</button>
</div>
{/* URL Input */}
{method === 'url' && (
<div className="mb-6">
<Input
label="URL to Scrape"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com or example.com"
accentColor="blue"
/>
{url && !url.startsWith('http://') && !url.startsWith('https://') && (
<p className="text-amber-600 dark:text-amber-400 text-sm mt-1">
Will automatically add https:// prefix
</p>
)}
</div>
)}
{/* File Upload */}
{method === 'file' && (
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Upload Document
</label>
<div className="relative">
<input
id="file-upload"
type="file"
accept=".pdf,.md,.doc,.docx,.txt"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="sr-only"
/>
<label
htmlFor="file-upload"
className="flex items-center justify-center gap-3 w-full p-6 rounded-md border-2 border-dashed cursor-pointer transition-all duration-300
bg-blue-500/10 hover:bg-blue-500/20
border-blue-500/30 hover:border-blue-500/50
text-blue-600 dark:text-blue-400
hover:shadow-[0_0_15px_rgba(59,130,246,0.3)]
backdrop-blur-sm"
>
<Upload className="w-6 h-6" />
<div className="text-center">
<div className="font-medium">
{selectedFile ? selectedFile.name : 'Choose File'}
</div>
<div className="text-sm opacity-75 mt-1">
{selectedFile
? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB`
: 'Click to browse or drag and drop'}
</div>
</div>
</label>
</div>
<p className="text-gray-500 dark:text-zinc-600 text-sm mt-2">
Supports PDF, MD, DOC up to 10MB
</p>
</div>
)}
{/* Crawl Depth - Only for URLs */}
{method === 'url' && (
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-4">
Crawl Depth
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onMouseEnter={() => setShowDepthTooltip(true)}
onMouseLeave={() => setShowDepthTooltip(false)}
>
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</label>
<GlassCrawlDepthSelector
value={crawlDepth}
onChange={setCrawlDepth}
showTooltip={showDepthTooltip}
onTooltipToggle={setShowDepthTooltip}
/>
</div>
)}
{/* Tags */}
<div className="mb-6">
<label className="block text-gray-600 dark:text-zinc-400 text-sm mb-2">
Tags (AI will add recommended tags if left blank)
</label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag) => (
<Badge key={tag} color="purple" variant="outline">
{tag}
<button
onClick={() => setTags(tags.filter(t => t !== tag))}
className="ml-1 text-purple-600 hover:text-purple-800"
>
×
</button>
</Badge>
))}
</div>
<Input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newTag.trim()) {
setTags([...tags, newTag.trim()]);
setNewTag('');
}
}}
placeholder="Add tags..."
accentColor="purple"
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-4">
<Button onClick={onClose} variant="ghost" disabled={loading}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="primary"
accentColor={method === 'url' ? 'blue' : 'pink'}
disabled={loading}
>
{loading ? 'Adding...' : 'Add Source'}
</Button>
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CrawlingProgressCard } from './CrawlingProgressCard';
import { CrawlProgressData } from '../../types/crawl';
import { AlertCircle } from 'lucide-react';
interface CrawlingTabProps {
progressItems: CrawlProgressData[];
onProgressComplete: (data: CrawlProgressData) => void;
onProgressError: (error: string, progressId?: string) => void;
onRetryProgress: (progressId: string) => void;
onStopProgress: (progressId: string) => void;
onDismissProgress: (progressId: string) => void;
}
export const CrawlingTab = ({
progressItems,
onProgressComplete,
onProgressError,
onRetryProgress,
onStopProgress,
onDismissProgress
}: CrawlingTabProps) => {
// Group progress items by type for better organization
const groupedItems = progressItems.reduce((acc, item) => {
const type = item.crawlType || (item.uploadType === 'document' ? 'upload' : 'normal');
if (!acc[type]) acc[type] = [];
acc[type].push(item);
return acc;
}, {} as Record<string, CrawlProgressData[]>);
const getSectionTitle = (type: string) => {
switch (type) {
case 'sitemap': return 'Sitemap Crawls';
case 'llms-txt': return 'LLMs.txt Crawls';
case 'upload': return 'Document Uploads';
case 'refresh': return 'Refreshing Sources';
default: return 'Web Crawls';
}
};
const getSectionDescription = (type: string) => {
switch (type) {
case 'sitemap':
return 'Processing sitemap.xml files to discover and crawl all listed pages';
case 'llms-txt':
return 'Extracting content from llms.txt files for AI model training';
case 'upload':
return 'Processing uploaded documents and extracting content';
case 'refresh':
return 'Re-crawling existing sources to update content';
default:
return 'Recursively crawling websites to extract knowledge';
}
};
if (progressItems.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 dark:text-zinc-600 mb-4" />
<h3 className="text-lg font-medium text-gray-700 dark:text-zinc-300 mb-2">
No Active Crawls
</h3>
<p className="text-gray-500 dark:text-zinc-500 max-w-md">
Start crawling a website or uploading a document to see progress here
</p>
</div>
);
}
return (
<div className="space-y-6">
<AnimatePresence mode="sync">
{Object.entries(groupedItems).map(([type, items]) => (
<motion.div
key={type}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-4"
>
{/* Section Header */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-zinc-300 uppercase tracking-wider">
{getSectionTitle(type)}
</h3>
<p className="text-xs text-gray-500 dark:text-zinc-500 mt-1">
{getSectionDescription(type)}
</p>
</div>
{/* Progress Cards */}
<div className="space-y-3">
{items.map((progressData) => (
<CrawlingProgressCard
key={progressData.progressId}
progressId={progressData.progressId}
initialData={progressData}
onComplete={onProgressComplete}
onError={(error) => onProgressError(error, progressData.progressId)}
onRetry={() => onRetryProgress(progressData.progressId)}
onDismiss={() => onDismissProgress(progressData.progressId)}
onStop={() => onStopProgress(progressData.progressId)}
/>
))}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,319 @@
import React, { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Search, Filter, FileText, Globe, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { knowledgeBaseService } from '../../services/knowledgeBaseService';
interface DocumentChunk {
id: string;
source_id: string;
content: string;
metadata?: any;
url?: string;
}
interface DocumentBrowserProps {
sourceId: string;
isOpen: boolean;
onClose: () => void;
}
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname;
// Remove 'www.' prefix if present
const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname;
// Keep full hostname (minus 'www.') to preserve subdomain-level filtering
return withoutWww;
} catch {
return url; // Return original if URL parsing fails
}
};
export const DocumentBrowser: React.FC<DocumentBrowserProps> = ({
sourceId,
isOpen,
onClose,
}) => {
const [chunks, setChunks] = useState<DocumentChunk[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedDomain, setSelectedDomain] = useState<string>('all');
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Extract unique domains from chunks
const domains = useMemo(() => {
const domainSet = new Set<string>();
chunks.forEach(chunk => {
if (chunk.url) {
domainSet.add(extractDomain(chunk.url));
}
});
return Array.from(domainSet).sort();
}, [chunks]);
// Filter chunks based on search and domain
const filteredChunks = useMemo(() => {
return chunks.filter(chunk => {
// Search filter
const searchLower = searchQuery.toLowerCase();
const searchMatch = !searchQuery ||
chunk.content.toLowerCase().includes(searchLower) ||
chunk.url?.toLowerCase().includes(searchLower);
// Domain filter
const domainMatch = selectedDomain === 'all' ||
(chunk.url && extractDomain(chunk.url) === selectedDomain);
return searchMatch && domainMatch;
});
}, [chunks, searchQuery, selectedDomain]);
// Get selected chunk
const selectedChunk = useMemo(() => {
return filteredChunks.find(chunk => chunk.id === selectedChunkId) || filteredChunks[0];
}, [filteredChunks, selectedChunkId]);
// Load chunks when component opens
useEffect(() => {
if (isOpen && sourceId) {
loadChunks();
}
}, [isOpen, sourceId]);
const loadChunks = async () => {
try {
setLoading(true);
setError(null);
const response = await knowledgeBaseService.getKnowledgeItemChunks(sourceId);
if (response.success) {
setChunks(response.chunks);
// Auto-select first chunk if none selected
if (response.chunks.length > 0 && !selectedChunkId) {
setSelectedChunkId(response.chunks[0].id);
}
} else {
setError('Failed to load document chunks');
}
} catch (error) {
console.error('Failed to load chunks:', error);
setError(error instanceof Error ? error.message : 'Failed to load document chunks');
} finally {
setLoading(false);
}
};
const loadChunksWithDomainFilter = async (domain: string) => {
try {
setLoading(true);
setError(null);
const domainFilter = domain === 'all' ? undefined : domain;
const response = await knowledgeBaseService.getKnowledgeItemChunks(sourceId, domainFilter);
if (response.success) {
setChunks(response.chunks);
} else {
setError('Failed to load document chunks');
}
} catch (error) {
console.error('Failed to load chunks with domain filter:', error);
setError(error instanceof Error ? error.message : 'Failed to load document chunks');
} finally {
setLoading(false);
}
};
const handleDomainChange = (domain: string) => {
setSelectedDomain(domain);
// Note: We could reload with server-side filtering, but for now we'll do client-side filtering
// loadChunksWithDomainFilter(domain);
};
if (!isOpen) return null;
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 flex items-center justify-center z-50 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative bg-gray-900/95 border border-gray-800 rounded-xl w-full max-w-7xl h-[85vh] flex overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Blue accent line at the top */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 to-cyan-500 shadow-[0_0_20px_5px_rgba(59,130,246,0.5)]"></div>
{/* Sidebar */}
<div className="w-80 bg-gray-950/50 border-r border-gray-800 flex flex-col overflow-hidden">
{/* Sidebar Header */}
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-blue-400">
Document Chunks ({(filteredChunks || []).length})
</h3>
</div>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search documents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-gray-900/70 border border-gray-800 rounded-lg text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20 transition-all"
/>
</div>
{/* Domain Filter */}
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-gray-500" />
<select
value={selectedDomain}
onChange={(e) => handleDomainChange(e.target.value)}
className="flex-1 bg-gray-900/70 border border-gray-800 rounded-lg text-sm text-gray-300 px-3 py-2 focus:outline-none focus:border-blue-500/50"
>
<option value="all">All Domains</option>
{domains?.map(domain => (
<option key={domain} value={domain}>{domain}</option>
)) || []}
</select>
</div>
</div>
{/* Document List */}
<div className="flex-1 overflow-y-auto p-2">
{filteredChunks.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-8">
No documents found
</div>
) : (
filteredChunks.map((chunk, index) => (
<button
key={chunk.id}
onClick={() => setSelectedChunkId(chunk.id)}
className={`w-full text-left p-3 mb-1 rounded-lg transition-all duration-200 ${
selectedChunk?.id === chunk.id
? 'bg-blue-500/20 border border-blue-500/40 shadow-[0_0_15px_rgba(59,130,246,0.2)]'
: 'hover:bg-gray-800/50 border border-transparent'
}`}
>
<div className="flex items-start gap-2">
<FileText className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
selectedChunk?.id === chunk.id ? 'text-blue-400' : 'text-gray-500'
}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium ${
selectedChunk?.id === chunk.id ? 'text-blue-300' : 'text-gray-300'
} line-clamp-1`}>
Chunk {index + 1}
</div>
<div className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{chunk.content?.substring(0, 100) || 'No content'}...
</div>
{chunk.url && (
<div className="text-xs text-blue-400 mt-1 truncate">
{extractDomain(chunk.url)}
</div>
)}
</div>
</div>
</button>
))
)}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-blue-400">
{selectedChunk ? `Document Chunk` : 'Document Browser'}
</h2>
{selectedChunk?.url && (
<Badge color="blue" className="flex items-center gap-1">
<Globe className="w-3 h-3" />
{extractDomain(selectedChunk.url)}
</Badge>
)}
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-white p-1 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-400 mx-auto mb-4"></div>
<p className="text-gray-400">Loading document chunks...</p>
</div>
</div>
) : !selectedChunk || filteredChunks.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Select a document chunk to view content</p>
</div>
</div>
) : (
<div className="h-full p-4">
<div className="bg-gray-900/70 rounded-lg border border-gray-800 h-full overflow-auto">
<div className="p-6">
{selectedChunk.url && (
<div className="text-sm text-blue-400 mb-4 font-mono">
{selectedChunk.url}
</div>
)}
<div className="prose prose-sm prose-invert max-w-none">
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
{selectedChunk.content || 'No content available'}
</div>
</div>
{selectedChunk.metadata && (
<div className="mt-6 pt-4 border-t border-gray-700">
<details className="text-sm text-gray-400">
<summary className="cursor-pointer hover:text-gray-300 font-medium">
View Metadata
</summary>
<pre className="mt-3 bg-gray-800 p-3 rounded text-xs overflow-x-auto text-gray-300">
{JSON.stringify(selectedChunk.metadata, null, 2)}
</pre>
</details>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</motion.div>
</motion.div>,
document.body
);
};

View File

@@ -129,6 +129,7 @@ interface KnowledgeItemCardProps {
onDelete: (sourceId: string) => void;
onUpdate?: () => void;
onRefresh?: (sourceId: string) => void;
onBrowseDocuments?: (sourceId: string) => void;
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelection?: (event: React.MouseEvent) => void;
@@ -139,6 +140,7 @@ export const KnowledgeItemCard = ({
onDelete,
onUpdate,
onRefresh,
onBrowseDocuments,
isSelectionMode = false,
isSelected = false,
onToggleSelection
@@ -151,6 +153,7 @@ export const KnowledgeItemCard = ({
const [showEditModal, setShowEditModal] = useState(false);
const [loadedCodeExamples, setLoadedCodeExamples] = useState<any[] | null>(null);
const [isLoadingCodeExamples, setIsLoadingCodeExamples] = useState(false);
const [isRecrawling, setIsRecrawling] = useState(false);
const statusColorMap = {
active: 'green',
@@ -210,8 +213,14 @@ export const KnowledgeItemCard = ({
};
const handleRefresh = () => {
if (onRefresh) {
if (onRefresh && !isRecrawling) {
setIsRecrawling(true);
onRefresh(item.source_id);
// Temporary fix: Auto-reset after timeout
// TODO: Reset based on actual crawl completion status from polling
setTimeout(() => {
setIsRecrawling(false);
}, 60000); // Reset after 60 seconds as a fallback
}
};
@@ -369,15 +378,18 @@ export const KnowledgeItemCard = ({
{item.metadata.source_type === 'url' && (
<button
onClick={handleRefresh}
disabled={isRecrawling}
className={`flex items-center gap-1 mb-1 px-2 py-1 transition-colors ${
item.metadata.knowledge_type === 'technical'
? 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
: 'text-cyan-500 hover:text-cyan-600 dark:text-cyan-400 dark:hover:text-cyan-300'
isRecrawling
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: item.metadata.knowledge_type === 'technical'
? 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
: 'text-cyan-500 hover:text-cyan-600 dark:text-cyan-400 dark:hover:text-cyan-300'
}`}
title={`Refresh from: ${item.metadata.original_url || item.url || 'URL not available'}`}
title={isRecrawling ? 'Recrawl in progress...' : `Refresh from: ${item.metadata.original_url || item.url || 'URL not available'}`}
>
<RefreshCw className="w-3 h-3" />
<span className="text-sm font-medium">Recrawl</span>
<RefreshCw className={`w-3 h-3 ${isRecrawling ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">{isRecrawling ? 'Recrawling...' : 'Recrawl'}</span>
</button>
)}
<span className="text-xs text-gray-500 dark:text-zinc-500">
@@ -444,13 +456,20 @@ export const KnowledgeItemCard = ({
</div>
)}
{/* Page count - orange neon container */}
{/* Page count - orange neon container (clickable for document browser) */}
<div
className="relative card-3d-layer-3"
className="relative card-3d-layer-3 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onBrowseDocuments) {
onBrowseDocuments(item.source_id);
}
}}
onMouseEnter={() => setShowPageTooltip(true)}
onMouseLeave={() => setShowPageTooltip(false)}
title="Click to browse document chunks"
>
<div className="flex items-center gap-1 px-2 py-1 bg-orange-500/20 border border-orange-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(251,146,60,0.3)] transition-all duration-300">
<div className="flex items-center gap-1 px-2 py-1 bg-orange-500/20 border border-orange-500/40 rounded-full backdrop-blur-sm shadow-[0_0_15px_rgba(251,146,60,0.3)] hover:shadow-[0_0_20px_rgba(251,146,60,0.5)] transition-all duration-300">
<FileText className="w-3 h-3 text-orange-400" />
<span className="text-xs text-orange-400 font-medium">
{Math.ceil(
@@ -461,10 +480,13 @@ export const KnowledgeItemCard = ({
{/* Page count tooltip - positioned relative to the badge */}
{showPageTooltip && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 bg-black dark:bg-zinc-800 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
<div className="font-medium mb-1">
{(item.metadata.word_count || 0).toLocaleString()} words
<div className="font-medium mb-1 text-orange-300">
Click to Browse Documents
</div>
<div className="text-gray-300 space-y-0.5">
<div>
{(item.metadata.word_count || 0).toLocaleString()} words
</div>
<div>
= {Math.ceil((item.metadata.word_count || 0) / 250).toLocaleString()} pages
</div>

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 '../../pages/ProjectPage';
// 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,598 +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';
import { ErrorAlert, useErrorHandler } from '../ui/ErrorAlert';
import { parseKnowledgeBaseError, EnhancedError } from '../../services/knowledgeBaseErrorHandler';
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 { error, setError, clearError } = useErrorHandler();
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]);
// Cleanup error state on component unmount
useEffect(() => {
return () => {
clearError(); // Clear any pending errors when component unmounts
};
}, [clearError]);
// 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);
// Parse error through enhanced error handler for better user experience
const enhancedError = parseKnowledgeBaseError(error);
setError(enhancedError);
setTimeout(() => {
addTypingLine(`> ERROR: Failed to execute tool on ${client.name}`, false, true);
addTypingLine(`> ${error.message || 'Unknown error occurred'}`, false, true);
// Add special handling for OpenAI/RAG related errors
if (enhancedError.isOpenAIError && enhancedError.errorDetails) {
addTypingLine(`> Error Type: ${enhancedError.errorDetails.error_type}`, false, true);
if (enhancedError.errorDetails.tokens_used) {
addTypingLine(`> Tokens Used: ${enhancedError.errorDetails.tokens_used}`, 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;
// Clear any previous errors
clearError();
// 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">
{/* Error Alert for OpenAI/RAG errors */}
<ErrorAlert error={error} onDismiss={clearError} />
{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

@@ -1,958 +0,0 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import '@xyflow/react/dist/style.css';
import { ReactFlow, Node, Edge, Background, Controls, MarkerType, NodeChange, applyNodeChanges, EdgeChange, applyEdgeChanges, ConnectionLineType, addEdge, Connection, Handle, Position } from '@xyflow/react';
import { Database, Info, Calendar, TrendingUp, Edit, Plus, X, Save, Trash2 } from 'lucide-react';
import { projectService } from '../../services/projectService';
import { taskUpdateSocketIO } from '../../services/socketIOService';
import { useToast } from '../../contexts/ToastContext';
// Custom node types - will be defined inside the component to access state
const createTableNode = (id: string, label: string, columns: string[], x: number, y: number): Node => ({
id,
type: 'table',
data: {
label,
columns
},
position: {
x,
y
}
});
// Default fallback nodes for basic database structure
const defaultNodes: Node[] = [
createTableNode('users', 'Users', ['id (PK) - UUID', 'email - VARCHAR(255)', 'password - VARCHAR(255)', 'firstName - VARCHAR(100)', 'lastName - VARCHAR(100)', 'createdAt - TIMESTAMP', 'updatedAt - TIMESTAMP'], 150, 100),
createTableNode('projects', 'Projects', ['id (PK) - UUID', 'title - VARCHAR(255)', 'description - TEXT', 'status - VARCHAR(50)', 'userId (FK) - UUID', 'createdAt - TIMESTAMP', 'updatedAt - TIMESTAMP'], 500, 100)
];
const defaultEdges: Edge[] = [{
id: 'projects-users',
source: 'users',
target: 'projects',
sourceHandle: 'Users-id',
targetHandle: 'Projects-userId',
animated: true,
style: {
stroke: '#d946ef'
},
markerEnd: {
type: MarkerType.Arrow,
color: '#d946ef'
}
}];
// Data metadata card component for the new data structure
const DataCard = ({ data }: { data: any }) => {
const iconMap: { [key: string]: any } = {
'ShoppingCart': Database,
'Database': Database,
'Info': Info,
'Calendar': Calendar,
'TrendingUp': TrendingUp
};
const IconComponent = iconMap[data.icon] || Database;
const colorClasses = {
cyan: 'from-cyan-900/40 to-cyan-800/30 border-cyan-500/50 text-cyan-400',
blue: 'from-blue-900/40 to-blue-800/30 border-blue-500/50 text-blue-400',
purple: 'from-purple-900/40 to-purple-800/30 border-purple-500/50 text-purple-400',
pink: 'from-pink-900/40 to-pink-800/30 border-pink-500/50 text-pink-400'
};
const colorClass = colorClasses[data.color as keyof typeof colorClasses] || colorClasses.cyan;
return (
<div className={`p-6 rounded-lg bg-gradient-to-r ${colorClass} backdrop-blur-md border min-w-[300px] transition-all duration-300 hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] group`}>
<div className="flex items-center gap-3 mb-4">
<IconComponent className="w-6 h-6" />
<div className="text-lg font-bold">Project Data Overview</div>
</div>
<div className="space-y-3">
<div className="text-sm opacity-90">
<div className="font-medium mb-1">Description:</div>
<div className="text-xs opacity-80">{data.description}</div>
</div>
<div className="flex justify-between items-center text-sm">
<span className="opacity-90">Progress:</span>
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-black/30 rounded-full overflow-hidden">
<div
className="h-full bg-current rounded-full transition-all duration-300"
style={{ width: `${data.progress}%` }}
/>
</div>
<span className="text-xs font-medium">{data.progress}%</span>
</div>
</div>
<div className="text-xs opacity-75">
Last updated: {data.updated}
</div>
</div>
</div>
);
};
interface DataTabProps {
project?: {
id: string;
title: string;
data?: any[];
} | null;
}
export const DataTab = ({ project }: DataTabProps) => {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<'metadata' | 'erd'>('metadata');
const [editingNode, setEditingNode] = useState<Node | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [nodeToDelete, setNodeToDelete] = useState<string | null>(null);
const { showToast } = useToast();
// Note: Removed aggressive WebSocket cleanup to prevent interference with normal connection lifecycle
// Helper function to normalize nodes to ensure required properties
const normalizeNode = (node: any): Node => {
return {
id: node.id || `node-${Date.now()}-${Math.random()}`,
type: node.type || 'table',
position: {
x: node.position?.x || 100,
y: node.position?.y || 100
},
data: {
label: node.data?.label || 'Untitled',
columns: node.data?.columns || ['id (PK) - UUID']
}
};
};
useEffect(() => {
console.log('DataTab project data:', project?.data);
// Determine view mode based on data structure
if (project?.data) {
if (Array.isArray(project.data) && project.data.length > 0) {
// Handle array format: [{"type": "erd", ...}] or [{"description": "...", "progress": 65}]
const firstItem = project.data[0];
console.log('First data item (array):', firstItem);
if (firstItem.description && typeof firstItem.progress === 'number') {
console.log('Setting metadata view');
setViewMode('metadata');
} else if (firstItem.type === 'erd' && firstItem.nodes && firstItem.edges) {
console.log('Setting ERD view with structured array data');
setViewMode('erd');
// Normalize nodes to ensure required properties
const normalizedNodes = firstItem.nodes.map(normalizeNode);
setNodes(normalizedNodes);
// Fix any ArrowClosed marker types in loaded edges
const sanitizedEdges = firstItem.edges.map((edge: any) => ({
...edge,
markerEnd: edge.markerEnd ? {
...edge.markerEnd,
type: edge.markerEnd.type === 'ArrowClosed' ? MarkerType.Arrow : edge.markerEnd.type
} : undefined
}));
setEdges(sanitizedEdges);
} else {
console.log('Setting ERD view for array data');
setViewMode('erd');
// Normalize nodes to ensure required properties
const normalizedNodes = project.data.map(normalizeNode);
setNodes(normalizedNodes);
setEdges([]);
}
} else if (typeof project.data === 'object' && !Array.isArray(project.data) &&
(project.data as any).type === 'erd' &&
(project.data as any).nodes &&
(project.data as any).edges) {
// Handle direct object format: {"type": "erd", "nodes": [...], "edges": [...]}
console.log('Setting ERD view with direct object data');
setViewMode('erd');
// Normalize nodes to ensure required properties
const normalizedNodes = (project.data as any).nodes.map(normalizeNode);
setNodes(normalizedNodes);
// Fix any ArrowClosed marker types in loaded edges
const sanitizedEdges = (project.data as any).edges.map((edge: any) => ({
...edge,
markerEnd: edge.markerEnd ? {
...edge.markerEnd,
type: edge.markerEnd.type === 'ArrowClosed' ? MarkerType.Arrow : edge.markerEnd.type
} : undefined
}));
setEdges(sanitizedEdges);
} else {
console.log('Unknown data format, showing empty state');
setViewMode('erd');
setNodes([]);
setEdges([]);
}
} else {
console.log('No data, using empty state');
setViewMode('erd');
setNodes([]);
setEdges([]);
}
setLoading(false);
}, [project]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes(nds => applyNodeChanges(changes, nds));
setHasUnsavedChanges(true);
}, []);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges(eds => applyEdgeChanges(changes, eds));
setHasUnsavedChanges(true);
}, []);
const onConnect = useCallback(async (connection: Connection) => {
const newEdgeProps = {
animated: true,
style: {
stroke: '#22d3ee'
},
markerEnd: {
type: MarkerType.Arrow,
color: '#22d3ee'
},
label: 'relates to',
labelStyle: {
fill: '#e94560',
fontWeight: 500
},
labelBgStyle: {
fill: 'rgba(0, 0, 0, 0.7)'
}
};
const newEdges = addEdge({ ...connection, ...newEdgeProps }, edges);
setEdges(newEdges);
// Auto-save to database
await saveToDatabase(nodes, newEdges);
}, [nodes, edges, project?.id]);
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
setEditingNode(node);
setShowEditModal(true);
}, []);
const addTableNode = async () => {
if (!project?.id) {
console.error('❌ No project ID available for adding table');
return;
}
console.log('🔄 Adding new table...');
const newNodeId = `table-${Date.now()}`;
const newNode = createTableNode(newNodeId, `New Table ${nodes.length + 1}`, ['id (PK) - UUID', 'name - VARCHAR(255)', 'description - TEXT', 'createdAt - TIMESTAMP', 'updatedAt - TIMESTAMP'], 400, 300);
const newNodes = [...nodes, newNode];
setNodes(newNodes);
// Auto-save to database
try {
console.log('💾 Saving new table to database...');
await saveToDatabase(newNodes, edges);
console.log('✅ New table saved successfully');
} catch (error) {
console.error('❌ Failed to save new table:', error);
// Optionally revert the UI change if save failed
setNodes(nodes);
}
};
const saveToDatabase = async (nodesToSave = nodes, edgesToSave = edges) => {
if (!project?.id) {
console.error('No project ID available for saving');
return;
}
console.log('💾 saveToDatabase called with:', {
projectId: project.id,
nodeCount: nodesToSave.length,
edgeCount: edgesToSave.length
});
setIsSaving(true);
try {
const updatedData = {
type: 'erd',
nodes: nodesToSave,
edges: edgesToSave
};
console.log('🔄 Calling projectService.updateProject with data:', updatedData);
const result = await projectService.updateProject(project.id, {
data: [updatedData] // Wrap in array to match UpdateProjectRequest type
});
console.log('✅ ERD data saved successfully, result:', result);
setHasUnsavedChanges(false);
} catch (error) {
console.error('❌ Failed to save ERD data:', error);
console.error('Error details:', error);
throw error; // Re-throw so calling function can handle it
} finally {
setIsSaving(false);
}
};
const saveNodeChanges = async (updatedNode: Node) => {
// Update local state first
const newNodes = nodes.map(node =>
node.id === updatedNode.id ? updatedNode : node
);
setNodes(newNodes);
// Save to database
await saveToDatabase(newNodes, edges);
setShowEditModal(false);
setEditingNode(null);
};
const handleManualSave = async () => {
await saveToDatabase();
};
const handleDeleteNode = useCallback(async (event: React.MouseEvent, nodeId: string) => {
event.stopPropagation(); // Prevent triggering the edit modal
if (!project?.id) {
console.error('❌ No project ID available for deleting table');
return;
}
// Show custom confirmation dialog
setNodeToDelete(nodeId);
setShowDeleteConfirm(true);
}, [project?.id]);
const confirmDelete = useCallback(async () => {
if (!nodeToDelete) return;
console.log('🗑️ Deleting table:', nodeToDelete);
try {
// Remove node from UI
const newNodes = nodes.filter(node => node.id !== nodeToDelete);
// Remove any edges connected to this node
const newEdges = edges.filter(edge =>
edge.source !== nodeToDelete && edge.target !== nodeToDelete
);
setNodes(newNodes);
setEdges(newEdges);
// Save to database
console.log('💾 Saving after table deletion...');
await saveToDatabase(newNodes, newEdges);
console.log('✅ Table deleted successfully');
showToast('Table deleted successfully', 'success');
// Close confirmation dialog
setShowDeleteConfirm(false);
setNodeToDelete(null);
} catch (error) {
console.error('❌ Failed to delete table:', error);
// Revert UI changes on error
setNodes(nodes);
setEdges(edges);
showToast('Failed to delete table', 'error');
}
}, [nodeToDelete, nodes, edges, saveToDatabase]);
const cancelDelete = useCallback(() => {
setShowDeleteConfirm(false);
setNodeToDelete(null);
}, []);
// Memoize nodeTypes to prevent recreation on every render
const nodeTypes = useMemo(() => ({
table: ({ data, id }: any) => (
<div
className="p-3 rounded-lg bg-gradient-to-r from-cyan-900/40 to-cyan-800/30 backdrop-blur-md border border-cyan-500/50 min-w-[220px] transition-all duration-300 hover:border-cyan-500/70 hover:shadow-[0_0_15px_rgba(34,211,238,0.2)] group"
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-cyan-400" />
<div className="text-sm font-bold text-white border-b border-gray-600 pb-2">
{data.label}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => handleDeleteNode(e, id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-600/20 rounded"
title="Delete table"
>
<Trash2 className="w-3 h-3 text-red-400 hover:text-red-300" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
// Find the actual node from the nodes array instead of creating a fake one
const actualNode = nodes.find(node => node.id === id);
if (actualNode) {
handleNodeClick(e, actualNode);
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-cyan-600/20 rounded"
title="Edit table"
>
<Edit className="w-3 h-3 text-cyan-400 hover:text-cyan-300" />
</button>
</div>
</div>
<div className="text-xs text-left text-cyan-600">
{data.columns.map((col: string, i: number) => {
const isPK = col.includes('PK');
const isFK = col.includes('FK');
return (
<div key={i} className={`py-1 relative ${isPK ? 'text-cyan-400 font-bold' : ''} ${isFK ? 'text-fuchsia-400 italic' : ''}`}>
{col}
{isPK && (
<Handle
type="source"
position={Position.Right}
id={`${data.label}-${col.split(' ')[0]}`}
className="w-2 h-2 !bg-cyan-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(34,211,238,0.6)]"
style={{ right: -10 }}
/>
)}
{isFK && (
<Handle
type="target"
position={Position.Left}
id={`${data.label}-${col.split(' ')[0]}`}
className="w-2 h-2 !bg-fuchsia-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(217,70,239,0.6)]"
style={{ left: -10 }}
/>
)}
</div>
);
})}
</div>
</div>
)
}), [handleNodeClick, handleDeleteNode, nodes]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading data...</div>
</div>
);
}
return (
<div className="relative pt-8">
<div className="absolute inset-0 pointer-events-none bg-[linear-gradient(to_right,rgba(0,255,255,0.03)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,255,255,0.03)_1px,transparent_1px)] bg-[size:20px_20px]"></div>
<div className="relative z-10">
<div className="flex justify-between items-center mb-4">
<div className="text-lg text-cyan-400 font-mono flex items-center">
<span className="w-2 h-2 rounded-full bg-cyan-400 mr-2 shadow-[0_0_8px_rgba(34,211,238,0.6)]"></span>
{viewMode === 'metadata' ? 'Data Overview' : 'Data Relationships'}
{viewMode === 'erd' && nodes.length > 0 && ` (${nodes.length} tables)`}
{viewMode === 'metadata' && Array.isArray(project?.data) && ` (${project.data.length} items)`}
</div>
{viewMode === 'metadata' && (
<button
onClick={() => setViewMode('erd')}
className="px-3 py-1.5 rounded-lg bg-cyan-900/20 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-900/30 hover:border-cyan-500/50 transition-all duration-300 text-xs"
>
Switch to ERD
</button>
)}
{viewMode === 'erd' && (
<div className="flex gap-2">
<button
onClick={() => setViewMode('metadata')}
className="px-3 py-1.5 rounded-lg bg-purple-900/20 border border-purple-500/30 text-purple-400 hover:bg-purple-900/30 hover:border-purple-500/50 transition-all duration-300 text-xs"
>
Data Overview
</button>
{hasUnsavedChanges && (
<button
onClick={handleManualSave}
disabled={isSaving}
className="px-3 py-1.5 rounded-lg bg-green-900/20 border border-green-500/30 text-green-400 hover:bg-green-900/30 hover:border-green-500/50 transition-all duration-300 text-xs flex items-center gap-2"
>
<Save className="w-3 h-3" />
{isSaving ? 'Saving...' : 'Save Layout'}
</button>
)}
<button onClick={addTableNode} className="p-2 rounded-lg bg-cyan-900/20 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-900/30 hover:border-cyan-500/50 hover:shadow-[0_0_15px_rgba(34,211,238,0.3)] transition-all duration-300 flex items-center justify-center gap-2 w-full md:w-auto relative overflow-hidden group">
<span className="absolute inset-0 bg-cyan-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></span>
<Database className="w-4 h-4 relative z-10" />
<span className="text-xs relative z-10">Add Table</span>
</button>
</div>
)}
</div>
{viewMode === 'metadata' ? (
<div className="space-y-6">
{Array.isArray(project?.data) && project.data.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{project.data.map((item, index) => (
<DataCard key={index} data={item} />
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Info className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg mb-2">No metadata available</p>
<p className="text-sm">Switch to ERD view to see database schema</p>
</div>
)}
</div>
) : (
<div className="h-[70vh] relative">
{/* Subtle neon glow at the top */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-cyan-500/30 shadow-[0_0_10px_rgba(34,211,238,0.2)] z-10"></div>
{nodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Database className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg mb-2">No data schema defined</p>
<p className="text-sm">Add tables to design your database</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
connectionLineType={ConnectionLineType.Step}
defaultEdgeOptions={{
type: 'step',
style: {
stroke: '#d946ef'
},
animated: true,
markerEnd: {
type: MarkerType.Arrow,
color: '#d946ef'
}
}}
fitView
>
<Controls className="!bg-white/70 dark:!bg-black/70 !border-gray-300 dark:!border-gray-800" />
</ReactFlow>
)}
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<DeleteConfirmModal
onConfirm={confirmDelete}
onCancel={cancelDelete}
tableName={nodes.find(n => n.id === nodeToDelete)?.data.label as string || 'table'}
/>
)}
{/* Edit Modal */}
{showEditModal && editingNode && (
<EditTableModal
node={editingNode}
nodes={nodes}
edges={edges}
onSave={saveNodeChanges}
onUpdateEdges={setEdges}
onClose={() => {
setShowEditModal(false);
setEditingNode(null);
}}
/>
)}
</div>
</div>
);
};
// Delete Confirmation Modal Component
const DeleteConfirmModal = ({
onConfirm,
onCancel,
tableName
}: {
onConfirm: () => void;
onCancel: () => void;
tableName: string;
}) => {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="relative p-6 rounded-md backdrop-blur-md w-full max-w-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)]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px]
before:rounded-t-[4px] before:bg-red-500
before:shadow-[0_0_10px_2px_rgba(239,68,68,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(239,68,68,0.7)]">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Delete Table
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
</div>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete the <span className="font-medium text-red-600 dark:text-red-400">"{tableName}"</span> table?
This will also remove all related connections.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors shadow-lg shadow-red-600/25 hover:shadow-red-700/25"
>
Delete Table
</button>
</div>
</div>
</div>
</div>
);
};
// Column interface for better type management
interface ColumnDefinition {
name: string;
dataType: string;
columnType: 'regular' | 'pk' | 'fk';
referencedTable?: string;
referencedColumn?: string;
}
// Edit Table Modal Component
const EditTableModal = ({
node,
nodes,
edges,
onSave,
onUpdateEdges,
onClose
}: {
node: Node;
nodes: Node[];
edges: Edge[];
onSave: (node: Node) => void;
onUpdateEdges: (edges: Edge[]) => void;
onClose: () => void;
}) => {
const [tableName, setTableName] = useState(node.data.label as string);
const [columns, setColumns] = useState<ColumnDefinition[]>([]);
// Parse existing columns into structured format
useEffect(() => {
const parsedColumns = (node.data.columns as string[]).map((colStr: string) => {
const parts = colStr.split(' - ');
const nameAndType = parts[0];
const dataType = parts[1] || 'VARCHAR(255)';
let columnType: 'regular' | 'pk' | 'fk' = 'regular';
let name = nameAndType;
const referencedTable = '';
const referencedColumn = '';
if (nameAndType.includes('(PK)')) {
columnType = 'pk';
name = nameAndType.replace(' (PK)', '');
} else if (nameAndType.includes('(FK)')) {
columnType = 'fk';
name = nameAndType.replace(' (FK)', '');
}
return {
name,
dataType,
columnType,
referencedTable,
referencedColumn
};
});
setColumns(parsedColumns);
}, [node.data.columns]);
const addColumn = () => {
setColumns([...columns, {
name: 'newColumn',
dataType: 'VARCHAR(255)',
columnType: 'regular',
referencedTable: '',
referencedColumn: ''
}]);
};
const updateColumn = (index: number, field: keyof ColumnDefinition, value: string) => {
const newColumns = [...columns];
newColumns[index] = { ...newColumns[index], [field]: value };
setColumns(newColumns);
};
const removeColumn = (index: number) => {
setColumns(columns.filter((_, i) => i !== index));
};
// Get available tables for FK references (exclude current table)
const getAvailableTables = () => {
return nodes.filter(n => n.id !== node.id).map(n => ({
id: n.id,
label: n.data.label as string
}));
};
// Get available columns for a specific table
const getAvailableColumns = (tableId: string) => {
const targetNode = nodes.find(n => n.id === tableId);
if (!targetNode) return [];
return (targetNode.data.columns as string[])
.filter(col => col.includes('(PK)')) // Only allow referencing primary keys
.map(col => {
const name = col.split(' - ')[0].replace(' (PK)', '');
return { name, label: name };
});
};
const handleSave = () => {
// Convert columns back to string format
const columnStrings = columns.map(col => {
let name = col.name;
if (col.columnType === 'pk') {
name += ' (PK)';
} else if (col.columnType === 'fk') {
name += ' (FK)';
}
return `${name} - ${col.dataType}`;
});
const updatedNode = {
...node,
data: {
...node.data,
label: tableName,
columns: columnStrings
}
};
// Create edges for FK relationships
const newEdges = [...edges];
// Remove existing edges from this table
const filteredEdges = newEdges.filter(edge => edge.source !== node.id);
// Add new edges for FK columns
columns.forEach(col => {
if (col.columnType === 'fk' && col.referencedTable && col.referencedColumn) {
const edgeId = `${col.referencedTable}-${node.id}`;
const newEdge = {
id: edgeId,
source: col.referencedTable,
target: node.id,
sourceHandle: `${nodes.find(n => n.id === col.referencedTable)?.data.label}-${col.referencedColumn}`,
targetHandle: `${tableName}-${col.name}`,
animated: true,
style: {
stroke: '#d946ef'
},
markerEnd: {
type: MarkerType.Arrow,
color: '#d946ef'
}
};
filteredEdges.push(newEdge);
}
});
// Update edges state
onUpdateEdges(filteredEdges);
onSave(updatedNode);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 border border-cyan-500/30 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-cyan-400 flex items-center gap-2">
<Database className="w-5 h-5" />
Edit Table
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{/* Table Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Table Name
</label>
<input
type="text"
value={tableName}
onChange={(e) => setTableName(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none"
/>
</div>
{/* Columns */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">
Columns
</label>
<button
onClick={addColumn}
className="px-2 py-1 bg-cyan-900/30 border border-cyan-500/30 text-cyan-400 rounded text-xs hover:bg-cyan-900/50 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Add Column
</button>
</div>
{/* Column Headers */}
<div className="grid grid-cols-12 items-center gap-2 mb-2 text-xs text-gray-400 font-medium">
<div className="col-span-3">Column Name</div>
<div className="col-span-2">Data Type</div>
<div className="col-span-2">Type</div>
<div className="col-span-4">References (FK only)</div>
<div className="col-span-1"></div>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{columns.map((column, index) => (
<div key={index} className="grid grid-cols-12 items-center gap-2">
{/* Column Name */}
<input
type="text"
placeholder="Column name"
value={column.name}
onChange={(e) => updateColumn(index, 'name', e.target.value)}
className="col-span-3 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none text-sm"
/>
{/* Data Type */}
<input
type="text"
placeholder="Data type"
value={column.dataType}
onChange={(e) => updateColumn(index, 'dataType', e.target.value)}
className="col-span-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none text-sm"
/>
{/* Column Type */}
<select
value={column.columnType}
onChange={(e) => updateColumn(index, 'columnType', e.target.value)}
className="col-span-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none text-sm"
>
<option value="regular">Regular</option>
<option value="pk">Primary Key</option>
<option value="fk">Foreign Key</option>
</select>
{/* FK Reference (only show for FK columns) */}
{column.columnType === 'fk' && (
<>
<select
value={column.referencedTable || ''}
onChange={(e) => updateColumn(index, 'referencedTable', e.target.value)}
className="col-span-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none text-sm"
>
<option value="">Select table...</option>
{getAvailableTables().map((table) => (
<option key={table.id} value={table.id}>
{table.label}
</option>
))}
</select>
<select
value={column.referencedColumn || ''}
onChange={(e) => updateColumn(index, 'referencedColumn', e.target.value)}
disabled={!column.referencedTable}
className="col-span-2 px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none text-sm disabled:opacity-50"
>
<option value="">Select column...</option>
{column.referencedTable && getAvailableColumns(column.referencedTable).map((col) => (
<option key={col.name} value={col.name}>
{col.label}
</option>
))}
</select>
</>
)}
{/* Spacer for non-FK columns */}
{column.columnType !== 'fk' && <div className="col-span-4"></div>}
{/* Remove Button */}
<button
onClick={() => removeColumn(index)}
className="col-span-1 flex items-center justify-center p-1 text-red-400 hover:text-red-300 hover:bg-red-600/10 rounded transition-colors"
title="Delete column"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 mt-6">
<button
onClick={handleSave}
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-lg transition-colors"
>
Save Changes
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,148 +0,0 @@
import React, { useState } from 'react';
import { Rocket, Code, Briefcase, Users, FileText, X, Plus, Clipboard } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
export interface ProjectDoc {
id: string;
title: string;
content: any;
document_type?: string;
updated_at: string;
created_at?: string;
}
interface DocumentCardProps {
document: ProjectDoc;
isActive: boolean;
onSelect: (doc: ProjectDoc) => void;
onDelete: (docId: string) => void;
isDarkMode: boolean;
}
export const DocumentCard: React.FC<DocumentCardProps> = ({
document,
isActive,
onSelect,
onDelete,
isDarkMode
}) => {
const [showDelete, setShowDelete] = useState(false);
const { showToast } = useToast();
const getDocumentIcon = (type?: string) => {
switch (type) {
case 'prp': return <Rocket className="w-4 h-4" />;
case 'technical': return <Code className="w-4 h-4" />;
case 'business': return <Briefcase className="w-4 h-4" />;
case 'meeting_notes': return <Users className="w-4 h-4" />;
default: return <FileText className="w-4 h-4" />;
}
};
const getTypeColor = (type?: string) => {
switch (type) {
case 'prp': return 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30';
case 'technical': return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30';
case 'business': return 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30';
case 'meeting_notes': return 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30';
default: return 'bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30';
}
};
const handleCopyId = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(document.id);
showToast('Document ID copied to clipboard', 'success');
// Visual feedback
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
};
return (
<div
className={`
relative flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer
transition-all duration-200 group
${isActive
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 shadow-lg scale-105'
: 'bg-white/50 dark:bg-black/30 border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md'
}
`}
onClick={() => onSelect(document)}
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
{/* Document Type Badge */}
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mb-2 border ${getTypeColor(document.document_type)}`}>
{getDocumentIcon(document.document_type)}
<span>{document.document_type || 'document'}</span>
</div>
{/* Title */}
<h4 className="font-medium text-gray-900 dark:text-white text-sm line-clamp-2 mb-1">
{document.title}
</h4>
{/* Metadata */}
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
{new Date(document.updated_at || document.created_at || Date.now()).toLocaleDateString()}
</p>
{/* ID Display Section - Always visible for active, hover for others */}
<div className={`flex items-center justify-between mt-2 ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-200`}>
<span className="text-xs text-gray-400 dark:text-gray-500 truncate max-w-[120px]" title={document.id}>
{document.id.slice(0, 8)}...
</span>
<button
type="button"
onClick={handleCopyId}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Copy Document ID to clipboard"
aria-label="Copy Document ID to clipboard"
>
<Clipboard className="w-3 h-3" aria-hidden="true" />
</button>
</div>
{/* Delete Button */}
{showDelete && !isActive && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete "${document.title}"?`)) {
onDelete(document.id);
}
}}
className="absolute top-2 right-2 p-1 rounded-md bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-400 transition-colors"
aria-label={`Delete ${document.title}`}
title="Delete document"
>
<X className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
);
};
// New Document Card Component
interface NewDocumentCardProps {
onClick: () => void;
}
export const NewDocumentCard: React.FC<NewDocumentCardProps> = ({ onClick }) => {
return (
<div
onClick={onClick}
className="flex-shrink-0 w-48 h-[120px] rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 flex flex-col items-center justify-center cursor-pointer transition-colors group"
>
<Plus className="w-8 h-8 text-gray-400 group-hover:text-blue-500 transition-colors mb-2" />
<span className="text-sm text-gray-500 group-hover:text-blue-500">New Document</span>
</div>
);
};

View File

@@ -1,257 +0,0 @@
import React, { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { Edit, Trash2, RefreshCw, Tag, User, Bot, Clipboard } from 'lucide-react';
import { Task } from './TaskTableView';
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
export interface DraggableTaskCardProps {
task: Task;
index: number;
onView: () => void;
onComplete: () => void;
onDelete: (task: Task) => void;
onTaskReorder: (taskId: string, targetIndex: number, status: Task['status']) => void;
tasksInStatus: Task[];
allTasks?: Task[];
hoveredTaskId?: string | null;
onTaskHover?: (taskId: string | null) => void;
}
export const DraggableTaskCard = ({
task,
index,
onView,
onDelete,
onTaskReorder,
allTasks = [],
hoveredTaskId,
onTaskHover,
}: DraggableTaskCardProps) => {
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.TASK,
item: { id: task.id, status: task.status, index },
collect: (monitor) => ({
isDragging: !!monitor.isDragging()
})
});
const [, drop] = useDrop({
accept: ItemTypes.TASK,
hover: (draggedItem: { id: string; status: Task['status']; index: number }, monitor) => {
if (!monitor.isOver({ shallow: true })) return;
if (draggedItem.id === task.id) return;
if (draggedItem.status !== task.status) return;
const draggedIndex = draggedItem.index;
const hoveredIndex = index;
if (draggedIndex === hoveredIndex) return;
console.log('BOARD HOVER: Moving task', draggedItem.id, 'from index', draggedIndex, 'to', hoveredIndex, 'in status', task.status);
// Move the task immediately for visual feedback (same pattern as table view)
onTaskReorder(draggedItem.id, hoveredIndex, task.status);
// Update the dragged item's index to prevent re-triggering
draggedItem.index = hoveredIndex;
}
});
const [isFlipped, setIsFlipped] = useState(false);
const toggleFlip = (e: React.MouseEvent) => {
e.stopPropagation();
setIsFlipped(!isFlipped);
};
// Calculate hover effects for parent-child relationships
const getRelatedTaskIds = () => {
const relatedIds = new Set<string>();
return relatedIds;
};
const relatedTaskIds = getRelatedTaskIds();
const isHighlighted = hoveredTaskId ? relatedTaskIds.has(hoveredTaskId) || hoveredTaskId === task.id : false;
const handleMouseEnter = () => {
onTaskHover?.(task.id);
};
const handleMouseLeave = () => {
onTaskHover?.(null);
};
// Card styling - using CSS-based height animation for better scrolling
// Card styling constants
const cardScale = 'scale-100';
const cardOpacity = 'opacity-100';
// Subtle highlight effect for related tasks - applied to the card, not parent
const highlightGlow = isHighlighted
? 'border-cyan-400/50 shadow-[0_0_8px_rgba(34,211,238,0.2)]'
: '';
// Simplified hover effect - just a glowing border
const hoverEffectClasses = 'group-hover:border-cyan-400/70 dark:group-hover:border-cyan-500/50 group-hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] dark:group-hover:shadow-[0_0_15px_rgba(34,211,238,0.6)]';
// Base card styles with proper rounded corners
const cardBaseStyles = 'bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-gray-700 rounded-lg';
// Transition settings
const transitionStyles = 'transition-all duration-200 ease-in-out';
return (
<div
ref={(node) => drag(drop(node))}
style={{
perspective: '1000px',
transformStyle: 'preserve-3d'
}}
className={`flip-card w-full min-h-[140px] cursor-move relative ${cardScale} ${cardOpacity} ${isDragging ? 'opacity-50 scale-90' : ''} ${transitionStyles} group`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={`relative w-full min-h-[140px] transform-style-preserve-3d ${isFlipped ? 'rotate-y-180' : ''}`}
>
{/* Front side with subtle hover effect */}
<div className={`absolute w-full h-full backface-hidden ${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} rounded-lg`}>
{/* Priority indicator */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] ${getOrderColor(task.task_order)} ${getOrderGlow(task.task_order)} rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300`}></div>
{/* Content container with fixed padding - exactly matching back side structure */}
<div className="flex flex-col h-full p-3">
<div className="flex items-center gap-2 mb-2 pl-1.5">
<div className="px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1 backdrop-blur-md"
style={{
backgroundColor: `${task.featureColor}20`,
color: task.featureColor,
boxShadow: `0 0 10px ${task.featureColor}20`
}}
>
<Tag className="w-3 h-3" />
{task.feature}
</div>
{/* Task order display */}
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold text-white ${getOrderColor(task.task_order)}`}>
{task.task_order}
</div>
{/* Action buttons group */}
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(task);
}}
className="w-5 h-5 rounded-full flex items-center justify-center bg-red-100/80 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 hover:shadow-[0_0_10px_rgba(239,68,68,0.3)] transition-all duration-300"
title="Delete task"
aria-label="Delete task"
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onView();
}}
className="w-5 h-5 rounded-full flex items-center justify-center bg-cyan-100/80 dark:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-200 dark:hover:bg-cyan-500/30 hover:shadow-[0_0_10px_rgba(34,211,238,0.3)] transition-all duration-300"
title="Edit task"
aria-label="Edit task"
>
<Edit className="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={toggleFlip}
className="w-5 h-5 rounded-full flex items-center justify-center bg-cyan-100/80 dark:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-200 dark:hover:bg-cyan-500/30 hover:shadow-[0_0_10px_rgba(34,211,238,0.3)] transition-all duration-300"
title="View task details"
aria-label="View task details"
>
<RefreshCw className="w-3 h-3" aria-hidden="true" />
</button>
</div>
</div>
<h4 className="text-xs font-medium text-gray-900 dark:text-white mb-2 pl-1.5 line-clamp-2 overflow-hidden" title={task.title}>
{task.title}
</h4>
{/* Spacer to push assignee section to bottom */}
<div className="flex-1"></div>
<div className="flex items-center justify-between mt-auto pt-2 pl-1.5 pr-3">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-white/80 dark:bg-black/70 border border-gray-300/50 dark:border-gray-700/50 backdrop-blur-md"
style={{boxShadow: getAssigneeGlow(task.assignee?.name || 'User')}}
>
{getAssigneeIcon(task.assignee?.name || 'User')}
</div>
<span className="text-gray-600 dark:text-gray-400 text-xs">{task.assignee?.name || 'User'}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
// Optional: Add a small toast or visual feedback here
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<span class="text-green-500">Copied!</span>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
}}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title="Copy Task ID to clipboard"
aria-label="Copy Task ID to clipboard"
>
<Clipboard className="w-3 h-3" aria-hidden="true" />
<span>Task ID</span>
</button>
</div>
</div>
</div>
{/* Back side */}
{/* Back side with same hover effect */}
<div className={`absolute w-full h-full backface-hidden ${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} rounded-lg rotate-y-180 ${isDragging ? 'opacity-0' : 'opacity-100'}`}>
{/* Priority indicator */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] ${getOrderColor(task.task_order)} ${getOrderGlow(task.task_order)} rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300`}></div>
{/* Content container with fixed padding */}
<div className="flex flex-col h-full p-3">
<div className="flex items-center gap-2 mb-2 pl-1.5">
<h4 className="text-xs font-medium text-gray-900 dark:text-white truncate max-w-[75%]">
{task.title}
</h4>
<button
type="button"
onClick={toggleFlip}
className="ml-auto w-5 h-5 rounded-full flex items-center justify-center bg-cyan-100/80 dark:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-200 dark:hover:bg-cyan-500/30 hover:shadow-[0_0_10px_rgba(34,211,238,0.3)] transition-all duration-300"
title="Flip back to front"
aria-label="Flip back to front"
>
<RefreshCw className="w-3 h-3" aria-hidden="true" />
</button>
</div>
{/* Description container with absolute positioning inside parent bounds */}
<div className="flex-1 overflow-hidden relative">
<div className="absolute inset-0 overflow-y-auto hide-scrollbar pl-1.5 pr-2">
<p className="text-xs text-gray-700 dark:text-gray-300 break-words whitespace-pre-wrap" style={{fontSize: '11px'}}>{task.description}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,243 +0,0 @@
import React, { memo, useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { Button } from '../ui/Button';
import { ArchonLoadingSpinner } from '../animations/Animations';
import { DebouncedInput, FeatureInput } from './TaskInputComponents';
import type { Task } from './TaskTableView';
interface EditTaskModalProps {
isModalOpen: boolean;
editingTask: Task | null;
projectFeatures: any[];
isLoadingFeatures: boolean;
isSavingTask: boolean;
onClose: () => void;
onSave: (task: Task) => Promise<void>;
getTasksForPrioritySelection: (status: Task['status']) => Array<{value: number, label: string}>;
}
const ASSIGNEE_OPTIONS = ['User', 'Archon', 'AI IDE Agent'] as const;
// Removed debounce utility - now using DebouncedInput component
export const EditTaskModal = memo(({
isModalOpen,
editingTask,
projectFeatures,
isLoadingFeatures,
isSavingTask,
onClose,
onSave,
getTasksForPrioritySelection
}: EditTaskModalProps) => {
const [localTask, setLocalTask] = useState<Task | null>(null);
// Diagnostic: Track render count
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
console.log(`[EditTaskModal] Render #${renderCount.current}`, {
localTask: localTask?.title,
isModalOpen,
timestamp: Date.now()
});
});
// Sync local state with editingTask when it changes
useEffect(() => {
if (editingTask) {
setLocalTask(editingTask);
}
}, [editingTask]);
const priorityOptions = useMemo(() => {
console.log(`[EditTaskModal] Recalculating priorityOptions for status: ${localTask?.status || 'backlog'}`);
return getTasksForPrioritySelection(localTask?.status || 'backlog');
}, [localTask?.status, getTasksForPrioritySelection]);
// Memoized handlers for input changes
const handleTitleChange = useCallback((value: string) => {
console.log('[EditTaskModal] Title changed via DebouncedInput:', value);
setLocalTask(prev => prev ? { ...prev, title: value } : null);
}, []);
const handleDescriptionChange = useCallback((value: string) => {
console.log('[EditTaskModal] Description changed via DebouncedInput:', value);
setLocalTask(prev => prev ? { ...prev, description: value } : null);
}, []);
const handleFeatureChange = useCallback((value: string) => {
console.log('[EditTaskModal] Feature changed via FeatureInput:', value);
setLocalTask(prev => prev ? { ...prev, feature: value } : null);
}, []);
const handleStatusChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const newStatus = e.target.value as Task['status'];
const newOrder = getTasksForPrioritySelection(newStatus)[0]?.value || 1;
setLocalTask(prev => prev ? { ...prev, status: newStatus, task_order: newOrder } : null);
}, [getTasksForPrioritySelection]);
const handlePriorityChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setLocalTask(prev => prev ? { ...prev, task_order: parseInt(e.target.value) } : null);
}, []);
const handleAssigneeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setLocalTask(prev => prev ? {
...prev,
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
} : null);
}, []);
const handleSave = useCallback(() => {
if (localTask) {
onSave(localTask);
}
}, [localTask, onSave]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
if (!isModalOpen) return null;
return (
<div className="fixed inset-0 bg-gray-500/50 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="relative p-6 rounded-md backdrop-blur-md w-full max-w-2xl 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)] before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px] before:rounded-t-[4px] before:bg-gradient-to-r before:from-cyan-500 before:to-fuchsia-500 before:shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)] after:content-[''] after:absolute after:top-0 after:left-0 after:right-0 after:h-16 after:bg-gradient-to-b after:from-cyan-100 after:to-white dark:after:from-cyan-500/20 dark:after:to-fuchsia-500/5 after:rounded-t-md after:pointer-events-none">
<div className="relative z-10">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold bg-gradient-to-r from-cyan-400 to-fuchsia-500 text-transparent bg-clip-text">
{editingTask?.id ? 'Edit Task' : 'New Task'}
</h3>
<button onClick={handleClose} className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Title</label>
<DebouncedInput
value={localTask?.title || ''}
onChange={handleTitleChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
/>
</div>
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Description</label>
<DebouncedInput
value={localTask?.description || ''}
onChange={handleDescriptionChange}
type="textarea"
rows={5}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select
value={localTask?.status || 'backlog'}
onChange={handleStatusChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
>
<option value="backlog">Backlog</option>
<option value="in-progress">In Process</option>
<option value="review">Review</option>
<option value="complete">Complete</option>
</select>
</div>
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={localTask?.task_order || 1}
onChange={handlePriorityChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
>
{priorityOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Assignee</label>
<select
value={localTask?.assignee?.name || 'User'}
onChange={handleAssigneeChange}
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
>
{ASSIGNEE_OPTIONS.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div>
<label className="block text-gray-700 dark:text-gray-300 mb-1">Feature</label>
<FeatureInput
value={localTask?.feature || ''}
onChange={handleFeatureChange}
projectFeatures={projectFeatures}
isLoadingFeatures={isLoadingFeatures}
placeholder="Type feature name"
className="w-full bg-white/50 dark:bg-black/70 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-white rounded-md py-2 px-3 pr-10 focus:outline-none focus:border-cyan-400 focus:shadow-[0_0_10px_rgba(34,211,238,0.2)] transition-all duration-300"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button onClick={handleClose} variant="ghost" disabled={isSavingTask}>Cancel</Button>
<Button
onClick={handleSave}
variant="primary"
accentColor="cyan"
className="shadow-lg shadow-cyan-500/20"
disabled={isSavingTask}
>
{isSavingTask ? (
<span className="flex items-center">
<ArchonLoadingSpinner size="sm" className="mr-2" />
{localTask?.id ? 'Saving...' : 'Creating...'}
</span>
) : (
localTask?.id ? 'Save Changes' : 'Create Task'
)}
</Button>
</div>
</div>
</div>
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
// Only re-render if these specific props change
const isEqual = (
prevProps.isModalOpen === nextProps.isModalOpen &&
prevProps.editingTask?.id === nextProps.editingTask?.id &&
prevProps.editingTask?.title === nextProps.editingTask?.title &&
prevProps.editingTask?.description === nextProps.editingTask?.description &&
prevProps.editingTask?.status === nextProps.editingTask?.status &&
prevProps.editingTask?.assignee?.name === nextProps.editingTask?.assignee?.name &&
prevProps.editingTask?.feature === nextProps.editingTask?.feature &&
prevProps.editingTask?.task_order === nextProps.editingTask?.task_order &&
prevProps.isSavingTask === nextProps.isSavingTask &&
prevProps.isLoadingFeatures === nextProps.isLoadingFeatures &&
prevProps.projectFeatures === nextProps.projectFeatures // Reference equality check
);
if (!isEqual) {
console.log('[EditTaskModal] Props changed, re-rendering');
}
return isEqual;
});
EditTaskModal.displayName = 'EditTaskModal';

View File

@@ -1,814 +0,0 @@
import { useCallback, useState, useEffect, useMemo } from 'react'
import '@xyflow/react/dist/style.css'
import {
ReactFlow,
Node,
Edge,
Controls,
MarkerType,
NodeProps,
Handle,
Position,
NodeChange,
applyNodeChanges,
EdgeChange,
applyEdgeChanges,
Connection,
addEdge,
} from '@xyflow/react'
import { Layout, Component as ComponentIcon, X, Trash2, Edit, Save } from 'lucide-react'
import { projectService } from '../../services/projectService'
import { useToast } from '../../contexts/ToastContext'
// Define custom node types following React Flow v12 pattern
type PageNodeData = {
label: string;
type: string;
route: string;
components: number;
};
type ServiceNodeData = {
label: string;
type: string;
};
// Define union type for all custom nodes
type CustomNodeTypes = Node<PageNodeData, 'page'> | Node<ServiceNodeData, 'service'>;
// Custom node components
const PageNode = ({ data }: NodeProps) => {
const pageData = data as PageNodeData;
return (
<div className="relative group">
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 !bg-cyan-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(34,211,238,0.6)]"
/>
<div className="p-4 rounded-lg bg-[#1a2c3b]/80 border border-cyan-500/30 min-w-[200px] backdrop-blur-sm transition-all duration-300 group-hover:border-cyan-500/70 group-hover:shadow-[0_5px_15px_rgba(34,211,238,0.15)]">
<div className="flex items-center gap-2 mb-2">
<Layout className="w-4 h-4 text-cyan-400" />
<div className="text-sm font-bold text-cyan-400">{pageData.label}</div>
</div>
<div className="text-xs text-gray-400">{pageData.type}</div>
<div className="mt-2 text-xs text-gray-500">
<div>Route: {pageData.route}</div>
<div>Components: {pageData.components}</div>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 !bg-cyan-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(34,211,238,0.6)]"
/>
</div>
);
};
const ServiceNode = ({ data }: NodeProps) => {
const serviceData = data as ServiceNodeData;
return (
<div className="relative group">
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 !bg-fuchsia-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(217,70,239,0.6)]"
/>
<div className="p-4 rounded-lg bg-[#2d1a3b]/80 border border-fuchsia-500/30 min-w-[200px] backdrop-blur-sm transition-all duration-300 group-hover:border-fuchsia-500/70 group-hover:shadow-[0_5px_15px_rgba(217,70,239,0.15)]">
<div className="flex items-center gap-2 mb-2">
<ComponentIcon className="w-4 h-4 text-fuchsia-400" />
<div className="text-sm font-bold text-fuchsia-400">{serviceData.label}</div>
</div>
<div className="text-xs text-gray-400">{serviceData.type}</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 !bg-fuchsia-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(217,70,239,0.6)]"
/>
</div>
);
};
const nodeTypes = {
page: PageNode,
service: ServiceNode,
}
// Default/fallback nodes for when project has no features data
const defaultNodes: Node[] = [
{
id: 'start',
type: 'page',
data: {
label: 'Start App',
type: 'Entry Point',
route: '/',
components: 3,
},
position: {
x: 400,
y: 0,
},
},
{
id: 'home',
type: 'page',
data: {
label: 'Homepage',
type: 'Main View',
route: '/home',
components: 6,
},
position: {
x: 400,
y: 150,
},
},
];
// Default/fallback edges
const defaultEdges: Edge[] = [
{
id: 'start-home',
source: 'start',
target: 'home',
animated: true,
style: {
stroke: '#22d3ee',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#22d3ee',
},
},
];
interface FeaturesTabProps {
project?: {
id: string;
title: string;
features?: any[];
} | null;
}
export const FeaturesTab = ({ project }: FeaturesTabProps) => {
const [nodes, setNodes] = useState<Node[]>([])
const [edges, setEdges] = useState<Edge[]>([])
const [loading, setLoading] = useState(true)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [nodeToDelete, setNodeToDelete] = useState<string | null>(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editingNode, setEditingNode] = useState<Node | null>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const { showToast } = useToast()
// Load features from project or show empty state
useEffect(() => {
if (project?.features && Array.isArray(project.features) && project.features.length > 0) {
// Ensure all nodes have required properties with defaults
const normalizedNodes = project.features.map((node: any, index: number) => ({
...node,
// Ensure position exists with sensible defaults
position: node.position || {
x: 250 + (index % 3) * 250, // Spread horizontally
y: 200 + Math.floor(index / 3) * 150 // Stack vertically
},
// Ensure type exists (fallback based on data structure)
type: node.type || (node.data?.route ? 'page' : 'service'),
// Ensure data exists
data: node.data || { label: 'Unknown', type: 'Unknown Component' }
}));
setNodes(normalizedNodes)
// Generate edges based on the flow (simplified logic)
const generatedEdges = generateEdgesFromNodes(normalizedNodes)
setEdges(generatedEdges)
} else {
// Show empty state - no nodes or edges
setNodes([])
setEdges([])
}
setLoading(false)
}, [project])
// Helper function to generate edges based on node positioning and types
const generateEdgesFromNodes = (nodes: Node[]): Edge[] => {
const edges: Edge[] = []
// Sort nodes by y position to create a logical flow (with safety check for position)
const sortedNodes = [...nodes].sort((a, b) => {
const aY = a.position?.y || 0;
const bY = b.position?.y || 0;
return aY - bY;
})
for (let i = 0; i < sortedNodes.length - 1; i++) {
const currentNode = sortedNodes[i]
const nextNode = sortedNodes[i + 1]
// Connect sequential nodes with appropriate styling
const edgeStyle = currentNode.type === 'service' ? '#d946ef' : '#22d3ee'
edges.push({
id: `${currentNode.id}-${nextNode.id}`,
source: currentNode.id,
target: nextNode.id,
animated: true,
style: {
stroke: edgeStyle,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: edgeStyle,
},
})
}
return edges
}
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes((nds) => applyNodeChanges(changes, nds))
setHasUnsavedChanges(true)
},
[],
)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
setEdges((eds) => applyEdgeChanges(changes, eds))
setHasUnsavedChanges(true)
},
[],
)
const onConnect = useCallback(
(connection: Connection) => {
const sourceNode = nodes.find((node) => node.id === connection.source)
// Set edge color based on source node type
const edgeStyle =
sourceNode?.type === 'service'
? {
stroke: '#d946ef',
}
: // Fuchsia for service nodes
{
stroke: '#22d3ee',
} // Cyan for page nodes
setEdges((eds) =>
addEdge(
{
...connection,
animated: true,
style: edgeStyle,
markerEnd: {
type: MarkerType.ArrowClosed,
color: edgeStyle.stroke,
},
},
eds,
),
)
setHasUnsavedChanges(true)
},
[nodes],
)
const saveToDatabase = async (nodesToSave = nodes, edgesToSave = edges) => {
if (!project?.id) {
console.error('❌ No project ID available for saving features');
return;
}
setIsSaving(true);
try {
console.log('💾 Saving features to database...');
await projectService.updateProject(project.id, {
features: nodesToSave
});
console.log('✅ Features saved successfully');
setHasUnsavedChanges(false);
} catch (error) {
console.error('❌ Failed to save features:', error);
throw error;
} finally {
setIsSaving(false);
}
};
const handleManualSave = async () => {
await saveToDatabase();
};
const addPageNode = async () => {
const newNode: Node = {
id: `page-${Date.now()}`,
type: 'page',
data: {
label: `New Page`,
type: 'Page Component',
route: '/new-page',
components: 0,
},
position: {
x: 250,
y: 200,
},
}
const newNodes = [...nodes, newNode];
setNodes(newNodes);
setHasUnsavedChanges(true);
// Auto-save when adding
try {
await saveToDatabase(newNodes, edges);
} catch (error) {
// Revert on error
setNodes(nodes);
}
}
const addServiceNode = async () => {
const newNode: Node = {
id: `service-${Date.now()}`,
type: 'service',
data: {
label: 'New Service',
type: 'Service Component',
},
position: {
x: 250,
y: 200,
},
}
const newNodes = [...nodes, newNode];
setNodes(newNodes);
setHasUnsavedChanges(true);
// Auto-save when adding
try {
await saveToDatabase(newNodes, edges);
} catch (error) {
// Revert on error
setNodes(nodes);
}
}
const handleDeleteNode = useCallback(async (event: React.MouseEvent, nodeId: string) => {
event.stopPropagation();
if (!project?.id) {
console.error('❌ No project ID available for deleting node');
return;
}
// Show custom confirmation dialog
setNodeToDelete(nodeId);
setShowDeleteConfirm(true);
}, [project?.id]);
const confirmDelete = useCallback(async () => {
if (!nodeToDelete) return;
console.log('🗑️ Deleting node:', nodeToDelete);
try {
// Remove node from UI
const newNodes = nodes.filter(node => node.id !== nodeToDelete);
// Remove any edges connected to this node
const newEdges = edges.filter(edge =>
edge.source !== nodeToDelete && edge.target !== nodeToDelete
);
setNodes(newNodes);
setEdges(newEdges);
// Save to database
await saveToDatabase(newNodes, newEdges);
showToast('Node deleted successfully', 'success');
// Close confirmation dialog
setShowDeleteConfirm(false);
setNodeToDelete(null);
} catch (error) {
console.error('❌ Failed to delete node:', error);
// Revert UI changes on error
setNodes(nodes);
setEdges(edges);
showToast('Failed to delete node', 'error');
}
}, [nodeToDelete, nodes, edges]);
const cancelDelete = useCallback(() => {
setShowDeleteConfirm(false);
setNodeToDelete(null);
}, []);
const handleNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
setEditingNode(node);
setShowEditModal(true);
}, []);
const saveNodeChanges = async (updatedNode: Node) => {
// Update local state first
const newNodes = nodes.map(node =>
node.id === updatedNode.id ? updatedNode : node
);
setNodes(newNodes);
// Save to database
await saveToDatabase(newNodes, edges);
setShowEditModal(false);
setEditingNode(null);
};
// Memoize node types with delete and edit functionality
const nodeTypes = useMemo(() => ({
page: ({ data, id }: NodeProps) => {
const pageData = data as any;
return (
<div className="relative group">
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 !bg-cyan-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(34,211,238,0.6)]"
/>
<div
className="p-4 rounded-lg bg-[#1a2c3b]/80 border border-cyan-500/30 min-w-[200px] backdrop-blur-sm transition-all duration-300 group-hover:border-cyan-500/70 group-hover:shadow-[0_5px_15px_rgba(34,211,238,0.15)] cursor-pointer"
onClick={(e) => {
const actualNode = nodes.find(node => node.id === id);
if (actualNode) {
handleNodeClick(e, actualNode);
}
}}
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Layout className="w-4 h-4 text-cyan-400" />
<div className="text-sm font-bold text-cyan-400">{pageData.label}</div>
</div>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
const actualNode = nodes.find(node => node.id === id);
if (actualNode) {
handleNodeClick(e, actualNode);
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-cyan-600/20 rounded"
title="Edit node"
>
<Edit className="w-3 h-3 text-cyan-400 hover:text-cyan-300" />
</button>
<button
onClick={(e) => handleDeleteNode(e, id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-600/20 rounded"
title="Delete node"
>
<Trash2 className="w-3 h-3 text-red-400 hover:text-red-300" />
</button>
</div>
</div>
<div className="text-xs text-gray-400">{pageData.type}</div>
<div className="mt-2 text-xs text-gray-500">
<div>Route: {pageData.route}</div>
<div>Components: {pageData.components}</div>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 !bg-cyan-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(34,211,238,0.6)]"
/>
</div>
);
},
service: ({ data, id }: NodeProps) => {
const serviceData = data as any;
return (
<div className="relative group">
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 !bg-fuchsia-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(217,70,239,0.6)]"
/>
<div
className="p-4 rounded-lg bg-[#2d1a3b]/80 border border-fuchsia-500/30 min-w-[200px] backdrop-blur-sm transition-all duration-300 group-hover:border-fuchsia-500/70 group-hover:shadow-[0_5px_15px_rgba(217,70,239,0.15)] cursor-pointer"
onClick={(e) => {
const actualNode = nodes.find(node => node.id === id);
if (actualNode) {
handleNodeClick(e, actualNode);
}
}}
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<ComponentIcon className="w-4 h-4 text-fuchsia-400" />
<div className="text-sm font-bold text-fuchsia-400">{serviceData.label}</div>
</div>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
const actualNode = nodes.find(node => node.id === id);
if (actualNode) {
handleNodeClick(e, actualNode);
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-fuchsia-600/20 rounded"
title="Edit node"
>
<Edit className="w-3 h-3 text-fuchsia-400 hover:text-fuchsia-300" />
</button>
<button
onClick={(e) => handleDeleteNode(e, id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-600/20 rounded"
title="Delete node"
>
<Trash2 className="w-3 h-3 text-red-400 hover:text-red-300" />
</button>
</div>
</div>
<div className="text-xs text-gray-400">{serviceData.type}</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 !bg-fuchsia-400 transition-all duration-300 !opacity-60 group-hover:!opacity-100 group-hover:!shadow-[0_0_8px_rgba(217,70,239,0.6)]"
/>
</div>
);
}
}), [handleNodeClick, handleDeleteNode, nodes]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading features...</div>
</div>
)
}
return (
<div className="relative pt-8">
<div className="absolute inset-0 pointer-events-none bg-[linear-gradient(to_right,rgba(0,255,255,0.03)_1px,transparent_1px),linear-gradient(to_bottom,rgba(0,255,255,0.03)_1px,transparent_1px)] bg-[size:20px_20px]" />
<div className="relative z-10">
<div className="flex justify-between items-center mb-4">
<div className="text-lg text-cyan-400 font-mono flex items-center">
<span className="w-2 h-2 rounded-full bg-cyan-400 mr-2 shadow-[0_0_8px_rgba(34,211,238,0.6)]"></span>
Feature Planner {project?.features ? `(${project.features.length} features)` : '(Default)'}
</div>
<div className="flex gap-2">
{hasUnsavedChanges && (
<button
onClick={handleManualSave}
disabled={isSaving}
className="px-3 py-1.5 rounded-lg bg-green-900/20 border border-green-500/30 text-green-400 hover:bg-green-900/30 hover:border-green-500/50 transition-all duration-300 text-xs flex items-center gap-2"
>
<Save className="w-3 h-3" />
{isSaving ? 'Saving...' : 'Save Layout'}
</button>
)}
<button
onClick={addPageNode}
className="px-3 py-1.5 rounded-lg bg-cyan-900/20 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-900/30 hover:border-cyan-500/50 hover:shadow-[0_0_15px_rgba(34,211,238,0.3)] transition-all duration-300 flex items-center gap-2 relative overflow-hidden group"
>
<span className="absolute inset-0 bg-cyan-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></span>
<Layout className="w-4 h-4 relative z-10" />
<span className="text-xs relative z-10">Add Page</span>
</button>
<button
onClick={addServiceNode}
className="px-3 py-1.5 rounded-lg bg-fuchsia-900/20 border border-fuchsia-500/30 text-fuchsia-400 hover:bg-fuchsia-900/30 hover:border-fuchsia-500/50 hover:shadow-[0_0_15px_rgba(217,70,239,0.3)] transition-all duration-300 flex items-center gap-2 relative overflow-hidden group"
>
<span className="absolute inset-0 bg-fuchsia-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></span>
<ComponentIcon className="w-4 h-4 relative z-10" />
<span className="text-xs relative z-10">Add Service</span>
</button>
</div>
</div>
<div className="h-[70vh] relative">
{/* Subtle neon glow at the top */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-cyan-500/30 shadow-[0_0_10px_rgba(34,211,238,0.2)] z-10"></div>
{nodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Layout className="w-16 h-16 mb-4 opacity-50" />
<p className="text-lg mb-2">No features defined</p>
<p className="text-sm">Add pages and services to get started</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-right"
>
<Controls className="!bg-white/70 dark:!bg-black/70 !border-gray-300 dark:!border-gray-800" />
</ReactFlow>
)}
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<DeleteConfirmModal
onConfirm={confirmDelete}
onCancel={cancelDelete}
nodeName={nodes.find(n => n.id === nodeToDelete)?.data.label as string || 'node'}
/>
)}
{/* Edit Modal */}
{showEditModal && editingNode && (
<EditFeatureModal
node={editingNode}
onSave={saveNodeChanges}
onClose={() => {
setShowEditModal(false);
setEditingNode(null);
}}
/>
)}
</div>
</div>
)
}
// Delete Confirmation Modal Component
const DeleteConfirmModal = ({
onConfirm,
onCancel,
nodeName
}: {
onConfirm: () => void;
onCancel: () => void;
nodeName: string;
}) => {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="relative p-6 rounded-md backdrop-blur-md w-full max-w-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)]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px]
before:rounded-t-[4px] before:bg-red-500
before:shadow-[0_0_10px_2px_rgba(239,68,68,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(239,68,68,0.7)]">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Delete Node
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
</div>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete <span className="font-medium text-red-600 dark:text-red-400">"{nodeName}"</span>?
This will also remove all related connections.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors shadow-lg shadow-red-600/25 hover:shadow-red-700/25"
>
Delete Node
</button>
</div>
</div>
</div>
</div>
);
};
// Edit Feature Modal Component
const EditFeatureModal = ({
node,
onSave,
onClose
}: {
node: Node;
onSave: (node: Node) => void;
onClose: () => void;
}) => {
const [name, setName] = useState(node.data.label as string);
const [route, setRoute] = useState((node.data as any).route || '');
const [components, setComponents] = useState((node.data as any).components || 0);
const isPageNode = node.type === 'page';
const handleSave = () => {
const updatedNode = {
...node,
data: {
...node.data,
label: name,
...(isPageNode && { route, components })
}
};
onSave(updatedNode);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-900 border border-cyan-500/30 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-cyan-400 flex items-center gap-2">
{isPageNode ? <Layout className="w-5 h-5" /> : <ComponentIcon className="w-5 h-5" />}
Edit {isPageNode ? 'Page' : 'Service'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{isPageNode ? 'Page' : 'Service'} Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none"
/>
</div>
{isPageNode && (
<>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Route
</label>
<input
type="text"
value={route}
onChange={(e) => setRoute(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none"
placeholder="/example-page"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Components Count
</label>
<input
type="number"
value={components}
onChange={(e) => setComponents(parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-cyan-500 focus:outline-none"
min="0"
/>
</div>
</>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-lg transition-colors shadow-lg shadow-cyan-600/25 hover:shadow-cyan-700/25 flex items-center gap-2"
>
<Save className="w-4 h-4" />
Save Changes
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,277 +0,0 @@
/* Milkdown Editor Custom Styles - Archon Theme */
/* Main editor container */
.milkdown-crepe-editor {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 12px;
padding: 1.5rem;
position: relative;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Gradient border effect */
.milkdown-crepe-editor::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #3b82f6, #a855f7);
opacity: 0.8;
}
/* Dark mode container */
.dark .milkdown-crepe-editor {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
}
/* Remove default Crepe theme styling */
.milkdown-crepe-editor .milkdown {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Editor content area */
.milkdown-crepe-editor .ProseMirror {
font-family: Inter, system-ui, -apple-system, sans-serif;
min-height: 400px;
max-width: 100%;
padding: 1rem;
background: transparent;
color: #1f2937;
line-height: 1.6;
}
.dark .milkdown-crepe-editor .ProseMirror {
color: #f9fafb;
}
/* Remove dark mode filter - use proper theming instead */
.milkdown-theme-dark {
filter: none;
}
/* Typography */
.milkdown-crepe-editor h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
color: #111827;
}
.dark .milkdown-crepe-editor h1 {
color: #f9fafb;
}
.milkdown-crepe-editor h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #374151;
}
.dark .milkdown-crepe-editor h2 {
color: #e5e7eb;
}
.milkdown-crepe-editor h3 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: #4b5563;
}
.dark .milkdown-crepe-editor h3 {
color: #d1d5db;
}
/* Links */
.milkdown-crepe-editor a {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
.milkdown-crepe-editor a:hover {
color: #2563eb;
text-decoration: underline;
}
.dark .milkdown-crepe-editor a {
color: #60a5fa;
}
.dark .milkdown-crepe-editor a:hover {
color: #93bbfc;
}
/* Code blocks */
.milkdown-crepe-editor pre {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.dark .milkdown-crepe-editor pre {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.milkdown-crepe-editor code {
background: rgba(59, 130, 246, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.dark .milkdown-crepe-editor code {
background: rgba(59, 130, 246, 0.2);
}
/* Lists */
.milkdown-crepe-editor ul,
.milkdown-crepe-editor ol {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.milkdown-crepe-editor li {
margin: 0.25rem 0;
}
/* Blockquotes */
.milkdown-crepe-editor blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
font-style: italic;
}
.dark .milkdown-crepe-editor blockquote {
color: #9ca3af;
border-left-color: #60a5fa;
}
/* Tables */
.milkdown-crepe-editor table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
.milkdown-crepe-editor th,
.milkdown-crepe-editor td {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.5rem;
text-align: left;
}
.dark .milkdown-crepe-editor th,
.dark .milkdown-crepe-editor td {
border-color: rgba(255, 255, 255, 0.1);
}
.milkdown-crepe-editor th {
background: rgba(59, 130, 246, 0.05);
font-weight: 600;
}
.dark .milkdown-crepe-editor th {
background: rgba(59, 130, 246, 0.1);
}
/* Toolbar styling */
.milkdown-crepe-editor .milkdown-toolbar {
background: transparent;
border: none;
padding: 0;
margin-bottom: 1rem;
}
/* Toolbar buttons */
.milkdown-crepe-editor .toolbar-item {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 0.375rem 0.75rem;
margin: 0 0.25rem;
cursor: pointer;
transition: all 0.2s;
color: #374151;
}
.dark .milkdown-crepe-editor .toolbar-item {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #e5e7eb;
}
.milkdown-crepe-editor .toolbar-item:hover {
background: #3b82f6;
border-color: #3b82f6;
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
}
/* Selection */
.milkdown-crepe-editor .ProseMirror ::selection {
background: rgba(59, 130, 246, 0.3);
}
.dark .milkdown-crepe-editor .ProseMirror ::selection {
background: rgba(96, 165, 250, 0.3);
}
/* Focus state */
.milkdown-crepe-editor .ProseMirror:focus {
outline: none;
}
/* Placeholder */
.milkdown-crepe-editor .ProseMirror.is-empty::before {
content: 'Start writing...';
color: #9ca3af;
position: absolute;
pointer-events: none;
}
/* Horizontal rule */
.milkdown-crepe-editor hr {
border: none;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.5), transparent);
margin: 2rem 0;
}
/* Save button animation */
@keyframes pulse-glow {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
.save-button-pulse {
animation: pulse-glow 2s infinite;
}

View File

@@ -1,555 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Crepe, CrepeFeature } from '@milkdown/crepe';
import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame.css';
import '@milkdown/crepe/theme/frame-dark.css';
import './MilkdownEditor.css';
import { Save, Undo } from 'lucide-react';
interface MilkdownEditorProps {
document: {
id: string;
title: string;
content?: any;
created_at: string;
updated_at: string;
};
onSave: (document: any) => void;
className?: string;
isDarkMode?: boolean;
}
export const MilkdownEditor: React.FC<MilkdownEditorProps> = ({
document: doc,
onSave,
className = '',
isDarkMode = false,
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const crepeRef = useRef<Crepe | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [isReverted, setIsReverted] = useState(false);
const [originalContent, setOriginalContent] = useState<string>('');
const [currentContent, setCurrentContent] = useState<string>('');
// Convert document content to markdown string
const getMarkdownContent = () => {
if (typeof doc.content === 'string') {
return doc.content;
}
if (doc.content && typeof doc.content === 'object') {
// If content has a markdown field, use it
if (doc.content.markdown) {
return doc.content.markdown;
}
// Check if this is a PRP document
if (doc.content.document_type === 'prp' || doc.document_type === 'prp') {
return convertPRPToMarkdown(doc.content);
}
// Otherwise, convert the content object to a readable markdown format
let markdown = `# ${doc.title}\n\n`;
Object.entries(doc.content).forEach(([key, value]) => {
const sectionTitle = key.replace(/_/g, ' ').charAt(0).toUpperCase() + key.replace(/_/g, ' ').slice(1);
markdown += `## ${sectionTitle}\n\n`;
if (Array.isArray(value)) {
value.forEach(item => {
markdown += `- ${item}\n`;
});
markdown += '\n';
} else if (typeof value === 'object' && value !== null) {
if (value.description) {
markdown += `${value.description}\n\n`;
} else {
Object.entries(value).forEach(([subKey, subValue]) => {
markdown += `**${subKey}:** ${subValue}\n\n`;
});
}
} else {
markdown += `${value}\n\n`;
}
});
return markdown;
}
return `# ${doc.title}\n\nStart writing...`;
};
// Helper function to format values for markdown
// Enhanced formatValue to handle complex nested structures
const formatValue = (value: any, indent = '', depth = 0): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) return '';
// Check if it's a simple array (strings/numbers)
const isSimple = value.every(item =>
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
);
if (isSimple) {
return value.map(item => `${indent}- ${item}`).join('\n') + '\n';
}
// Complex array with objects
return value.map((item, index) => {
if (typeof item === 'object' && item !== null) {
const itemLines = formatValue(item, indent + ' ', depth + 1).split('\n');
const firstLine = itemLines[0];
const restLines = itemLines.slice(1).join('\n');
if (itemLines.length === 1 || (itemLines.length === 2 && !itemLines[1])) {
// Single line item
return `${indent}- ${firstLine}`;
} else {
// Multi-line item
return `${indent}-\n${indent} ${firstLine}${restLines ? '\n' + restLines : ''}`;
}
}
return `${indent}- ${formatValue(item, indent + ' ', depth + 1)}`;
}).join('\n') + '\n';
}
if (typeof value === 'object' && value !== null) {
const entries = Object.entries(value);
if (entries.length === 0) return '';
// Check if it's a simple object (all values are primitives)
const isSimple = entries.every(([_, val]) =>
typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean'
);
if (isSimple && entries.length <= 3 && depth > 0) {
// Inline simple objects
const pairs = entries.map(([k, v]) => `${formatKey(k)}: ${v}`);
return pairs.join(', ');
}
let result = '';
entries.forEach(([key, val], index) => {
const formattedKey = formatKey(key);
if (val === null || val === undefined) {
return; // Skip null/undefined
}
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
result += `${indent}**${formattedKey}:** ${val}\n`;
} else if (Array.isArray(val)) {
result += `${indent}**${formattedKey}:**\n${formatValue(val, indent, depth + 1)}`;
} else if (typeof val === 'object') {
// Use appropriate heading level based on depth
const headingLevel = Math.min(depth + 3, 6);
const heading = '#'.repeat(headingLevel);
result += `${indent}${heading} ${formattedKey}\n\n${formatValue(val, indent, depth + 1)}`;
}
// Add spacing between top-level sections
if (depth === 0 && index < entries.length - 1) {
result += '\n';
}
});
return result;
}
return String(value);
};
// Helper to format keys nicely
const formatKey = (key: string): string => {
return key
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.filter(word => word.length > 0)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
// Convert PRP document structure to readable markdown - fully dynamic
const convertPRPToMarkdown = (content: any): string => {
// Handle raw string content
if (typeof content === 'string') {
return content;
}
// Handle null/undefined
if (!content || typeof content !== 'object') {
return `# ${doc.title}\n\nNo content available.`;
}
// Start with title
let markdown = `# ${content.title || doc.title || 'Untitled Document'}\n\n`;
// Group metadata fields
const metadataFields = ['version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
const metadata = metadataFields.filter(field => content[field]);
if (metadata.length > 0) {
markdown += `## Metadata\n\n`;
metadata.forEach(field => {
const value = content[field];
const label = formatKey(field);
markdown += `- **${label}:** ${value}\n`;
});
markdown += '\n';
}
// Process all other fields dynamically
const skipFields = ['title', ...metadataFields, 'id', '_id', 'project_id'];
// Sort fields by priority (known important fields first)
const priorityFields = [
'goal', 'goals', 'objective', 'objectives',
'why', 'rationale', 'background',
'what', 'description', 'overview',
'context', 'background_context',
'user_personas', 'personas', 'users', 'stakeholders',
'user_flows', 'flows', 'journeys', 'workflows',
'requirements', 'functional_requirements', 'non_functional_requirements',
'success_metrics', 'metrics', 'kpis', 'success_criteria',
'timeline', 'roadmap', 'milestones', 'phases',
'implementation_plan', 'implementation_roadmap', 'plan',
'technical_requirements', 'technical_implementation', 'architecture',
'validation_gates', 'testing_strategy', 'quality_gates',
'risks', 'risk_assessment', 'mitigation_strategies'
];
// Create ordered list of fields
const orderedFields = [];
const remainingFields = [];
Object.keys(content).forEach(key => {
if (skipFields.includes(key)) return;
const lowerKey = key.toLowerCase();
const priorityIndex = priorityFields.findIndex(pf =>
lowerKey === pf || lowerKey.includes(pf) || pf.includes(lowerKey)
);
if (priorityIndex !== -1) {
orderedFields.push({ key, priority: priorityIndex });
} else {
remainingFields.push(key);
}
});
// Sort by priority
orderedFields.sort((a, b) => a.priority - b.priority);
// Process fields in order
const allFields = [...orderedFields.map(f => f.key), ...remainingFields];
allFields.forEach(key => {
const value = content[key];
if (value === null || value === undefined) return;
const sectionTitle = formatKey(key);
markdown += `## ${sectionTitle}\n\n`;
// Handle different value types
if (typeof value === 'string') {
markdown += `${value}\n\n`;
} else if (typeof value === 'number' || typeof value === 'boolean') {
markdown += `${value}\n\n`;
} else if (Array.isArray(value)) {
markdown += formatValue(value) + '\n';
} else if (typeof value === 'object') {
markdown += formatValue(value) + '\n';
}
});
return markdown.trim();
};
// Initialize editor
useEffect(() => {
if (!editorRef.current || crepeRef.current) return;
const initialContent = getMarkdownContent();
setOriginalContent(initialContent);
setCurrentContent(initialContent);
// Add theme class to root element
if (isDarkMode) {
editorRef.current.classList.add('milkdown-theme-dark');
}
const crepe = new Crepe({
root: editorRef.current,
defaultValue: initialContent,
features: {
[CrepeFeature.HeaderMeta]: true,
[CrepeFeature.LinkTooltip]: true,
[CrepeFeature.ImageBlock]: true,
[CrepeFeature.BlockEdit]: true,
[CrepeFeature.ListItem]: true,
[CrepeFeature.CodeBlock]: true,
[CrepeFeature.Table]: true,
[CrepeFeature.Toolbar]: true,
},
});
crepe.create().then(() => {
console.log('Milkdown editor created');
// Set up content change tracking
const editorElement = editorRef.current?.querySelector('.ProseMirror');
if (editorElement) {
// Listen for input events on the editor
const handleInput = () => {
// Get current markdown content
const markdown = crepe.getMarkdown();
console.log('Editor content changed via input:', markdown.substring(0, 50) + '...');
setCurrentContent(markdown);
// Compare trimmed content to avoid whitespace issues
const hasUnsavedChanges = markdown.trim() !== originalContent.trim();
setHasChanges(hasUnsavedChanges);
setIsReverted(false);
};
// Listen to multiple events to catch all changes
editorElement.addEventListener('input', handleInput);
editorElement.addEventListener('keyup', handleInput);
editorElement.addEventListener('paste', handleInput);
editorElement.addEventListener('cut', handleInput);
// Store the handlers for cleanup
(editorElement as any)._milkdownHandlers = {
input: handleInput,
keyup: handleInput,
paste: handleInput,
cut: handleInput
};
}
}).catch((error) => {
console.error('Failed to create Milkdown editor:', error);
});
crepeRef.current = crepe;
return () => {
// Clean up event listeners
const editorElement = editorRef.current?.querySelector('.ProseMirror');
if (editorElement && (editorElement as any)._milkdownHandlers) {
const handlers = (editorElement as any)._milkdownHandlers;
editorElement.removeEventListener('input', handlers.input);
editorElement.removeEventListener('keyup', handlers.keyup);
editorElement.removeEventListener('paste', handlers.paste);
editorElement.removeEventListener('cut', handlers.cut);
delete (editorElement as any)._milkdownHandlers;
}
if (crepeRef.current) {
crepeRef.current.destroy();
crepeRef.current = null;
}
};
}, [doc.id, originalContent]);
// Update theme class when isDarkMode changes
useEffect(() => {
if (editorRef.current) {
if (isDarkMode) {
editorRef.current.classList.add('milkdown-theme-dark');
} else {
editorRef.current.classList.remove('milkdown-theme-dark');
}
}
}, [isDarkMode]);
// Add keyboard shortcut for saving
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (hasChanges && !isLoading) {
handleSave();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [hasChanges, isLoading, currentContent]);
// Handle manual save
const handleSave = async () => {
if (!hasChanges || isLoading) return;
try {
setIsLoading(true);
console.log('Saving document with content:', currentContent.substring(0, 100) + '...');
// Create updated document with markdown content stored in content field
const updatedDocument = {
...doc,
content: {
markdown: currentContent,
// Preserve any other content fields
...(typeof doc.content === 'object' && doc.content !== null ? doc.content : {})
},
updated_at: new Date().toISOString(),
};
await onSave(updatedDocument);
// Update state after successful save
setHasChanges(false);
setIsReverted(false);
setOriginalContent(currentContent);
console.log('Document saved successfully');
} catch (error) {
console.error('Error saving document:', error);
// You might want to show an error toast here
} finally {
setIsLoading(false);
}
};
// Handle undo changes
const handleUndo = () => {
if (crepeRef.current && editorRef.current) {
// Destroy and recreate editor with original content
crepeRef.current.destroy();
const crepe = new Crepe({
root: editorRef.current,
defaultValue: originalContent,
features: {
[CrepeFeature.HeaderMeta]: true,
[CrepeFeature.LinkTooltip]: true,
[CrepeFeature.ImageBlock]: true,
[CrepeFeature.BlockEdit]: true,
[CrepeFeature.ListItem]: true,
[CrepeFeature.CodeBlock]: true,
[CrepeFeature.Table]: true,
[CrepeFeature.Toolbar]: true,
},
});
crepe.create().then(() => {
console.log('Milkdown editor reverted to original content');
// Set up content change tracking for the new editor instance
const editorElement = editorRef.current?.querySelector('.ProseMirror');
if (editorElement) {
const handleInput = () => {
const markdown = crepe.getMarkdown();
console.log('Editor content changed after undo:', markdown.substring(0, 50) + '...');
setCurrentContent(markdown);
const hasUnsavedChanges = markdown.trim() !== originalContent.trim();
setHasChanges(hasUnsavedChanges);
setIsReverted(false);
};
editorElement.addEventListener('input', handleInput);
editorElement.addEventListener('keyup', handleInput);
editorElement.addEventListener('paste', handleInput);
editorElement.addEventListener('cut', handleInput);
(editorElement as any)._milkdownHandlers = {
input: handleInput,
keyup: handleInput,
paste: handleInput,
cut: handleInput
};
}
setCurrentContent(originalContent);
setHasChanges(false);
setIsReverted(true);
}).catch((error) => {
console.error('Failed to revert Milkdown editor:', error);
});
crepeRef.current = crepe;
}
};
return (
<div className={`milkdown-editor ${className}`}>
<div className="mb-6 flex items-center justify-between bg-white/50 dark:bg-black/30 backdrop-blur-sm rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{doc.title}
</h3>
<div className="flex items-center gap-2">
{isLoading ? (
<span className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
Saving...
</span>
) : isReverted ? (
<span className="text-sm text-purple-600 dark:text-purple-400 flex items-center gap-2">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
Reverted
</span>
) : hasChanges ? (
<span className="text-sm text-orange-600 dark:text-orange-400 flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
Unsaved changes
</span>
) : (
<span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
All changes saved
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<button
onClick={handleUndo}
disabled={isLoading}
className="px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 border border-gray-300 dark:border-gray-600"
>
<Undo className="w-4 h-4" />
Undo
</button>
)}
<button
onClick={handleSave}
disabled={isLoading || !hasChanges}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
flex items-center gap-2 border
${hasChanges
? 'bg-blue-500 hover:bg-blue-600 text-white border-blue-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 save-button-pulse'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-600 border-gray-300 dark:border-gray-700 cursor-not-allowed'
}
disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none
`}
>
<Save className="w-4 h-4" />
{isLoading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div
ref={editorRef}
className={`prose prose-lg max-w-none milkdown-crepe-editor ${isDarkMode ? 'prose-invert' : ''}`}
style={{ minHeight: '400px' }}
/>
</div>
);
};

View File

@@ -1,135 +0,0 @@
import React, { useMemo, useState, createContext, useContext } from 'react';
interface TabsProps {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: React.ReactNode;
className?: string;
}
const TabsContext = createContext<{
value: string;
onValueChange: (value: string) => void;
}>({
value: '',
onValueChange: () => {}
});
export const Tabs = ({
defaultValue,
value,
onValueChange,
children,
className = ''
}: TabsProps) => {
const [internalValue, setInternalValue] = useState(defaultValue);
const activeValue = value !== undefined ? value : internalValue;
const contextValue = useMemo(() => ({
value: activeValue,
onValueChange: (newValue: string) => {
setInternalValue(newValue);
onValueChange?.(newValue);
}
}), [activeValue, onValueChange]);
return <TabsContext.Provider value={contextValue}>
<div className={className}>{children}</div>
</TabsContext.Provider>;
};
interface TabsListProps {
children: React.ReactNode;
className?: string;
}
export const TabsList = ({
children,
className = ''
}: TabsListProps) => {
return <div className={`relative ${className}`} role="tablist">
{/* Subtle neon glow effect */}
<div className="absolute inset-0 rounded-lg opacity-30 blur-[1px] bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 pointer-events-none"></div>
{children}
</div>;
};
interface TabsTriggerProps {
value: string;
children: React.ReactNode;
className?: string;
onClick?: () => void;
color?: 'blue' | 'purple' | 'pink' | 'orange' | 'cyan' | 'green';
}
export const TabsTrigger = ({
value,
children,
className = '',
onClick,
color = 'blue'
}: TabsTriggerProps) => {
const {
value: activeValue,
onValueChange
} = useContext(TabsContext);
const isActive = activeValue === value;
const handleClick = () => {
onValueChange(value);
onClick?.();
};
const colorMap = {
blue: {
text: 'text-blue-600 dark:text-blue-400',
glow: '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)]',
hover: 'hover:text-blue-500 dark:hover:text-blue-400/70'
},
purple: {
text: 'text-purple-600 dark:text-purple-400',
glow: 'bg-purple-500 shadow-[0_0_10px_2px_rgba(168,85,247,0.4)] dark:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]',
hover: 'hover:text-purple-500 dark:hover:text-purple-400/70'
},
pink: {
text: 'text-pink-600 dark:text-pink-400',
glow: 'bg-pink-500 shadow-[0_0_10px_2px_rgba(236,72,153,0.4)] dark:shadow-[0_0_20px_5px_rgba(236,72,153,0.7)]',
hover: 'hover:text-pink-500 dark:hover:text-pink-400/70'
},
orange: {
text: 'text-orange-600 dark:text-orange-400',
glow: 'bg-orange-500 shadow-[0_0_10px_2px_rgba(249,115,22,0.4)] dark:shadow-[0_0_20px_5px_rgba(249,115,22,0.7)]',
hover: 'hover:text-orange-500 dark:hover:text-orange-400/70'
},
cyan: {
text: 'text-cyan-600 dark:text-cyan-400',
glow: 'bg-cyan-500 shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]',
hover: 'hover:text-cyan-500 dark:hover:text-cyan-400/70'
},
green: {
text: 'text-emerald-600 dark:text-emerald-400',
glow: 'bg-emerald-500 shadow-[0_0_10px_2px_rgba(16,185,129,0.4)] dark:shadow-[0_0_20px_5px_rgba(16,185,129,0.7)]',
hover: 'hover:text-emerald-500 dark:hover:text-emerald-400/70'
}
};
return <button className={`
relative px-24 py-10 font-mono transition-all duration-300 z-10
${isActive ? colorMap[color].text : `text-gray-600 dark:text-gray-400 ${colorMap[color].hover}`}
${className}
`} onClick={handleClick} type="button" role="tab" aria-selected={isActive} data-state={isActive ? 'active' : 'inactive'}>
{children}
{/* Active state neon indicator */}
{isActive && <>
<span className={`absolute bottom-0 left-0 right-0 w-full h-[2px] ${colorMap[color].glow}`}></span>
</>}
</button>;
};
interface TabsContentProps {
value: string;
children: React.ReactNode;
className?: string;
}
export const TabsContent = ({
value,
children,
className = ''
}: TabsContentProps) => {
const {
value: activeValue
} = useContext(TabsContext);
// Simplified TabsContent - we're handling animations in the parent component now
if (activeValue !== value) return null;
return <div className={className} role="tabpanel" data-state={activeValue === value ? 'active' : 'inactive'}>
{children}
</div>;
};

View File

@@ -1,397 +0,0 @@
import React, { useRef, useState, useCallback } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { useToast } from '../../contexts/ToastContext';
import { DeleteConfirmModal } from '../../pages/ProjectPage';
import { CheckSquare, Square, Trash2, ArrowRight } from 'lucide-react';
import { projectService } from '../../services/projectService';
import { Task } from './TaskTableView'; // Import Task interface
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
import { DraggableTaskCard, DraggableTaskCardProps } from './DraggableTaskCard'; // Import the new component and its props
interface TaskBoardViewProps {
tasks: Task[];
onTaskView: (task: Task) => void;
onTaskComplete: (taskId: string) => void;
onTaskDelete: (task: Task) => void;
onTaskMove: (taskId: string, newStatus: Task['status']) => void;
onTaskReorder: (taskId: string, targetIndex: number, status: Task['status']) => void;
}
interface ColumnDropZoneProps {
status: Task['status'];
title: string;
tasks: Task[];
onTaskMove: (taskId: string, newStatus: Task['status']) => void;
onTaskView: (task: Task) => void;
onTaskComplete: (taskId: string) => void;
onTaskDelete: (task: Task) => void;
onTaskReorder: (taskId: string, targetIndex: number, status: Task['status']) => void;
allTasks: Task[];
hoveredTaskId: string | null;
onTaskHover: (taskId: string | null) => void;
selectedTasks: Set<string>;
onTaskSelect: (taskId: string) => void;
}
const ColumnDropZone = ({
status,
title,
tasks,
onTaskMove,
onTaskView,
onTaskComplete,
onTaskDelete,
onTaskReorder,
allTasks,
hoveredTaskId,
onTaskHover,
selectedTasks,
onTaskSelect
}: ColumnDropZoneProps) => {
const ref = useRef<HTMLDivElement>(null);
const [{ isOver }, drop] = useDrop({
accept: ItemTypes.TASK,
drop: (item: { id: string; status: string }) => {
if (item.status !== status) {
// Moving to different status - use length of current column as new order
onTaskMove(item.id, status);
}
},
collect: (monitor) => ({
isOver: !!monitor.isOver()
})
});
drop(ref);
// Get column header color based on status
const getColumnColor = () => {
switch (status) {
case 'backlog':
return 'text-gray-600 dark:text-gray-400';
case 'in-progress':
return 'text-blue-600 dark:text-blue-400';
case 'review':
return 'text-purple-600 dark:text-purple-400';
case 'complete':
return 'text-green-600 dark:text-green-400';
}
};
// Get column header glow based on status
const getColumnGlow = () => {
switch (status) {
case 'backlog':
return 'bg-gray-500/30';
case 'in-progress':
return 'bg-blue-500/30 shadow-[0_0_10px_2px_rgba(59,130,246,0.2)]';
case 'review':
return 'bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]';
case 'complete':
return 'bg-green-500/30 shadow-[0_0_10px_2px_rgba(16,185,129,0.2)]';
}
};
// Just use the tasks as-is since they're already parent tasks only
const organizedTasks = tasks;
return (
<div
ref={ref}
className={`flex flex-col bg-white/20 dark:bg-black/30 ${isOver ? 'bg-gray-100/50 dark:bg-gray-800/20 border-t-2 border-t-[#00ff00] shadow-[inset_0_1px_10px_rgba(0,255,0,0.1)]' : ''} transition-colors duration-200 h-full`}
>
<div className="text-center py-3 sticky top-0 z-10 bg-white/80 dark:bg-black/80 backdrop-blur-sm">
<h3 className={`font-mono ${getColumnColor()} text-sm`}>{title}</h3>
{/* Column header divider with glow */}
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] ${getColumnGlow()}`}></div>
</div>
<div className="px-1 flex-1 overflow-y-auto space-y-3 py-3">
{organizedTasks.map((task, index) => (
<DraggableTaskCard
key={task.id}
task={task}
index={index}
onView={() => onTaskView(task)}
onComplete={() => onTaskComplete(task.id)}
onDelete={onTaskDelete}
onTaskReorder={onTaskReorder}
tasksInStatus={organizedTasks}
allTasks={allTasks}
hoveredTaskId={hoveredTaskId}
onTaskHover={onTaskHover}
/>
))}
</div>
</div>
);
};
export const TaskBoardView = ({
tasks,
onTaskView,
onTaskComplete,
onTaskDelete,
onTaskMove,
onTaskReorder
}: TaskBoardViewProps) => {
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set());
// State for delete confirmation modal
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
const { showToast } = useToast();
// Multi-select handlers
const toggleTaskSelection = useCallback((taskId: string) => {
setSelectedTasks(prev => {
const newSelection = new Set(prev);
if (newSelection.has(taskId)) {
newSelection.delete(taskId);
} else {
newSelection.add(taskId);
}
return newSelection;
});
}, []);
const selectAllTasks = useCallback(() => {
setSelectedTasks(new Set(tasks.map(task => task.id)));
}, [tasks]);
const clearSelection = useCallback(() => {
setSelectedTasks(new Set());
}, []);
// Mass delete handler
const handleMassDelete = useCallback(async () => {
if (selectedTasks.size === 0) return;
const tasksToDelete = tasks.filter(task => selectedTasks.has(task.id));
try {
// Delete all selected tasks
await Promise.all(
tasksToDelete.map(task => projectService.deleteTask(task.id))
);
// Clear selection
clearSelection();
showToast(`${tasksToDelete.length} tasks deleted successfully`, 'success');
} catch (error) {
console.error('Failed to delete tasks:', error);
showToast('Failed to delete some tasks', 'error');
}
}, [selectedTasks, tasks, clearSelection, showToast]);
// Mass status change handler
const handleMassStatusChange = useCallback(async (newStatus: Task['status']) => {
if (selectedTasks.size === 0) return;
const tasksToUpdate = tasks.filter(task => selectedTasks.has(task.id));
try {
// Update all selected tasks
await Promise.all(
tasksToUpdate.map(task =>
projectService.updateTask(task.id, {
status: mapUIStatusToDBStatus(newStatus)
})
)
);
// Clear selection
clearSelection();
showToast(`${tasksToUpdate.length} tasks moved to ${newStatus}`, 'success');
} catch (error) {
console.error('Failed to update tasks:', error);
showToast('Failed to update some tasks', 'error');
}
}, [selectedTasks, tasks, clearSelection, showToast]);
// Helper function to map UI status to DB status (reuse from TasksTab)
const mapUIStatusToDBStatus = (uiStatus: Task['status']) => {
switch (uiStatus) {
case 'backlog': return 'todo';
case 'in-progress': return 'doing';
case 'review': return 'review';
case 'complete': return 'done';
default: return 'todo';
}
};
// Handle task deletion (opens confirmation modal)
const handleDeleteTask = useCallback((task: Task) => {
setTaskToDelete(task);
setShowDeleteConfirm(true);
}, [setTaskToDelete, setShowDeleteConfirm]);
// Confirm deletion and execute
const confirmDeleteTask = useCallback(async () => {
if (!taskToDelete) return;
try {
await projectService.deleteTask(taskToDelete.id);
// Notify parent to update tasks
onTaskDelete(taskToDelete);
showToast(`Task "${taskToDelete.title}" deleted successfully`, 'success');
} catch (error) {
console.error('Failed to delete task:', error);
showToast(error instanceof Error ? error.message : 'Failed to delete task', 'error');
} finally {
setShowDeleteConfirm(false);
setTaskToDelete(null);
}
}, [taskToDelete, onTaskDelete, showToast, setShowDeleteConfirm, setTaskToDelete, projectService]);
// Cancel deletion
const cancelDeleteTask = useCallback(() => {
setShowDeleteConfirm(false);
setTaskToDelete(null);
}, [setShowDeleteConfirm, setTaskToDelete]);
// Simple task filtering for board view
const getTasksByStatus = (status: Task['status']) => {
return tasks
.filter(task => task.status === status)
.sort((a, b) => a.task_order - b.task_order);
};
return (
<div className="flex flex-col h-full min-h-[70vh]">
{/* Multi-select toolbar */}
{selectedTasks.size > 0 && (
<div className="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{selectedTasks.size} task{selectedTasks.size !== 1 ? 's' : ''} selected
</span>
</div>
<div className="flex items-center gap-2">
{/* Status change dropdown */}
<select
className="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"
onChange={(e) => {
if (e.target.value) {
handleMassStatusChange(e.target.value as Task['status']);
e.target.value = ''; // Reset dropdown
}
}}
defaultValue=""
>
<option value="" disabled>Move to...</option>
<option value="backlog">Backlog</option>
<option value="in-progress">In Progress</option>
<option value="review">Review</option>
<option value="complete">Complete</option>
</select>
{/* Mass delete button */}
<button
onClick={handleMassDelete}
className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white rounded flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
{/* Clear selection */}
<button
onClick={clearSelection}
className="px-3 py-1 text-sm bg-gray-600 hover:bg-gray-700 text-white rounded"
>
Clear
</button>
</div>
</div>
)}
{/* Board Columns */}
<div className="grid grid-cols-4 gap-0 flex-1">
{/* Backlog Column */}
<ColumnDropZone
status="backlog"
title="Backlog"
tasks={getTasksByStatus('backlog')}
onTaskMove={onTaskMove}
onTaskView={onTaskView}
onTaskComplete={onTaskComplete}
onTaskDelete={onTaskDelete}
onTaskReorder={onTaskReorder}
allTasks={tasks}
hoveredTaskId={hoveredTaskId}
onTaskHover={setHoveredTaskId}
selectedTasks={selectedTasks}
onTaskSelect={toggleTaskSelection}
/>
{/* In Progress Column */}
<ColumnDropZone
status="in-progress"
title="In Process"
tasks={getTasksByStatus('in-progress')}
onTaskMove={onTaskMove}
onTaskView={onTaskView}
onTaskComplete={onTaskComplete}
onTaskDelete={onTaskDelete}
onTaskReorder={onTaskReorder}
allTasks={tasks}
hoveredTaskId={hoveredTaskId}
onTaskHover={setHoveredTaskId}
selectedTasks={selectedTasks}
onTaskSelect={toggleTaskSelection}
/>
{/* Review Column */}
<ColumnDropZone
status="review"
title="Review"
tasks={getTasksByStatus('review')}
onTaskMove={onTaskMove}
onTaskView={onTaskView}
onTaskComplete={onTaskComplete}
onTaskDelete={onTaskDelete}
onTaskReorder={onTaskReorder}
allTasks={tasks}
hoveredTaskId={hoveredTaskId}
onTaskHover={setHoveredTaskId}
selectedTasks={selectedTasks}
onTaskSelect={toggleTaskSelection}
/>
{/* Complete Column */}
<ColumnDropZone
status="complete"
title="Complete"
tasks={getTasksByStatus('complete')}
onTaskMove={onTaskMove}
onTaskView={onTaskView}
onTaskComplete={onTaskComplete}
onTaskDelete={onTaskDelete}
onTaskReorder={onTaskReorder}
allTasks={tasks}
hoveredTaskId={hoveredTaskId}
onTaskHover={setHoveredTaskId}
selectedTasks={selectedTasks}
onTaskSelect={toggleTaskSelection}
/>
</div>
{/* Delete Confirmation Modal for Tasks */}
{showDeleteConfirm && taskToDelete && (
<DeleteConfirmModal
itemName={taskToDelete.title}
onConfirm={confirmDeleteTask}
onCancel={cancelDeleteTask}
type="task"
/>
)}
</div>
);
};

View File

@@ -1,172 +0,0 @@
import React, { memo, useState, useEffect, useCallback, useRef } from 'react';
interface DebouncedInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
type?: 'text' | 'textarea';
rows?: number;
}
// Memoized input component that manages its own state
export const DebouncedInput = memo(({
value,
onChange,
placeholder,
className,
type = 'text',
rows = 5
}: DebouncedInputProps) => {
const [localValue, setLocalValue] = useState(value);
const timeoutRef = useRef<NodeJS.Timeout>();
const isFirstRender = useRef(true);
// Sync with external value only on mount or when externally changed
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
// Only update if the external value is different from local
if (value !== localValue) {
setLocalValue(value);
}
}, [value]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout for debounced update
timeoutRef.current = setTimeout(() => {
onChange(newValue);
}, 300);
}, [onChange]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (type === 'textarea') {
return (
<textarea
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className={className}
rows={rows}
/>
);
}
return (
<input
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className={className}
/>
);
}, (prevProps, nextProps) => {
// Custom comparison - only re-render if external value or onChange changes
return prevProps.value === nextProps.value &&
prevProps.onChange === nextProps.onChange &&
prevProps.placeholder === nextProps.placeholder &&
prevProps.className === nextProps.className;
});
DebouncedInput.displayName = 'DebouncedInput';
interface FeatureInputProps {
value: string;
onChange: (value: string) => void;
projectFeatures: any[];
isLoadingFeatures: boolean;
placeholder?: string;
className?: string;
}
// Memoized feature input with datalist
export const FeatureInput = memo(({
value,
onChange,
projectFeatures,
isLoadingFeatures,
placeholder,
className
}: FeatureInputProps) => {
const [localValue, setLocalValue] = useState(value);
const timeoutRef = useRef<NodeJS.Timeout>();
// Sync with external value
useEffect(() => {
if (value !== localValue) {
setLocalValue(value);
}
}, [value]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
onChange(newValue);
}, 300);
}, [onChange]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<div className="relative">
<input
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className={className}
list="features-list"
/>
<datalist id="features-list">
{projectFeatures.map((feature) => (
<option key={feature.id} value={feature.label}>
{feature.label} ({feature.type})
</option>
))}
</datalist>
{isLoadingFeatures && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="w-4 h-4 animate-spin rounded-full border-2 border-cyan-400 border-t-transparent" />
</div>
)}
</div>
);
}, (prevProps, nextProps) => {
return prevProps.value === nextProps.value &&
prevProps.onChange === nextProps.onChange &&
prevProps.isLoadingFeatures === nextProps.isLoadingFeatures &&
prevProps.projectFeatures === nextProps.projectFeatures;
});
FeatureInput.displayName = 'FeatureInput';

View File

@@ -1,891 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { Check, Trash2, Edit, Tag, User, Bot, Clipboard, Save, Plus } from 'lucide-react';
import { useToast } from '../../contexts/ToastContext';
import { DeleteConfirmModal } from '../../pages/ProjectPage';
import { projectService } from '../../services/projectService';
import { ItemTypes, getAssigneeIcon, getAssigneeGlow, getOrderColor, getOrderGlow } from '../../lib/task-utils';
import { DraggableTaskCard } from './DraggableTaskCard';
export interface Task {
id: string;
title: string;
description: string;
status: 'backlog' | 'in-progress' | 'review' | 'complete';
assignee: {
name: 'User' | 'Archon' | 'AI IDE Agent';
avatar: string;
};
feature: string;
featureColor: string;
task_order: number;
}
interface TaskTableViewProps {
tasks: Task[];
onTaskView: (task: Task) => void;
onTaskComplete: (taskId: string) => void;
onTaskDelete: (task: Task) => void;
onTaskReorder: (taskId: string, newOrder: number, status: Task['status']) => void;
onTaskCreate?: (task: Omit<Task, 'id'>) => Promise<void>;
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
}
const getAssigneeGlassStyle = (assigneeName: 'User' | 'Archon' | 'AI IDE Agent') => {
switch (assigneeName) {
case 'User':
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-blue-400 dark:border-blue-500'; // blue glass
case 'AI IDE Agent':
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-emerald-400 dark:border-emerald-500'; // emerald green glass (like toggle)
case 'Archon':
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-pink-400 dark:border-pink-500'; // pink glass
default:
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-blue-400 dark:border-blue-500';
}
};
// Get glass morphism style based on task order (lower = higher priority = warmer color)
const getOrderGlassStyle = (order: number) => {
if (order <= 3) return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-rose-400 dark:border-rose-500'; // red glass
if (order <= 6) return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-orange-400 dark:border-orange-500'; // orange glass
if (order <= 10) return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-blue-400 dark:border-blue-500'; // blue glass
return 'backdrop-blur-md bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border-emerald-400 dark:border-emerald-500'; // green glass
};
const getOrderTextColor = (order: number) => {
if (order <= 3) return 'text-rose-500 dark:text-rose-400'; // red text
if (order <= 6) return 'text-orange-500 dark:text-orange-400'; // orange text
if (order <= 10) return 'text-blue-500 dark:text-blue-400'; // blue text
return 'text-emerald-500 dark:text-emerald-400'; // green text
};
// Helper function to reorder tasks properly
const reorderTasks = (tasks: Task[], fromIndex: number, toIndex: number): Task[] => {
const result = [...tasks];
const [movedTask] = result.splice(fromIndex, 1);
result.splice(toIndex, 0, movedTask);
// Update task_order to be sequential (1, 2, 3, ...)
return result.map((task, index) => ({
...task,
task_order: index + 1
}));
};
// Inline editable cell component
interface EditableCellProps {
value: string;
onSave: (value: string) => void;
type?: 'text' | 'textarea' | 'select';
options?: string[];
placeholder?: string;
isEditing: boolean;
onEdit: () => void;
onCancel: () => void;
}
const EditableCell = ({
value,
onSave,
type = 'text',
options = [],
placeholder = '',
isEditing,
onEdit,
onCancel
}: EditableCellProps) => {
const [editValue, setEditValue] = useState(value);
const handleSave = () => {
onSave(editValue);
};
const handleCancel = () => {
setEditValue(value);
onCancel();
};
// Handle keyboard events for Tab/Enter to save, Escape to cancel
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
// Handle blur to save (when clicking outside)
const handleBlur = () => {
handleSave();
};
if (!isEditing) {
return (
<div
onClick={onEdit}
className="cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-800/30 p-1 rounded transition-colors min-h-[24px] flex items-center truncate"
title={value || placeholder}
>
<span className="truncate">
{value || <span className="text-gray-400 italic">Click to edit</span>}
</span>
</div>
);
}
return (
<div className="flex items-center w-full">
{type === 'select' ? (
<select
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
// Auto-save on select change
setTimeout(() => handleSave(), 0);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
autoFocus
>
{options.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
) : type === 'textarea' ? (
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={placeholder}
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)] resize-none"
rows={2}
autoFocus
/>
) : (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={placeholder}
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
autoFocus
/>
)}
</div>
);
};
interface DraggableTaskRowProps {
task: Task;
index: number;
onTaskView: (task: Task) => void;
onTaskComplete: (taskId: string) => void;
onTaskDelete: (task: Task) => void;
onTaskReorder: (taskId: string, newOrder: number, status: Task['status']) => void;
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => Promise<void>;
tasksInStatus: Task[];
style?: React.CSSProperties;
}
const DraggableTaskRow = ({
task,
index,
onTaskView,
onTaskComplete,
onTaskDelete,
onTaskReorder,
onTaskUpdate,
tasksInStatus,
style
}: DraggableTaskRowProps) => {
const [editingField, setEditingField] = useState<string | null>(null);
const [isHovering, setIsHovering] = useState(false);
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.TASK,
item: { id: task.id, index, status: task.status },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: ItemTypes.TASK,
hover: (draggedItem: { id: string; index: number; status: Task['status'] }, monitor) => {
if (!monitor.isOver({ shallow: true })) return;
if (draggedItem.id === task.id) return;
if (draggedItem.status !== task.status) return;
const draggedIndex = draggedItem.index;
const hoveredIndex = index;
if (draggedIndex === hoveredIndex) return;
console.log('HOVER: Moving task', draggedItem.id, 'to index', draggedIndex, 'to', hoveredIndex);
// Move the task immediately for visual feedback
onTaskReorder(draggedItem.id, hoveredIndex, task.status);
// Update the dragged item's index to prevent re-triggering
draggedItem.index = hoveredIndex;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
});
const handleUpdateField = async (field: string, value: string) => {
if (onTaskUpdate) {
const updates: Partial<Task> = {};
if (field === 'title') {
updates.title = value;
} else if (field === 'status') {
updates.status = value as Task['status'];
} else if (field === 'assignee') {
updates.assignee = { name: value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' };
} else if (field === 'feature') {
updates.feature = value;
}
try {
await onTaskUpdate(task.id, updates);
setEditingField(null);
} catch (error) {
console.error('Failed to update task:', error);
}
}
};
return (
<tr
ref={(node) => drag(drop(node))}
className={`
group transition-all duration-200 cursor-move
${index % 2 === 0 ? 'bg-white/50 dark:bg-black/50' : 'bg-gray-50/80 dark:bg-gray-900/30'}
hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70 dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20
border-b border-gray-200 dark:border-gray-800 last:border-b-0
${isDragging ? 'opacity-50 scale-105 shadow-lg z-50' : ''}
${isOver && canDrop ? 'bg-cyan-100/50 dark:bg-cyan-900/20 border-cyan-400' : ''}
${isHovering ? 'transform translate-y-1 shadow-md' : ''}
`}
onMouseLeave={() => setIsHovering(false)}
style={style}
>
<td className="p-3">
<div className="flex items-center justify-center">
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold transition-all duration-300 ${getOrderGlassStyle(task.task_order)} ${getOrderTextColor(task.task_order)} ${getOrderGlow(task.task_order)}`}>
{task.task_order}
</div>
</div>
</td>
<td className="p-3 text-gray-800 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors relative">
<div className="min-w-0 flex items-center">
<div className="truncate flex-1">
<EditableCell
value={task.title}
onSave={(value) => handleUpdateField('title', value)}
isEditing={editingField === 'title'}
onEdit={() => setEditingField('title')}
onCancel={() => setEditingField(null)}
placeholder="Task title..."
/>
</div>
</div>
</td>
<td className="p-3">
<EditableCell
value={task.status === 'backlog' ? 'Backlog' :
task.status === 'in-progress' ? 'In Progress' :
task.status === 'review' ? 'Review' : 'Complete'}
onSave={(value) => {
const statusMap: Record<string, Task['status']> = {
'Backlog': 'backlog',
'In Progress': 'in-progress',
'Review': 'review',
'Complete': 'complete'
};
handleUpdateField('status', statusMap[value] || 'backlog');
}}
type="select"
options={['Backlog', 'In Progress', 'Review', 'Complete']}
isEditing={editingField === 'status'}
onEdit={() => setEditingField('status')}
onCancel={() => setEditingField(null)}
/>
</td>
<td className="p-3">
<div className="truncate">
<EditableCell
value={task.feature}
onSave={(value) => handleUpdateField('feature', value)}
isEditing={editingField === 'feature'}
onEdit={() => setEditingField('feature')}
onCancel={() => setEditingField(null)}
placeholder="Feature name..."
/>
</div>
</td>
<td className="p-3">
<div className="flex items-center justify-center">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-300 cursor-pointer hover:scale-110 ${getAssigneeGlassStyle(task.assignee?.name || 'User')} ${getAssigneeGlow(task.assignee?.name || 'User')}`}
onClick={() => setEditingField('assignee')}
title={`Assignee: ${task.assignee?.name || 'User'}`}
>
{getAssigneeIcon(task.assignee?.name || 'User')}
</div>
{editingField === 'assignee' && (
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-2">
<select
value={task.assignee?.name || 'User'}
onChange={(e) => {
handleUpdateField('assignee', e.target.value);
setEditingField(null);
}}
className="bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-cyan-500"
autoFocus
>
<option value="User">User</option>
<option value="Archon">Archon</option>
<option value="AI IDE Agent">AI IDE Agent</option>
</select>
</div>
)}
</div>
</td>
<td className="p-3">
<div className="flex justify-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onTaskDelete(task)}
className="p-1.5 rounded-full bg-red-500/20 text-red-500 hover:bg-red-500/30 hover:shadow-[0_0_10px_rgba(239,68,68,0.3)] transition-all duration-300"
title="Delete task"
aria-label="Delete task"
>
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onTaskComplete(task.id)}
className="p-1.5 rounded-full bg-green-500/20 text-green-500 hover:bg-green-500/30 hover:shadow-[0_0_10px_rgba(34,197,94,0.3)] transition-all duration-300"
title="Mark task as complete"
aria-label="Mark task as complete"
>
<Check className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onTaskView(task)}
className="p-1.5 rounded-full bg-cyan-500/20 text-cyan-500 hover:bg-cyan-500/30 hover:shadow-[0_0_10px_rgba(34,211,238,0.3)] transition-all duration-300"
title="Edit task"
aria-label="Edit task"
>
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
</button>
{/* Copy Task ID Button - Matching Board View */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.id);
// Visual feedback like in board view
const button = e.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<div class="flex items-center gap-1"><span class="w-3 h-3 text-green-500">✓</span><span class="text-green-500 text-xs">Copied</span></div>';
setTimeout(() => {
button.innerHTML = originalHTML;
}, 2000);
}}
className="p-1.5 rounded-full bg-gray-500/20 text-gray-500 hover:bg-gray-500/30 hover:shadow-[0_0_10px_rgba(107,114,128,0.3)] transition-all duration-300"
title="Copy Task ID to clipboard"
aria-label="Copy Task ID to clipboard"
>
<Clipboard className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</td>
</tr>
);
};
// Add Task Row Component - Always visible empty input row
interface AddTaskRowProps {
onTaskCreate?: (task: Omit<Task, 'id'>) => Promise<void>;
tasks: Task[];
statusFilter: Task['status'] | 'all';
}
const AddTaskRow = ({ onTaskCreate, tasks, statusFilter }: AddTaskRowProps) => {
const [newTask, setNewTask] = useState<Omit<Task, 'id'>>({
title: '',
description: '',
status: statusFilter === 'all' ? 'backlog' : statusFilter,
assignee: { name: 'AI IDE Agent', avatar: '' },
feature: '',
featureColor: '#3b82f6',
task_order: 1
});
const handleCreateTask = async () => {
if (!newTask.title.trim() || !onTaskCreate) return;
// Calculate the next order number for the target status
const targetStatus = newTask.status;
const tasksInStatus = tasks.filter(t => t.status === targetStatus);
const nextOrder = tasksInStatus.length > 0 ? Math.max(...tasksInStatus.map(t => t.task_order)) + 1 : 1;
try {
await onTaskCreate({
...newTask,
task_order: nextOrder
});
// Reset only the title to allow quick adding
setNewTask(prev => ({
...prev,
title: '',
description: ''
}));
} catch (error) {
console.error('Failed to create task:', error);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCreateTask();
}
};
// Update status when filter changes
React.useEffect(() => {
if (statusFilter !== 'all') {
setNewTask(prev => ({ ...prev, status: statusFilter }));
}
}, [statusFilter]);
return (
<>
<tr className="border-t border-cyan-400 dark:border-cyan-500 bg-cyan-50/30 dark:bg-cyan-900/10 relative">
{/* Toned down neon blue line separator */}
<td colSpan={6} className="p-0 relative">
<div className="absolute inset-x-0 top-0 h-[1px] bg-gradient-to-r from-transparent via-cyan-400 to-transparent shadow-[0_0_4px_1px_rgba(34,211,238,0.4)] dark:shadow-[0_0_6px_2px_rgba(34,211,238,0.5)]"></div>
</td>
</tr>
<tr className="bg-cyan-50/20 dark:bg-cyan-900/5">
<td className="p-3">
<div className="flex items-center justify-center">
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white bg-cyan-500 shadow-[0_0_10px_rgba(34,211,238,0.5)]">
+
</div>
</div>
</td>
<td className="p-3">
<input
type="text"
value={newTask.title}
onChange={(e) => setNewTask(prev => ({ ...prev, title: e.target.value }))}
onKeyPress={handleKeyPress}
placeholder="Type task title and press Enter..."
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)] transition-all duration-200"
autoFocus
/>
</td>
<td className="p-3">
<select
value={newTask.status === 'backlog' ? 'Backlog' :
newTask.status === 'in-progress' ? 'In Progress' :
newTask.status === 'review' ? 'Review' : 'Complete'}
onChange={(e) => {
const statusMap: Record<string, Task['status']> = {
'Backlog': 'backlog',
'In Progress': 'in-progress',
'Review': 'review',
'Complete': 'complete'
};
setNewTask(prev => ({ ...prev, status: statusMap[e.target.value] || 'backlog' }));
}}
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
>
<option value="Backlog">Backlog</option>
<option value="In Progress">In Progress</option>
<option value="Review">Review</option>
<option value="Complete">Complete</option>
</select>
</td>
<td className="p-3">
<input
type="text"
value={newTask.feature}
onChange={(e) => setNewTask(prev => ({ ...prev, feature: e.target.value }))}
onKeyPress={handleKeyPress}
placeholder="Feature..."
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
/>
</td>
<td className="p-3">
<select
value={newTask.assignee.name}
onChange={(e) => setNewTask(prev => ({
...prev,
assignee: { name: e.target.value as 'User' | 'Archon' | 'AI IDE Agent', avatar: '' }
}))}
className="w-full bg-white/90 dark:bg-black/90 border border-cyan-300 dark:border-cyan-600 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-cyan-500 focus:shadow-[0_0_5px_rgba(34,211,238,0.3)]"
>
<option value="AI IDE Agent">AI IDE Agent</option>
<option value="User">User</option>
<option value="Archon">Archon</option>
</select>
</td>
<td className="p-3">
<div className="flex justify-center">
<span className="text-xs text-cyan-600 dark:text-cyan-400 font-medium">Press Enter</span>
</div>
</td>
</tr>
</>
);
};
export const TaskTableView = ({
tasks,
onTaskView,
onTaskComplete,
onTaskDelete,
onTaskReorder,
onTaskCreate,
onTaskUpdate
}: TaskTableViewProps) => {
const [statusFilter, setStatusFilter] = useState<Task['status'] | 'all'>('backlog');
// State for delete confirmation modal
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [taskToDelete, setTaskToDelete] = useState<Task | null>(null);
const { showToast } = useToast();
// Refs for scroll fade effect
const tableContainerRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const [scrollOpacities, setScrollOpacities] = useState<Map<string, number>>(new Map());
// Calculate opacity based on row position
const calculateOpacity = (rowElement: HTMLElement, containerElement: HTMLElement) => {
const containerRect = containerElement.getBoundingClientRect();
const rowRect = rowElement.getBoundingClientRect();
// Calculate the row's position relative to the container
const rowCenter = rowRect.top + rowRect.height / 2;
const containerCenter = containerRect.top + containerRect.height / 2;
const containerHeight = containerRect.height;
// Distance from center (0 at center, 1 at edges)
const distanceFromCenter = Math.abs(rowCenter - containerCenter) / (containerHeight / 2);
// Create a smooth fade effect
// Rows at the top 40% of viewport get full opacity
// Rows fade out smoothly towards the bottom
const relativePosition = (rowRect.top - containerRect.top) / containerHeight;
if (relativePosition < 0) {
return 1; // Full opacity for rows above viewport
} else if (relativePosition < 0.4) {
return 1; // Full opacity for top 40%
} else if (relativePosition > 0.9) {
return 0.15; // Very faded at bottom (slightly more visible)
} else {
// Smooth transition from 1 to 0.15
const fadeRange = 0.9 - 0.4; // 0.5
const fadePosition = (relativePosition - 0.4) / fadeRange;
return 1 - (fadePosition * 0.85); // Fade from 1 to 0.15
}
};
// Update opacities on scroll
const updateOpacities = useCallback(() => {
if (!tableContainerRef.current || !tableRef.current) return;
const container = tableContainerRef.current;
const rows = tableRef.current.querySelectorAll('tbody tr');
const newOpacities = new Map<string, number>();
rows.forEach((row, index) => {
const opacity = calculateOpacity(row as HTMLElement, container);
newOpacities.set(`row-${index}`, opacity);
});
setScrollOpacities(newOpacities);
}, []);
// Set up scroll listener
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
// Initial opacity calculation
updateOpacities();
// Update on scroll
container.addEventListener('scroll', updateOpacities);
// Also update on window resize
window.addEventListener('resize', updateOpacities);
return () => {
container.removeEventListener('scroll', updateOpacities);
window.removeEventListener('resize', updateOpacities);
};
}, [updateOpacities, tasks]); // Re-calculate when tasks change
// Handle task deletion (opens confirmation modal)
const handleDeleteTask = useCallback((task: Task) => {
setTaskToDelete(task);
setShowDeleteConfirm(true);
}, [setTaskToDelete, setShowDeleteConfirm]);
// Confirm deletion and execute
const confirmDeleteTask = useCallback(async () => {
if (!taskToDelete) return;
try {
await projectService.deleteTask(taskToDelete.id); // Call backend service
onTaskDelete(taskToDelete); // Notify parent component
showToast(`Task "${taskToDelete.title}" deleted successfully`, 'success');
} catch (error) {
console.error('Failed to delete task:', error);
showToast(error instanceof Error ? error.message : 'Failed to delete task', 'error');
} finally {
setShowDeleteConfirm(false);
setTaskToDelete(null);
}
}, [taskToDelete, onTaskDelete, showToast, setShowDeleteConfirm, setTaskToDelete, projectService]);
// Cancel deletion
const cancelDeleteTask = useCallback(() => {
setShowDeleteConfirm(false);
setTaskToDelete(null);
}, [setShowDeleteConfirm, setTaskToDelete]);
// Group tasks by status and sort by task_order
const getTasksByStatus = (status: Task['status']) => {
return tasks
.filter(task => task.status === status)
.sort((a, b) => a.task_order - b.task_order);
};
// Simply return tasks as-is (no hierarchy)
const organizeTasksHierarchically = (taskList: Task[]) => {
return taskList;
};
// Apply status filtering
let statusFilteredTasks: Task[];
if (statusFilter === 'all') {
statusFilteredTasks = tasks;
} else {
statusFilteredTasks = tasks.filter(task => task.status === statusFilter);
}
const filteredTasks = organizeTasksHierarchically(statusFilteredTasks);
const statuses: Task['status'][] = ['backlog', 'in-progress', 'review', 'complete'];
// Get column header color and glow based on header type (matching board view style)
const getHeaderColor = (type: 'primary' | 'secondary') => {
return type === 'primary' ? 'text-cyan-600 dark:text-cyan-400' : 'text-purple-600 dark:text-purple-400';
};
const getHeaderGlow = (type: 'primary' | 'secondary') => {
return type === 'primary' ? 'bg-cyan-500 shadow-[0_0_8px_rgba(34,211,238,0.6)]' : 'bg-purple-500 shadow-[0_0_8px_rgba(168,85,247,0.6)]';
};
return (
<div className="relative">
{/* Status Filter */}
<div className="mb-4 flex gap-2 flex-wrap py-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setStatusFilter('all')}
className={`
px-3 py-1.5 rounded-full text-xs transition-all duration-200
${statusFilter === 'all'
? 'bg-cyan-100 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 ring-1 ring-cyan-500/50 shadow-[0_0_8px_rgba(34,211,238,0.3)]'
: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200/70 dark:hover:bg-gray-700/50'
}
`}
>
All Tasks
</button>
{statuses.map((status) => {
// Define colors for each status
const getStatusColors = (status: Task['status']) => {
switch (status) {
case 'backlog':
return {
selected: 'bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400 ring-1 ring-gray-500/50 shadow-[0_0_8px_rgba(107,114,128,0.3)]',
unselected: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200/70 dark:hover:bg-gray-700/50'
};
case 'in-progress':
return {
selected: 'bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/50 shadow-[0_0_8px_rgba(59,130,246,0.3)]',
unselected: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-blue-200/30 dark:hover:bg-blue-900/20'
};
case 'review':
return {
selected: 'bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400 ring-1 ring-purple-500/50 shadow-[0_0_8px_rgba(168,85,247,0.3)]',
unselected: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-purple-200/30 dark:hover:bg-purple-900/20'
};
case 'complete':
return {
selected: 'bg-green-100 dark:bg-green-900/20 text-green-600 dark:text-green-400 ring-1 ring-green-500/50 shadow-[0_0_8px_rgba(34,197,94,0.3)]',
unselected: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-green-200/30 dark:hover:bg-green-900/20'
};
default:
return {
selected: 'bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400 ring-1 ring-gray-500/50 shadow-[0_0_8px_rgba(107,114,128,0.3)]',
unselected: 'bg-gray-100/70 dark:bg-gray-800/50 text-gray-600 dark:text-gray-400 hover:bg-gray-200/70 dark:hover:bg-gray-700/50'
};
}
};
const colors = getStatusColors(status);
return (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`
px-3 py-1.5 rounded-full text-xs transition-all duration-200
${statusFilter === status ? colors.selected : colors.unselected}
`}
>
{status === 'backlog' ? 'Backlog' :
status === 'in-progress' ? 'In Progress' :
status === 'review' ? 'Review' : 'Complete'}
</button>
);
})}
</div>
</div>
{/* Scrollable table container */}
<div
ref={tableContainerRef}
className="overflow-x-auto overflow-y-auto max-h-[600px] relative"
style={{
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,0.5) 70%, rgba(0,0,0,0.1) 90%, rgba(0,0,0,0) 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 40%, rgba(0,0,0,0.5) 70%, rgba(0,0,0,0.1) 90%, rgba(0,0,0,0) 100%)'
}}
>
<table ref={tableRef} className="w-full border-collapse table-fixed">
<colgroup>
<col className="w-16" />
<col className="w-auto" />
<col className="w-24" />
<col className="w-28" />
<col className="w-32" />
<col className="w-40" />
</colgroup>
<thead>
<tr className="bg-white/80 dark:bg-black/80 backdrop-blur-sm sticky top-0 z-10">
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('secondary')}>Order</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('secondary')}`}></span>
</div>
{/* Header divider with glow matching board view */}
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]`}></div>
</th>
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('primary')}>Task</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('primary')}`}></span>
</div>
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-cyan-500/30 shadow-[0_0_10px_2px_rgba(34,211,238,0.2)]`}></div>
</th>
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('secondary')}>Status</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('secondary')}`}></span>
</div>
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]`}></div>
</th>
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('secondary')}>Feature</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('secondary')}`}></span>
</div>
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-purple-500/30 shadow-[0_0_10px_2px_rgba(168,85,247,0.2)]`}></div>
</th>
<th className="text-left p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center gap-2">
<span className={getHeaderColor('primary')}>Assignee</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('primary')}`}></span>
</div>
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-cyan-500/30 shadow-[0_0_10px_2px_rgba(34,211,238,0.2)]`}></div>
</th>
<th className="text-center p-3 font-mono border-b border-gray-300 dark:border-gray-800 relative">
<div className="flex items-center justify-center gap-2">
<span className={getHeaderColor('primary')}>Actions</span>
<span className={`w-1 h-1 rounded-full ${getHeaderGlow('primary')}`}></span>
</div>
<div className={`absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[1px] bg-cyan-500/30 shadow-[0_0_10px_2px_rgba(34,211,238,0.2)]`}></div>
</th>
</tr>
</thead>
<tbody>
{filteredTasks.map((task, index) => (
<DraggableTaskRow
key={task.id}
task={task}
index={index}
onTaskView={onTaskView}
onTaskComplete={onTaskComplete}
onTaskDelete={handleDeleteTask}
onTaskReorder={onTaskReorder}
onTaskUpdate={onTaskUpdate}
tasksInStatus={getTasksByStatus(task.status)}
style={{
opacity: scrollOpacities.get(`row-${index}`) || 1,
transition: 'opacity 0.2s ease-out'
}}
/>
))}
{/* Add Task Row - only show if create permission exists */}
{onTaskCreate && (
<AddTaskRow
onTaskCreate={onTaskCreate}
tasks={tasks}
statusFilter={statusFilter}
/>
)}
</tbody>
</table>
{/* Spacer to allow scrolling last rows to top */}
<div style={{ height: '70vh' }} aria-hidden="true" />
</div>
{/* Delete Confirmation Modal for Tasks */}
{showDeleteConfirm && taskToDelete && (
<DeleteConfirmModal
itemName={taskToDelete.title}
onConfirm={confirmDeleteTask}
onCancel={cancelDeleteTask}
type="task"
/>
)}
</div>
);
};

View File

@@ -1,680 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, LayoutGrid, Plus, Wifi, WifiOff, List } from 'lucide-react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Toggle } from '../ui/Toggle';
import { projectService } from '../../services/projectService';
import { useTaskSocket } from '../../hooks/useTaskSocket';
import type { CreateTaskRequest, UpdateTaskRequest, DatabaseTaskStatus } from '../../types/project';
import { TaskTableView, Task } from './TaskTableView';
import { TaskBoardView } from './TaskBoardView';
import { EditTaskModal } from './EditTaskModal';
// Assignee utilities
const ASSIGNEE_OPTIONS = ['User', 'Archon', 'AI IDE Agent'] as const;
// Mapping functions for status conversion
const mapUIStatusToDBStatus = (uiStatus: Task['status']): DatabaseTaskStatus => {
switch (uiStatus) {
case 'backlog': return 'todo';
case 'in-progress': return 'doing';
case 'review': return 'review'; // Map UI 'review' to database 'review'
case 'complete': return 'done';
default: return 'todo';
}
};
const mapDBStatusToUIStatus = (dbStatus: DatabaseTaskStatus): Task['status'] => {
switch (dbStatus) {
case 'todo': return 'backlog';
case 'doing': return 'in-progress';
case 'review': return 'review'; // Map database 'review' to UI 'review'
case 'done': return 'complete';
default: return 'backlog';
}
};
// Helper function to map database task format to UI task format
const mapDatabaseTaskToUITask = (dbTask: any): Task => {
return {
id: dbTask.id,
title: dbTask.title,
description: dbTask.description || '',
status: mapDBStatusToUIStatus(dbTask.status),
assignee: {
name: dbTask.assignee || 'User',
avatar: ''
},
feature: dbTask.feature || 'General',
featureColor: '#3b82f6', // Default blue color
task_order: dbTask.task_order || 0,
};
};
export const TasksTab = ({
initialTasks,
onTasksChange,
projectId
}: {
initialTasks: Task[];
onTasksChange: (tasks: Task[]) => void;
projectId: string;
}) => {
const [viewMode, setViewMode] = useState<'table' | 'board'>('board');
const [tasks, setTasks] = useState<Task[]>([]);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectFeatures, setProjectFeatures] = useState<any[]>([]);
const [isLoadingFeatures, setIsLoadingFeatures] = useState(false);
const [isSavingTask, setIsSavingTask] = useState<boolean>(false);
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
// Initialize tasks
useEffect(() => {
setTasks(initialTasks);
}, [initialTasks]);
// Load project features on component mount
useEffect(() => {
loadProjectFeatures();
}, [projectId]);
// Optimized socket handlers with conflict resolution
const handleTaskUpdated = useCallback((message: any) => {
const updatedTask = message.data || message;
const mappedTask = mapDatabaseTaskToUITask(updatedTask);
// Skip updates while modal is open for the same task to prevent conflicts
if (isModalOpen && editingTask?.id === updatedTask.id) {
console.log('[Socket] Skipping update for task being edited:', updatedTask.id);
return;
}
setTasks(prev => {
// Use server timestamp for conflict resolution
const existingTask = prev.find(task => task.id === updatedTask.id);
if (existingTask) {
// Check if this is a more recent update
const serverTimestamp = message.server_timestamp || Date.now();
const lastUpdate = existingTask.lastUpdate || 0;
if (serverTimestamp <= lastUpdate) {
console.log('[Socket] Ignoring stale update for task:', updatedTask.id);
return prev;
}
}
const updated = prev.map(task =>
task.id === updatedTask.id
? { ...mappedTask, lastUpdate: message.server_timestamp || Date.now() }
: task
);
// Notify parent after state settles
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
}, [onTasksChange, isModalOpen, editingTask?.id]);
const handleTaskCreated = useCallback((message: any) => {
const newTask = message.data || message;
console.log('🆕 Real-time task created:', newTask);
const mappedTask = mapDatabaseTaskToUITask(newTask);
setTasks(prev => {
// Check if task already exists to prevent duplicates
if (prev.some(task => task.id === newTask.id)) {
console.log('Task already exists, skipping create');
return prev;
}
const updated = [...prev, mappedTask];
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
}, [onTasksChange]);
const handleTaskDeleted = useCallback((message: any) => {
const deletedTask = message.data || message;
console.log('🗑️ Real-time task deleted:', deletedTask);
setTasks(prev => {
const updated = prev.filter(task => task.id !== deletedTask.id);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
}, [onTasksChange]);
const handleTaskArchived = useCallback((message: any) => {
const archivedTask = message.data || message;
console.log('📦 Real-time task archived:', archivedTask);
setTasks(prev => {
const updated = prev.filter(task => task.id !== archivedTask.id);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
}, [onTasksChange]);
const handleTasksReordered = useCallback((message: any) => {
const reorderData = message.data || message;
console.log('🔄 Real-time tasks reordered:', reorderData);
// Handle bulk task reordering from server
if (reorderData.tasks && Array.isArray(reorderData.tasks)) {
const uiTasks: Task[] = reorderData.tasks.map(mapDatabaseTaskToUITask);
setTasks(uiTasks);
setTimeout(() => onTasksChange(uiTasks), 0);
}
}, [onTasksChange]);
const handleInitialTasks = useCallback((message: any) => {
const initialWebSocketTasks = message.data || message;
const uiTasks: Task[] = initialWebSocketTasks.map(mapDatabaseTaskToUITask);
setTasks(uiTasks);
onTasksChange(uiTasks);
}, [onTasksChange]);
// Simplified socket connection with better lifecycle management
const { isConnected, connectionState } = useTaskSocket({
projectId,
onTaskCreated: handleTaskCreated,
onTaskUpdated: handleTaskUpdated,
onTaskDeleted: handleTaskDeleted,
onTaskArchived: handleTaskArchived,
onTasksReordered: handleTasksReordered,
onInitialTasks: handleInitialTasks,
onConnectionStateChange: (state) => {
setIsWebSocketConnected(state === 'connected');
}
});
// Update connection state when hook state changes
useEffect(() => {
setIsWebSocketConnected(isConnected);
}, [isConnected]);
const loadProjectFeatures = async () => {
if (!projectId) return;
setIsLoadingFeatures(true);
try {
const response = await projectService.getProjectFeatures(projectId);
setProjectFeatures(response.features || []);
} catch (error) {
console.error('Failed to load project features:', error);
setProjectFeatures([]);
} finally {
setIsLoadingFeatures(false);
}
};
// Modal management functions
const openEditModal = async (task: Task) => {
setEditingTask(task);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingTask(null);
};
const saveTask = async (task: Task) => {
setEditingTask(task);
setIsSavingTask(true);
try {
let parentTaskId = task.id;
if (task.id) {
// Update existing task
const updateData: UpdateTaskRequest = {
title: task.title,
description: task.description,
status: mapUIStatusToDBStatus(task.status),
assignee: task.assignee?.name || 'User',
task_order: task.task_order,
...(task.feature && { feature: task.feature }),
...(task.featureColor && { featureColor: task.featureColor })
};
await projectService.updateTask(task.id, updateData);
} else {
// Create new task first to get UUID
const createData: CreateTaskRequest = {
project_id: projectId,
title: task.title,
description: task.description,
status: mapUIStatusToDBStatus(task.status),
assignee: task.assignee?.name || 'User',
task_order: task.task_order,
...(task.feature && { feature: task.feature }),
...(task.featureColor && { featureColor: task.featureColor })
};
const createdTask = await projectService.createTask(createData);
parentTaskId = createdTask.id;
}
// Don't reload tasks - let socket updates handle synchronization
closeModal();
} catch (error) {
console.error('Failed to save task:', error);
alert(`Failed to save task: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSavingTask(false);
}
};
// Update tasks helper
const updateTasks = (newTasks: Task[]) => {
setTasks(newTasks);
onTasksChange(newTasks);
};
// Helper function to reorder tasks by status to ensure no gaps (1,2,3...)
const reorderTasksByStatus = async (status: Task['status']) => {
const tasksInStatus = tasks
.filter(task => task.status === status)
.sort((a, b) => a.task_order - b.task_order);
const updatePromises = tasksInStatus.map((task, index) =>
projectService.updateTask(task.id, { task_order: index + 1 })
);
await Promise.all(updatePromises);
};
// Helper function to get next available order number for a status
const getNextOrderForStatus = (status: Task['status']): number => {
const tasksInStatus = tasks.filter(task =>
task.status === status
);
if (tasksInStatus.length === 0) return 1;
const maxOrder = Math.max(...tasksInStatus.map(task => task.task_order));
return maxOrder + 1;
};
// Simple debounce function
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
};
// Improved debounced persistence with better coordination
const debouncedPersistSingleTask = useMemo(
() => debounce(async (task: Task) => {
try {
console.log('REORDER: Persisting position change for task:', task.title, 'new position:', task.task_order);
// Update only the moved task with server timestamp for conflict resolution
await projectService.updateTask(task.id, {
task_order: task.task_order,
client_timestamp: Date.now()
});
console.log('REORDER: Single task position persisted successfully');
} catch (error) {
console.error('REORDER: Failed to persist task position:', error);
// Don't reload tasks immediately - let socket handle recovery
console.log('REORDER: Socket will handle state recovery');
}
}, 800), // Slightly reduced delay for better responsiveness
[projectId]
);
// Optimized task reordering without optimistic update conflicts
const handleTaskReorder = useCallback((taskId: string, targetIndex: number, status: Task['status']) => {
console.log('REORDER: Moving task', taskId, 'to index', targetIndex, 'in status', status);
// Get all tasks in the target status, sorted by current order
const statusTasks = tasks
.filter(task => task.status === status)
.sort((a, b) => a.task_order - b.task_order);
const otherTasks = tasks.filter(task => task.status !== status);
// Find the moving task
const movingTaskIndex = statusTasks.findIndex(task => task.id === taskId);
if (movingTaskIndex === -1) {
console.log('REORDER: Task not found in status');
return;
}
// Prevent invalid moves
if (targetIndex < 0 || targetIndex >= statusTasks.length) {
console.log('REORDER: Invalid target index', targetIndex);
return;
}
// Skip if moving to same position
if (movingTaskIndex === targetIndex) {
console.log('REORDER: Task already in target position');
return;
}
const movingTask = statusTasks[movingTaskIndex];
console.log('REORDER: Moving', movingTask.title, 'from', movingTaskIndex, 'to', targetIndex);
// Calculate new position using improved algorithm
let newPosition: number;
if (targetIndex === 0) {
// Moving to first position
const firstTask = statusTasks[0];
newPosition = firstTask.task_order / 2;
} else if (targetIndex === statusTasks.length - 1) {
// Moving to last position
const lastTask = statusTasks[statusTasks.length - 1];
newPosition = lastTask.task_order + 1024;
} else {
// Moving between two items
let prevTask, nextTask;
if (targetIndex > movingTaskIndex) {
// Moving down
prevTask = statusTasks[targetIndex];
nextTask = statusTasks[targetIndex + 1];
} else {
// Moving up
prevTask = statusTasks[targetIndex - 1];
nextTask = statusTasks[targetIndex];
}
if (prevTask && nextTask) {
newPosition = (prevTask.task_order + nextTask.task_order) / 2;
} else if (prevTask) {
newPosition = prevTask.task_order + 1024;
} else if (nextTask) {
newPosition = nextTask.task_order / 2;
} else {
newPosition = 1024; // Fallback
}
}
console.log('REORDER: New position calculated:', newPosition);
// Create updated task with new position and timestamp
const updatedTask = {
...movingTask,
task_order: newPosition,
lastUpdate: Date.now() // Add timestamp for conflict resolution
};
// Immediate UI update without optimistic tracking interference
const allUpdatedTasks = otherTasks.concat(
statusTasks.map(task => task.id === taskId ? updatedTask : task)
);
updateTasks(allUpdatedTasks);
// Persist to backend (single API call)
debouncedPersistSingleTask(updatedTask);
}, [tasks, updateTasks, debouncedPersistSingleTask]);
// Task move function (for board view)
const moveTask = async (taskId: string, newStatus: Task['status']) => {
console.log(`[TasksTab] Attempting to move task ${taskId} to new status: ${newStatus}`);
try {
const movingTask = tasks.find(task => task.id === taskId);
if (!movingTask) {
console.warn(`[TasksTab] Task ${taskId} not found for move operation.`);
return;
}
const oldStatus = movingTask.status;
const newOrder = getNextOrderForStatus(newStatus);
console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`);
// Update the task with new status and order
await projectService.updateTask(taskId, {
status: mapUIStatusToDBStatus(newStatus),
task_order: newOrder,
client_timestamp: Date.now()
});
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
// Don't update local state immediately - let socket handle it
console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`);
} catch (error) {
console.error(`[TasksTab] Failed to move task ${taskId}:`, error);
alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const completeTask = (taskId: string) => {
console.log(`[TasksTab] Calling completeTask for ${taskId}`);
moveTask(taskId, 'complete');
};
const deleteTask = async (task: Task) => {
try {
// Delete the task - backend will emit socket event
await projectService.deleteTask(task.id);
console.log(`[TasksTab] Task ${task.id} deletion sent to backend`);
// Don't update local state - let socket handle it
} catch (error) {
console.error('Failed to delete task:', error);
// Note: The toast notification for deletion is now handled by TaskBoardView and TaskTableView
}
};
// Inline task creation function
const createTaskInline = async (newTask: Omit<Task, 'id'>) => {
try {
// Auto-assign next order number if not provided
const nextOrder = newTask.task_order || getNextOrderForStatus(newTask.status);
const createData: CreateTaskRequest = {
project_id: projectId,
title: newTask.title,
description: newTask.description,
status: mapUIStatusToDBStatus(newTask.status),
assignee: newTask.assignee?.name || 'User',
task_order: nextOrder,
...(newTask.feature && { feature: newTask.feature }),
...(newTask.featureColor && { featureColor: newTask.featureColor })
};
await projectService.createTask(createData);
// Don't reload tasks - let socket updates handle synchronization
console.log('[TasksTab] Task creation sent to backend, waiting for socket update');
} catch (error) {
console.error('Failed to create task:', error);
throw error;
}
};
// Inline task update function
const updateTaskInline = async (taskId: string, updates: Partial<Task>) => {
console.log(`[TasksTab] Inline update for task ${taskId} with updates:`, updates);
try {
const updateData: Partial<UpdateTaskRequest> = {
client_timestamp: Date.now()
};
if (updates.title !== undefined) updateData.title = updates.title;
if (updates.description !== undefined) updateData.description = updates.description;
if (updates.status !== undefined) {
console.log(`[TasksTab] Mapping UI status ${updates.status} to DB status.`);
updateData.status = mapUIStatusToDBStatus(updates.status);
console.log(`[TasksTab] Mapped status for ${taskId}: ${updates.status} -> ${updateData.status}`);
}
if (updates.assignee !== undefined) updateData.assignee = updates.assignee.name;
if (updates.task_order !== undefined) updateData.task_order = updates.task_order;
if (updates.feature !== undefined) updateData.feature = updates.feature;
if (updates.featureColor !== undefined) updateData.featureColor = updates.featureColor;
console.log(`[TasksTab] Sending update request for task ${taskId} to projectService:`, updateData);
await projectService.updateTask(taskId, updateData);
console.log(`[TasksTab] projectService.updateTask successful for ${taskId}.`);
// Don't update local state optimistically - let socket handle it
console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`);
} catch (error) {
console.error(`[TasksTab] Failed to update task ${taskId} inline:`, error);
alert(`Failed to update task: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
};
// Get tasks for priority selection with descriptive labels
const getTasksForPrioritySelection = (status: Task['status']): Array<{value: number, label: string}> => {
const tasksInStatus = tasks
.filter(task => task.status === status && task.id !== editingTask?.id) // Exclude current task if editing
.sort((a, b) => a.task_order - b.task_order);
const options: Array<{value: number, label: string}> = [];
if (tasksInStatus.length === 0) {
// No tasks in this status
options.push({ value: 1, label: "1 - First task in this status" });
} else {
// Add option to be first
options.push({
value: 1,
label: `1 - Before "${tasksInStatus[0].title.substring(0, 30)}${tasksInStatus[0].title.length > 30 ? '...' : ''}"`
});
// Add options between existing tasks
for (let i = 0; i < tasksInStatus.length - 1; i++) {
const currentTask = tasksInStatus[i];
const nextTask = tasksInStatus[i + 1];
options.push({
value: i + 2,
label: `${i + 2} - After "${currentTask.title.substring(0, 20)}${currentTask.title.length > 20 ? '...' : ''}", Before "${nextTask.title.substring(0, 20)}${nextTask.title.length > 20 ? '...' : ''}"`
});
}
// Add option to be last
const lastTask = tasksInStatus[tasksInStatus.length - 1];
options.push({
value: tasksInStatus.length + 1,
label: `${tasksInStatus.length + 1} - After "${lastTask.title.substring(0, 30)}${lastTask.title.length > 30 ? '...' : ''}"`
});
}
return options;
};
// Memoized version of getTasksForPrioritySelection to prevent recalculation on every render
const memoizedGetTasksForPrioritySelection = useMemo(
() => getTasksForPrioritySelection,
[tasks, editingTask?.id]
);
return (
<DndProvider backend={HTML5Backend}>
<div className="min-h-[70vh] relative">
{/* Main content - Table or Board view */}
<div className="relative h-[calc(100vh-220px)] overflow-auto">
{viewMode === 'table' ? (
<TaskTableView
tasks={tasks}
onTaskView={openEditModal}
onTaskComplete={completeTask}
onTaskDelete={deleteTask}
onTaskReorder={handleTaskReorder}
onTaskCreate={createTaskInline}
onTaskUpdate={updateTaskInline}
/>
) : (
<TaskBoardView
tasks={tasks}
onTaskView={openEditModal}
onTaskComplete={completeTask}
onTaskDelete={deleteTask}
onTaskMove={moveTask}
onTaskReorder={handleTaskReorder}
/>
)}
</div>
{/* Fixed View Controls */}
<div className="fixed bottom-6 left-0 right-0 flex justify-center z-50 pointer-events-none">
<div className="flex items-center gap-4">
{/* WebSocket Status Indicator */}
<div className="flex items-center gap-2 px-3 py-2 bg-white/80 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg shadow-[0_0_20px_rgba(0,0,0,0.1)] dark:shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-md pointer-events-auto">
{isWebSocketConnected ? (
<>
<Wifi className="w-4 h-4 text-green-500" />
<span className="text-xs text-green-600 dark:text-green-400">Live</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-red-500" />
<span className="text-xs text-red-600 dark:text-red-400">Offline</span>
</>
)}
</div>
{/* Add Task Button with Luminous Style */}
<button
onClick={() => {
const defaultOrder = getTasksForPrioritySelection('backlog')[0]?.value || 1;
setEditingTask({
id: '',
title: '',
description: '',
status: 'backlog',
assignee: { name: 'AI IDE Agent', avatar: '' },
feature: '',
featureColor: '#3b82f6',
task_order: defaultOrder
});
setIsModalOpen(true);
}}
className="relative px-5 py-2.5 flex items-center gap-2 bg-white/80 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg shadow-[0_0_20px_rgba(0,0,0,0.1)] dark:shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-md pointer-events-auto text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 transition-all duration-300"
>
<Plus className="w-4 h-4 mr-1" />
<span>Add Task</span>
<span className="absolute bottom-0 left-[0%] right-[0%] w-[95%] mx-auto h-[2px] bg-cyan-500 shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]"></span>
</button>
{/* View Toggle Controls */}
<div className="flex items-center bg-white/80 dark:bg-black/90 border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden shadow-[0_0_20px_rgba(0,0,0,0.1)] dark:shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-md pointer-events-auto">
<button
onClick={() => setViewMode('table')}
className={`px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300 ${viewMode === 'table' ? 'text-cyan-600 dark:text-cyan-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300'}`}
>
<Table className="w-4 h-4" />
<span>Table</span>
{viewMode === 'table' && <span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-cyan-500 shadow-[0_0_10px_2px_rgba(34,211,238,0.4)] dark:shadow-[0_0_20px_5px_rgba(34,211,238,0.7)]"></span>}
</button>
<button
onClick={() => setViewMode('board')}
className={`px-5 py-2.5 flex items-center gap-2 relative transition-all duration-300 ${viewMode === 'board' ? 'text-purple-600 dark:text-purple-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-300'}`}
>
<LayoutGrid className="w-4 h-4" />
<span>Board</span>
{viewMode === 'board' && <span className="absolute bottom-0 left-[15%] right-[15%] w-[70%] mx-auto h-[2px] bg-purple-500 shadow-[0_0_10px_2px_rgba(168,85,247,0.4)] dark:shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]"></span>}
</button>
</div>
</div>
</div>
{/* Edit Task Modal */}
<EditTaskModal
isModalOpen={isModalOpen}
editingTask={editingTask}
projectFeatures={projectFeatures}
isLoadingFeatures={isLoadingFeatures}
isSavingTask={isSavingTask}
onClose={closeModal}
onSave={saveTask}
getTasksForPrioritySelection={memoizedGetTasksForPrioritySelection}
/>
</div>
</DndProvider>
);
};

View File

@@ -1,661 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, Clock, RotateCcw, Eye, Calendar, User, FileText, Diff, GitBranch, Layers, Plus, Minus, AlertTriangle } from 'lucide-react';
import projectService from '../../services/projectService';
import { Button } from '../ui/Button';
import { useToast } from '../../contexts/ToastContext';
interface Version {
id: string;
version_number: number;
change_summary: string;
change_type: string;
created_by: string;
created_at: string;
content: any;
document_id?: string;
}
interface VersionHistoryModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
documentId?: string;
fieldName?: string;
onRestore?: () => void;
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
content: string;
lineNumber?: number;
}
interface RestoreConfirmModalProps {
isOpen: boolean;
versionNumber: number;
onConfirm: () => void;
onCancel: () => void;
}
const RestoreConfirmModal: React.FC<RestoreConfirmModalProps> = ({
isOpen,
versionNumber,
onConfirm,
onCancel
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60]">
<div className="relative p-6 rounded-md backdrop-blur-md w-full max-w-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)]
before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-[2px]
before:rounded-t-[4px] before:bg-orange-500
before:shadow-[0_0_10px_2px_rgba(249,115,22,0.4)] dark:before:shadow-[0_0_20px_5px_rgba(249,115,22,0.7)]">
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Restore Version
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
This will create a new version
</p>
</div>
</div>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to restore to <span className="font-medium text-orange-600 dark:text-orange-400">version {versionNumber}</span>?
This will create a new version with the restored content.
</p>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
accentColor="orange"
onClick={onConfirm}
icon={<RotateCcw className="w-4 h-4" />}
>
Restore Version
</Button>
</div>
</div>
</div>
</div>
);
};
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({
isOpen,
onClose,
projectId,
documentId,
fieldName = 'docs',
onRestore
}) => {
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<any>(null);
const [previewVersion, setPreviewVersion] = useState<number | null>(null);
const [restoring, setRestoring] = useState<number | null>(null);
const [currentContent, setCurrentContent] = useState<any>(null);
const [viewMode, setViewMode] = useState<'diff' | 'rendered'>('diff');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [versionToRestore, setVersionToRestore] = useState<number | null>(null);
const { showToast } = useToast();
useEffect(() => {
if (isOpen && projectId) {
loadVersionHistory();
loadCurrentContent();
}
}, [isOpen, projectId, fieldName, documentId]);
const loadVersionHistory = async () => {
setLoading(true);
setError(null);
try {
const versionData = await projectService.getDocumentVersionHistory(projectId, fieldName);
// Filter versions by document if documentId is provided
let filteredVersions = versionData || [];
if (documentId) {
filteredVersions = versionData.filter((version: Version) => {
// Check if this version contains changes to the specific document
if (version.document_id === documentId) {
return true;
}
// Also check if the content contains the document
if (Array.isArray(version.content)) {
return version.content.some((doc: any) => doc.id === documentId);
}
return false;
});
}
setVersions(filteredVersions);
} catch (error) {
console.error('Error loading version history:', error);
setError('Failed to load version history');
showToast('Failed to load version history', 'error');
} finally {
setLoading(false);
}
};
const loadCurrentContent = async () => {
try {
const currentProject = await projectService.getProject(projectId);
setCurrentContent((currentProject as any)[fieldName] || []);
} catch (error) {
console.error('Error loading current content:', error);
showToast('Failed to load current content', 'error');
}
};
const handlePreview = async (versionNumber: number) => {
try {
setPreviewVersion(versionNumber);
const contentData = await projectService.getVersionContent(projectId, versionNumber, fieldName);
setPreviewContent(contentData.content);
} catch (error) {
console.error('Error loading version content:', error);
setError('Failed to load version content');
showToast('Failed to load version content', 'error');
}
};
const handleRestoreClick = (versionNumber: number) => {
setVersionToRestore(versionNumber);
setShowRestoreConfirm(true);
};
const handleRestoreConfirm = async () => {
if (!versionToRestore) return;
setRestoring(versionToRestore);
setError(null);
setShowRestoreConfirm(false);
try {
await projectService.restoreDocumentVersion(projectId, versionToRestore, fieldName);
await loadVersionHistory();
await loadCurrentContent();
if (onRestore) {
onRestore();
}
showToast(`Successfully restored to version ${versionToRestore}`, 'success');
} catch (error) {
console.error('Error restoring version:', error);
setError('Failed to restore version');
showToast('Failed to restore version', 'error');
} finally {
setRestoring(null);
setVersionToRestore(null);
}
};
const handleRestoreCancel = () => {
setShowRestoreConfirm(false);
setVersionToRestore(null);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getChangeTypeIcon = (changeType: string) => {
switch (changeType) {
case 'create':
return <FileText className="w-4 h-4 text-emerald-400" />;
case 'update':
return <Clock className="w-4 h-4 text-blue-400" />;
case 'delete':
return <X className="w-4 h-4 text-red-400" />;
case 'restore':
return <RotateCcw className="w-4 h-4 text-purple-400" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
}
};
const extractTextContent = (content: any, docId?: string): string => {
if (!content) return '';
// If content is an array of documents
if (Array.isArray(content)) {
// If we have a documentId, filter to just that document
if (docId) {
const doc = content.find(d => d.id === docId);
if (doc) {
// If it has markdown content, return that
if (doc.content?.markdown) {
return doc.content.markdown;
}
// Otherwise try to extract text content
return extractDocumentText(doc);
}
return 'Document not found in this version';
}
// Otherwise show all documents
return content.map(doc => {
if (doc.content?.markdown) {
return `=== ${doc.title || 'Document'} ===\n${doc.content.markdown}`;
}
return `=== ${doc.title || 'Document'} ===\n${extractDocumentText(doc)}`;
}).join('\n\n');
}
// If content is an object with markdown
if (typeof content === 'object' && content.markdown) {
return content.markdown;
}
if (typeof content === 'object') {
return JSON.stringify(content, null, 2);
}
return String(content);
};
const extractDocumentText = (doc: any): string => {
let text = '';
if (doc.blocks) {
text = doc.blocks.map((block: any) => {
if (block.content) {
return block.content;
}
return '';
}).filter(Boolean).join('\n');
} else if (doc.content && typeof doc.content === 'string') {
text = doc.content;
} else if (doc.content && typeof doc.content === 'object') {
text = JSON.stringify(doc.content, null, 2);
}
return text;
};
const generateDiff = (oldContent: any, newContent: any): DiffLine[] => {
const oldText = extractTextContent(oldContent, documentId);
const newText = extractTextContent(newContent, documentId);
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const diff: DiffLine[] = [];
// Simple line-by-line diff (in a real app you'd use a proper diff library)
const maxLines = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] || '';
const newLine = newLines[i] || '';
if (oldLine === newLine) {
if (oldLine) {
diff.push({ type: 'unchanged', content: oldLine, lineNumber: i + 1 });
}
} else {
if (oldLine && !newLines.includes(oldLine)) {
diff.push({ type: 'removed', content: oldLine, lineNumber: i + 1 });
}
if (newLine && !oldLines.includes(newLine)) {
diff.push({ type: 'added', content: newLine, lineNumber: i + 1 });
}
}
}
return diff;
};
const renderInlineDiff = () => {
if (!previewContent || !currentContent) {
return (
<div className="text-center py-12">
<Diff className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">Select a version to see changes</p>
</div>
);
}
const diffLines = generateDiff(previewContent, currentContent);
// If filtering by document but no changes found
if (documentId && diffLines.length === 0) {
return (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">No changes found for this document in the selected version</p>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-400 mb-4">
<Diff className="w-4 h-4" />
Comparing Version {previewVersion} Current
</div>
<div className="bg-gray-900/50 rounded-lg border border-gray-700/50 overflow-hidden">
<div className="bg-gray-800/50 px-4 py-2 border-b border-gray-700/50">
<span className="text-gray-300 text-sm font-mono">Changes</span>
</div>
<div className="max-h-96 overflow-y-auto font-mono text-sm">
{diffLines.map((line, index) => (
<div
key={index}
className={`flex items-start px-4 py-1 ${
line.type === 'added'
? 'bg-green-500/10 border-l-2 border-green-500'
: line.type === 'removed'
? 'bg-red-500/10 border-l-2 border-red-500'
: 'hover:bg-gray-800/30'
}`}
>
<span className="text-gray-500 w-8 text-right mr-4 select-none">
{line.lineNumber}
</span>
<span className="mr-3 w-4 flex-shrink-0">
{line.type === 'added' && <Plus className="w-3 h-3 text-green-400" />}
{line.type === 'removed' && <Minus className="w-3 h-3 text-red-400" />}
</span>
<span className={`flex-1 ${
line.type === 'added'
? 'text-green-300'
: line.type === 'removed'
? 'text-red-300'
: 'text-gray-300'
}`}>
{line.content || ' '}
</span>
</div>
))}
</div>
</div>
</div>
);
};
const renderDocumentContent = (content: any) => {
if (!content) return <div className="text-gray-500 text-center py-8">No content available</div>;
// Extract the markdown content for the specific document
const markdownContent = extractTextContent(content, documentId);
if (markdownContent === 'Document not found in this version') {
return (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500 text-lg">Document not found in this version</p>
</div>
);
}
// Render the markdown content
return (
<div className="prose prose-invert max-w-none">
<pre className="whitespace-pre-wrap bg-gray-900/50 p-6 rounded-lg border border-gray-700/50 text-gray-300 font-mono text-sm overflow-auto">
{markdownContent}
</pre>
</div>
);
// Old array handling code - keeping for reference but not using
if (Array.isArray(content) && false) {
return (
<div className="space-y-6">
{content.map((doc, index) => (
<div key={index} className="border border-gray-700/50 rounded-lg p-4 bg-gray-900/30">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-5 h-5 text-blue-400" />
<h3 className="text-lg font-semibold text-white">{doc.title || `Document ${index + 1}`}</h3>
</div>
{doc.blocks && (
<div className="prose prose-invert max-w-none">
{doc.blocks.map((block: any, blockIndex: number) => (
<div key={blockIndex} className="mb-4">
{block.type === 'heading_1' && (
<h1 className="text-2xl font-bold text-white mb-2">{block.content}</h1>
)}
{block.type === 'heading_2' && (
<h2 className="text-xl font-semibold text-white mb-2">{block.content}</h2>
)}
{block.type === 'heading_3' && (
<h3 className="text-lg font-medium text-white mb-2">{block.content}</h3>
)}
{block.type === 'paragraph' && (
<p className="text-gray-300 leading-relaxed mb-2">{block.content}</p>
)}
{block.type === 'bulletListItem' && (
<ul className="list-disc list-inside text-gray-300 mb-2">
<li>{block.content}</li>
</ul>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
);
}
if (typeof content === 'object') {
return (
<div className="border border-gray-700/50 rounded-lg p-4 bg-gray-900/30">
<pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-x-auto">
{JSON.stringify(content, null, 2)}
</pre>
</div>
);
}
return <div className="text-gray-500">Unsupported content type</div>;
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="relative w-full max-w-6xl h-5/6 flex flex-col bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-purple-500/30 rounded-lg overflow-hidden shadow-[0_10px_30px_-15px_rgba(0,0,0,0.7)] backdrop-blur-md">
{/* Neon top edge */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-purple-500 shadow-[0_0_20px_5px_rgba(168,85,247,0.7)]"></div>
{/* Header */}
<div className="relative z-10 flex items-center justify-between p-6 border-b border-purple-500/30">
<h2 className="text-xl font-semibold text-white flex items-center gap-3">
<Clock className="w-6 h-6 text-purple-400" />
Version History
<span className="text-purple-400">- {fieldName}{documentId ? ' (Document Filtered)' : ''}</span>
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 transition-all duration-300"
>
<X className="w-6 h-6 text-gray-400 hover:text-red-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 flex overflow-hidden relative z-10">
{/* Version List */}
<div className="w-1/3 border-r border-purple-500/30 overflow-y-auto">
<div className="p-6">
<h3 className="font-medium text-white mb-4 flex items-center gap-2">
<GitBranch className="w-5 h-5 text-purple-400" />
Versions
</h3>
{loading && (
<div className="text-center py-8">
<div className="w-8 h-8 border-2 border-purple-500/30 border-t-purple-500 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-400">Loading versions...</p>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 mb-4">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{!loading && versions.length === 0 && (
<div className="text-center py-8">
<Clock className="w-12 h-12 text-gray-600 mx-auto mb-4 opacity-50" />
<p className="text-gray-500">No versions found</p>
</div>
)}
<div className="space-y-3">
{versions.map((version) => (
<div
key={version.id}
className={`relative p-4 rounded-lg cursor-pointer transition-all duration-300 border ${
previewVersion === version.version_number
? 'bg-blue-500/20 border-blue-500/50'
: 'bg-white/5 border-gray-500/30 hover:bg-white/10 hover:border-gray-400/50'
}`}
onClick={() => handlePreview(version.version_number)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getChangeTypeIcon(version.change_type)}
<span className="font-medium text-white">
Version {version.version_number}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
accentColor="green"
onClick={(e) => {
e.stopPropagation();
handleRestoreClick(version.version_number);
}}
disabled={restoring === version.version_number}
icon={restoring === version.version_number ?
<div className="w-4 h-4 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" /> :
<RotateCcw className="w-4 h-4" />
}
>
Restore
</Button>
</div>
</div>
<p className="text-sm text-gray-300 mb-3">
{version.change_summary}
{version.document_id && documentId && version.document_id === documentId && (
<span className="ml-2 text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded">
This document
</span>
)}
</p>
<div className="flex items-center gap-4 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<User className="w-3 h-3" />
{version.created_by}
</div>
<div className="flex items-center gap-1.5">
<Calendar className="w-3 h-3" />
{formatDate(version.created_at)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Preview Panel */}
<div className="flex-1 overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="font-medium text-white flex items-center gap-3">
<Eye className="w-5 h-5 text-blue-400" />
{viewMode === 'diff' ? 'Changes' : 'Content'}
</h3>
{previewVersion !== null && (
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'diff' ? 'primary' : 'ghost'}
size="sm"
accentColor="purple"
onClick={() => setViewMode('diff')}
icon={<Diff className="w-4 h-4" />}
>
Diff View
</Button>
<Button
variant={viewMode === 'rendered' ? 'primary' : 'ghost'}
size="sm"
accentColor="blue"
onClick={() => setViewMode('rendered')}
icon={<Layers className="w-4 h-4" />}
>
Rendered
</Button>
</div>
)}
</div>
{previewVersion === null ? (
<div className="text-center py-12">
<Eye className="w-16 h-16 text-gray-600 mx-auto mb-6 opacity-50" />
<p className="text-gray-500 text-lg">Select a version to preview</p>
</div>
) : (
<div>
{viewMode === 'diff' ? renderInlineDiff() : renderDocumentContent(previewContent)}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="relative z-10 border-t border-purple-500/30 p-6">
<div className="flex justify-end">
<Button
variant="ghost"
accentColor="purple"
onClick={onClose}
>
Close
</Button>
</div>
</div>
</div>
</div>
{/* Restore Confirmation Modal */}
<RestoreConfirmModal
isOpen={showRestoreConfirm}
versionNumber={versionToRestore || 0}
onConfirm={handleRestoreConfirm}
onCancel={handleRestoreCancel}
/>
</>
);
};

View File

@@ -1,304 +0,0 @@
/* PRP Viewer Styles - Beautiful Archon Theme */
.prp-viewer {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Smooth collapse animations */
.prp-viewer .collapsible-content {
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease-out;
}
/* Hover effects for cards */
.prp-viewer .persona-card,
.prp-viewer .metric-item,
.prp-viewer .flow-diagram {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Glow effects for icons */
.prp-viewer .icon-glow {
filter: drop-shadow(0 0 8px currentColor);
}
/* Gradient text animations */
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.prp-viewer .gradient-text {
background-size: 200% 200%;
animation: gradientShift 3s ease infinite;
}
/* Section reveal animations */
.prp-viewer .section-content {
animation: slideIn 0.4s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Pulse animation for important metrics */
@keyframes metricPulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
.prp-viewer .metric-highlight {
animation: metricPulse 2s ease-in-out infinite;
}
/* Flow diagram connections */
.prp-viewer .flow-connection {
position: relative;
}
.prp-viewer .flow-connection::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
width: 8px;
height: 8px;
background: linear-gradient(135deg, #3b82f6, #a855f7);
border-radius: 50%;
transform: translateY(-50%);
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6);
}
/* Interactive hover states */
.prp-viewer .interactive-section:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Dark mode enhancements */
.dark .prp-viewer .interactive-section:hover {
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5),
0 10px 10px -5px rgba(0, 0, 0, 0.2),
0 0 20px rgba(59, 130, 246, 0.3);
}
/* Loading skeleton animation */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.prp-viewer .skeleton {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Collapsible chevron animation */
.prp-viewer .chevron-animate {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Card entrance animations */
.prp-viewer .card-entrance {
animation: cardSlideUp 0.5s ease-out;
animation-fill-mode: both;
}
@keyframes cardSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Stagger animation for lists */
.prp-viewer .stagger-item {
animation: fadeInUp 0.4s ease-out;
animation-fill-mode: both;
}
.prp-viewer .stagger-item:nth-child(1) { animation-delay: 0.1s; }
.prp-viewer .stagger-item:nth-child(2) { animation-delay: 0.2s; }
.prp-viewer .stagger-item:nth-child(3) { animation-delay: 0.3s; }
.prp-viewer .stagger-item:nth-child(4) { animation-delay: 0.4s; }
.prp-viewer .stagger-item:nth-child(5) { animation-delay: 0.5s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Floating animation for icons */
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.prp-viewer .float-icon {
animation: float 3s ease-in-out infinite;
}
/* Glow border effect */
.prp-viewer .glow-border {
position: relative;
overflow: hidden;
}
.prp-viewer .glow-border::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, #3b82f6, #a855f7, #ec4899, #3b82f6);
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
background-size: 400% 400%;
animation: gradientRotate 3s ease infinite;
}
.prp-viewer .glow-border:hover::before {
opacity: 1;
}
@keyframes gradientRotate {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Success metric animations */
.prp-viewer .metric-success {
position: relative;
}
.prp-viewer .metric-success::after {
content: '✓';
position: absolute;
right: -20px;
top: 50%;
transform: translateY(-50%);
color: #10b981;
font-weight: bold;
opacity: 0;
transition: all 0.3s ease;
}
.prp-viewer .metric-success:hover::after {
opacity: 1;
right: 10px;
}
/* Smooth scrolling for sections */
.prp-viewer {
scroll-behavior: smooth;
}
/* Progress indicator for implementation phases */
.prp-viewer .phase-progress {
position: relative;
padding-left: 30px;
}
.prp-viewer .phase-progress::before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #3b82f6, #a855f7);
}
.prp-viewer .phase-progress .phase-dot {
position: absolute;
left: 6px;
top: 20px;
width: 10px;
height: 10px;
background: white;
border: 2px solid #3b82f6;
border-radius: 50%;
z-index: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.prp-viewer .grid {
grid-template-columns: 1fr;
}
.prp-viewer .text-3xl {
font-size: 1.5rem;
}
.prp-viewer .p-6 {
padding: 1rem;
}
}

View File

@@ -1,279 +0,0 @@
import React from 'react';
import { PRPContent } from './types/prp.types';
import { MetadataSection } from './sections/MetadataSection';
import { SectionRenderer } from './renderers/SectionRenderer';
import { normalizePRPDocument } from './utils/normalizer';
import { processContentForPRP, isMarkdownContent, isDocumentWithMetadata } from './utils/markdownParser';
import { MarkdownDocumentRenderer } from './components/MarkdownDocumentRenderer';
import './PRPViewer.css';
interface PRPViewerProps {
content: PRPContent;
isDarkMode?: boolean;
sectionOverrides?: Record<string, React.ComponentType<any>>;
}
/**
* Process content to handle [Image #N] placeholders
*/
const processContent = (content: any): any => {
if (typeof content === 'string') {
// Replace [Image #N] with proper markdown image syntax
return content.replace(/\[Image #(\d+)\]/g, (match, num) => {
return `![Image ${num}](placeholder-image-${num})`;
});
}
if (Array.isArray(content)) {
return content.map(item => processContent(item));
}
if (typeof content === 'object' && content !== null) {
const processed: any = {};
for (const [key, value] of Object.entries(content)) {
processed[key] = processContent(value);
}
return processed;
}
return content;
};
/**
* Flexible PRP Viewer that dynamically renders sections based on content structure
*/
export const PRPViewer: React.FC<PRPViewerProps> = ({
content,
isDarkMode = false,
sectionOverrides = {}
}) => {
try {
if (!content) {
return <div className="text-gray-500">No PRP content available</div>;
}
console.log('PRPViewer: Received content:', {
type: typeof content,
isString: typeof content === 'string',
isObject: typeof content === 'object',
hasMetadata: typeof content === 'object' && content !== null ? isDocumentWithMetadata(content) : false,
isMarkdown: typeof content === 'string' ? isMarkdownContent(content) : false,
keys: typeof content === 'object' && content !== null ? Object.keys(content) : [],
contentPreview: typeof content === 'string' ? content.substring(0, 200) + '...' : 'Not a string'
});
// Route to appropriate renderer based on content type
// 1. Check if it's a document with metadata + markdown content
if (isDocumentWithMetadata(content)) {
console.log('PRPViewer: Detected document with metadata, using MarkdownDocumentRenderer');
return (
<MarkdownDocumentRenderer
content={content}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
);
}
// 2. Check if it's a pure markdown string
if (typeof content === 'string' && isMarkdownContent(content)) {
console.log('PRPViewer: Detected pure markdown content, using MarkdownDocumentRenderer');
return (
<MarkdownDocumentRenderer
content={content}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
);
}
// 3. Check if it's an object that might contain markdown content in any field
if (typeof content === 'object' && content !== null) {
// Check for markdown field first (common in PRP documents)
if (typeof content.markdown === 'string') {
console.log('PRPViewer: Found markdown field, using MarkdownDocumentRenderer');
return (
<MarkdownDocumentRenderer
content={content}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
);
}
// Look for markdown content in any field
for (const [key, value] of Object.entries(content)) {
if (typeof value === 'string' && isMarkdownContent(value)) {
console.log(`PRPViewer: Found markdown content in field '${key}', using MarkdownDocumentRenderer`);
// Create a proper document structure
const documentContent = {
title: content.title || 'Document',
content: value,
...content // Include all other fields as metadata
};
return (
<MarkdownDocumentRenderer
content={documentContent}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
);
}
}
}
// 4. For any other content that might contain documents, try MarkdownDocumentRenderer first
console.log('PRPViewer: Checking if content should use MarkdownDocumentRenderer anyway');
// If it's an object with any text content, try MarkdownDocumentRenderer
if (typeof content === 'object' && content !== null) {
const hasAnyTextContent = Object.values(content).some(value =>
typeof value === 'string' && value.length > 50
);
if (hasAnyTextContent) {
console.log('PRPViewer: Object has substantial text content, trying MarkdownDocumentRenderer');
return (
<MarkdownDocumentRenderer
content={content}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
);
}
}
// 5. Final fallback to original PRPViewer logic for purely structured JSON content
console.log('PRPViewer: Using standard JSON structure renderer as final fallback');
// First, check if content is raw markdown and process it
let processedForPRP = content;
// Handle the case where content is a raw markdown string (non-markdown strings)
if (typeof content === 'string') {
// For non-markdown strings, wrap in a simple structure
processedForPRP = {
title: 'Document Content',
content: content,
document_type: 'text'
};
} else if (typeof content === 'object' && content !== null) {
// For objects, process normally
processedForPRP = processContentForPRP(content);
}
// Ensure we have an object to work with
if (!processedForPRP || typeof processedForPRP !== 'object') {
return <div className="text-gray-500">Unable to process PRP content</div>;
}
// Normalize the content
const normalizedContent = normalizePRPDocument(processedForPRP);
// Process content to handle [Image #N] placeholders
const processedContent = processContent(normalizedContent);
// Extract sections (skip metadata fields)
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'id', '_id', 'project_id', 'created_at', 'updated_at'];
const sections = Object.entries(processedContent).filter(([key]) => !metadataFields.includes(key));
// Debug: Log sections being rendered
console.log('PRP Sections found:', sections.map(([key]) => key));
// Priority-based sorting for common PRP sections
const getSectionPriority = (key: string): number => {
const normalizedKey = key.toLowerCase();
// Define priority order (lower number = higher priority)
if (normalizedKey.includes('goal') || normalizedKey.includes('objective')) return 1;
if (normalizedKey.includes('why') || normalizedKey.includes('rationale')) return 2;
if (normalizedKey.includes('what') || normalizedKey === 'description') return 3;
if (normalizedKey.includes('context') || normalizedKey.includes('background')) return 4;
if (normalizedKey.includes('persona') || normalizedKey.includes('user') || normalizedKey.includes('stakeholder')) return 5;
if (normalizedKey.includes('flow') || normalizedKey.includes('journey') || normalizedKey.includes('workflow')) return 6;
if (normalizedKey.includes('requirement') && !normalizedKey.includes('technical')) return 7;
if (normalizedKey.includes('metric') || normalizedKey.includes('success') || normalizedKey.includes('kpi')) return 8;
if (normalizedKey.includes('timeline') || normalizedKey.includes('roadmap') || normalizedKey.includes('milestone')) return 9;
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return 10;
if (normalizedKey.includes('technical') || normalizedKey.includes('architecture') || normalizedKey.includes('tech')) return 11;
if (normalizedKey.includes('validation') || normalizedKey.includes('testing') || normalizedKey.includes('quality')) return 12;
if (normalizedKey.includes('risk') || normalizedKey.includes('mitigation')) return 13;
// Default priority for unknown sections
return 50;
};
// Sort sections by priority
const sortedSections = sections.sort(([a], [b]) => {
return getSectionPriority(a) - getSectionPriority(b);
});
return (
<div className={`prp-viewer ${isDarkMode ? 'dark' : ''}`}>
{/* Metadata Header */}
<MetadataSection content={processedContent} isDarkMode={isDarkMode} />
{/* Dynamic Sections */}
{sortedSections.map(([sectionKey, sectionData], index) => (
<div key={sectionKey} className="mb-6">
<SectionRenderer
sectionKey={sectionKey}
data={sectionData}
index={index}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
</div>
))}
{sections.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p>No additional sections found in this PRP document.</p>
</div>
)}
</div>
);
} catch (error) {
console.error('PRPViewer: Error rendering content:', error);
// Provide a meaningful error display instead of black screen
return (
<div className="p-6 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">Error Rendering PRP</h3>
<p className="text-red-600 dark:text-red-300 text-sm mb-4">
There was an error rendering this PRP document. The content may be in an unexpected format.
</p>
{/* Show error details for debugging */}
<details className="mt-4">
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
Show error details
</summary>
<div className="mt-2 space-y-2">
<pre className="p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto">
{error instanceof Error ? error.message : String(error)}
</pre>
{error instanceof Error && error.stack && (
<pre className="p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-48">
{error.stack}
</pre>
)}
</div>
</details>
{/* Show raw content for debugging */}
<details className="mt-2">
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
Show raw content
</summary>
<pre className="mt-2 p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-96">
{typeof content === 'string'
? content
: JSON.stringify(content, null, 2)}
</pre>
</details>
</div>
);
}
};

View File

@@ -1,411 +0,0 @@
import React, { useState, useEffect, ReactNode } from 'react';
import {
ChevronDown,
Brain, Users, Workflow, BarChart3, Clock, Shield,
Code, Layers, FileText, List, Hash, Box, Type, ToggleLeft,
CheckCircle, AlertCircle, Info, Lightbulb
} from 'lucide-react';
import { SectionProps } from '../types/prp.types';
import { SimpleMarkdown } from './SimpleMarkdown';
import { formatValue } from '../utils/formatters';
interface CollapsibleSectionRendererProps extends SectionProps {
children?: ReactNode;
headerContent?: ReactNode;
sectionKey?: string;
contentType?: 'markdown' | 'code' | 'json' | 'list' | 'object' | 'auto';
animationDuration?: number;
showPreview?: boolean;
previewLines?: number;
}
/**
* Enhanced CollapsibleSectionRenderer with beautiful animations and content-aware styling
* Features:
* - Section-specific icons and colors
* - Smooth expand/collapse animations with dynamic height
* - Content type detection and appropriate formatting
* - Code block syntax highlighting support
* - Nested structure handling
* - Preview mode for collapsed content
*/
export const CollapsibleSectionRenderer: React.FC<CollapsibleSectionRendererProps> = ({
title,
data,
icon,
accentColor = 'gray',
defaultOpen = true,
isDarkMode = false,
isCollapsible = true,
isOpen: controlledIsOpen,
onToggle,
children,
headerContent,
sectionKey = '',
contentType = 'auto',
animationDuration = 300,
showPreview = true,
previewLines = 2
}) => {
// State management for collapsible behavior
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
const [isAnimating, setIsAnimating] = useState(false);
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
// Content ref for measuring height
const contentRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (controlledIsOpen === undefined) {
setInternalIsOpen(defaultOpen);
}
}, [defaultOpen, controlledIsOpen]);
// Measure content height for smooth animations
useEffect(() => {
if (contentRef.current && isCollapsible) {
const height = contentRef.current.scrollHeight;
setContentHeight(isOpen ? height : 0);
}
}, [isOpen, data, children]);
const handleToggle = () => {
if (!isCollapsible) return;
setIsAnimating(true);
if (controlledIsOpen === undefined) {
setInternalIsOpen(!internalIsOpen);
}
onToggle?.();
// Reset animation state after duration
setTimeout(() => setIsAnimating(false), animationDuration);
};
// Auto-detect section type and get appropriate icon
const getSectionIcon = (): ReactNode => {
if (icon) return icon;
const normalizedKey = sectionKey.toLowerCase();
const normalizedTitle = title.toLowerCase();
// Check both section key and title for better detection
const checkKeywords = (keywords: string[]) =>
keywords.some(keyword =>
normalizedKey.includes(keyword) || normalizedTitle.includes(keyword)
);
if (checkKeywords(['context', 'overview', 'background']))
return <Brain className="w-5 h-5" />;
if (checkKeywords(['persona', 'user', 'actor', 'stakeholder']))
return <Users className="w-5 h-5" />;
if (checkKeywords(['flow', 'journey', 'workflow', 'process']))
return <Workflow className="w-5 h-5" />;
if (checkKeywords(['metric', 'success', 'kpi', 'measurement']))
return <BarChart3 className="w-5 h-5" />;
if (checkKeywords(['plan', 'implementation', 'roadmap', 'timeline']))
return <Clock className="w-5 h-5" />;
if (checkKeywords(['validation', 'gate', 'criteria', 'acceptance']))
return <Shield className="w-5 h-5" />;
if (checkKeywords(['technical', 'tech', 'architecture', 'system']))
return <Code className="w-5 h-5" />;
if (checkKeywords(['architecture', 'structure', 'design']))
return <Layers className="w-5 h-5" />;
if (checkKeywords(['feature', 'functionality', 'capability']))
return <Lightbulb className="w-5 h-5" />;
if (checkKeywords(['requirement', 'spec', 'specification']))
return <CheckCircle className="w-5 h-5" />;
if (checkKeywords(['risk', 'issue', 'concern', 'challenge']))
return <AlertCircle className="w-5 h-5" />;
if (checkKeywords(['info', 'note', 'detail']))
return <Info className="w-5 h-5" />;
// Fallback based on data type
if (typeof data === 'string') return <Type className="w-5 h-5" />;
if (typeof data === 'number') return <Hash className="w-5 h-5" />;
if (typeof data === 'boolean') return <ToggleLeft className="w-5 h-5" />;
if (Array.isArray(data)) return <List className="w-5 h-5" />;
if (typeof data === 'object' && data !== null) return <Box className="w-5 h-5" />;
return <FileText className="w-5 h-5" />;
};
// Get section-specific color scheme
const getColorScheme = () => {
const normalizedKey = sectionKey.toLowerCase();
const normalizedTitle = title.toLowerCase();
const checkKeywords = (keywords: string[]) =>
keywords.some(keyword =>
normalizedKey.includes(keyword) || normalizedTitle.includes(keyword)
);
if (checkKeywords(['context', 'overview'])) return 'blue';
if (checkKeywords(['persona', 'user'])) return 'purple';
if (checkKeywords(['flow', 'journey'])) return 'orange';
if (checkKeywords(['metric', 'success'])) return 'green';
if (checkKeywords(['plan', 'implementation'])) return 'cyan';
if (checkKeywords(['validation', 'gate'])) return 'emerald';
if (checkKeywords(['technical', 'architecture'])) return 'indigo';
if (checkKeywords(['feature'])) return 'yellow';
if (checkKeywords(['risk', 'issue'])) return 'red';
return accentColor;
};
// Auto-detect content type if not specified
const getContentType = () => {
if (contentType !== 'auto') return contentType;
if (typeof data === 'string') {
// Check for code patterns
if (/^```[\s\S]*```$/m.test(data) ||
/^\s*(function|class|const|let|var|import|export)\s/m.test(data) ||
/^\s*[{[][\s\S]*[}\]]$/m.test(data)) {
return 'code';
}
// Check for markdown patterns
if (/^#{1,6}\s+.+$|^[-*+]\s+.+$|^\d+\.\s+.+$|```|^\>.+$|\*\*.+\*\*|\*.+\*|`[^`]+`/m.test(data)) {
return 'markdown';
}
}
if (Array.isArray(data)) return 'list';
if (typeof data === 'object' && data !== null) {
try {
JSON.stringify(data);
return 'json';
} catch {
return 'object';
}
}
return 'auto';
};
// Render content based on type
const renderContent = (): ReactNode => {
if (children) return children;
const detectedType = getContentType();
switch (detectedType) {
case 'markdown':
return <SimpleMarkdown content={data} className="text-gray-700 dark:text-gray-300" />;
case 'code':
return (
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-100">
<code>{data}</code>
</pre>
</div>
);
case 'json':
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-sm text-gray-700 dark:text-gray-300">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
case 'list':
if (!Array.isArray(data)) return <span className="text-gray-500 italic">Invalid list data</span>;
return (
<ul className="space-y-2">
{data.map((item, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-gray-400 mt-0.5 flex-shrink-0"></span>
<span className="text-gray-700 dark:text-gray-300">{formatValue(item)}</span>
</li>
))}
</ul>
);
default:
return <span className="text-gray-700 dark:text-gray-300">{formatValue(data)}</span>;
}
};
// Generate preview content when collapsed
const renderPreview = (): ReactNode => {
if (!showPreview || isOpen || !data) return null;
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
const lines = dataStr.split('\n').slice(0, previewLines);
const preview = lines.join('\n');
const hasMore = dataStr.split('\n').length > previewLines;
return (
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2 px-4 pb-2">
<div className="truncate">
{preview}
{hasMore && <span className="ml-1">...</span>}
</div>
</div>
);
};
const colorScheme = getColorScheme();
const sectionIcon = getSectionIcon();
// Color mapping for backgrounds and borders
const getColorClasses = () => {
const colorMap = {
blue: {
bg: 'bg-blue-50/50 dark:bg-blue-950/20',
border: 'border-blue-200 dark:border-blue-800',
iconBg: 'bg-blue-100 dark:bg-blue-900',
iconText: 'text-blue-600 dark:text-blue-400',
accent: 'border-l-blue-500'
},
purple: {
bg: 'bg-purple-50/50 dark:bg-purple-950/20',
border: 'border-purple-200 dark:border-purple-800',
iconBg: 'bg-purple-100 dark:bg-purple-900',
iconText: 'text-purple-600 dark:text-purple-400',
accent: 'border-l-purple-500'
},
green: {
bg: 'bg-green-50/50 dark:bg-green-950/20',
border: 'border-green-200 dark:border-green-800',
iconBg: 'bg-green-100 dark:bg-green-900',
iconText: 'text-green-600 dark:text-green-400',
accent: 'border-l-green-500'
},
orange: {
bg: 'bg-orange-50/50 dark:bg-orange-950/20',
border: 'border-orange-200 dark:border-orange-800',
iconBg: 'bg-orange-100 dark:bg-orange-900',
iconText: 'text-orange-600 dark:text-orange-400',
accent: 'border-l-orange-500'
},
cyan: {
bg: 'bg-cyan-50/50 dark:bg-cyan-950/20',
border: 'border-cyan-200 dark:border-cyan-800',
iconBg: 'bg-cyan-100 dark:bg-cyan-900',
iconText: 'text-cyan-600 dark:text-cyan-400',
accent: 'border-l-cyan-500'
},
indigo: {
bg: 'bg-indigo-50/50 dark:bg-indigo-950/20',
border: 'border-indigo-200 dark:border-indigo-800',
iconBg: 'bg-indigo-100 dark:bg-indigo-900',
iconText: 'text-indigo-600 dark:text-indigo-400',
accent: 'border-l-indigo-500'
},
emerald: {
bg: 'bg-emerald-50/50 dark:bg-emerald-950/20',
border: 'border-emerald-200 dark:border-emerald-800',
iconBg: 'bg-emerald-100 dark:bg-emerald-900',
iconText: 'text-emerald-600 dark:text-emerald-400',
accent: 'border-l-emerald-500'
},
yellow: {
bg: 'bg-yellow-50/50 dark:bg-yellow-950/20',
border: 'border-yellow-200 dark:border-yellow-800',
iconBg: 'bg-yellow-100 dark:bg-yellow-900',
iconText: 'text-yellow-600 dark:text-yellow-400',
accent: 'border-l-yellow-500'
},
red: {
bg: 'bg-red-50/50 dark:bg-red-950/20',
border: 'border-red-200 dark:border-red-800',
iconBg: 'bg-red-100 dark:bg-red-900',
iconText: 'text-red-600 dark:text-red-400',
accent: 'border-l-red-500'
},
gray: {
bg: 'bg-gray-50/50 dark:bg-gray-950/20',
border: 'border-gray-200 dark:border-gray-800',
iconBg: 'bg-gray-100 dark:bg-gray-900',
iconText: 'text-gray-600 dark:text-gray-400',
accent: 'border-l-gray-500'
}
};
return colorMap[colorScheme as keyof typeof colorMap] || colorMap.gray;
};
const colors = getColorClasses();
if (!isCollapsible) {
return (
<div className={`rounded-lg border-l-4 ${colors.accent} ${colors.bg} ${colors.border} shadow-sm`}>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className={`p-2 rounded-lg ${colors.iconBg} ${colors.iconText}`}>
{sectionIcon}
</div>
<h3 className="font-semibold text-gray-800 dark:text-white flex-1">
{title}
</h3>
{headerContent}
</div>
<div className="space-y-4">
{renderContent()}
</div>
</div>
</div>
);
}
return (
<div className={`rounded-lg border-l-4 ${colors.accent} ${colors.bg} ${colors.border} shadow-sm overflow-hidden`}>
{/* Header */}
<div
className={`
cursor-pointer select-none p-6
hover:bg-opacity-75 transition-colors duration-200
${isAnimating ? 'pointer-events-none' : ''}
`}
onClick={handleToggle}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${colors.iconBg} ${colors.iconText}`}>
{sectionIcon}
</div>
<h3 className="font-semibold text-gray-800 dark:text-white flex-1">
{title}
</h3>
{headerContent}
<div className={`
transform transition-transform duration-200
${isOpen ? 'rotate-180' : 'rotate-0'}
text-gray-500 dark:text-gray-400
hover:text-gray-700 dark:hover:text-gray-200
`}>
<ChevronDown className="w-5 h-5" />
</div>
</div>
{renderPreview()}
</div>
{/* Content with smooth height animation */}
<div
className="overflow-hidden transition-all ease-in-out"
style={{
maxHeight: isOpen ? contentHeight : 0,
transitionDuration: `${animationDuration}ms`
}}
>
<div
ref={contentRef}
className={`px-6 pb-6 space-y-4 ${
isOpen ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-200`}
style={{
transitionDelay: isOpen ? '100ms' : '0ms'
}}
>
{renderContent()}
</div>
</div>
</div>
);
};

View File

@@ -1,80 +0,0 @@
import React, { useState, useEffect, ReactNode } from 'react';
import { ChevronDown } from 'lucide-react';
interface CollapsibleSectionWrapperProps {
children: ReactNode;
header: ReactNode;
isCollapsible?: boolean;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: () => void;
}
/**
* A wrapper component that makes any section collapsible by clicking on its header
*/
export const CollapsibleSectionWrapper: React.FC<CollapsibleSectionWrapperProps> = ({
children,
header,
isCollapsible = true,
defaultOpen = true,
isOpen: controlledIsOpen,
onToggle
}) => {
// Use controlled state if provided, otherwise manage internally
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
useEffect(() => {
if (controlledIsOpen === undefined) {
setInternalIsOpen(defaultOpen);
}
}, [defaultOpen, controlledIsOpen]);
const handleToggle = () => {
if (controlledIsOpen === undefined) {
setInternalIsOpen(!internalIsOpen);
}
onToggle?.();
};
if (!isCollapsible) {
return (
<>
{header}
{children}
</>
);
}
return (
<>
<div
className="cursor-pointer select-none group"
onClick={handleToggle}
>
<div className="relative">
{header}
<div className={`
absolute right-4 top-1/2 -translate-y-1/2
transform transition-transform duration-200
${isOpen ? 'rotate-180' : ''}
text-gray-500 dark:text-gray-400
group-hover:text-gray-700 dark:group-hover:text-gray-200
`}>
<ChevronDown className="w-5 h-5" />
</div>
</div>
</div>
<div className={`
transition-all duration-300
${isOpen ? 'max-h-none opacity-100' : 'max-h-0 opacity-0 overflow-hidden'}
`}>
<div className={isOpen ? 'pb-4' : ''}>
{children}
</div>
</div>
</>
);
};

View File

@@ -1,323 +0,0 @@
import React from 'react';
import { ParsedMarkdownDocument, parseMarkdownToDocument, isDocumentWithMetadata, isMarkdownContent } from '../utils/markdownParser';
import { MetadataSection } from '../sections/MetadataSection';
import { MarkdownSectionRenderer } from './MarkdownSectionRenderer';
interface MarkdownDocumentRendererProps {
content: any;
isDarkMode?: boolean;
sectionOverrides?: Record<string, React.ComponentType<any>>;
}
/**
* Renders markdown documents with metadata header and flowing content sections
* Handles both pure markdown strings and documents with metadata + content structure
*/
/**
* Processes JSON content and converts it to markdown format
* Handles nested objects, arrays, and various data types
*/
function processContentToMarkdown(content: any): string {
if (typeof content === 'string') {
return content;
}
if (typeof content !== 'object' || content === null) {
return String(content);
}
const markdownSections: string[] = [];
// Extract metadata fields first (don't include in content conversion)
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
for (const [key, value] of Object.entries(content)) {
// Skip metadata fields as they're handled separately
if (metadataFields.includes(key)) {
continue;
}
// Skip null or undefined values
if (value === null || value === undefined) {
continue;
}
const sectionTitle = formatSectionTitle(key);
const sectionContent = formatSectionContent(value);
if (sectionContent.trim()) {
markdownSections.push(`## ${sectionTitle}\n\n${sectionContent}`);
}
}
return markdownSections.join('\n\n');
}
/**
* Formats a section title from a JSON key
*/
function formatSectionTitle(key: string): string {
return key
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
.replace(/[_-]/g, ' ') // Replace underscores and hyphens with spaces
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
.trim();
}
/**
* Formats section content based on its type
*/
function formatSectionContent(value: any): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
return formatArrayContent(value);
}
if (typeof value === 'object' && value !== null) {
return formatObjectContent(value);
}
return String(value);
}
/**
* Formats array content as markdown list or nested structure
*/
function formatArrayContent(array: any[]): string {
if (array.length === 0) {
return '_No items_';
}
// Check if all items are simple values (strings, numbers, booleans)
const allSimple = array.every(item =>
typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'
);
if (allSimple) {
return array.map(item => `- ${String(item)}`).join('\n');
}
// Handle complex objects in array
return array.map((item, index) => {
if (typeof item === 'object' && item !== null) {
const title = item.title || item.name || `Item ${index + 1}`;
const content = formatObjectContent(item, true);
return `### ${title}\n\n${content}`;
}
return `- ${String(item)}`;
}).join('\n\n');
}
/**
* Formats object content as key-value pairs or nested structure
*/
function formatObjectContent(obj: Record<string, any>, isNested: boolean = false): string {
const entries = Object.entries(obj);
if (entries.length === 0) {
return '_Empty_';
}
const formatted = entries.map(([key, value]) => {
if (value === null || value === undefined) {
return null;
}
const label = formatSectionTitle(key);
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return `**${label}:** ${String(value)}`;
}
if (Array.isArray(value)) {
const arrayContent = formatArrayContent(value);
return `**${label}:**\n${arrayContent}`;
}
if (typeof value === 'object') {
const nestedContent = formatObjectContent(value, true);
return `**${label}:**\n${nestedContent}`;
}
return `**${label}:** ${String(value)}`;
}).filter(Boolean);
return formatted.join('\n\n');
}
export const MarkdownDocumentRenderer: React.FC<MarkdownDocumentRendererProps> = ({
content,
isDarkMode = false,
sectionOverrides = {}
}) => {
try {
let parsedDocument: ParsedMarkdownDocument;
let documentMetadata: any = {};
console.log('MarkdownDocumentRenderer: Processing content:', {
type: typeof content,
keys: typeof content === 'object' && content !== null ? Object.keys(content) : [],
isDocWithMetadata: typeof content === 'object' && content !== null ? isDocumentWithMetadata(content) : false
});
// Handle different content structures
if (typeof content === 'string') {
console.log('MarkdownDocumentRenderer: Processing pure markdown string');
// Pure markdown string
parsedDocument = parseMarkdownToDocument(content);
// Create synthetic metadata for display
documentMetadata = {
title: parsedDocument.title || 'Document',
document_type: 'markdown'
};
} else if (typeof content === 'object' && content !== null) {
console.log('MarkdownDocumentRenderer: Processing object content');
// Extract all potential metadata fields first
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
metadataFields.forEach(field => {
if (content[field]) {
documentMetadata[field] = content[field];
}
});
// Find the markdown content in any field
let markdownContent = '';
// First check common markdown field names
if (typeof content.markdown === 'string') {
markdownContent = content.markdown;
console.log('MarkdownDocumentRenderer: Found markdown in "markdown" field');
} else if (typeof content.content === 'string' && isMarkdownContent(content.content)) {
markdownContent = content.content;
console.log('MarkdownDocumentRenderer: Found markdown in "content" field');
} else {
// Look for markdown content in any field
for (const [key, value] of Object.entries(content)) {
if (typeof value === 'string' && isMarkdownContent(value)) {
markdownContent = value;
console.log(`MarkdownDocumentRenderer: Found markdown in field '${key}'`);
break;
}
}
}
// If no existing markdown found, try to convert JSON structure to markdown
if (!markdownContent) {
console.log('MarkdownDocumentRenderer: No markdown found, converting JSON to markdown');
markdownContent = processContentToMarkdown(content);
}
if (markdownContent) {
console.log('MarkdownDocumentRenderer: Parsing markdown content:', {
contentLength: markdownContent.length,
contentPreview: markdownContent.substring(0, 100) + '...'
});
parsedDocument = parseMarkdownToDocument(markdownContent);
console.log('MarkdownDocumentRenderer: Parsed document:', {
sectionsCount: parsedDocument.sections.length,
sections: parsedDocument.sections.map(s => ({ title: s.title, type: s.type }))
});
} else {
// No markdown content found, create empty document
console.log('MarkdownDocumentRenderer: No markdown content found in document');
parsedDocument = { sections: [], metadata: {}, hasMetadata: false };
}
// Use document title from metadata if available
if (content.title && !parsedDocument.title) {
parsedDocument.title = content.title;
}
} else {
console.log('MarkdownDocumentRenderer: Unexpected content structure');
// Fallback for unexpected content structure
return (
<div className="text-center py-12 text-gray-500">
<p>Unable to parse document content</p>
</div>
);
}
// ALWAYS show metadata - force hasMetadata to true
parsedDocument.hasMetadata = true;
// Combine parsed metadata with document metadata and add defaults
const finalMetadata = {
// Default values for better display
document_type: 'prp',
version: '1.0',
status: 'draft',
...parsedDocument.metadata,
...documentMetadata,
title: parsedDocument.title || documentMetadata.title || 'Untitled Document'
};
console.log('MarkdownDocumentRenderer: Final render data:', {
hasMetadata: parsedDocument.hasMetadata,
finalMetadata,
sectionsCount: parsedDocument.sections.length,
sections: parsedDocument.sections.map(s => ({ title: s.title, type: s.type, templateType: s.templateType }))
});
return (
<div className="markdown-document-renderer">
{/* ALWAYS show metadata header */}
<MetadataSection content={finalMetadata} isDarkMode={isDarkMode} />
{/* Document Sections */}
<div className="space-y-2">
{parsedDocument.sections.map((section, index) => (
<MarkdownSectionRenderer
key={`${section.sectionKey}-${index}`}
section={section}
index={index}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
))}
</div>
{/* Empty state */}
{parsedDocument.sections.length === 0 && (
<div className="text-center py-12 text-gray-500">
<p>No content sections found in this document.</p>
</div>
)}
</div>
);
} catch (error) {
console.error('MarkdownDocumentRenderer: Error rendering content:', error);
// Provide a meaningful error display instead of black screen
return (
<div className="p-6 bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">Error Rendering Document</h3>
<p className="text-red-600 dark:text-red-300 text-sm mb-4">
There was an error rendering this document. The content may be in an unexpected format.
</p>
{/* Show raw content for debugging */}
<details className="mt-4">
<summary className="cursor-pointer text-sm text-red-600 dark:text-red-400 hover:underline">
Show raw content
</summary>
<pre className="mt-2 p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-h-96">
{typeof content === 'string'
? content
: JSON.stringify(content, null, 2)}
</pre>
</details>
</div>
);
}
};

View File

@@ -1,71 +0,0 @@
import React from 'react';
import { ParsedSection } from '../utils/markdownParser';
import { SectionRenderer } from '../renderers/SectionRenderer';
import { SimpleMarkdown } from './SimpleMarkdown';
import { detectSectionType } from '../utils/sectionDetector';
interface MarkdownSectionRendererProps {
section: ParsedSection;
index: number;
isDarkMode?: boolean;
sectionOverrides?: Record<string, React.ComponentType<any>>;
}
/**
* Renders individual markdown sections with smart template detection
* Uses specialized components for known PRP templates, beautiful styling for generic sections
*/
export const MarkdownSectionRenderer: React.FC<MarkdownSectionRendererProps> = ({
section,
index,
isDarkMode = false,
sectionOverrides = {}
}) => {
// If section matches a known PRP template, use the specialized component
if (section.templateType) {
const { type } = detectSectionType(section.sectionKey, section.rawContent);
// Use the existing SectionRenderer with the detected type
return (
<div className="mb-6">
<SectionRenderer
sectionKey={section.sectionKey}
data={section.rawContent}
index={index}
isDarkMode={isDarkMode}
sectionOverrides={sectionOverrides}
/>
</div>
);
}
// For generic sections, render with beautiful floating styling
return (
<section className="mb-8">
<div className="relative">
{/* Section Header */}
<div className="mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{section.title}
</h2>
<div className="mt-1 h-0.5 w-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
</div>
{/* Section Content */}
<div className="relative">
{/* Subtle background for sections with complex content */}
{(section.type === 'code' || section.type === 'mixed') && (
<div className="absolute inset-0 bg-gray-50/30 dark:bg-gray-900/20 rounded-xl -m-4 backdrop-blur-sm border border-gray-200/30 dark:border-gray-700/30"></div>
)}
<div className="relative z-10">
<SimpleMarkdown
content={section.content}
className="prose prose-gray dark:prose-invert max-w-none prose-headings:text-gray-900 dark:prose-headings:text-white prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-li:text-gray-700 dark:prose-li:text-gray-300"
/>
</div>
</div>
</div>
</section>
);
};

View File

@@ -1,340 +0,0 @@
import React from 'react';
import { formatValue } from '../utils/formatters';
interface SimpleMarkdownProps {
content: string;
className?: string;
}
/**
* Simple markdown renderer that handles basic formatting without external dependencies
*/
export const SimpleMarkdown: React.FC<SimpleMarkdownProps> = ({ content, className = '' }) => {
try {
// Process image placeholders first
const processedContent = formatValue(content);
// Split content into lines for processing
const lines = processedContent.split('\n');
const elements: React.ReactNode[] = [];
let currentList: string[] = [];
let listType: 'ul' | 'ol' | null = null;
const flushList = () => {
if (currentList.length > 0 && listType) {
const ListComponent = listType === 'ul' ? 'ul' : 'ol';
elements.push(
<div key={elements.length} className="my-3">
<ListComponent className={`space-y-2 ${listType === 'ul' ? 'list-disc' : 'list-decimal'} pl-6 text-gray-700 dark:text-gray-300`}>
{currentList.map((item, idx) => (
<li key={idx} className="leading-relaxed">{processInlineMarkdown(item)}</li>
))}
</ListComponent>
</div>
);
currentList = [];
listType = null;
}
};
const processInlineMarkdown = (text: string): React.ReactNode => {
const processed = text;
const elements: React.ReactNode[] = [];
let lastIndex = 0;
// Process **bold** text
const boldRegex = /\*\*(.*?)\*\*/g;
let match;
while ((match = boldRegex.exec(processed)) !== null) {
if (match.index > lastIndex) {
elements.push(processed.slice(lastIndex, match.index));
}
elements.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
lastIndex = match.index + match[0].length;
}
// Process *italic* text
const italicRegex = /\*(.*?)\*/g;
const remainingText = processed.slice(lastIndex);
lastIndex = 0;
const italicElements: React.ReactNode[] = [];
while ((match = italicRegex.exec(remainingText)) !== null) {
if (match.index > lastIndex) {
italicElements.push(remainingText.slice(lastIndex, match.index));
}
italicElements.push(<em key={match.index} className="italic">{match[1]}</em>);
lastIndex = match.index + match[0].length;
}
if (lastIndex < remainingText.length) {
italicElements.push(remainingText.slice(lastIndex));
}
if (elements.length > 0) {
elements.push(...italicElements);
return <>{elements}</>;
}
if (italicElements.length > 0) {
return <>{italicElements}</>;
}
// Process `inline code`
const codeRegex = /`([^`]+)`/g;
const parts = text.split(codeRegex);
if (parts.length > 1) {
return (
<>
{parts.map((part, index) =>
index % 2 === 0 ? (
part
) : (
<code key={index} className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200">
{part}
</code>
)
)}
</>
);
}
return <span>{text}</span>;
};
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLanguage = '';
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
const flushTable = () => {
if (tableRows.length > 0) {
elements.push(
<div key={elements.length} className="my-6 overflow-x-auto">
<div className="inline-block min-w-full overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<table className="min-w-full">
{tableHeaders.length > 0 && (
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
{tableHeaders.map((header, idx) => (
<th key={idx} className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700">
{processInlineMarkdown(header.trim())}
</th>
))}
</tr>
</thead>
)}
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{tableRows.map((row, rowIdx) => (
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
{processInlineMarkdown(cell.trim())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
tableRows = [];
tableHeaders = [];
inTable = false;
}
};
lines.forEach((line, index) => {
// Handle code block start/end
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Starting code block
flushList();
inCodeBlock = true;
codeBlockLanguage = line.substring(3).trim();
codeBlockContent = [];
} else {
// Ending code block
inCodeBlock = false;
elements.push(
<div key={index} className="my-4 rounded-lg overflow-hidden bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 border border-gray-700 shadow-lg">
{codeBlockLanguage && (
<div className="px-4 py-2 bg-gray-800/50 border-b border-gray-700 text-sm text-gray-300 font-mono">
{codeBlockLanguage}
</div>
)}
<pre className="p-4 overflow-x-auto">
<code className="text-gray-100 font-mono text-sm leading-relaxed">
{codeBlockContent.join('\n')}
</code>
</pre>
</div>
);
codeBlockContent = [];
codeBlockLanguage = '';
}
return;
}
// If inside code block, collect content
if (inCodeBlock) {
codeBlockContent.push(line);
return;
}
// Handle table rows
if (line.includes('|') && line.trim() !== '') {
const cells = line.split('|').map(cell => cell.trim()).filter(cell => cell !== '');
if (cells.length > 0) {
if (!inTable) {
// Starting a new table
flushList();
inTable = true;
tableHeaders = cells;
} else if (cells.every(cell => cell.match(/^:?-+:?$/))) {
// This is a header separator line (|---|---|), skip it
return;
} else {
// This is a regular table row
tableRows.push(cells);
}
return;
}
} else if (inTable) {
// End of table (empty line or non-table content)
flushTable();
}
// Handle headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
flushList();
const level = headingMatch[1].length;
const text = headingMatch[2];
const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
const sizeClasses = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
const colorClasses = ['text-gray-900 dark:text-white', 'text-gray-800 dark:text-gray-100', 'text-gray-700 dark:text-gray-200', 'text-gray-700 dark:text-gray-200', 'text-gray-600 dark:text-gray-300', 'text-gray-600 dark:text-gray-300'];
elements.push(
<HeadingTag key={index} className={`font-bold mb-3 mt-6 ${sizeClasses[level - 1] || 'text-base'} ${colorClasses[level - 1] || 'text-gray-700 dark:text-gray-200'} border-b border-gray-200 dark:border-gray-700 pb-1`}>
{processInlineMarkdown(text)}
</HeadingTag>
);
return;
}
// Handle checkboxes (task lists)
const checkboxMatch = line.match(/^[-*+]\s+\[([ x])\]\s+(.+)$/);
if (checkboxMatch) {
flushList();
const isChecked = checkboxMatch[1] === 'x';
const content = checkboxMatch[2];
elements.push(
<div key={index} className="flex items-start gap-3 my-2">
<div className={`flex-shrink-0 w-5 h-5 rounded-md border-2 flex items-center justify-center mt-0.5 transition-colors ${
isChecked
? 'bg-green-500 border-green-500 text-white'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}>
{isChecked && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</div>
<div className={`flex-1 leading-relaxed ${isChecked ? 'text-gray-500 dark:text-gray-400 line-through' : 'text-gray-700 dark:text-gray-300'}`}>
{processInlineMarkdown(content)}
</div>
</div>
);
return;
}
// Handle bullet lists
const bulletMatch = line.match(/^[-*+]\s+(.+)$/);
if (bulletMatch) {
if (listType !== 'ul') {
flushList();
listType = 'ul';
}
currentList.push(bulletMatch[1]);
return;
}
// Handle numbered lists
const numberMatch = line.match(/^\d+\.\s+(.+)$/);
if (numberMatch) {
if (listType !== 'ol') {
flushList();
listType = 'ol';
}
currentList.push(numberMatch[1]);
return;
}
// Handle code blocks
if (line.startsWith('```')) {
flushList();
// Simple code block handling - just skip the backticks
return;
}
// Handle blockquotes
if (line.startsWith('>')) {
flushList();
const content = line.substring(1).trim();
elements.push(
<blockquote key={index} className="border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/50 dark:bg-blue-900/20 pl-4 pr-4 py-3 italic my-4 rounded-r-lg backdrop-blur-sm">
<div className="text-gray-700 dark:text-gray-300">
{processInlineMarkdown(content)}
</div>
</blockquote>
);
return;
}
// Handle horizontal rules
if (line.match(/^(-{3,}|_{3,}|\*{3,})$/)) {
flushList();
elements.push(<hr key={index} className="my-4 border-gray-300 dark:border-gray-700" />);
return;
}
// Regular paragraph
if (line.trim()) {
flushList();
elements.push(
<p key={index} className="mb-3 leading-relaxed text-gray-700 dark:text-gray-300">
{processInlineMarkdown(line)}
</p>
);
}
});
// Flush any remaining list or table
flushList();
flushTable();
return (
<div className={`max-w-none ${className}`}>
<div className="space-y-1">
{elements}
</div>
</div>
);
} catch (error) {
console.error('Error rendering markdown:', error, content);
return (
<div className={`text-gray-700 dark:text-gray-300 ${className}`}>
<p>Error rendering markdown content</p>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded mt-2 whitespace-pre-wrap">
{content}
</pre>
</div>
);
}
};

View File

@@ -1,25 +0,0 @@
// Main component exports
export { PRPViewer } from './PRPViewer';
// Section component exports
export { MetadataSection } from './sections/MetadataSection';
export { ContextSection } from './sections/ContextSection';
export { PersonaSection } from './sections/PersonaSection';
export { FlowSection } from './sections/FlowSection';
export { MetricsSection } from './sections/MetricsSection';
export { PlanSection } from './sections/PlanSection';
export { ListSection } from './sections/ListSection';
export { ObjectSection } from './sections/ObjectSection';
export { KeyValueSection } from './sections/KeyValueSection';
export { FeatureSection } from './sections/FeatureSection';
export { GenericSection } from './sections/GenericSection';
// Renderer exports
export { SectionRenderer } from './renderers/SectionRenderer';
// Type exports
export * from './types/prp.types';
// Utility exports
export { detectSectionType, formatSectionTitle, getSectionIcon } from './utils/sectionDetector';
export { formatKey, formatValue, truncateText, getAccentColor } from './utils/formatters';

View File

@@ -1,141 +0,0 @@
import React from 'react';
import {
Brain, Users, Workflow, BarChart3, Clock, Shield,
Code, Layers, FileText, List, Hash, Box
} from 'lucide-react';
import { detectSectionType, formatSectionTitle } from '../utils/sectionDetector';
import { getAccentColor } from '../utils/formatters';
// Import all section components
import { ContextSection } from '../sections/ContextSection';
import { PersonaSection } from '../sections/PersonaSection';
import { FlowSection } from '../sections/FlowSection';
import { MetricsSection } from '../sections/MetricsSection';
import { PlanSection } from '../sections/PlanSection';
import { ListSection } from '../sections/ListSection';
import { ObjectSection } from '../sections/ObjectSection';
import { KeyValueSection } from '../sections/KeyValueSection';
import { FeatureSection } from '../sections/FeatureSection';
import { GenericSection } from '../sections/GenericSection';
import { RolloutPlanSection } from '../sections/RolloutPlanSection';
import { TokenSystemSection } from '../sections/TokenSystemSection';
interface SectionRendererProps {
sectionKey: string;
data: any;
index: number;
isDarkMode?: boolean;
sectionOverrides?: Record<string, React.ComponentType<any>>;
}
/**
* Dynamically renders sections based on their type
*/
export const SectionRenderer: React.FC<SectionRendererProps> = ({
sectionKey,
data,
index,
isDarkMode = false,
sectionOverrides = {},
}) => {
// Skip metadata fields (handled by MetadataSection)
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type'];
if (metadataFields.includes(sectionKey)) {
return null;
}
// Check for custom override first
if (sectionOverrides[sectionKey]) {
const CustomComponent = sectionOverrides[sectionKey];
return <CustomComponent data={data} title={formatSectionTitle(sectionKey)} />;
}
// Detect section type
const { type } = detectSectionType(sectionKey, data);
// Get appropriate icon based on section key
const getIcon = () => {
const normalizedKey = sectionKey.toLowerCase();
if (normalizedKey.includes('context') || normalizedKey.includes('overview')) return <Brain className="w-5 h-5" />;
if (normalizedKey.includes('persona') || normalizedKey.includes('user')) return <Users className="w-5 h-5" />;
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return <Workflow className="w-5 h-5" />;
if (normalizedKey.includes('metric') || normalizedKey.includes('success')) return <BarChart3 className="w-5 h-5" />;
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return <Clock className="w-5 h-5" />;
if (normalizedKey.includes('validation') || normalizedKey.includes('gate')) return <Shield className="w-5 h-5" />;
if (normalizedKey.includes('technical') || normalizedKey.includes('tech')) return <Code className="w-5 h-5" />;
if (normalizedKey.includes('architecture')) return <Layers className="w-5 h-5" />;
if (Array.isArray(data)) return <List className="w-5 h-5" />;
if (typeof data === 'object') return <Box className="w-5 h-5" />;
return <FileText className="w-5 h-5" />;
};
// Get accent color based on section or index
const getColor = () => {
const normalizedKey = sectionKey.toLowerCase();
if (normalizedKey.includes('context')) return 'blue';
if (normalizedKey.includes('persona')) return 'purple';
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return 'orange';
if (normalizedKey.includes('metric')) return 'green';
if (normalizedKey.includes('plan')) return 'cyan';
if (normalizedKey.includes('validation')) return 'emerald';
return getAccentColor(index);
};
const commonProps = {
title: formatSectionTitle(sectionKey),
data,
icon: getIcon(),
accentColor: getColor(),
isDarkMode,
defaultOpen: index < 5, // Open first 5 sections by default
isCollapsible: true, // Make all sections collapsible by default
};
// Check for specific section types by key name first
const normalizedKey = sectionKey.toLowerCase();
// Special handling for rollout plans
if (normalizedKey.includes('rollout') || normalizedKey === 'rollout_plan') {
return <RolloutPlanSection {...commonProps} />;
}
// Special handling for token systems
if (normalizedKey.includes('token') || normalizedKey === 'token_system' ||
normalizedKey === 'design_tokens' || normalizedKey === 'design_system') {
return <TokenSystemSection {...commonProps} />;
}
// Render based on detected type
switch (type) {
case 'context':
return <ContextSection {...commonProps} />;
case 'personas':
return <PersonaSection {...commonProps} />;
case 'flows':
return <FlowSection {...commonProps} />;
case 'metrics':
return <MetricsSection {...commonProps} />;
case 'plan':
return <PlanSection {...commonProps} />;
case 'list':
return <ListSection {...commonProps} />;
case 'keyvalue':
return <KeyValueSection {...commonProps} />;
case 'object':
return <ObjectSection {...commonProps} />;
case 'features':
return <FeatureSection {...commonProps} />;
case 'generic':
default:
return <GenericSection {...commonProps} />;
}
};

View File

@@ -1,82 +0,0 @@
import React from 'react';
import { Target, BookOpen, Sparkles, CheckCircle2 } from 'lucide-react';
import { SectionProps } from '../types/prp.types';
// Temporarily disabled to debug black screen issue
// import { renderValue, renderValueInline } from '../utils/objectRenderer';
/**
* Renders context sections like scope, background, objectives
*/
export const ContextSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'blue',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!data || typeof data !== 'object') return null;
const renderContextItem = (key: string, value: any) => {
const getItemIcon = (itemKey: string) => {
const normalizedKey = itemKey.toLowerCase();
if (normalizedKey.includes('scope')) return <Target className="w-4 h-4 text-blue-500" />;
if (normalizedKey.includes('background')) return <BookOpen className="w-4 h-4 text-purple-500" />;
if (normalizedKey.includes('objective')) return <Sparkles className="w-4 h-4 text-green-500" />;
if (normalizedKey.includes('requirement')) return <CheckCircle2 className="w-4 h-4 text-orange-500" />;
return <CheckCircle2 className="w-4 h-4 text-gray-500" />;
};
const getItemColor = (itemKey: string) => {
const normalizedKey = itemKey.toLowerCase();
if (normalizedKey.includes('scope')) return 'blue';
if (normalizedKey.includes('background')) return 'purple';
if (normalizedKey.includes('objective')) return 'green';
if (normalizedKey.includes('requirement')) return 'orange';
return 'gray';
};
const color = getItemColor(key);
const colorMap = {
blue: 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
purple: 'bg-purple-50/50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
green: 'bg-green-50/50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
orange: 'bg-orange-50/50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
gray: 'bg-gray-50/50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800',
};
const itemTitle = key.replace(/_/g, ' ').charAt(0).toUpperCase() + key.replace(/_/g, ' ').slice(1);
return (
<div key={key} className={`p-4 rounded-lg border ${colorMap[color as keyof typeof colorMap]}`}>
<h4 className="font-semibold text-gray-800 dark:text-white mb-2 flex items-center gap-2">
{getItemIcon(key)}
{itemTitle}
</h4>
{Array.isArray(value) ? (
<ul className="space-y-2">
{value.map((item: any, idx: number) => (
<li key={idx} className="flex items-start gap-2 text-gray-700 dark:text-gray-300">
<CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
{typeof item === 'string' ? item : JSON.stringify(item)}
</li>
))}
</ul>
) : typeof value === 'string' ? (
<p className="text-gray-700 dark:text-gray-300">{value}</p>
) : (
<div className="text-gray-700 dark:text-gray-300">
{JSON.stringify(value, null, 2)}
</div>
)}
</div>
);
};
return (
<div className="space-y-4">
{Object.entries(data).map(([key, value]) => renderContextItem(key, value))}
</div>
);
};

View File

@@ -1,155 +0,0 @@
import React from 'react';
import { Package, Star, FileText } from 'lucide-react';
import { PRPSectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
/**
* Specialized component for feature requirements and capabilities
* Renders features in organized categories with proper hierarchy
*/
export const FeatureSection: React.FC<PRPSectionProps> = ({
title,
data,
icon = <Package className="w-5 h-5" />,
accentColor = 'blue',
isDarkMode = false,
defaultOpen = true
}) => {
if (!data || typeof data !== 'object') return null;
const colorMap = {
blue: 'from-blue-400 to-blue-600',
purple: 'from-purple-400 to-purple-600',
green: 'from-green-400 to-green-600',
orange: 'from-orange-400 to-orange-600',
pink: 'from-pink-400 to-pink-600',
cyan: 'from-cyan-400 to-cyan-600',
indigo: 'from-indigo-400 to-indigo-600',
emerald: 'from-emerald-400 to-emerald-600',
};
const bgColorMap = {
blue: 'bg-blue-50 dark:bg-blue-950',
purple: 'bg-purple-50 dark:bg-purple-950',
green: 'bg-green-50 dark:bg-green-950',
orange: 'bg-orange-50 dark:bg-orange-950',
pink: 'bg-pink-50 dark:bg-pink-950',
cyan: 'bg-cyan-50 dark:bg-cyan-950',
indigo: 'bg-indigo-50 dark:bg-indigo-950',
emerald: 'bg-emerald-50 dark:bg-emerald-950',
};
const renderFeatureGroup = (groupName: string, features: any, isPremium: boolean = false) => {
if (!features || typeof features !== 'object') return null;
const IconComponent = isPremium ? Star : FileText;
const iconColor = isPremium ? 'text-yellow-500' : 'text-blue-500';
return (
<div key={groupName} className="mb-6">
<div className="flex items-center gap-3 mb-4">
<IconComponent className={`w-5 h-5 ${iconColor}`} />
<h4 className="font-semibold text-gray-800 dark:text-white text-lg">
{formatKey(groupName)}
</h4>
{isPremium && (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 text-xs rounded-full font-medium">
Premium
</span>
)}
</div>
<div className="space-y-4 ml-8">
{Object.entries(features).map(([featureName, featureData]) => (
<div key={featureName} className="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<h5 className="font-medium text-gray-700 dark:text-gray-300 mb-2">
{formatKey(featureName)}
</h5>
{Array.isArray(featureData) ? (
<ul className="space-y-1">
{featureData.map((item, index) => (
<li key={index} className="flex items-start gap-2 text-gray-600 dark:text-gray-400">
<span className="text-gray-400 mt-1"></span>
<span>{formatValue(item)}</span>
</li>
))}
</ul>
) : typeof featureData === 'string' ? (
<p className="text-gray-600 dark:text-gray-400">{featureData}</p>
) : (
<div className="text-gray-600 dark:text-gray-400">
{formatValue(featureData)}
</div>
)}
</div>
))}
</div>
</div>
);
};
const renderFeatureList = (features: any) => {
if (Array.isArray(features)) {
return (
<ul className="space-y-2">
{features.map((feature, index) => (
<li key={index} className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<Package className="w-4 h-4 text-blue-500 mt-1 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">{formatValue(feature)}</span>
</li>
))}
</ul>
);
}
if (typeof features === 'object' && features !== null) {
return (
<div className="space-y-6">
{Object.entries(features).map(([key, value]) => {
const isPremium = key.toLowerCase().includes('premium') ||
key.toLowerCase().includes('advanced') ||
key.toLowerCase().includes('pro');
if (typeof value === 'object' && value !== null) {
return renderFeatureGroup(key, value, isPremium);
}
return (
<div key={key} className="flex items-start gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<Package className="w-5 h-5 text-blue-500 mt-1 flex-shrink-0" />
<div className="flex-1">
<h4 className="font-medium text-gray-800 dark:text-white mb-1">
{formatKey(key)}
</h4>
<div className="text-gray-600 dark:text-gray-400">
{formatValue(value)}
</div>
</div>
</div>
);
})}
</div>
);
}
return <div className="text-gray-600 dark:text-gray-400">{formatValue(features)}</div>;
};
return (
<div className="space-y-4">
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.blue} border-l-4 border-blue-500`}>
<div className="flex items-center gap-3 mb-6">
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap] || colorMap.blue} text-white shadow-lg`}>
{icon}
</div>
<h3 className="text-xl font-bold text-gray-800 dark:text-white">
{title}
</h3>
</div>
{renderFeatureList(data)}
</div>
</div>
);
};

View File

@@ -1,72 +0,0 @@
import React from 'react';
import { Workflow, Navigation } from 'lucide-react';
import { SectionProps } from '../types/prp.types';
import { formatKey } from '../utils/formatters';
/**
* Renders user flows and journey diagrams
*/
export const FlowSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'orange',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!data || typeof data !== 'object') return null;
const renderFlowNode = (obj: any, depth: number = 0): React.ReactNode => {
if (!obj || typeof obj !== 'object') {
return <span className="text-gray-600 dark:text-gray-400">{String(obj)}</span>;
}
return Object.entries(obj).map(([key, value]) => {
const nodeKey = `${key}-${depth}-${Math.random()}`;
if (typeof value === 'string') {
return (
<div key={nodeKey} className="flex items-center gap-2 p-2" style={{ marginLeft: depth * 24 }}>
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{formatKey(key)}:
</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{value}</span>
</div>
);
} else if (typeof value === 'object' && value !== null) {
return (
<div key={nodeKey} className="mb-3">
<div className="flex items-center gap-2 p-2 font-medium text-gray-800 dark:text-white" style={{ marginLeft: depth * 24 }}>
<Navigation className="w-4 h-4 text-purple-500" />
{formatKey(key)}
</div>
<div className="border-l-2 border-purple-200 dark:border-purple-800 ml-6">
{renderFlowNode(value, depth + 1)}
</div>
</div>
);
}
return null;
});
};
return (
<div className="grid gap-4">
{Object.entries(data).map(([flowName, flow]) => (
<div
key={flowName}
className="p-4 rounded-lg bg-gradient-to-br from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border border-purple-200 dark:border-purple-800"
>
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 flex items-center gap-2">
<Workflow className="w-5 h-5 text-purple-500" />
{formatKey(flowName)}
</h4>
<div className="overflow-x-auto">
{renderFlowNode(flow)}
</div>
</div>
))}
</div>
);
};

View File

@@ -1,233 +0,0 @@
import React from 'react';
import { FileText, Hash, List, Box, Type, ToggleLeft } from 'lucide-react';
import { SectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
import { hasComplexNesting } from '../utils/normalizer';
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
import { SimpleMarkdown } from '../components/SimpleMarkdown';
/**
* Generic fallback section component that intelligently renders any data structure
* This component provides comprehensive rendering for any data type with proper formatting
*/
export const GenericSection: React.FC<SectionProps> = ({
title,
data,
icon = <FileText className="w-5 h-5" />,
accentColor = 'gray',
defaultOpen = true,
isDarkMode = false,
isCollapsible = true,
isOpen,
onToggle
}) => {
// Auto-detect appropriate icon based on data type
const getAutoIcon = () => {
if (typeof data === 'string') return <Type className="w-5 h-5" />;
if (typeof data === 'number') return <Hash className="w-5 h-5" />;
if (typeof data === 'boolean') return <ToggleLeft className="w-5 h-5" />;
if (Array.isArray(data)) return <List className="w-5 h-5" />;
if (typeof data === 'object' && data !== null) return <Box className="w-5 h-5" />;
return icon;
};
const renderValue = (value: any, depth: number = 0): React.ReactNode => {
const indent = depth * 16;
const maxDepth = 5; // Prevent infinite recursion
// Handle null/undefined
if (value === null || value === undefined) {
return <span className="text-gray-400 italic">Empty</span>;
}
// Handle primitives
if (typeof value === 'string') {
// Check if the string looks like markdown content
const hasMarkdownIndicators = /^#{1,6}\s+.+$|^[-*+]\s+.+$|^\d+\.\s+.+$|```|^\>.+$|\*\*.+\*\*|\*.+\*|`[^`]+`/m.test(value);
if (hasMarkdownIndicators && value.length > 20) {
// Render as markdown for content with markdown syntax
// Remove any leading headers since the section already has a title
const contentWithoutLeadingHeaders = value.replace(/^#{1,6}\s+.+$/m, '').trim();
const finalContent = contentWithoutLeadingHeaders || value;
return <SimpleMarkdown content={finalContent} className="text-gray-700 dark:text-gray-300" />;
}
// For shorter strings or non-markdown, use simple formatting
return <span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return <span className="text-gray-700 dark:text-gray-300 font-mono">{formatValue(value)}</span>;
}
// Prevent deep recursion
if (depth >= maxDepth) {
return (
<span className="text-gray-500 italic text-sm">
[Complex nested structure - too deep to display]
</span>
);
}
// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-gray-400 italic">No items</span>;
}
// Check if it's an array of primitives
const isSimpleArray = value.every(item =>
typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean' ||
item === null ||
item === undefined
);
if (isSimpleArray) {
// For very long arrays, show first 10 and count
const displayItems = value.length > 10 ? value.slice(0, 10) : value;
const hasMore = value.length > 10;
return (
<div>
<ul className="space-y-1 mt-2">
{displayItems.map((item, index) => (
<li key={index} className="flex items-start gap-2" style={{ marginLeft: indent }}>
<span className="text-gray-400 mt-0.5"></span>
<span className="text-gray-700 dark:text-gray-300">{formatValue(item)}</span>
</li>
))}
</ul>
{hasMore && (
<p className="text-sm text-gray-500 italic mt-2" style={{ marginLeft: indent + 16 }}>
... and {value.length - 10} more items
</p>
)}
</div>
);
}
// Array of objects
const displayItems = value.length > 5 ? value.slice(0, 5) : value;
const hasMore = value.length > 5;
return (
<div className="space-y-3 mt-2">
{displayItems.map((item, index) => (
<div key={index} className="relative" style={{ marginLeft: indent }}>
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-gray-300 to-transparent dark:from-gray-600"></div>
<div className="pl-4">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
[{index}]
</div>
{renderValue(item, depth + 1)}
</div>
</div>
))}
{hasMore && (
<p className="text-sm text-gray-500 italic" style={{ marginLeft: indent + 16 }}>
... and {value.length - 5} more items
</p>
)}
</div>
);
}
// Handle objects
if (typeof value === 'object' && value !== null) {
// Simplified object rendering to debug black screen
return (
<div className="mt-2 text-gray-700 dark:text-gray-300">
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded whitespace-pre-wrap">
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
}
// Fallback for any other type (functions, symbols, etc.)
return (
<span className="text-gray-500 italic text-sm">
[{typeof value}]
</span>
);
};
const getBackgroundColor = () => {
const colorMap = {
blue: 'bg-blue-50/50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
purple: 'bg-purple-50/50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
green: 'bg-green-50/50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
orange: 'bg-orange-50/50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
pink: 'bg-pink-50/50 dark:bg-pink-900/20 border-pink-200 dark:border-pink-800',
cyan: 'bg-cyan-50/50 dark:bg-cyan-900/20 border-cyan-200 dark:border-cyan-800',
gray: 'bg-gray-50/50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800',
};
return colorMap[accentColor as keyof typeof colorMap] || colorMap.gray;
};
const finalIcon = icon === <FileText className="w-5 h-5" /> ? getAutoIcon() : icon;
// Enhanced styling based on data complexity
const isComplexData = hasComplexNesting(data);
const headerClass = isComplexData
? `p-6 rounded-lg border-2 shadow-sm ${getBackgroundColor()}`
: `p-4 rounded-lg border ${getBackgroundColor()}`;
const header = (
<div className={headerClass}>
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<div className="p-1.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
{finalIcon}
</div>
<span className="flex-1">{title}</span>
</h3>
</div>
);
const contentClass = isComplexData
? `px-6 pb-6 -mt-1 rounded-b-lg border-2 border-t-0 shadow-sm ${getBackgroundColor()}`
: `px-4 pb-4 -mt-1 rounded-b-lg border border-t-0 ${getBackgroundColor()}`;
const content = (
<div className={contentClass}>
<div className="overflow-x-auto">
{/* Add a subtle background for complex data */}
{isComplexData ? (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded p-3 -mx-2">
{renderValue(data)}
</div>
) : (
renderValue(data)
)}
</div>
</div>
);
try {
return (
<CollapsibleSectionWrapper
header={header}
isCollapsible={isCollapsible}
defaultOpen={defaultOpen}
isOpen={isOpen}
onToggle={onToggle}
>
{content}
</CollapsibleSectionWrapper>
);
} catch (error) {
console.error('Error rendering GenericSection:', error, { title, data });
return (
<div className="p-4 border border-red-300 rounded bg-red-50 dark:bg-red-900">
<h3 className="text-red-800 dark:text-red-200 font-semibold">{title}</h3>
<p className="text-red-600 dark:text-red-300 text-sm mt-2">Error rendering section content</p>
<pre className="text-xs mt-2 bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
}
};

View File

@@ -1,111 +0,0 @@
import React from 'react';
import { Hash } from 'lucide-react';
import { PRPSectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
/**
* Component for rendering simple key-value pairs
* Used for sections like budget, resources, team, etc.
*/
export const KeyValueSection: React.FC<PRPSectionProps> = ({
title,
data,
icon = <Hash className="w-5 h-5" />,
accentColor = 'green',
isDarkMode = false,
defaultOpen = true
}) => {
if (!data || typeof data !== 'object') return null;
const colorMap = {
blue: 'from-blue-400 to-blue-600',
purple: 'from-purple-400 to-purple-600',
green: 'from-green-400 to-green-600',
orange: 'from-orange-400 to-orange-600',
pink: 'from-pink-400 to-pink-600',
cyan: 'from-cyan-400 to-cyan-600',
indigo: 'from-indigo-400 to-indigo-600',
emerald: 'from-emerald-400 to-emerald-600',
};
const borderColorMap = {
blue: 'border-blue-200 dark:border-blue-800',
purple: 'border-purple-200 dark:border-purple-800',
green: 'border-green-200 dark:border-green-800',
orange: 'border-orange-200 dark:border-orange-800',
pink: 'border-pink-200 dark:border-pink-800',
cyan: 'border-cyan-200 dark:border-cyan-800',
indigo: 'border-indigo-200 dark:border-indigo-800',
emerald: 'border-emerald-200 dark:border-emerald-800',
};
const renderValue = (value: any): React.ReactNode => {
if (Array.isArray(value)) {
return (
<ul className="list-disc list-inside space-y-1 mt-1">
{value.map((item, index) => (
<li key={index} className="text-gray-600 dark:text-gray-400">
{formatValue(item)}
</li>
))}
</ul>
);
}
if (typeof value === 'object' && value !== null) {
return (
<div className="mt-2 space-y-2 bg-gray-50 dark:bg-gray-700 p-3 rounded">
{Object.entries(value).map(([k, v]) => (
<div key={k} className="flex items-center justify-between">
<span className="font-medium text-gray-600 dark:text-gray-400">
{formatKey(k)}
</span>
<span className="text-gray-700 dark:text-gray-300 font-semibold">
{formatValue(v)}
</span>
</div>
))}
</div>
);
}
return (
<span className="text-gray-700 dark:text-gray-300 font-semibold">
{formatValue(value)}
</span>
);
};
return (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-6">
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap] || colorMap.green} text-white shadow-lg`}>
{icon}
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white">
{title}
</h3>
</div>
<div className="space-y-4">
{Object.entries(data).map(([key, value]) => (
<div
key={key}
className={`pb-4 border-b ${borderColorMap[accentColor as keyof typeof borderColorMap] || borderColorMap.green} last:border-0 last:pb-0`}
>
<div className="flex items-start justify-between gap-4">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 min-w-[120px]">
{formatKey(key)}
</h4>
<div className="flex-1 text-right">
{renderValue(value)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -1,79 +0,0 @@
import React from 'react';
import { CheckCircle2, Circle } from 'lucide-react';
import { SectionProps } from '../types/prp.types';
/**
* Renders simple list/array data
*/
export const ListSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'green',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!Array.isArray(data)) return null;
const getItemIcon = (item: any, index: number) => {
// Use checkmarks for validation/success items
if (title.toLowerCase().includes('validation') ||
title.toLowerCase().includes('success') ||
title.toLowerCase().includes('complete')) {
return <CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />;
}
// Use circles for general items
return <Circle className="w-3 h-3 text-gray-400 mt-1 flex-shrink-0" />;
};
const getBackgroundColor = () => {
const colorMap = {
green: 'bg-gradient-to-br from-green-50/50 to-emerald-50/50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-200 dark:border-green-800',
blue: 'bg-gradient-to-br from-blue-50/50 to-cyan-50/50 dark:from-blue-900/20 dark:to-cyan-900/20 border-blue-200 dark:border-blue-800',
purple: 'bg-gradient-to-br from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border-purple-200 dark:border-purple-800',
orange: 'bg-gradient-to-br from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border-orange-200 dark:border-orange-800',
gray: 'bg-gradient-to-br from-gray-50/50 to-slate-50/50 dark:from-gray-900/20 dark:to-slate-900/20 border-gray-200 dark:border-gray-800',
};
return colorMap[accentColor as keyof typeof colorMap] || colorMap.gray;
};
if (data.length === 0) {
return (
<div className={`p-4 rounded-lg border ${getBackgroundColor()}`}>
<p className="text-gray-500 dark:text-gray-500 italic">No items</p>
</div>
);
}
return (
<div className={`p-4 rounded-lg border ${getBackgroundColor()}`}>
<ul className="space-y-2">
{data.map((item: any, idx: number) => (
<li key={idx} className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
{getItemIcon(item, idx)}
<div className="flex-1">
{typeof item === 'string' ? (
<span className="text-gray-700 dark:text-gray-300">{item}</span>
) : typeof item === 'object' && item !== null ? (
<div className="space-y-2">
{Object.entries(item).map(([key, value]) => (
<div key={key} className="flex items-start gap-2">
<span className="font-medium text-gray-600 dark:text-gray-400 min-w-[80px] capitalize">
{key.replace(/_/g, ' ')}:
</span>
<span className="text-gray-700 dark:text-gray-300 flex-1">
{typeof value === 'string' ? value : JSON.stringify(value)}
</span>
</div>
))}
</div>
) : (
<span className="text-gray-700 dark:text-gray-300">{String(item)}</span>
)}
</div>
</li>
))}
</ul>
</div>
);
};

View File

@@ -1,85 +0,0 @@
import React from 'react';
import { Award, Users, Clock, Tag, FileText } from 'lucide-react';
import { PRPContent } from '../types/prp.types';
interface MetadataSectionProps {
content: PRPContent;
isDarkMode?: boolean;
}
/**
* Renders the metadata header section of a PRP document
*/
export const MetadataSection: React.FC<MetadataSectionProps> = ({ content, isDarkMode = false }) => {
const getIcon = (field: string) => {
switch (field) {
case 'version': return <Award className="w-4 h-4 text-blue-500" />;
case 'author': return <Users className="w-4 h-4 text-purple-500" />;
case 'date': return <Clock className="w-4 h-4 text-green-500" />;
case 'status': return <Tag className="w-4 h-4 text-orange-500" />;
default: return <FileText className="w-4 h-4 text-gray-500" />;
}
};
const formatStatus = (status: string) => {
const statusColors = {
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
review: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
approved: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
published: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
};
const colorClass = statusColors[status.toLowerCase() as keyof typeof statusColors] ||
'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const metadataFields = ['version', 'author', 'date', 'status'];
const hasMetadata = metadataFields.some(field => content[field]);
if (!hasMetadata && !content.title) {
return null;
}
return (
<div className="mb-8 p-6 rounded-xl bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-200 dark:border-blue-800">
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">
{content.title || 'Product Requirements Prompt'}
</h1>
<div className="flex flex-wrap gap-4 text-sm">
{metadataFields.map(field => {
const value = content[field];
if (!value) return null;
return (
<div key={field} className="flex items-center gap-2">
{getIcon(field)}
{field === 'status' ? (
formatStatus(value)
) : (
<span className="text-gray-600 dark:text-gray-400">
{field === 'version' && 'Version'} {value}
</span>
)}
</div>
);
})}
{content.document_type && (
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-indigo-500" />
<span className="text-gray-600 dark:text-gray-400 capitalize">
{content.document_type}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { BarChart3, Settings, Users, Gauge } from 'lucide-react';
import { SectionProps } from '../types/prp.types';
import { formatKey } from '../utils/formatters';
/**
* Renders success metrics and KPIs
*/
export const MetricsSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'green',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!data || typeof data !== 'object') return null;
const getCategoryColor = (category: string): string => {
const normalizedCategory = category.toLowerCase();
if (normalizedCategory.includes('admin')) return 'from-blue-400 to-blue-600';
if (normalizedCategory.includes('business')) return 'from-purple-400 to-purple-600';
if (normalizedCategory.includes('customer')) return 'from-green-400 to-green-600';
if (normalizedCategory.includes('technical')) return 'from-orange-400 to-orange-600';
if (normalizedCategory.includes('performance')) return 'from-red-400 to-red-600';
return 'from-gray-400 to-gray-600';
};
const getCategoryIcon = (category: string): React.ReactNode => {
const normalizedCategory = category.toLowerCase();
if (normalizedCategory.includes('admin')) return <Settings className="w-4 h-4" />;
if (normalizedCategory.includes('business')) return <BarChart3 className="w-4 h-4" />;
if (normalizedCategory.includes('customer')) return <Users className="w-4 h-4" />;
return <Gauge className="w-4 h-4" />;
};
const renderMetric = (metric: string, category: string, index: number) => {
return (
<div
key={`${category}-${index}`}
className="flex items-center gap-3 p-3 rounded-lg bg-white/50 dark:bg-black/30 border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 transition-all duration-200 group"
>
<div className={`p-2 rounded-lg bg-gradient-to-br ${getCategoryColor(category)} text-white shadow-md group-hover:scale-110 transition-transform duration-200`}>
{getCategoryIcon(category)}
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 flex-1">{metric}</p>
</div>
);
};
return (
<div className="grid gap-4">
{Object.entries(data).map(([category, metrics]: [string, any]) => (
<div key={category}>
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 capitalize">
{formatKey(category)}
</h4>
<div className="grid gap-2">
{Array.isArray(metrics) ?
metrics.map((metric: string, idx: number) =>
renderMetric(metric, category, idx)
) :
typeof metrics === 'object' && metrics !== null ?
Object.entries(metrics).map(([key, value], idx) =>
renderMetric(`${formatKey(key)}: ${value}`, category, idx)
) :
renderMetric(String(metrics), category, 0)
}
</div>
</div>
))}
</div>
);
};

View File

@@ -1,193 +0,0 @@
import React from 'react';
import { Box, FileText } from 'lucide-react';
import { PRPSectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
/**
* Component for rendering complex object structures with nested data
* Used for sections like design systems, architecture, etc.
*/
export const ObjectSection: React.FC<PRPSectionProps> = ({
title,
data,
icon = <Box className="w-5 h-5" />,
accentColor = 'indigo',
isDarkMode = false,
defaultOpen = true,
isCollapsible = true,
isOpen,
onToggle
}) => {
if (!data || typeof data !== 'object') return null;
const colorMap = {
blue: 'from-blue-400 to-blue-600 border-blue-500',
purple: 'from-purple-400 to-purple-600 border-purple-500',
green: 'from-green-400 to-green-600 border-green-500',
orange: 'from-orange-400 to-orange-600 border-orange-500',
pink: 'from-pink-400 to-pink-600 border-pink-500',
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
};
const bgColorMap = {
blue: 'bg-blue-50 dark:bg-blue-950',
purple: 'bg-purple-50 dark:bg-purple-950',
green: 'bg-green-50 dark:bg-green-950',
orange: 'bg-orange-50 dark:bg-orange-950',
pink: 'bg-pink-50 dark:bg-pink-950',
cyan: 'bg-cyan-50 dark:bg-cyan-950',
indigo: 'bg-indigo-50 dark:bg-indigo-950',
emerald: 'bg-emerald-50 dark:bg-emerald-950',
};
const renderNestedObject = (obj: any, depth: number = 0): React.ReactNode => {
if (!obj || typeof obj !== 'object') {
return <span className="text-gray-700 dark:text-gray-300">{formatValue(obj)}</span>;
}
if (Array.isArray(obj)) {
// Handle empty arrays
if (obj.length === 0) {
return <span className="text-gray-500 italic">No items</span>;
}
// Check if it's a simple array (strings/numbers/booleans)
const isSimpleArray = obj.every(item =>
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
);
if (isSimpleArray) {
return (
<ul className="space-y-1 mt-2">
{obj.map((item, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-gray-400 mt-0.5"></span>
<span className="text-gray-700 dark:text-gray-300">{String(item)}</span>
</li>
))}
</ul>
);
}
// Complex array with objects
return (
<div className="space-y-3 mt-2">
{obj.map((item, index) => (
<div key={index} className={`${depth > 0 ? 'border-l-2 border-gray-200 dark:border-gray-700 pl-4' : ''}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Item {index + 1}</div>
{renderNestedObject(item, depth + 1)}
</div>
))}
</div>
);
}
// Handle objects
const entries = Object.entries(obj);
// Group entries by type for better organization
const stringEntries = entries.filter(([_, v]) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
const arrayEntries = entries.filter(([_, v]) => Array.isArray(v));
const objectEntries = entries.filter(([_, v]) => typeof v === 'object' && v !== null && !Array.isArray(v));
return (
<div className={`space-y-3 ${depth > 0 ? 'mt-2' : ''}`}>
{/* Render simple key-value pairs first */}
{stringEntries.length > 0 && (
<div className={`${depth > 0 ? 'ml-4' : ''} space-y-2`}>
{stringEntries.map(([key, value]) => (
<div key={key} className="flex items-start gap-2">
<span className="text-gray-600 dark:text-gray-400 min-w-[100px] text-sm">
{formatKey(key)}:
</span>
<span className="text-gray-700 dark:text-gray-300 text-sm">
{String(value)}
</span>
</div>
))}
</div>
)}
{/* Render arrays */}
{arrayEntries.map(([key, value]) => (
<div key={key} className={`${depth > 0 ? 'ml-4' : ''}`}>
<div className="flex items-start gap-2 mb-2">
<FileText className="w-4 h-4 text-gray-400 mt-1 flex-shrink-0" />
<div className="flex-1">
<h5 className={`font-semibold text-gray-700 dark:text-gray-300 ${depth > 2 ? 'text-sm' : ''}`}>
{formatKey(key)}
</h5>
<div className="text-sm">
{renderNestedObject(value, depth + 1)}
</div>
</div>
</div>
</div>
))}
{/* Render nested objects */}
{objectEntries.map(([key, value]) => {
// Determine if this is a complex nested structure
const isComplex = Object.values(value as object).some(v =>
typeof v === 'object' && v !== null
);
return (
<div key={key} className={`${depth > 0 ? 'ml-4' : ''}`}>
<div className={`
${isComplex ? 'border-l-4 border-gray-300 dark:border-gray-600 pl-4' : ''}
${depth > 1 ? 'mt-4' : ''}
`}>
<h5 className={`
font-semibold text-gray-700 dark:text-gray-300 mb-2
${depth === 0 ? 'text-base' : depth === 1 ? 'text-sm' : 'text-xs'}
`}>
{formatKey(key)}
</h5>
<div className={depth > 2 ? 'text-xs' : 'text-sm'}>
{renderNestedObject(value, depth + 1)}
</div>
</div>
</div>
);
})}
</div>
);
};
const header = (
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
{icon}
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex-1">
{title}
</h3>
</div>
</div>
);
const content = (
<div className={`rounded-b-lg px-6 pb-6 -mt-1 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
{renderNestedObject(data)}
</div>
);
return (
<div className="space-y-0">
<CollapsibleSectionWrapper
header={header}
isCollapsible={isCollapsible}
defaultOpen={defaultOpen}
isOpen={isOpen}
onToggle={onToggle}
>
{content}
</CollapsibleSectionWrapper>
</div>
);
};

View File

@@ -1,184 +0,0 @@
import React, { useState } from 'react';
import { Target, Zap } from 'lucide-react';
import { SectionProps, PRPPersona } from '../types/prp.types';
/**
* Renders user personas with expandable cards
*/
export const PersonaSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'purple',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!data || typeof data !== 'object') return null;
return (
<div className="grid gap-4">
{Object.entries(data).map(([key, persona]) => (
<PersonaCard key={key} persona={persona as PRPPersona} personaKey={key} />
))}
</div>
);
};
interface PersonaCardProps {
persona: PRPPersona;
personaKey: string;
}
const PersonaCard: React.FC<PersonaCardProps> = ({ persona, personaKey }) => {
const [isExpanded, setIsExpanded] = useState(false);
const getPersonaIcon = (key: string) => {
if (key.includes('admin')) return '👨‍💼';
if (key.includes('formulator')) return '🧪';
if (key.includes('purchasing')) return '💰';
if (key.includes('developer')) return '👨‍💻';
if (key.includes('designer')) return '🎨';
if (key.includes('manager')) return '👔';
if (key.includes('customer')) return '🛍️';
return '👤';
};
const renderJourney = (journey: Record<string, any>) => {
return (
<div className="space-y-1">
{Object.entries(journey).map(([stage, description]) => (
<div key={stage} className="flex items-start gap-2 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300 capitalize min-w-[100px]">
{stage}:
</span>
<span className="text-gray-600 dark:text-gray-400">
{typeof description === 'string' ? description : JSON.stringify(description)}
</span>
</div>
))}
</div>
);
};
const renderWorkflow = (workflow: Record<string, any>) => {
return (
<div className="space-y-1">
{Object.entries(workflow).map(([time, task]) => (
<div key={time} className="flex items-start gap-2 text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300 capitalize min-w-[100px]">
{time}:
</span>
<span className="text-gray-600 dark:text-gray-400">
{typeof task === 'string' ? task : JSON.stringify(task)}
</span>
</div>
))}
</div>
);
};
return (
<div className="group">
<div
className="p-6 rounded-xl bg-gradient-to-br from-white/80 to-white/60 dark:from-gray-800/50 dark:to-gray-900/50 border border-gray-200 dark:border-gray-700 hover:border-purple-400 dark:hover:border-purple-500 transition-all duration-300 shadow-lg hover:shadow-xl hover:scale-[1.02] cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-start gap-4">
<div className="text-4xl">{getPersonaIcon(personaKey)}</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-800 dark:text-white mb-1">
{persona.name || personaKey}
</h3>
{persona.role && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{persona.role}</p>
)}
{/* Always visible goals */}
{persona.goals && Array.isArray(persona.goals) && persona.goals.length > 0 && (
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-green-500" />
Goals
</h4>
<ul className="space-y-1">
{persona.goals.slice(0, isExpanded ? undefined : 2).map((goal: string, idx: number) => (
<li key={idx} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-green-500 mt-0.5"></span>
{goal}
</li>
))}
{!isExpanded && persona.goals.length > 2 && (
<li className="text-sm text-gray-500 dark:text-gray-500 italic">
+{persona.goals.length - 2} more...
</li>
)}
</ul>
</div>
)}
{/* Expandable content */}
{isExpanded && (
<>
{persona.pain_points && Array.isArray(persona.pain_points) && persona.pain_points.length > 0 && (
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<Zap className="w-4 h-4 text-orange-500" />
Pain Points
</h4>
<ul className="space-y-1">
{persona.pain_points.map((point: string, idx: number) => (
<li key={idx} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
<span className="text-orange-500 mt-0.5"></span>
{point}
</li>
))}
</ul>
</div>
)}
{persona.journey && Object.keys(persona.journey).length > 0 && (
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
User Journey
</h4>
{renderJourney(persona.journey)}
</div>
)}
{persona.workflow && Object.keys(persona.workflow).length > 0 && (
<div className="mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Daily Workflow
</h4>
{renderWorkflow(persona.workflow)}
</div>
)}
{/* Render any other fields */}
{Object.entries(persona).map(([key, value]) => {
if (['name', 'role', 'goals', 'pain_points', 'journey', 'workflow'].includes(key)) {
return null;
}
return (
<div key={key} className="mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 capitalize">
{key.replace(/_/g, ' ')}
</h4>
<div className="text-sm text-gray-600 dark:text-gray-400">
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</div>
</div>
);
})}
</>
)}
</div>
</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-500 text-right">
Click to {isExpanded ? 'collapse' : 'expand'} details
</div>
</div>
</div>
);
};

View File

@@ -1,136 +0,0 @@
import React from 'react';
import { Clock, Zap, CheckCircle2 } from 'lucide-react';
import { SectionProps, PRPPhase } from '../types/prp.types';
/**
* Renders implementation plans and phases
*/
export const PlanSection: React.FC<SectionProps> = ({
title,
data,
icon,
accentColor = 'orange',
defaultOpen = true,
isDarkMode = false,
}) => {
if (!data || typeof data !== 'object') return null;
const getPhaseColor = (index: number): string => {
const colors = ['orange', 'yellow', 'green', 'blue', 'purple'];
return colors[index % colors.length];
};
const renderPhase = (phaseKey: string, phase: PRPPhase, index: number) => {
const color = getPhaseColor(index);
const colorMap = {
orange: 'from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border-orange-200 dark:border-orange-800',
yellow: 'from-yellow-50/50 to-amber-50/50 dark:from-yellow-900/20 dark:to-amber-900/20 border-yellow-200 dark:border-yellow-800',
green: 'from-green-50/50 to-emerald-50/50 dark:from-green-900/20 dark:to-emerald-900/20 border-green-200 dark:border-green-800',
blue: 'from-blue-50/50 to-cyan-50/50 dark:from-blue-900/20 dark:to-cyan-900/20 border-blue-200 dark:border-blue-800',
purple: 'from-purple-50/50 to-pink-50/50 dark:from-purple-900/20 dark:to-pink-900/20 border-purple-200 dark:border-purple-800',
};
return (
<div
key={phaseKey}
className={`p-4 rounded-lg bg-gradient-to-r ${colorMap[color as keyof typeof colorMap]} border`}
>
<h4 className="font-bold text-gray-800 dark:text-white mb-2 flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" />
{phaseKey.toUpperCase()}
{phase.duration && (
<span className="text-sm font-normal text-gray-600 dark:text-gray-400 ml-2">
({phase.duration})
</span>
)}
</h4>
{phase.deliverables && Array.isArray(phase.deliverables) && (
<div className="mb-3">
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Deliverables
</h5>
<ul className="space-y-1">
{phase.deliverables.map((item: string, idx: number) => (
<li key={idx} className="text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
{item}
</li>
))}
</ul>
</div>
)}
{phase.tasks && Array.isArray(phase.tasks) && (
<div>
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Tasks
</h5>
<ul className="space-y-1">
{phase.tasks.map((task: any, idx: number) => (
<li key={idx} className="text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2">
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600 mt-0.5 flex-shrink-0" />
{typeof task === 'string' ? task : task.description || JSON.stringify(task)}
</li>
))}
</ul>
</div>
)}
{/* Render any other phase properties */}
{Object.entries(phase).map(([key, value]) => {
if (['duration', 'deliverables', 'tasks'].includes(key)) return null;
return (
<div key={key} className="mt-3">
<h5 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1 capitalize">
{key.replace(/_/g, ' ')}
</h5>
<div className="text-sm text-gray-600 dark:text-gray-400">
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</div>
</div>
);
})}
</div>
);
};
// Check if this is a phased plan or a general plan structure
const isPhased = Object.values(data).some(value =>
typeof value === 'object' &&
value !== null &&
(value.duration || value.deliverables || value.tasks)
);
if (isPhased) {
return (
<div className="space-y-4">
{Object.entries(data).map(([phaseKey, phase], index) =>
renderPhase(phaseKey, phase as PRPPhase, index)
)}
</div>
);
}
// Fallback to generic rendering for non-phased plans
return (
<div className="p-4 rounded-lg bg-gradient-to-r from-orange-50/50 to-yellow-50/50 dark:from-orange-900/20 dark:to-yellow-900/20 border border-orange-200 dark:border-orange-800">
<h4 className="font-semibold text-gray-800 dark:text-white mb-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-orange-500" />
{title}
</h4>
<div className="space-y-2">
{Object.entries(data).map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">
{key.replace(/_/g, ' ').charAt(0).toUpperCase() + key.replace(/_/g, ' ').slice(1)}:
</span>{' '}
<span className="text-gray-600 dark:text-gray-400">
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
</span>
</div>
))}
</div>
</div>
);
};

View File

@@ -1,236 +0,0 @@
import React from 'react';
import { Calendar, CheckCircle, AlertCircle } from 'lucide-react';
import { PRPSectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
import { CollapsibleSectionWrapper } from '../components/CollapsibleSectionWrapper';
/**
* Component for rendering rollout plans and deployment strategies
*/
export const RolloutPlanSection: React.FC<PRPSectionProps> = ({
title,
data,
icon = <Calendar className="w-5 h-5" />,
accentColor = 'orange',
isDarkMode = false,
defaultOpen = true,
isCollapsible = true,
isOpen,
onToggle
}) => {
if (!data) return null;
const colorMap = {
blue: 'from-blue-400 to-blue-600 border-blue-500',
purple: 'from-purple-400 to-purple-600 border-purple-500',
green: 'from-green-400 to-green-600 border-green-500',
orange: 'from-orange-400 to-orange-600 border-orange-500',
pink: 'from-pink-400 to-pink-600 border-pink-500',
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
};
const bgColorMap = {
blue: 'bg-blue-50 dark:bg-blue-950',
purple: 'bg-purple-50 dark:bg-purple-950',
green: 'bg-green-50 dark:bg-green-950',
orange: 'bg-orange-50 dark:bg-orange-950',
pink: 'bg-pink-50 dark:bg-pink-950',
cyan: 'bg-cyan-50 dark:bg-cyan-950',
indigo: 'bg-indigo-50 dark:bg-indigo-950',
emerald: 'bg-emerald-50 dark:bg-emerald-950',
};
const renderPhase = (phase: any, index: number) => {
if (typeof phase === 'string') {
return (
<div key={index} className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-sm font-bold">
{index + 1}
</div>
<div className="flex-1">
<p className="text-gray-700 dark:text-gray-300">{phase}</p>
</div>
</div>
);
}
if (typeof phase === 'object' && phase !== null) {
const phaseName = phase.name || phase.title || phase.phase || `Phase ${index + 1}`;
const duration = phase.duration || phase.timeline || phase.timeframe;
const description = phase.description || phase.details || phase.summary;
const tasks = phase.tasks || phase.activities || phase.items;
const risks = phase.risks || phase.considerations;
return (
<div key={index} className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 ml-4">
<div className="flex items-start gap-3 mb-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 text-white flex items-center justify-center font-bold shadow-md">
{index + 1}
</div>
<div className="flex-1">
<h4 className="font-bold text-gray-800 dark:text-white text-lg">{phaseName}</h4>
{duration && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{duration}</p>
)}
</div>
</div>
{description && (
<p className="text-gray-700 dark:text-gray-300 mb-3 ml-13">{description}</p>
)}
{tasks && Array.isArray(tasks) && tasks.length > 0 && (
<div className="ml-13 mb-3">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Tasks:</p>
<ul className="space-y-1">
{tasks.map((task, taskIndex) => (
<li key={taskIndex} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">{formatValue(task)}</span>
</li>
))}
</ul>
</div>
)}
{risks && Array.isArray(risks) && risks.length > 0 && (
<div className="ml-13">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Risks & Considerations:</p>
<ul className="space-y-1">
{risks.map((risk, riskIndex) => (
<li key={riskIndex} className="flex items-start gap-2 text-sm">
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">{formatValue(risk)}</span>
</li>
))}
</ul>
</div>
)}
{/* Render any other properties */}
{Object.entries(phase).map(([key, value]) => {
if (['name', 'title', 'phase', 'duration', 'timeline', 'timeframe', 'description', 'details', 'summary', 'tasks', 'activities', 'items', 'risks', 'considerations'].includes(key)) {
return null;
}
return (
<div key={key} className="ml-13 mt-3">
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400">{formatKey(key)}:</p>
<div className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{typeof value === 'string' || typeof value === 'number' ? (
<span>{value}</span>
) : Array.isArray(value) ? (
<ul className="space-y-1 mt-1">
{value.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-gray-400"></span>
<span>{formatValue(item)}</span>
</li>
))}
</ul>
) : (
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto">
{JSON.stringify(value, null, 2)}
</pre>
)}
</div>
</div>
);
})}
</div>
);
}
return null;
};
const renderRolloutPlan = () => {
// Handle array of phases
if (Array.isArray(data)) {
return (
<div className="space-y-6">
{data.map((phase, index) => renderPhase(phase, index))}
</div>
);
}
// Handle object with phases
if (typeof data === 'object' && data !== null) {
const phases = data.phases || data.plan || data.steps || data.stages;
if (phases && Array.isArray(phases)) {
return (
<div className="space-y-6">
{phases.map((phase, index) => renderPhase(phase, index))}
</div>
);
}
// Handle object with other properties
return (
<div className="space-y-4">
{Object.entries(data).map(([key, value]) => (
<div key={key}>
<h4 className="font-semibold text-gray-700 dark:text-gray-300 mb-2">
{formatKey(key)}
</h4>
{Array.isArray(value) ? (
<div className="space-y-4">
{value.map((item, index) => renderPhase(item, index))}
</div>
) : typeof value === 'object' && value !== null ? (
<div className="pl-4 border-l-2 border-gray-200 dark:border-gray-700">
{renderPhase(value, 0)}
</div>
) : (
<p className="text-gray-700 dark:text-gray-300">{formatValue(value)}</p>
)}
</div>
))}
</div>
);
}
// Handle string
if (typeof data === 'string') {
return <p className="text-gray-700 dark:text-gray-300">{data}</p>;
}
return null;
};
const header = (
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.orange} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
{icon}
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white flex-1">
{title}
</h3>
</div>
</div>
);
const content = (
<div className={`rounded-b-lg px-6 pb-6 -mt-1 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.orange} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
{renderRolloutPlan()}
</div>
);
return (
<div className="space-y-0">
<CollapsibleSectionWrapper
header={header}
isCollapsible={isCollapsible}
defaultOpen={defaultOpen}
isOpen={isOpen}
onToggle={onToggle}
>
{content}
</CollapsibleSectionWrapper>
</div>
);
};

View File

@@ -1,235 +0,0 @@
import React from 'react';
import { Palette, Layers } from 'lucide-react';
import { PRPSectionProps } from '../types/prp.types';
import { formatKey, formatValue } from '../utils/formatters';
/**
* Component for rendering design token systems and style guides
*/
export const TokenSystemSection: React.FC<PRPSectionProps> = ({
title,
data,
icon = <Palette className="w-5 h-5" />,
accentColor = 'indigo',
isDarkMode = false,
defaultOpen = true
}) => {
if (!data) return null;
const colorMap = {
blue: 'from-blue-400 to-blue-600 border-blue-500',
purple: 'from-purple-400 to-purple-600 border-purple-500',
green: 'from-green-400 to-green-600 border-green-500',
orange: 'from-orange-400 to-orange-600 border-orange-500',
pink: 'from-pink-400 to-pink-600 border-pink-500',
cyan: 'from-cyan-400 to-cyan-600 border-cyan-500',
indigo: 'from-indigo-400 to-indigo-600 border-indigo-500',
emerald: 'from-emerald-400 to-emerald-600 border-emerald-500',
};
const bgColorMap = {
blue: 'bg-blue-50 dark:bg-blue-950',
purple: 'bg-purple-50 dark:bg-purple-950',
green: 'bg-green-50 dark:bg-green-950',
orange: 'bg-orange-50 dark:bg-orange-950',
pink: 'bg-pink-50 dark:bg-pink-950',
cyan: 'bg-cyan-50 dark:bg-cyan-950',
indigo: 'bg-indigo-50 dark:bg-indigo-950',
emerald: 'bg-emerald-50 dark:bg-emerald-950',
};
const renderColorSwatch = (color: string, name: string) => {
// Check if it's a valid color value
const isHex = /^#[0-9A-F]{6}$/i.test(color);
const isRgb = /^rgb/.test(color);
const isHsl = /^hsl/.test(color);
const isNamedColor = /^[a-z]+$/i.test(color);
if (isHex || isRgb || isHsl || isNamedColor) {
return (
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-sm"
style={{ backgroundColor: color }}
/>
<div>
<p className="font-medium text-gray-700 dark:text-gray-300">{name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{color}</p>
</div>
</div>
);
}
return null;
};
const renderSpacingValue = (value: string | number, name: string) => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
const unit = typeof value === 'string' ? value.replace(/[0-9.-]/g, '') : 'px';
return (
<div className="flex items-center gap-3">
<div
className="bg-indigo-500 rounded"
style={{
width: `${Math.min(numValue * 2, 100)}px`,
height: '24px'
}}
/>
<div>
<p className="font-medium text-gray-700 dark:text-gray-300">{name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{value}{unit}</p>
</div>
</div>
);
};
const renderTokenGroup = (tokens: any, groupName: string) => {
if (!tokens || typeof tokens !== 'object') return null;
const entries = Object.entries(tokens);
const isColorGroup = groupName.toLowerCase().includes('color') ||
entries.some(([_, v]) => typeof v === 'string' && (v.startsWith('#') || v.startsWith('rgb')));
const isSpacingGroup = groupName.toLowerCase().includes('spacing') ||
groupName.toLowerCase().includes('size') ||
groupName.toLowerCase().includes('radius');
return (
<div className="space-y-3">
<h4 className="font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Layers className="w-4 h-4" />
{formatKey(groupName)}
</h4>
<div className={`grid gap-4 ${isColorGroup ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'}`}>
{entries.map(([key, value]) => {
if (isColorGroup && typeof value === 'string') {
const swatch = renderColorSwatch(value, formatKey(key));
if (swatch) return <div key={key}>{swatch}</div>;
}
if (isSpacingGroup && (typeof value === 'string' || typeof value === 'number')) {
return <div key={key}>{renderSpacingValue(value, formatKey(key))}</div>;
}
// Handle nested token groups
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return (
<div key={key} className="col-span-full">
{renderTokenGroup(value, key)}
</div>
);
}
// Default rendering
return (
<div key={key} className="flex items-start gap-2">
<span className="font-medium text-gray-600 dark:text-gray-400">{formatKey(key)}:</span>
<span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>
</div>
);
})}
</div>
</div>
);
};
const renderTokenSystem = () => {
// Handle string description
if (typeof data === 'string') {
return <p className="text-gray-700 dark:text-gray-300">{data}</p>;
}
// Handle array of token groups
if (Array.isArray(data)) {
return (
<div className="space-y-6">
{data.map((group, index) => (
<div key={index}>
{typeof group === 'object' && group !== null ? (
renderTokenGroup(group, `Group ${index + 1}`)
) : (
<p className="text-gray-700 dark:text-gray-300">{formatValue(group)}</p>
)}
</div>
))}
</div>
);
}
// Handle object with token categories
if (typeof data === 'object' && data !== null) {
const categories = Object.entries(data);
// Special handling for common token categories
const colorTokens = categories.filter(([k]) => k.toLowerCase().includes('color'));
const typographyTokens = categories.filter(([k]) => k.toLowerCase().includes('typography') || k.toLowerCase().includes('font'));
const spacingTokens = categories.filter(([k]) => k.toLowerCase().includes('spacing') || k.toLowerCase().includes('size'));
const otherTokens = categories.filter(([k]) =>
!k.toLowerCase().includes('color') &&
!k.toLowerCase().includes('typography') &&
!k.toLowerCase().includes('font') &&
!k.toLowerCase().includes('spacing') &&
!k.toLowerCase().includes('size')
);
return (
<div className="space-y-8">
{/* Colors */}
{colorTokens.length > 0 && (
<div className="space-y-6">
{colorTokens.map(([key, value]) => (
<div key={key}>{renderTokenGroup(value, key)}</div>
))}
</div>
)}
{/* Typography */}
{typographyTokens.length > 0 && (
<div className="space-y-6">
{typographyTokens.map(([key, value]) => (
<div key={key}>{renderTokenGroup(value, key)}</div>
))}
</div>
)}
{/* Spacing */}
{spacingTokens.length > 0 && (
<div className="space-y-6">
{spacingTokens.map(([key, value]) => (
<div key={key}>{renderTokenGroup(value, key)}</div>
))}
</div>
)}
{/* Others */}
{otherTokens.length > 0 && (
<div className="space-y-6">
{otherTokens.map(([key, value]) => (
<div key={key}>{renderTokenGroup(value, key)}</div>
))}
</div>
)}
</div>
);
}
return null;
};
return (
<div className="space-y-4">
<div className={`rounded-lg p-6 ${bgColorMap[accentColor as keyof typeof bgColorMap] || bgColorMap.indigo} border-l-4 ${colorMap[accentColor as keyof typeof colorMap].split(' ')[2]}`}>
<div className="flex items-center gap-3 mb-4">
<div className={`p-2 rounded-lg bg-gradient-to-br ${colorMap[accentColor as keyof typeof colorMap].split(' ').slice(0, 2).join(' ')} text-white shadow-lg`}>
{icon}
</div>
<h3 className="text-lg font-bold text-gray-800 dark:text-white">
{title}
</h3>
</div>
{renderTokenSystem()}
</div>
</div>
);
};

View File

@@ -1,121 +0,0 @@
import { ReactNode } from 'react';
// Base section types
export type SectionType =
| 'metadata'
| 'context'
| 'personas'
| 'flows'
| 'metrics'
| 'plan'
| 'list'
| 'object'
| 'keyvalue'
| 'features'
| 'generic';
export interface SectionProps {
title: string;
data: any;
icon?: ReactNode;
accentColor?: string;
defaultOpen?: boolean;
isDarkMode?: boolean;
isCollapsible?: boolean;
onToggle?: () => void;
isOpen?: boolean;
}
// Alias for component compatibility
export type PRPSectionProps = SectionProps;
export interface PRPMetadata {
title?: string;
version?: string;
author?: string;
date?: string;
status?: string;
document_type?: string;
[key: string]: any;
}
export interface PRPContext {
scope?: string;
background?: string;
objectives?: string[];
requirements?: any;
[key: string]: any;
}
export interface PRPPersona {
name?: string;
role?: string;
goals?: string[];
pain_points?: string[];
journey?: Record<string, any>;
workflow?: Record<string, any>;
[key: string]: any;
}
export interface PRPPhase {
duration?: string;
deliverables?: string[];
tasks?: any[];
[key: string]: any;
}
export interface PRPContent {
// Common fields
title?: string;
version?: string;
author?: string;
date?: string;
status?: string;
document_type?: string;
// Section fields
context?: PRPContext;
user_personas?: Record<string, PRPPersona>;
user_flows?: Record<string, any>;
success_metrics?: Record<string, string[] | Record<string, any>>;
implementation_plan?: Record<string, PRPPhase>;
validation_gates?: Record<string, string[]>;
technical_implementation?: Record<string, any>;
ui_improvements?: Record<string, any>;
information_architecture?: Record<string, any>;
current_state_analysis?: Record<string, any>;
component_architecture?: Record<string, any>;
// Allow any other fields
[key: string]: any;
}
export interface SectionDetectorResult {
type: SectionType;
confidence: number;
}
export interface SectionComponentProps extends SectionProps {
content: PRPContent;
sectionKey: string;
}
// Color maps for consistent theming
export const sectionColorMap: Record<string, string> = {
metadata: 'blue',
context: 'purple',
personas: 'pink',
flows: 'orange',
metrics: 'green',
plan: 'cyan',
technical: 'indigo',
validation: 'emerald',
generic: 'gray'
};
// Icon size constants
export const ICON_SIZES = {
section: 'w-5 h-5',
subsection: 'w-4 h-4',
item: 'w-3 h-3'
} as const;

View File

@@ -1,53 +0,0 @@
import { normalizeImagePlaceholders } from './normalizer';
/**
* Formats a key into a human-readable label
*/
export function formatKey(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Truncates text with ellipsis
*/
export function truncateText(text: string, maxLength: number = 100): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
* Formats a value for display
*/
export function formatValue(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'number') return value.toLocaleString();
if (typeof value === 'string') {
// Temporarily disabled to debug black screen issue
// return normalizeImagePlaceholders(value);
return value;
}
if (Array.isArray(value)) return `${value.length} items`;
if (typeof value === 'object') return `${Object.keys(value).length} properties`;
return String(value);
}
/**
* Gets accent color based on index for variety
*/
export function getAccentColor(index: number): string {
const colors = ['blue', 'purple', 'green', 'orange', 'pink', 'cyan', 'indigo', 'emerald'];
return colors[index % colors.length];
}
/**
* Generates a unique key for React components
*/
export function generateKey(prefix: string, ...parts: (string | number)[]): string {
return [prefix, ...parts].filter(Boolean).join('-');
}

View File

@@ -1,397 +0,0 @@
/**
* Markdown Parser for PRP Documents
*
* Parses raw markdown content into structured sections that can be rendered
* by the PRPViewer component with collapsible sections and beautiful formatting.
*/
export interface ParsedSection {
title: string;
content: string;
level: number;
type: 'text' | 'list' | 'code' | 'mixed';
rawContent: string;
sectionKey: string;
templateType?: string; // For matching to PRP templates
}
export interface ParsedMarkdownDocument {
title?: string;
sections: ParsedSection[];
metadata: Record<string, any>;
hasMetadata: boolean;
}
export interface ParsedMarkdown {
title?: string;
sections: Record<string, ParsedSection>;
metadata: Record<string, any>;
}
/**
* Parses markdown content into structured sections based on headers
*/
export function parseMarkdownToPRP(content: string): ParsedMarkdown {
if (!content || typeof content !== 'string') {
return { sections: {}, metadata: {} };
}
const lines = content.split('\n');
const sections: Record<string, ParsedSection> = {};
let currentSection: ParsedSection | null = null;
let documentTitle: string | undefined;
let sectionCounter = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for headers (## Section Name or # Document Title)
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
// Save previous section if exists
if (currentSection) {
const sectionKey = generateSectionKey(currentSection.title, sectionCounter);
sections[sectionKey] = {
...currentSection,
content: currentSection.content.trim(),
rawContent: currentSection.rawContent.trim(),
type: detectContentType(currentSection.content)
};
sectionCounter++;
}
// Handle document title (# level headers)
if (level === 1 && !documentTitle) {
documentTitle = title;
currentSection = null;
continue;
}
// Start new section
currentSection = {
title,
content: '',
level,
type: 'text',
rawContent: ''
};
} else if (currentSection) {
// Add content to current section
currentSection.content += line + '\n';
currentSection.rawContent += line + '\n';
} else if (!documentTitle && line.trim()) {
// If we haven't found a title yet and encounter content, treat first non-empty line as title
documentTitle = line.trim();
}
}
// Save final section
if (currentSection) {
const sectionKey = generateSectionKey(currentSection.title, sectionCounter);
sections[sectionKey] = {
...currentSection,
content: currentSection.content.trim(),
rawContent: currentSection.rawContent.trim(),
type: detectContentType(currentSection.content)
};
}
return {
title: documentTitle,
sections,
metadata: {
document_type: 'prp',
parsed_from_markdown: true,
section_count: Object.keys(sections).length
}
};
}
/**
* Generates a consistent section key for use in the sections object
*/
function generateSectionKey(title: string, counter: number): string {
// Convert title to a key format
const baseKey = title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 30); // Limit length
return baseKey || `section_${counter}`;
}
/**
* Detects the type of content in a section
*/
function detectContentType(content: string): 'text' | 'list' | 'code' | 'mixed' {
if (!content.trim()) return 'text';
const lines = content.split('\n').filter(line => line.trim());
let hasText = false;
let hasList = false;
let hasCode = false;
for (const line of lines) {
if (line.startsWith('```')) {
hasCode = true;
} else if (line.match(/^[-*+]\s/) || line.match(/^\d+\.\s/)) {
hasList = true;
} else if (line.trim()) {
hasText = true;
}
}
if (hasCode) return 'code';
if (hasList && hasText) return 'mixed';
if (hasList) return 'list';
return 'text';
}
/**
* Converts parsed markdown back to a structure compatible with PRPViewer
* Each section becomes a separate collapsible section in the viewer
*/
export function convertParsedMarkdownToPRPStructure(parsed: ParsedMarkdown): any {
const result: any = {
title: parsed.title || 'Untitled Document',
...parsed.metadata
};
// Add each section as a top-level property
// The content will be the raw markdown for that section only
for (const [key, section] of Object.entries(parsed.sections)) {
result[key] = section.rawContent;
}
return result;
}
/**
* Checks if content appears to be raw markdown
*/
export function isMarkdownContent(content: any): boolean {
if (typeof content !== 'string') return false;
// Look for markdown indicators
const markdownIndicators = [
/^#{1,6}\s+.+$/m, // Headers
/^[-*+]\s+.+$/m, // Bullet lists
/^\d+\.\s+.+$/m, // Numbered lists
/```/, // Code blocks
/^\>.+$/m, // Blockquotes
/\*\*.+\*\*/, // Bold text
/\*.+\*/, // Italic text
];
return markdownIndicators.some(pattern => pattern.test(content));
}
/**
* Parses markdown content into a flowing document structure
*/
export function parseMarkdownToDocument(content: string): ParsedMarkdownDocument {
if (!content || typeof content !== 'string') {
return { sections: [], metadata: {}, hasMetadata: false };
}
const lines = content.split('\n');
const sections: ParsedSection[] = [];
let currentSection: Partial<ParsedSection> | null = null;
let documentTitle: string | undefined;
let sectionCounter = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for headers (## Section Name or # Document Title)
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
// Save previous section if exists
if (currentSection && currentSection.title) {
sections.push({
title: currentSection.title,
content: (currentSection.content || '').trim(),
level: currentSection.level || 2,
type: detectContentType(currentSection.content || ''),
rawContent: (currentSection.rawContent || '').trim(),
sectionKey: generateSectionKey(currentSection.title, sectionCounter),
templateType: detectTemplateType(currentSection.title)
});
sectionCounter++;
}
// Handle document title (# level headers)
if (level === 1 && !documentTitle) {
documentTitle = title;
currentSection = null;
continue;
}
// Start new section
currentSection = {
title,
content: '',
level,
rawContent: ''
};
} else if (currentSection) {
// Add content to current section
currentSection.content = (currentSection.content || '') + line + '\n';
currentSection.rawContent = (currentSection.rawContent || '') + line + '\n';
} else if (!documentTitle && line.trim()) {
// If we haven't found a title yet and encounter content, treat first non-empty line as title
documentTitle = line.trim();
}
}
// Save final section
if (currentSection && currentSection.title) {
sections.push({
title: currentSection.title,
content: (currentSection.content || '').trim(),
level: currentSection.level || 2,
type: detectContentType(currentSection.content || ''),
rawContent: (currentSection.rawContent || '').trim(),
sectionKey: generateSectionKey(currentSection.title, sectionCounter),
templateType: detectTemplateType(currentSection.title)
});
}
return {
title: documentTitle,
sections,
metadata: {
document_type: 'prp', // Set as PRP to get the right styling
section_count: sections.length,
parsed_from_markdown: true
},
hasMetadata: false
};
}
/**
* Detects if a section title matches a known PRP template type
*/
function detectTemplateType(title: string): string | undefined {
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
// Map common PRP section names to template types
const templateMap: Record<string, string> = {
'goal': 'context',
'objective': 'context',
'purpose': 'context',
'why': 'context',
'rationale': 'context',
'what': 'context',
'description': 'context',
'overview': 'context',
'context': 'context',
'background': 'context',
'problem statement': 'context',
'success metrics': 'metrics',
'metrics': 'metrics',
'kpis': 'metrics',
'success criteria': 'metrics',
'estimated impact': 'metrics',
'implementation plan': 'plan',
'plan': 'plan',
'roadmap': 'plan',
'timeline': 'plan',
'phases': 'plan',
'rollout plan': 'plan',
'migration strategy': 'plan',
'personas': 'personas',
'users': 'personas',
'stakeholders': 'personas',
'target audience': 'personas',
'user flow': 'flows',
'user journey': 'flows',
'workflow': 'flows',
'user experience': 'flows',
'validation': 'list',
'testing': 'list',
'quality gates': 'list',
'acceptance criteria': 'list',
'features': 'features',
'feature requirements': 'features',
'capabilities': 'features',
'technical requirements': 'object',
'architecture': 'object',
'design': 'object',
'components': 'object',
'budget': 'keyvalue',
'resources': 'keyvalue',
'team': 'keyvalue',
'cost': 'keyvalue'
};
return templateMap[normalizedTitle];
}
/**
* Checks if content is a document with metadata structure
*/
export function isDocumentWithMetadata(content: any): boolean {
if (typeof content !== 'object' || content === null) return false;
// Check if it has typical document metadata fields
const metadataFields = ['title', 'version', 'author', 'date', 'status', 'document_type', 'created_at', 'updated_at'];
const hasMetadata = metadataFields.some(field => field in content);
// Check if it has a content field that looks like markdown
const hasMarkdownContent = typeof content.content === 'string' &&
isMarkdownContent(content.content);
// Also check if any field contains markdown content (broader detection)
const hasAnyMarkdownField = Object.values(content).some(value =>
typeof value === 'string' && isMarkdownContent(value)
);
// Return true if it has metadata AND markdown content, OR if it has obvious document structure
return (hasMetadata && (hasMarkdownContent || hasAnyMarkdownField)) ||
(hasMetadata && Object.keys(content).length <= 10); // Simple document structure
}
/**
* Main function to process content for PRPViewer
*/
export function processContentForPRP(content: any): any {
// If it's already an object, return as-is
if (typeof content === 'object' && content !== null) {
return content;
}
// If it's a string that looks like markdown, parse it
if (typeof content === 'string' && isMarkdownContent(content)) {
const parsed = parseMarkdownToPRP(content);
return convertParsedMarkdownToPRPStructure(parsed);
}
// For any other string content, wrap it in a generic structure
if (typeof content === 'string') {
return {
title: 'Document Content',
content: content,
document_type: 'text'
};
}
return content;
}

View File

@@ -1,211 +0,0 @@
/**
* Normalizes PRP document data to ensure consistent rendering
*/
/**
* Normalizes image placeholders to proper markdown format
*/
export function normalizeImagePlaceholders(content: string): string {
return content.replace(/\[Image #(\d+)\]/g, (match, num) => {
return `![Image ${num}](placeholder-image-${num})`;
});
}
/**
* Attempts to parse JSON strings into objects
*/
export function parseJsonStrings(value: any): any {
if (typeof value === 'string') {
const trimmed = value.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch (e) {
// Return original string if parsing fails
return value;
}
}
// Normalize image placeholders in strings
return normalizeImagePlaceholders(value);
}
if (Array.isArray(value)) {
return value.map(item => parseJsonStrings(item));
}
if (value && typeof value === 'object') {
const normalized: any = {};
for (const [key, val] of Object.entries(value)) {
normalized[key] = parseJsonStrings(val);
}
return normalized;
}
return value;
}
/**
* Flattens nested content fields
*/
export function flattenNestedContent(data: any): any {
// Handle nested content field
if (data && typeof data === 'object' && 'content' in data) {
const { content, ...rest } = data;
// If content is an object, merge it with the rest
if (content && typeof content === 'object' && !Array.isArray(content)) {
return flattenNestedContent({ ...rest, ...content });
}
// If content is a string or array, keep it as a field
return { ...rest, content };
}
return data;
}
/**
* Normalizes section names to be more readable
*/
export function normalizeSectionName(name: string): string {
// Common abbreviations and their expansions
const expansions: Record<string, string> = {
'ui': 'User Interface',
'ux': 'User Experience',
'api': 'API',
'kpi': 'KPI',
'prp': 'PRP',
'prd': 'PRD',
'mvp': 'MVP',
'poc': 'Proof of Concept',
};
// Split by underscore or camelCase
const words = name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.filter(word => word.length > 0);
// Process each word
const processed = words.map(word => {
const lower = word.toLowerCase();
// Check if it's a known abbreviation
if (expansions[lower]) {
return expansions[lower];
}
// Otherwise, capitalize first letter
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return processed.join(' ');
}
/**
* Normalizes the entire PRP document structure
*/
export function normalizePRPDocument(content: any): any {
if (!content) return content;
// First, flatten any nested content fields
let normalized = flattenNestedContent(content);
// Then parse any JSON strings
normalized = parseJsonStrings(normalized);
// Handle raw markdown content
if (typeof normalized === 'string') {
// For strings, just normalize image placeholders and return as-is
// The PRPViewer will handle the markdown parsing
return normalizeImagePlaceholders(normalized);
}
// For objects, process each field recursively
if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
const result: any = {};
for (const [key, value] of Object.entries(normalized)) {
// Skip empty values
if (value === null || value === undefined ||
(typeof value === 'string' && value.trim() === '') ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && Object.keys(value).length === 0)) {
continue;
}
// Recursively process nested values
if (typeof value === 'string') {
result[key] = normalizeImagePlaceholders(value);
} else if (Array.isArray(value)) {
result[key] = value.map(item =>
typeof item === 'string' ? normalizeImagePlaceholders(item) : normalizePRPDocument(item)
);
} else if (typeof value === 'object') {
result[key] = normalizePRPDocument(value);
} else {
result[key] = value;
}
}
return result;
}
// For arrays, process each item
if (Array.isArray(normalized)) {
return normalized.map(item =>
typeof item === 'string' ? normalizeImagePlaceholders(item) : normalizePRPDocument(item)
);
}
return normalized;
}
/**
* Checks if a value contains complex nested structures
*/
export function hasComplexNesting(value: any): boolean {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) {
return value.some(item =>
typeof item === 'object' && item !== null
);
}
return Object.values(value).some(val =>
(typeof val === 'object' && val !== null) ||
(Array.isArray(val) && val.some(item => typeof item === 'object'))
);
}
/**
* Extracts metadata fields from content
*/
export function extractMetadata(content: any): { metadata: any; sections: any } {
if (!content || typeof content !== 'object') {
return { metadata: {}, sections: content };
}
const metadataFields = [
'title', 'version', 'author', 'date', 'status',
'document_type', 'created_at', 'updated_at',
'id', '_id', 'project_id'
];
const metadata: any = {};
const sections: any = {};
for (const [key, value] of Object.entries(content)) {
if (metadataFields.includes(key)) {
metadata[key] = value;
} else {
sections[key] = value;
}
}
return { metadata, sections };
}

View File

@@ -1,107 +0,0 @@
import React from 'react';
import { formatKey, formatValue } from './formatters';
/**
* Renders any value in a formatted way without using JSON.stringify
*/
export function renderValue(value: any, depth: number = 0): React.ReactNode {
try {
// Prevent infinite recursion
if (depth > 10) {
return <span className="text-gray-500 italic">Too deeply nested</span>;
}
// Handle null/undefined
if (value === null || value === undefined) {
return <span className="text-gray-400 italic">Empty</span>;
}
// Handle primitives
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return <span className="text-gray-700 dark:text-gray-300">{formatValue(value)}</span>;
}
// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-gray-400 italic">No items</span>;
}
// Check if it's a simple array
const isSimple = value.every(item =>
typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean'
);
if (isSimple) {
return (
<ul className="list-disc list-inside space-y-1">
{value.map((item, index) => (
<li key={index} className="text-gray-700 dark:text-gray-300">
{formatValue(item)}
</li>
))}
</ul>
);
}
// Complex array
return (
<div className="space-y-2">
{value.map((item, index) => (
<div key={index} className="pl-4 border-l-2 border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Item {index + 1}</div>
{renderValue(item, depth + 1)}
</div>
))}
</div>
);
}
// Handle objects
if (typeof value === 'object' && value !== null) {
const entries = Object.entries(value);
if (entries.length === 0) {
return <span className="text-gray-400 italic">No properties</span>;
}
return (
<div className="space-y-2">
{entries.map(([key, val]) => (
<div key={key} className="flex flex-col gap-1">
<span className="font-medium text-gray-600 dark:text-gray-400">
{formatKey(key)}:
</span>
<div className="pl-4">
{renderValue(val, depth + 1)}
</div>
</div>
))}
</div>
);
}
// Fallback
return <span className="text-gray-700 dark:text-gray-300">{String(value)}</span>;
} catch (error) {
console.error('Error rendering value:', error, value);
return <span className="text-red-500 italic">Error rendering content</span>;
}
}
/**
* Renders a value inline for simple display
*/
export function renderValueInline(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return formatValue(value);
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.map(v => renderValueInline(v)).join(', ');
if (typeof value === 'object') {
// For objects, just show a summary
const keys = Object.keys(value);
if (keys.length === 0) return 'Empty object';
if (keys.length <= 3) return keys.map(k => `${k}: ${renderValueInline(value[k])}`).join(', ');
return `${keys.length} properties`;
}
return String(value);
}

View File

@@ -1,204 +0,0 @@
import { SectionType, SectionDetectorResult } from '../types/prp.types';
/**
* Detects the type of a section based on its key and content structure
*/
export function detectSectionType(key: string, value: any): SectionDetectorResult {
const normalizedKey = key.toLowerCase().replace(/_/g, '').replace(/\s+/g, '');
// Check metadata fields
if (['title', 'version', 'author', 'date', 'status', 'documenttype'].includes(normalizedKey)) {
return { type: 'metadata', confidence: 1.0 };
}
// Check context sections (including common markdown headers)
if (normalizedKey === 'context' || normalizedKey === 'overview' ||
normalizedKey === 'executivesummary' || normalizedKey === 'problemstatement' ||
normalizedKey === 'visionstatement' || normalizedKey === 'proposedsolution' ||
normalizedKey === 'goal' || normalizedKey === 'objective' || normalizedKey === 'purpose' ||
normalizedKey === 'why' || normalizedKey === 'rationale' || normalizedKey === 'what' ||
normalizedKey === 'description' || normalizedKey === 'background') {
return { type: 'context', confidence: 1.0 };
}
// Check personas
if (normalizedKey.includes('persona') || normalizedKey.includes('user') ||
normalizedKey === 'stakeholders' || normalizedKey === 'targetaudience') {
// Always treat these as personas, even if structure doesn't match perfectly
return { type: 'personas', confidence: 0.9 };
}
// Check flows/journeys
if (normalizedKey.includes('flow') || normalizedKey.includes('journey') ||
normalizedKey.includes('workflow') || normalizedKey === 'userexperience') {
return { type: 'flows', confidence: 0.9 };
}
// Check metrics (including common markdown headers)
if (normalizedKey.includes('metric') || normalizedKey.includes('success') ||
normalizedKey.includes('kpi') || normalizedKey === 'estimatedimpact' ||
normalizedKey === 'successmetrics' || normalizedKey === 'successcriteria') {
return { type: 'metrics', confidence: 0.9 };
}
// Check implementation plans (including common markdown headers)
if (normalizedKey.includes('plan') || normalizedKey.includes('phase') ||
normalizedKey.includes('implementation') || normalizedKey.includes('roadmap') ||
normalizedKey === 'timeline' || normalizedKey === 'rolloutplan' ||
normalizedKey === 'migrationstrategy' || normalizedKey === 'implementationplan') {
return { type: 'plan', confidence: 0.9 };
}
// Check validation/testing (including common markdown headers)
if (normalizedKey.includes('validation') || normalizedKey.includes('test') ||
normalizedKey.includes('gate') || normalizedKey === 'compliance' ||
normalizedKey.includes('quality') || normalizedKey === 'accessibilitystandards' ||
normalizedKey === 'acceptancecriteria' || normalizedKey === 'qualitygates') {
return { type: 'list', confidence: 0.8 };
}
// Check risk assessment
if (normalizedKey.includes('risk') || normalizedKey === 'riskassessment') {
return { type: 'list', confidence: 0.9 };
}
// Check design/architecture sections
if (normalizedKey.includes('design') || normalizedKey.includes('architecture') ||
normalizedKey.includes('component') || normalizedKey === 'tokensystem' ||
normalizedKey === 'designprinciples' || normalizedKey === 'designguidelines') {
return { type: 'object', confidence: 0.8 };
}
// Check budget/resources
if (normalizedKey.includes('budget') || normalizedKey.includes('resource') ||
normalizedKey.includes('cost') || normalizedKey === 'team' ||
normalizedKey === 'budgetestimate' || normalizedKey === 'budgetandresources') {
return { type: 'keyvalue', confidence: 0.9 };
}
// Check feature requirements specifically
if (normalizedKey === 'featurerequirements' || normalizedKey === 'features' ||
normalizedKey === 'capabilities') {
return { type: 'features', confidence: 0.9 };
}
// Check requirements
if (normalizedKey.includes('requirement') ||
normalizedKey === 'technicalrequirements') {
return { type: 'object', confidence: 0.8 };
}
// Check data/information sections
if (normalizedKey.includes('data') || normalizedKey.includes('information') ||
normalizedKey === 'currentstateanalysis' || normalizedKey === 'informationarchitecture') {
return { type: 'object', confidence: 0.8 };
}
// Check governance/process sections
if (normalizedKey.includes('governance') || normalizedKey.includes('process') ||
normalizedKey === 'governancemodel' || normalizedKey === 'testingstrategy') {
return { type: 'object', confidence: 0.8 };
}
// Check technical sections
if (normalizedKey.includes('technical') || normalizedKey.includes('tech') ||
normalizedKey === 'aimodelspecifications' || normalizedKey === 'performancerequirements' ||
normalizedKey === 'toolingandinfrastructure' || normalizedKey === 'monitoringandanalytics') {
return { type: 'object', confidence: 0.8 };
}
// Analyze value structure
if (Array.isArray(value)) {
return { type: 'list', confidence: 0.7 };
}
if (typeof value === 'object' && value !== null) {
// Check if it's a simple key-value object
if (isSimpleKeyValue(value)) {
return { type: 'keyvalue', confidence: 0.7 };
}
// Check if it's a complex nested object
if (hasNestedObjects(value)) {
return { type: 'object', confidence: 0.7 };
}
}
// Default fallback
return { type: 'generic', confidence: 0.5 };
}
/**
* Checks if the value structure matches a persona pattern
*/
function isPersonaStructure(value: any): boolean {
if (typeof value !== 'object' || value === null) return false;
// Check if it's a collection of personas
const values = Object.values(value);
if (values.length === 0) return false;
// Check if first value has persona-like properties
const firstValue = values[0];
if (typeof firstValue !== 'object') return false;
const personaKeys = ['name', 'role', 'goals', 'pain_points', 'journey', 'workflow'];
return personaKeys.some(key => key in firstValue);
}
/**
* Checks if an object is a simple key-value structure
*/
function isSimpleKeyValue(obj: any): boolean {
if (typeof obj !== 'object' || obj === null) return false;
const values = Object.values(obj);
return values.every(val =>
typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean'
);
}
/**
* Checks if an object has nested objects
*/
function hasNestedObjects(obj: any): boolean {
if (typeof obj !== 'object' || obj === null) return false;
const values = Object.values(obj);
return values.some(val =>
typeof val === 'object' &&
val !== null &&
!Array.isArray(val)
);
}
/**
* Gets a suggested icon based on section key
*/
export function getSectionIcon(key: string): string {
const normalizedKey = key.toLowerCase();
if (normalizedKey.includes('persona') || normalizedKey.includes('user')) return 'Users';
if (normalizedKey.includes('flow') || normalizedKey.includes('journey')) return 'Workflow';
if (normalizedKey.includes('metric') || normalizedKey.includes('success')) return 'BarChart3';
if (normalizedKey.includes('plan') || normalizedKey.includes('implementation')) return 'Clock';
if (normalizedKey.includes('context') || normalizedKey.includes('overview')) return 'Brain';
if (normalizedKey.includes('technical') || normalizedKey.includes('tech')) return 'Code';
if (normalizedKey.includes('validation') || normalizedKey.includes('test')) return 'Shield';
if (normalizedKey.includes('component') || normalizedKey.includes('architecture')) return 'Layers';
return 'FileText';
}
/**
* Formats a section key into a human-readable title
*/
export function formatSectionTitle(key: string): string {
return key
.replace(/_/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View File

@@ -325,7 +325,7 @@ export const RAGSettings = ({
})}
className="w-full px-3 py-2 border border-green-500/30 rounded-md bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white focus:border-green-500 focus:ring-1 focus:ring-green-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Browser sessions (1-20)</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Pages to crawl in parallel per operation (1-20)</p>
</div>
</div>

View File

@@ -1,704 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Terminal, RefreshCw, Play, Square, Clock, CheckCircle, XCircle, FileText, ChevronUp, ChevronDown, BarChart } from 'lucide-react';
// Card component not used but preserved for future use
// import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { TestResultsModal } from '../ui/TestResultsModal';
import { TestResultDashboard } from '../ui/TestResultDashboard';
import { testService, TestExecution, TestStreamMessage, TestType } from '../../services/testService';
import { useToast } from '../../contexts/ToastContext';
import { motion, AnimatePresence } from 'framer-motion';
import { useTerminalScroll } from '../../hooks/useTerminalScroll';
interface TestResult {
name: string;
status: 'running' | 'passed' | 'failed' | 'skipped';
duration?: number;
error?: string;
}
interface TestExecutionState {
execution?: TestExecution;
logs: string[];
isRunning: boolean;
duration?: number;
exitCode?: number;
// Pretty mode data
results: TestResult[];
summary?: {
total: number;
passed: number;
failed: number;
skipped: number;
};
}
export const TestStatus = () => {
const [displayMode, setDisplayMode] = useState<'pretty' | 'dashboard'>('pretty');
const [mcpErrorsExpanded, setMcpErrorsExpanded] = useState(false);
const [uiErrorsExpanded, setUiErrorsExpanded] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true); // Start collapsed by default
const [showTestResultsModal, setShowTestResultsModal] = useState(false);
const [showDashboard, setShowDashboard] = useState(false);
const [hasResults, setHasResults] = useState(false);
const [mcpTest, setMcpTest] = useState<TestExecutionState>({
logs: ['> Ready to run Python tests...'],
isRunning: false,
results: []
});
const [uiTest, setUiTest] = useState<TestExecutionState>({
logs: ['> Ready to run React UI tests...'],
isRunning: false,
results: []
});
// Use terminal scroll hooks
const mcpTerminalRef = useTerminalScroll([mcpTest.logs], !isCollapsed);
const uiTerminalRef = useTerminalScroll([uiTest.logs], !isCollapsed);
// WebSocket cleanup functions
const wsCleanupRefs = useRef<Map<string, () => void>>(new Map());
const { showToast } = useToast();
// Cleanup WebSocket connections on unmount
useEffect(() => {
return () => {
wsCleanupRefs.current.forEach((cleanup) => cleanup());
testService.disconnectAllStreams();
};
}, []);
// Test results availability - not implemented yet
useEffect(() => {
setHasResults(false);
}, []);
// Check for results when UI tests complete
useEffect(() => {
if (!uiTest.isRunning && uiTest.exitCode === 0) {
setHasResults(false);
}
}, [uiTest.isRunning, uiTest.exitCode]);
const updateTestState = (
testType: TestType,
updater: (prev: TestExecutionState) => TestExecutionState
) => {
switch (testType) {
case 'mcp':
setMcpTest(updater);
break;
case 'ui':
setUiTest(updater);
break;
}
};
const parseTestOutput = (log: string): TestResult | null => {
// Parse Python test output (pytest format)
if (log.includes('::') && (log.includes('PASSED') || log.includes('FAILED') || log.includes('SKIPPED'))) {
const parts = log.split('::');
if (parts.length >= 2) {
const name = parts[parts.length - 1].split(' ')[0];
const status = log.includes('PASSED') ? 'passed' :
log.includes('FAILED') ? 'failed' : 'skipped';
// Extract duration if present
const durationMatch = log.match(/\[([\d.]+)s\]/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
return { name, status, duration };
}
}
// Parse React test output (vitest format)
if (log.includes('✓') || log.includes('✕') || log.includes('○')) {
const testNameMatch = log.match(/[✓✕○]\s+(.+?)(?:\s+\([\d.]+s\))?$/);
if (testNameMatch) {
const name = testNameMatch[1];
const status = log.includes('✓') ? 'passed' :
log.includes('✕') ? 'failed' : 'skipped';
const durationMatch = log.match(/\(([\d.]+)s\)/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
return { name, status, duration };
}
}
return null;
};
const updateSummaryFromLogs = (logs: string[]) => {
// Extract summary from test output
const summaryLine = logs.find(log =>
log.includes('passed') && log.includes('failed') ||
log.includes('Test Files') ||
log.includes('Tests ')
);
if (summaryLine) {
// Python format: "10 failed | 37 passed (47)"
const pythonMatch = summaryLine.match(/(\d+)\s+failed\s+\|\s+(\d+)\s+passed\s+\((\d+)\)/);
if (pythonMatch) {
return {
failed: parseInt(pythonMatch[1]),
passed: parseInt(pythonMatch[2]),
total: parseInt(pythonMatch[3]),
skipped: 0
};
}
// React format: "Test Files 3 failed | 4 passed (7)"
const reactMatch = summaryLine.match(/Test Files\s+(\d+)\s+failed\s+\|\s+(\d+)\s+passed\s+\((\d+)\)/);
if (reactMatch) {
return {
failed: parseInt(reactMatch[1]),
passed: parseInt(reactMatch[2]),
total: parseInt(reactMatch[3]),
skipped: 0
};
}
}
return undefined;
};
const handleStreamMessage = (testType: TestType, message: TestStreamMessage) => {
updateTestState(testType, (prev) => {
const newLogs = [...prev.logs];
const newResults = [...prev.results];
switch (message.type) {
case 'status':
if (message.data?.status) {
newLogs.push(`> Status: ${message.data.status}`);
}
break;
case 'output':
if (message.message) {
newLogs.push(message.message);
// Parse test results for pretty mode
const testResult = parseTestOutput(message.message);
if (testResult) {
// Update existing result or add new one
const existingIndex = newResults.findIndex(r => r.name === testResult.name);
if (existingIndex >= 0) {
newResults[existingIndex] = testResult;
} else {
newResults.push(testResult);
}
}
}
break;
case 'completed':
newLogs.push('> Test execution completed.');
const summary = updateSummaryFromLogs(newLogs);
return {
...prev,
logs: newLogs,
results: newResults,
summary,
isRunning: false,
duration: message.data?.duration,
exitCode: message.data?.exit_code
};
case 'error':
newLogs.push(`> Error: ${message.message || 'Unknown error'}`);
return {
...prev,
logs: newLogs,
results: newResults,
isRunning: false,
exitCode: 1
};
case 'cancelled':
newLogs.push('> Test execution cancelled.');
return {
...prev,
logs: newLogs,
results: newResults,
isRunning: false,
exitCode: -1
};
}
return {
...prev,
logs: newLogs,
results: newResults
};
});
};
const runTest = async (testType: TestType) => {
console.log(`[DEBUG] runTest called with testType: ${testType}`);
try {
// Reset test state
console.log(`[DEBUG] Resetting test state for ${testType}`);
updateTestState(testType, (prev) => ({
...prev,
logs: [`> Starting ${testType === 'mcp' ? 'Python' : 'React UI'} tests...`],
results: [],
summary: undefined,
isRunning: true,
duration: undefined,
exitCode: undefined
}));
if (testType === 'mcp') {
console.log('[DEBUG] Running MCP tests via backend API');
// Python tests: Use backend API with WebSocket streaming
const execution = await testService.runMCPTests();
console.log('[DEBUG] MCP test execution response:', execution);
// Update state with execution info
updateTestState(testType, (prev) => ({
...prev,
execution,
logs: [...prev.logs, `> Execution ID: ${execution.execution_id}`, '> Connecting to real-time stream...']
}));
// Connect to WebSocket stream for real-time updates
const cleanup = testService.connectToTestStream(
execution.execution_id,
(message) => handleStreamMessage(testType, message),
(error) => {
console.error('WebSocket error:', error);
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, '> WebSocket connection error'],
isRunning: false
}));
showToast('WebSocket connection error', 'error');
},
(event) => {
console.log('WebSocket closed:', event.code, event.reason);
// Only update state if it wasn't a normal closure
if (event.code !== 1000) {
updateTestState(testType, (prev) => ({
...prev,
isRunning: false
}));
}
}
);
// Store cleanup function
wsCleanupRefs.current.set(execution.execution_id, cleanup);
} else if (testType === 'ui') {
console.log('[DEBUG] Running UI tests locally in the same container');
// React tests: Run locally using vitest
const execution_id = await testService.runUITestsWithStreaming(
(message) => handleStreamMessage(testType, message),
(error) => {
console.error('UI test error:', error);
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, `> Error: ${error.message}`],
isRunning: false,
exitCode: 1
}));
showToast('React test execution error', 'error');
},
() => {
console.log('UI tests completed');
}
);
// Update state with execution info
updateTestState(testType, (prev) => ({
...prev,
execution: {
execution_id,
test_type: 'ui',
status: 'running',
start_time: new Date().toISOString()
},
logs: [...prev.logs, `> Execution ID: ${execution_id}`, '> Running tests locally...']
}));
}
} catch (error) {
console.error(`[DEBUG] Failed to run ${testType} tests:`, error);
console.error('[DEBUG] Error stack:', error instanceof Error ? error.stack : 'No stack');
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, `> Error: ${error instanceof Error ? error.message : 'Unknown error'}`],
isRunning: false,
exitCode: 1
}));
showToast(`Failed to run ${testType} tests`, 'error');
}
};
const cancelTest = async (testType: TestType) => {
const currentState = testType === 'mcp' ? mcpTest : uiTest;
if (currentState.execution?.execution_id) {
try {
await testService.cancelTestExecution(currentState.execution.execution_id);
// Clean up WebSocket connection
const cleanup = wsCleanupRefs.current.get(currentState.execution.execution_id);
if (cleanup) {
cleanup();
wsCleanupRefs.current.delete(currentState.execution.execution_id);
}
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, '> Test execution cancelled by user'],
isRunning: false,
exitCode: -1
}));
showToast(`${testType.toUpperCase()} test execution cancelled`, 'success');
} catch (error) {
console.error(`Failed to cancel ${testType} tests:`, error);
showToast(`Failed to cancel ${testType} tests`, 'error');
}
}
};
const getStatusIcon = (testState: TestExecutionState) => {
if (testState.isRunning) {
return <RefreshCw className="w-4 h-4 animate-spin text-orange-500" />;
}
if (testState.exitCode === 0) {
return <CheckCircle className="w-4 h-4 text-green-500" />;
}
if (testState.exitCode === -1) {
return <Square className="w-4 h-4 text-gray-500" />;
}
if (testState.exitCode === 1) {
return <XCircle className="w-4 h-4 text-red-500" />;
}
return <Clock className="w-4 h-4 text-gray-400" />;
};
const getStatusText = (testState: TestExecutionState) => {
if (testState.isRunning) return 'Running...';
if (testState.exitCode === 0) return 'Passed';
if (testState.exitCode === -1) return 'Cancelled';
if (testState.exitCode === 1) return 'Failed';
return 'Ready';
};
const formatLogLine = (log: string, index: number) => {
let textColor = 'text-gray-700 dark:text-gray-300';
if (log.includes('PASS') || log.includes('✓') || log.includes('passed')) textColor = 'text-green-600 dark:text-green-400';
if (log.includes('FAIL') || log.includes('✕') || log.includes('failed')) textColor = 'text-red-600 dark:text-red-400';
if (log.includes('Error:') || log.includes('ERROR')) textColor = 'text-red-600 dark:text-red-400';
if (log.includes('Warning:') || log.includes('WARN')) textColor = 'text-yellow-600 dark:text-yellow-400';
if (log.includes('Status:') || log.includes('Duration:') || log.includes('Execution ID:')) textColor = 'text-cyan-600 dark:text-cyan-400';
if (log.startsWith('>')) textColor = 'text-blue-600 dark:text-blue-400';
return (
<div key={index} className={`${textColor} py-0.5 whitespace-pre-wrap font-mono`}>
{log}
</div>
);
};
const renderPrettyResults = (testState: TestExecutionState, testType: TestType) => {
const hasErrors = testState.logs.some(log => log.includes('Error:') || log.includes('ERROR'));
const isErrorsExpanded = testType === 'mcp' ? mcpErrorsExpanded : uiErrorsExpanded;
const setErrorsExpanded = testType === 'mcp' ? setMcpErrorsExpanded : setUiErrorsExpanded;
// Calculate available height for test results (when errors not expanded, use full height)
const summaryHeight = testState.summary ? 44 : 0; // 44px for summary bar
const runningHeight = (testState.isRunning && testState.results.length === 0) ? 36 : 0; // 36px for running indicator
const errorHeaderHeight = hasErrors ? 32 : 0; // 32px for error header
const availableHeight = isErrorsExpanded ? 0 : (256 - summaryHeight - runningHeight - errorHeaderHeight - 16); // When errors expanded, hide test results
return (
<div className="h-full flex flex-col relative">
{/* Summary */}
{testState.summary && (
<div className="flex items-center gap-4 mb-3 p-2 bg-gray-800 rounded-md flex-shrink-0">
<div className="text-xs">
<span className="text-gray-400">Total: </span>
<span className="text-white font-medium">{testState.summary.total}</span>
</div>
<div className="text-xs">
<span className="text-gray-400">Passed: </span>
<span className="text-green-400 font-medium">{testState.summary.passed}</span>
</div>
<div className="text-xs">
<span className="text-gray-400">Failed: </span>
<span className="text-red-400 font-medium">{testState.summary.failed}</span>
</div>
{testState.summary.skipped > 0 && (
<div className="text-xs">
<span className="text-gray-400">Skipped: </span>
<span className="text-yellow-400 font-medium">{testState.summary.skipped}</span>
</div>
)}
</div>
)}
{/* Running indicator */}
{testState.isRunning && testState.results.length === 0 && (
<div className="flex items-center gap-2 p-2 bg-gray-800 rounded-md mb-3 flex-shrink-0">
<RefreshCw className="w-3 h-3 animate-spin text-orange-500" />
<span className="text-gray-300 text-xs">Starting tests...</span>
</div>
)}
{/* Test results - hidden when errors expanded */}
{!isErrorsExpanded && (
<div
ref={testType === 'mcp' ? mcpTerminalRef : uiTerminalRef}
className="flex-1 overflow-y-auto"
style={{ maxHeight: `${availableHeight}px` }}
>
{testState.results.map((result, index) => (
<div key={index} className="flex items-center gap-2 py-1 text-xs">
{result.status === 'running' && <RefreshCw className="w-3 h-3 animate-spin text-orange-500 flex-shrink-0" />}
{result.status === 'passed' && <CheckCircle className="w-3 h-3 text-green-500 flex-shrink-0" />}
{result.status === 'failed' && <XCircle className="w-3 h-3 text-red-500 flex-shrink-0" />}
{result.status === 'skipped' && <Square className="w-3 h-3 text-yellow-500 flex-shrink-0" />}
<span className="flex-1 text-gray-700 dark:text-gray-300 font-mono text-xs truncate">{result.name}</span>
{result.duration && (
<span className="text-xs text-gray-500 flex-shrink-0">
{result.duration.toFixed(2)}s
</span>
)}
</div>
))}
</div>
)}
{/* Collapsible errors section */}
{hasErrors && (
<div
className={`transition-all duration-300 ease-in-out ${
isErrorsExpanded ? 'absolute inset-0 flex flex-col' : 'flex-shrink-0 mt-auto -mx-4 -mb-4'
}`}
>
{/* Error header with toggle */}
<button
onClick={() => setErrorsExpanded(!isErrorsExpanded)}
className="w-full flex items-center justify-between p-2 bg-red-100/80 dark:bg-red-900/20 border border-red-300 dark:border-red-800 hover:bg-red-200 dark:hover:bg-red-900/30 transition-all duration-300 ease-in-out flex-shrink-0"
>
<div className="flex items-center gap-2">
<XCircle className="w-3 h-3 text-red-600 dark:text-red-400" />
<h4 className="text-xs font-medium text-red-600 dark:text-red-400">
Errors ({testState.logs.filter(log => log.includes('Error:') || log.includes('ERROR')).length})
</h4>
</div>
<div className={`transform transition-transform duration-300 ease-in-out ${isErrorsExpanded ? 'rotate-180' : ''}`}>
<ChevronUp className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
</button>
{/* Collapsible error content */}
<div
className={`bg-red-50 dark:bg-red-900/20 border-x border-b border-red-300 dark:border-red-800 overflow-hidden transition-all duration-300 ease-in-out ${
isErrorsExpanded ? 'flex-1' : 'h-0'
}`}
>
<div className="h-full overflow-y-auto p-2 space-y-2">
{testState.logs
.filter(log => log.includes('Error:') || log.includes('ERROR') || log.includes('FAILED') || log.includes('AssertionError') || log.includes('Traceback'))
.map((log, index) => {
const isMainError = log.includes('ERROR:') || log.includes('FAILED');
const isAssertion = log.includes('AssertionError');
const isTraceback = log.includes('Traceback') || log.includes('File "');
return (
<div key={index} className={`p-2 rounded ${
isMainError ? 'bg-red-200/80 dark:bg-red-800/30 border-l-4 border-red-500' :
isAssertion ? 'bg-red-100/80 dark:bg-red-700/20 border-l-2 border-red-400' :
isTraceback ? 'bg-gray-100 dark:bg-gray-800/50 border-l-2 border-gray-500' :
'bg-red-50 dark:bg-red-900/10'
}`}>
<div className="text-red-700 dark:text-red-300 text-xs font-mono whitespace-pre-wrap break-words">
{log}
</div>
{isMainError && (
<div className="mt-1 text-xs text-red-600 dark:text-red-400">
<span className="font-medium">Error Type:</span> {
log.includes('Health_check') ? 'Health Check Failure' :
log.includes('AssertionError') ? 'Test Assertion Failed' :
log.includes('NoneType') ? 'Null Reference Error' :
'General Error'
}
</div>
)}
</div>
);
})}
{/* Error summary */}
<div className="mt-4 p-2 bg-red-100/80 dark:bg-red-900/30 rounded border border-red-300 dark:border-red-700">
<h5 className="text-red-600 dark:text-red-400 font-medium text-xs mb-2">Error Summary:</h5>
<div className="text-xs text-red-700 dark:text-red-300 space-y-1">
<div>Total Errors: {testState.logs.filter(log => log.includes('ERROR:') || log.includes('FAILED')).length}</div>
<div>Assertion Failures: {testState.logs.filter(log => log.includes('AssertionError')).length}</div>
<div>Test Type: {testType === 'mcp' ? 'Python MCP Tools' : 'React UI Components'}</div>
<div>Status: Failed</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
const TestSection = ({
title,
testType,
testState,
onRun,
onCancel
}: {
title: string;
testType: TestType;
testState: TestExecutionState;
onRun: () => void;
onCancel: () => void;
}) => (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-gray-700 dark:text-gray-300">
{title}
</h3>
{getStatusIcon(testState)}
<span className="text-sm text-gray-500 dark:text-gray-400">
{getStatusText(testState)}
</span>
{testState.duration && (
<span className="text-xs text-gray-400">
({testState.duration.toFixed(1)}s)
</span>
)}
</div>
<div className="flex gap-2">
{/* Test Results button for React UI tests only */}
{testType === 'ui' && hasResults && !testState.isRunning && (
<Button
variant="outline"
accentColor="blue"
size="sm"
onClick={() => setShowTestResultsModal(true)}
>
<BarChart className="w-4 h-4 mr-2" />
Test Results
</Button>
)}
{testState.isRunning ? (
<Button
variant="outline"
accentColor="pink"
size="sm"
onClick={onCancel}
>
<Square className="w-4 h-4 mr-2" />
Cancel
</Button>
) : (
<Button
variant="primary"
accentColor="orange"
size="sm"
onClick={onRun}
className="shadow-lg shadow-orange-500/20"
>
<Play className="w-4 h-4 mr-2" />
Run Tests
</Button>
)}
</div>
</div>
<div className="bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-md p-4 h-64 relative">
{renderPrettyResults(testState, testType)}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsCollapsed(!isCollapsed)}>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-orange-500 dark:text-orange-400 filter drop-shadow-[0_0_8px_rgba(251,146,60,0.8)]" />
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">Archon Unit Tests</h2>
<div className={`transform transition-transform duration-300 ${isCollapsed ? '' : 'rotate-180'}`}>
<ChevronDown className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
</div>
{/* Display mode toggle - only visible when expanded */}
{!isCollapsed && (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button
variant={displayMode === 'pretty' ? 'primary' : 'outline'}
accentColor="blue"
size="sm"
onClick={() => setDisplayMode('pretty')}
>
<CheckCircle className="w-4 h-4 mr-1" />
Summary
</Button>
<Button
variant={displayMode === 'dashboard' ? 'primary' : 'outline'}
accentColor="blue"
size="sm"
onClick={() => setDisplayMode('dashboard')}
>
<BarChart className="w-4 h-4 mr-1" />
Dashboard
</Button>
</div>
)}
</div>
{/* Collapsible content */}
<div className={`space-y-4 transition-all duration-300 ${isCollapsed ? 'hidden' : 'block'}`}>
{displayMode === 'pretty' ? (
<>
<TestSection
title="Python Tests"
testType="mcp"
testState={mcpTest}
onRun={() => runTest('mcp')}
onCancel={() => cancelTest('mcp')}
/>
<TestSection
title="React UI Tests"
testType="ui"
testState={uiTest}
onRun={() => runTest('ui')}
onCancel={() => cancelTest('ui')}
/>
</>
) : (
<TestResultDashboard
className="mt-6"
compact={false}
showCoverage={true}
refreshInterval={30}
/>
)}
</div>
{/* Test Results Modal */}
<TestResultsModal
isOpen={showTestResultsModal}
onClose={() => setShowTestResultsModal(false)}
/>
</div>
);
};

View File

@@ -1,684 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Terminal, RefreshCw, Play, Square, Clock, CheckCircle, XCircle, FileText, ChevronUp, ChevronDown, BarChart } from 'lucide-react';
// Card component not used but preserved for future use
// import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { TestResultsModal } from '../ui/TestResultsModal';
import { testService, TestExecution, TestStreamMessage, TestType } from '../../services/testService';
import { useToast } from '../../contexts/ToastContext';
import { motion, AnimatePresence } from 'framer-motion';
import { useTerminalScroll } from '../../hooks/useTerminalScroll';
interface TestResult {
name: string;
status: 'running' | 'passed' | 'failed' | 'skipped';
duration?: number;
error?: string;
}
interface TestExecutionState {
execution?: TestExecution;
logs: string[];
isRunning: boolean;
duration?: number;
exitCode?: number;
// Pretty mode data
results: TestResult[];
summary?: {
total: number;
passed: number;
failed: number;
skipped: number;
};
}
export const TestStatus = () => {
const [displayMode, setDisplayMode] = useState<'pretty'>('pretty');
const [mcpErrorsExpanded, setMcpErrorsExpanded] = useState(false);
const [uiErrorsExpanded, setUiErrorsExpanded] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true); // Start collapsed by default
const [showTestResultsModal, setShowTestResultsModal] = useState(false);
const [hasResults, setHasResults] = useState(false);
const [mcpTest, setMcpTest] = useState<TestExecutionState>({
logs: ['> Ready to run Python tests...'],
isRunning: false,
results: []
});
const [uiTest, setUiTest] = useState<TestExecutionState>({
logs: ['> Ready to run React UI tests...'],
isRunning: false,
results: []
});
// Use terminal scroll hooks
const mcpTerminalRef = useTerminalScroll([mcpTest.logs], !isCollapsed);
const uiTerminalRef = useTerminalScroll([uiTest.logs], !isCollapsed);
// WebSocket cleanup functions
const wsCleanupRefs = useRef<Map<string, () => void>>(new Map());
const { showToast } = useToast();
// Cleanup WebSocket connections on unmount
useEffect(() => {
return () => {
wsCleanupRefs.current.forEach((cleanup) => cleanup());
testService.disconnectAllStreams();
};
}, []);
// Check for test results availability
useEffect(() => {
const checkResults = async () => {
const hasTestResults = await testService.hasTestResults();
setHasResults(hasTestResults);
};
checkResults();
}, []);
// Check for results when UI tests complete
useEffect(() => {
if (!uiTest.isRunning && uiTest.exitCode === 0) {
// Small delay to ensure files are written
setTimeout(async () => {
const hasTestResults = await testService.hasTestResults();
setHasResults(hasTestResults);
}, 2000);
}
}, [uiTest.isRunning, uiTest.exitCode]);
const updateTestState = (
testType: TestType,
updater: (prev: TestExecutionState) => TestExecutionState
) => {
switch (testType) {
case 'mcp':
setMcpTest(updater);
break;
case 'ui':
setUiTest(updater);
break;
}
};
const parseTestOutput = (log: string): TestResult | null => {
// Parse Python test output (pytest format)
if (log.includes('::') && (log.includes('PASSED') || log.includes('FAILED') || log.includes('SKIPPED'))) {
const parts = log.split('::');
if (parts.length >= 2) {
const name = parts[parts.length - 1].split(' ')[0];
const status = log.includes('PASSED') ? 'passed' :
log.includes('FAILED') ? 'failed' : 'skipped';
// Extract duration if present
const durationMatch = log.match(/\[([\d.]+)s\]/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
return { name, status, duration };
}
}
// Parse React test output (vitest format)
if (log.includes('✓') || log.includes('✕') || log.includes('○')) {
const testNameMatch = log.match(/[✓✕○]\s+(.+?)(?:\s+\([\d.]+s\))?$/);
if (testNameMatch) {
const name = testNameMatch[1];
const status = log.includes('✓') ? 'passed' :
log.includes('✕') ? 'failed' : 'skipped';
const durationMatch = log.match(/\(([\d.]+)s\)/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
return { name, status, duration };
}
}
return null;
};
const updateSummaryFromLogs = (logs: string[]) => {
// Extract summary from test output
const summaryLine = logs.find(log =>
log.includes('passed') && log.includes('failed') ||
log.includes('Test Files') ||
log.includes('Tests ')
);
if (summaryLine) {
// Python format: "10 failed | 37 passed (47)"
const pythonMatch = summaryLine.match(/(\d+)\s+failed\s+\|\s+(\d+)\s+passed\s+\((\d+)\)/);
if (pythonMatch) {
return {
failed: parseInt(pythonMatch[1]),
passed: parseInt(pythonMatch[2]),
total: parseInt(pythonMatch[3]),
skipped: 0
};
}
// React format: "Test Files 3 failed | 4 passed (7)"
const reactMatch = summaryLine.match(/Test Files\s+(\d+)\s+failed\s+\|\s+(\d+)\s+passed\s+\((\d+)\)/);
if (reactMatch) {
return {
failed: parseInt(reactMatch[1]),
passed: parseInt(reactMatch[2]),
total: parseInt(reactMatch[3]),
skipped: 0
};
}
}
return undefined;
};
const handleStreamMessage = (testType: TestType, message: TestStreamMessage) => {
updateTestState(testType, (prev) => {
const newLogs = [...prev.logs];
let newResults = [...prev.results];
switch (message.type) {
case 'status':
if (message.data?.status) {
newLogs.push(`> Status: ${message.data.status}`);
}
break;
case 'output':
if (message.message) {
newLogs.push(message.message);
// Parse test results for pretty mode
const testResult = parseTestOutput(message.message);
if (testResult) {
// Update existing result or add new one
const existingIndex = newResults.findIndex(r => r.name === testResult.name);
if (existingIndex >= 0) {
newResults[existingIndex] = testResult;
} else {
newResults.push(testResult);
}
}
}
break;
case 'completed':
newLogs.push('> Test execution completed.');
const summary = updateSummaryFromLogs(newLogs);
return {
...prev,
logs: newLogs,
results: newResults,
summary,
isRunning: false,
duration: message.data?.duration,
exitCode: message.data?.exit_code
};
case 'error':
newLogs.push(`> Error: ${message.message || 'Unknown error'}`);
return {
...prev,
logs: newLogs,
results: newResults,
isRunning: false,
exitCode: 1
};
case 'cancelled':
newLogs.push('> Test execution cancelled.');
return {
...prev,
logs: newLogs,
results: newResults,
isRunning: false,
exitCode: -1
};
}
return {
...prev,
logs: newLogs,
results: newResults
};
});
};
const runTest = async (testType: TestType) => {
try {
// Reset test state
updateTestState(testType, (prev) => ({
...prev,
logs: [`> Starting ${testType === 'mcp' ? 'Python' : 'React UI'} tests...`],
results: [],
summary: undefined,
isRunning: true,
duration: undefined,
exitCode: undefined
}));
if (testType === 'mcp') {
// Python tests: Use backend API with WebSocket streaming
const execution = await testService.runMCPTests();
// Update state with execution info
updateTestState(testType, (prev) => ({
...prev,
execution,
logs: [...prev.logs, `> Execution ID: ${execution.execution_id}`, '> Connecting to real-time stream...']
}));
// Connect to WebSocket stream for real-time updates
const cleanup = testService.connectToTestStream(
execution.execution_id,
(message) => handleStreamMessage(testType, message),
(error) => {
console.error('WebSocket error:', error);
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, '> WebSocket connection error'],
isRunning: false
}));
showToast('WebSocket connection error', 'error');
},
(event) => {
console.log('WebSocket closed:', event.code, event.reason);
// Only update state if it wasn't a normal closure
if (event.code !== 1000) {
updateTestState(testType, (prev) => ({
...prev,
isRunning: false
}));
}
}
);
// Store cleanup function
wsCleanupRefs.current.set(execution.execution_id, cleanup);
} else if (testType === 'ui') {
// React tests: Run locally in frontend
const execution_id = await testService.runUITestsWithStreaming(
(message) => handleStreamMessage(testType, message),
(error) => {
console.error('UI test error:', error);
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, `> Error: ${error.message}`],
isRunning: false,
exitCode: 1
}));
showToast('React test execution error', 'error');
},
() => {
console.log('UI tests completed');
}
);
// Update state with execution info
updateTestState(testType, (prev) => ({
...prev,
execution: {
execution_id,
test_type: 'ui',
status: 'running',
start_time: new Date().toISOString()
},
logs: [...prev.logs, `> Execution ID: ${execution_id}`, '> Running tests locally...']
}));
}
} catch (error) {
console.error(`Failed to run ${testType} tests:`, error);
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, `> Error: ${error instanceof Error ? error.message : 'Unknown error'}`],
isRunning: false,
exitCode: 1
}));
showToast(`Failed to run ${testType} tests`, 'error');
}
};
const cancelTest = async (testType: TestType) => {
const currentState = testType === 'mcp' ? mcpTest : uiTest;
if (currentState.execution?.execution_id) {
try {
await testService.cancelTestExecution(currentState.execution.execution_id);
// Clean up WebSocket connection
const cleanup = wsCleanupRefs.current.get(currentState.execution.execution_id);
if (cleanup) {
cleanup();
wsCleanupRefs.current.delete(currentState.execution.execution_id);
}
updateTestState(testType, (prev) => ({
...prev,
logs: [...prev.logs, '> Test execution cancelled by user'],
isRunning: false,
exitCode: -1
}));
showToast(`${testType.toUpperCase()} test execution cancelled`, 'success');
} catch (error) {
console.error(`Failed to cancel ${testType} tests:`, error);
showToast(`Failed to cancel ${testType} tests`, 'error');
}
}
};
const getStatusIcon = (testState: TestExecutionState) => {
if (testState.isRunning) {
return <RefreshCw className="w-4 h-4 animate-spin text-orange-500" />;
}
if (testState.exitCode === 0) {
return <CheckCircle className="w-4 h-4 text-green-500" />;
}
if (testState.exitCode === -1) {
return <Square className="w-4 h-4 text-gray-500" />;
}
if (testState.exitCode === 1) {
return <XCircle className="w-4 h-4 text-red-500" />;
}
return <Clock className="w-4 h-4 text-gray-400" />;
};
const getStatusText = (testState: TestExecutionState) => {
if (testState.isRunning) return 'Running...';
if (testState.exitCode === 0) return 'Passed';
if (testState.exitCode === -1) return 'Cancelled';
if (testState.exitCode === 1) return 'Failed';
return 'Ready';
};
const formatLogLine = (log: string, index: number) => {
let textColor = 'text-gray-700 dark:text-gray-300';
if (log.includes('PASS') || log.includes('✓') || log.includes('passed')) textColor = 'text-green-600 dark:text-green-400';
if (log.includes('FAIL') || log.includes('✕') || log.includes('failed')) textColor = 'text-red-600 dark:text-red-400';
if (log.includes('Error:') || log.includes('ERROR')) textColor = 'text-red-600 dark:text-red-400';
if (log.includes('Warning:') || log.includes('WARN')) textColor = 'text-yellow-600 dark:text-yellow-400';
if (log.includes('Status:') || log.includes('Duration:') || log.includes('Execution ID:')) textColor = 'text-cyan-600 dark:text-cyan-400';
if (log.startsWith('>')) textColor = 'text-blue-600 dark:text-blue-400';
return (
<div key={index} className={`${textColor} py-0.5 whitespace-pre-wrap font-mono`}>
{log}
</div>
);
};
const renderPrettyResults = (testState: TestExecutionState, testType: TestType) => {
const hasErrors = testState.logs.some(log => log.includes('Error:') || log.includes('ERROR'));
const isErrorsExpanded = testType === 'mcp' ? mcpErrorsExpanded : uiErrorsExpanded;
const setErrorsExpanded = testType === 'mcp' ? setMcpErrorsExpanded : setUiErrorsExpanded;
// Calculate available height for test results (when errors not expanded, use full height)
const summaryHeight = testState.summary ? 44 : 0; // 44px for summary bar
const runningHeight = (testState.isRunning && testState.results.length === 0) ? 36 : 0; // 36px for running indicator
const errorHeaderHeight = hasErrors ? 32 : 0; // 32px for error header
const availableHeight = isErrorsExpanded ? 0 : (256 - summaryHeight - runningHeight - errorHeaderHeight - 16); // When errors expanded, hide test results
return (
<div className="h-full flex flex-col relative">
{/* Summary */}
{testState.summary && (
<div className="flex items-center gap-4 mb-3 p-2 bg-gray-800 rounded-md flex-shrink-0">
<div className="text-xs">
<span className="text-gray-400">Total: </span>
<span className="text-white font-medium">{testState.summary.total}</span>
</div>
<div className="text-xs">
<span className="text-gray-400">Passed: </span>
<span className="text-green-400 font-medium">{testState.summary.passed}</span>
</div>
<div className="text-xs">
<span className="text-gray-400">Failed: </span>
<span className="text-red-400 font-medium">{testState.summary.failed}</span>
</div>
{testState.summary.skipped > 0 && (
<div className="text-xs">
<span className="text-gray-400">Skipped: </span>
<span className="text-yellow-400 font-medium">{testState.summary.skipped}</span>
</div>
)}
</div>
)}
{/* Running indicator */}
{testState.isRunning && testState.results.length === 0 && (
<div className="flex items-center gap-2 p-2 bg-gray-800 rounded-md mb-3 flex-shrink-0">
<RefreshCw className="w-3 h-3 animate-spin text-orange-500" />
<span className="text-gray-300 text-xs">Starting tests...</span>
</div>
)}
{/* Test results - hidden when errors expanded */}
{!isErrorsExpanded && (
<div
ref={testType === 'mcp' ? mcpTerminalRef : uiTerminalRef}
className="flex-1 overflow-y-auto"
style={{ maxHeight: `${availableHeight}px` }}
>
{testState.results.map((result, index) => (
<div key={index} className="flex items-center gap-2 py-1 text-xs">
{result.status === 'running' && <RefreshCw className="w-3 h-3 animate-spin text-orange-500 flex-shrink-0" />}
{result.status === 'passed' && <CheckCircle className="w-3 h-3 text-green-500 flex-shrink-0" />}
{result.status === 'failed' && <XCircle className="w-3 h-3 text-red-500 flex-shrink-0" />}
{result.status === 'skipped' && <Square className="w-3 h-3 text-yellow-500 flex-shrink-0" />}
<span className="flex-1 text-gray-700 dark:text-gray-300 font-mono text-xs truncate">{result.name}</span>
{result.duration && (
<span className="text-xs text-gray-500 flex-shrink-0">
{result.duration.toFixed(2)}s
</span>
)}
</div>
))}
</div>
)}
{/* Collapsible errors section */}
{hasErrors && (
<div
className={`transition-all duration-300 ease-in-out ${
isErrorsExpanded ? 'absolute inset-0 flex flex-col' : 'flex-shrink-0 mt-auto -mx-4 -mb-4'
}`}
>
{/* Error header with toggle */}
<button
onClick={() => setErrorsExpanded(!isErrorsExpanded)}
className="w-full flex items-center justify-between p-2 bg-red-100/80 dark:bg-red-900/20 border border-red-300 dark:border-red-800 hover:bg-red-200 dark:hover:bg-red-900/30 transition-all duration-300 ease-in-out flex-shrink-0"
>
<div className="flex items-center gap-2">
<XCircle className="w-3 h-3 text-red-600 dark:text-red-400" />
<h4 className="text-xs font-medium text-red-600 dark:text-red-400">
Errors ({testState.logs.filter(log => log.includes('Error:') || log.includes('ERROR')).length})
</h4>
</div>
<div className={`transform transition-transform duration-300 ease-in-out ${isErrorsExpanded ? 'rotate-180' : ''}`}>
<ChevronUp className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
</button>
{/* Collapsible error content */}
<div
className={`bg-red-50 dark:bg-red-900/20 border-x border-b border-red-300 dark:border-red-800 overflow-hidden transition-all duration-300 ease-in-out ${
isErrorsExpanded ? 'flex-1' : 'h-0'
}`}
>
<div className="h-full overflow-y-auto p-2 space-y-2">
{testState.logs
.filter(log => log.includes('Error:') || log.includes('ERROR') || log.includes('FAILED') || log.includes('AssertionError') || log.includes('Traceback'))
.map((log, index) => {
const isMainError = log.includes('ERROR:') || log.includes('FAILED');
const isAssertion = log.includes('AssertionError');
const isTraceback = log.includes('Traceback') || log.includes('File "');
return (
<div key={index} className={`p-2 rounded ${
isMainError ? 'bg-red-200/80 dark:bg-red-800/30 border-l-4 border-red-500' :
isAssertion ? 'bg-red-100/80 dark:bg-red-700/20 border-l-2 border-red-400' :
isTraceback ? 'bg-gray-100 dark:bg-gray-800/50 border-l-2 border-gray-500' :
'bg-red-50 dark:bg-red-900/10'
}`}>
<div className="text-red-700 dark:text-red-300 text-xs font-mono whitespace-pre-wrap break-words">
{log}
</div>
{isMainError && (
<div className="mt-1 text-xs text-red-600 dark:text-red-400">
<span className="font-medium">Error Type:</span> {
log.includes('Health_check') ? 'Health Check Failure' :
log.includes('AssertionError') ? 'Test Assertion Failed' :
log.includes('NoneType') ? 'Null Reference Error' :
'General Error'
}
</div>
)}
</div>
);
})}
{/* Error summary */}
<div className="mt-4 p-2 bg-red-100/80 dark:bg-red-900/30 rounded border border-red-300 dark:border-red-700">
<h5 className="text-red-600 dark:text-red-400 font-medium text-xs mb-2">Error Summary:</h5>
<div className="text-xs text-red-700 dark:text-red-300 space-y-1">
<div>Total Errors: {testState.logs.filter(log => log.includes('ERROR:') || log.includes('FAILED')).length}</div>
<div>Assertion Failures: {testState.logs.filter(log => log.includes('AssertionError')).length}</div>
<div>Test Type: {testType === 'mcp' ? 'Python MCP Tools' : 'React UI Components'}</div>
<div>Status: Failed</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
const TestSection = ({
title,
testType,
testState,
onRun,
onCancel
}: {
title: string;
testType: TestType;
testState: TestExecutionState;
onRun: () => void;
onCancel: () => void;
}) => (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-md font-medium text-gray-700 dark:text-gray-300">
{title}
</h3>
{getStatusIcon(testState)}
<span className="text-sm text-gray-500 dark:text-gray-400">
{getStatusText(testState)}
</span>
{testState.duration && (
<span className="text-xs text-gray-400">
({testState.duration.toFixed(1)}s)
</span>
)}
</div>
<div className="flex gap-2">
{/* Test Results button for React UI tests only */}
{testType === 'ui' && hasResults && !testState.isRunning && (
<Button
variant="outline"
accentColor="blue"
size="sm"
onClick={() => setShowTestResultsModal(true)}
>
<BarChart className="w-4 h-4 mr-2" />
Test Results
</Button>
)}
{testState.isRunning ? (
<Button
variant="outline"
accentColor="pink"
size="sm"
onClick={onCancel}
>
<Square className="w-4 h-4 mr-2" />
Cancel
</Button>
) : (
<Button
variant="primary"
accentColor="orange"
size="sm"
onClick={onRun}
className="shadow-lg shadow-orange-500/20"
>
<Play className="w-4 h-4 mr-2" />
Run Tests
</Button>
)}
</div>
</div>
<div className="bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-md p-4 h-64 relative">
{renderPrettyResults(testState, testType)}
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsCollapsed(!isCollapsed)}>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-orange-500 dark:text-orange-400 filter drop-shadow-[0_0_8px_rgba(251,146,60,0.8)]" />
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">Archon Unit Tests</h2>
<div className={`transform transition-transform duration-300 ${isCollapsed ? '' : 'rotate-180'}`}>
<ChevronDown className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</div>
</div>
{/* Display mode toggle - only visible when expanded */}
{!isCollapsed && (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button
variant={displayMode === 'pretty' ? 'primary' : 'outline'}
accentColor="blue"
size="sm"
onClick={() => setDisplayMode('pretty')}
>
<CheckCircle className="w-4 h-4 mr-1" />
Summary
</Button>
</div>
)}
</div>
{/* Collapsible content */}
<div className={`space-y-4 transition-all duration-300 ${isCollapsed ? 'hidden' : 'block'}`}>
<TestSection
title="Python Tests"
testType="mcp"
testState={mcpTest}
onRun={() => runTest('mcp')}
onCancel={() => cancelTest('mcp')}
/>
<TestSection
title="React UI Tests"
testType="ui"
testState={uiTest}
onRun={() => runTest('ui')}
onCancel={() => cancelTest('ui')}
/>
</div>
{/* Test Results Modal */}
<TestResultsModal
isOpen={showTestResultsModal}
onClose={() => setShowTestResultsModal(false)}
/>
</div>
);
};

View File

@@ -1,196 +0,0 @@
import { useEffect, useState } from 'react'
import { BarChart, AlertCircle, CheckCircle, Activity } from 'lucide-react'
interface CoverageSummary {
total: {
lines: { pct: number }
statements: { pct: number }
functions: { pct: number }
branches: { pct: number }
}
}
export function CoverageBar() {
const [summary, setSummary] = useState<CoverageSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchCoverage = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/coverage/coverage-summary.json')
if (!response.ok) {
throw new Error(`Failed to fetch coverage: ${response.status}`)
}
const data: CoverageSummary = await response.json()
setSummary(data)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load coverage data'
setError(message)
console.error('Coverage fetch error:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchCoverage()
}, [])
if (loading) {
return (
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
<Activity className="w-4 h-4 animate-pulse text-blue-500" />
<span className="text-sm text-blue-600 dark:text-blue-400">Loading coverage...</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center gap-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Coverage not available
</span>
<button
onClick={fetchCoverage}
className="text-xs text-yellow-700 dark:text-yellow-300 hover:underline ml-2"
>
Retry
</button>
</div>
)
}
if (!summary) {
return null
}
const linesPct = summary.total.lines.pct
const statementsPct = summary.total.statements.pct
const functionsPct = summary.total.functions.pct
const branchesPct = summary.total.branches.pct
const getColorClass = (pct: number) => {
if (pct >= 80) return 'bg-green-500'
if (pct >= 60) return 'bg-yellow-500'
return 'bg-red-500'
}
const getTextColor = (pct: number) => {
if (pct >= 80) return 'text-green-600 dark:text-green-400'
if (pct >= 60) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
const overallPct = Math.round((linesPct + statementsPct + functionsPct + branchesPct) / 4)
return (
<div className="space-y-3">
{/* Overall Coverage */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{overallPct >= 80 ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<BarChart className="w-5 h-5 text-blue-500" />
)}
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Overall Coverage
</span>
</div>
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-6 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColorClass(overallPct)}`}
style={{ width: `${overallPct}%` }}
/>
</div>
<span className={`text-sm font-medium ${getTextColor(overallPct)} min-w-[3rem] text-right`}>
{overallPct}%
</span>
</div>
{/* Detailed Metrics */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Lines:</span>
<div className="flex items-center gap-2">
<div className="w-12 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div
className={`h-full rounded-full ${getColorClass(linesPct)}`}
style={{ width: `${linesPct}%` }}
/>
</div>
<span className={`${getTextColor(linesPct)} min-w-[2rem] text-right`}>
{linesPct.toFixed(1)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Functions:</span>
<div className="flex items-center gap-2">
<div className="w-12 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div
className={`h-full rounded-full ${getColorClass(functionsPct)}`}
style={{ width: `${functionsPct}%` }}
/>
</div>
<span className={`${getTextColor(functionsPct)} min-w-[2rem] text-right`}>
{functionsPct.toFixed(1)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Statements:</span>
<div className="flex items-center gap-2">
<div className="w-12 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div
className={`h-full rounded-full ${getColorClass(statementsPct)}`}
style={{ width: `${statementsPct}%` }}
/>
</div>
<span className={`${getTextColor(statementsPct)} min-w-[2rem] text-right`}>
{statementsPct.toFixed(1)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Branches:</span>
<div className="flex items-center gap-2">
<div className="w-12 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div
className={`h-full rounded-full ${getColorClass(branchesPct)}`}
style={{ width: `${branchesPct}%` }}
/>
</div>
<span className={`${getTextColor(branchesPct)} min-w-[2rem] text-right`}>
{branchesPct.toFixed(1)}%
</span>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => window.open('/coverage/index.html', '_blank')}
className="text-xs bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/20 dark:hover:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded transition-colors"
>
View Full Report
</button>
<button
onClick={fetchCoverage}
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded transition-colors"
>
Refresh
</button>
</div>
</div>
)
}

View File

@@ -1,337 +0,0 @@
import { useEffect, useState } from 'react'
import { X, BarChart, AlertCircle, CheckCircle, Activity, RefreshCw, ExternalLink } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
interface CoverageSummary {
total: {
lines: { pct: number; covered: number; total: number }
statements: { pct: number; covered: number; total: number }
functions: { pct: number; covered: number; total: number }
branches: { pct: number; covered: number; total: number }
}
}
interface CoverageModalProps {
isOpen: boolean
onClose: () => void
}
export function CoverageModal({ isOpen, onClose }: CoverageModalProps) {
const [summary, setSummary] = useState<CoverageSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const fetchCoverage = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/coverage/coverage-summary.json')
if (!response.ok) {
throw new Error(`Failed to fetch coverage: ${response.status}`)
}
const data: CoverageSummary = await response.json()
setSummary(data)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load coverage data'
setError(message)
console.error('Coverage fetch error:', err)
} finally {
setLoading(false)
}
}
const generateCoverage = async () => {
setGenerating(true)
setError(null)
try {
const response = await fetch('/api/generate-coverage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
if (!response.ok) {
throw new Error('Failed to generate coverage')
}
// Stream the response
const reader = response.body?.getReader()
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = new TextDecoder().decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'completed' && data.exit_code === 0) {
// Coverage generated successfully, fetch the new data
setTimeout(fetchCoverage, 1000) // Small delay to ensure files are written
}
} catch (e) {
// Ignore JSON parse errors for streaming
}
}
}
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate coverage'
setError(message)
} finally {
setGenerating(false)
}
}
useEffect(() => {
if (isOpen) {
fetchCoverage()
}
}, [isOpen])
const getColorClass = (pct: number) => {
if (pct >= 80) return 'bg-green-500'
if (pct >= 60) return 'bg-yellow-500'
return 'bg-red-500'
}
const getTextColor = (pct: number) => {
if (pct >= 80) return 'text-green-600 dark:text-green-400'
if (pct >= 60) return 'text-yellow-600 dark:text-yellow-400'
return 'text-red-600 dark:text-red-400'
}
const getBgColor = (pct: number) => {
if (pct >= 80) return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
if (pct >= 60) return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
}
if (!isOpen) return null
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<BarChart className="w-6 h-6 text-blue-500" />
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
Test Coverage Report
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 animate-pulse text-blue-500" />
<span className="text-gray-600 dark:text-gray-400">Loading coverage data...</span>
</div>
</div>
)}
{error && !summary && (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<AlertCircle className="w-12 h-12 text-yellow-500" />
<div className="text-center">
<p className="text-gray-600 dark:text-gray-400 mb-2">Coverage data not available</p>
<p className="text-sm text-gray-500 dark:text-gray-500">Run tests with coverage to generate the report</p>
</div>
<button
onClick={generateCoverage}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{generating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
<BarChart className="w-4 h-4" />
Generate Coverage
</>
)}
</button>
</div>
)}
{summary && (
<div className="space-y-6">
{/* Overall Coverage */}
<div className={`p-4 rounded-lg border ${getBgColor(summary.total.lines.pct)}`}>
<div className="flex items-center gap-3 mb-3">
{summary.total.lines.pct >= 80 ? (
<CheckCircle className="w-6 h-6 text-green-500" />
) : (
<BarChart className="w-6 h-6 text-blue-500" />
)}
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Overall Coverage
</h3>
</div>
<div className="flex items-center gap-4">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-8 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ${getColorClass(summary.total.lines.pct)}`}
style={{ width: `${summary.total.lines.pct}%` }}
/>
</div>
<span className={`text-2xl font-bold ${getTextColor(summary.total.lines.pct)}`}>
{summary.total.lines.pct.toFixed(1)}%
</span>
</div>
</div>
{/* Detailed Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Lines Coverage */}
<div className="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-700 dark:text-gray-300">Lines</h4>
<span className={`font-semibold ${getTextColor(summary.total.lines.pct)}`}>
{summary.total.lines.pct.toFixed(1)}%
</span>
</div>
<div className="mb-2 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColorClass(summary.total.lines.pct)}`}
style={{ width: `${summary.total.lines.pct}%` }}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{summary.total.lines.covered} of {summary.total.lines.total} lines covered
</p>
</div>
{/* Functions Coverage */}
<div className="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-700 dark:text-gray-300">Functions</h4>
<span className={`font-semibold ${getTextColor(summary.total.functions.pct)}`}>
{summary.total.functions.pct.toFixed(1)}%
</span>
</div>
<div className="mb-2 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColorClass(summary.total.functions.pct)}`}
style={{ width: `${summary.total.functions.pct}%` }}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{summary.total.functions.covered} of {summary.total.functions.total} functions covered
</p>
</div>
{/* Statements Coverage */}
<div className="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-700 dark:text-gray-300">Statements</h4>
<span className={`font-semibold ${getTextColor(summary.total.statements.pct)}`}>
{summary.total.statements.pct.toFixed(1)}%
</span>
</div>
<div className="mb-2 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColorClass(summary.total.statements.pct)}`}
style={{ width: `${summary.total.statements.pct}%` }}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{summary.total.statements.covered} of {summary.total.statements.total} statements covered
</p>
</div>
{/* Branches Coverage */}
<div className="bg-gray-50 dark:bg-gray-900/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-700 dark:text-gray-300">Branches</h4>
<span className={`font-semibold ${getTextColor(summary.total.branches.pct)}`}>
{summary.total.branches.pct.toFixed(1)}%
</span>
</div>
<div className="mb-2 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColorClass(summary.total.branches.pct)}`}
style={{ width: `${summary.total.branches.pct}%` }}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{summary.total.branches.covered} of {summary.total.branches.total} branches covered
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={fetchCoverage}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={generateCoverage}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors"
>
{generating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
<BarChart className="w-4 h-4" />
Regenerate
</>
)}
</button>
<button
onClick={() => window.open('/coverage/index.html', '_blank')}
className="flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
>
<ExternalLink className="w-4 h-4" />
Full Report
</button>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -1,410 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
TestTube,
CheckCircle,
XCircle,
Clock,
Activity,
TrendingUp,
RefreshCw,
BarChart,
AlertTriangle,
Target,
Zap
} from 'lucide-react';
import { CoverageVisualization, CoverageData } from './CoverageVisualization';
import { testService } from '../../services/testService';
export interface TestResults {
summary: {
total: number;
passed: number;
failed: number;
skipped: number;
duration: number;
};
suites: Array<{
name: string;
tests: number;
passed: number;
failed: number;
skipped: number;
duration: number;
failedTests?: Array<{
name: string;
error?: string;
}>;
}>;
timestamp?: string;
}
interface TestResultDashboardProps {
className?: string;
compact?: boolean;
showCoverage?: boolean;
refreshInterval?: number; // Auto-refresh interval in seconds
}
interface TestSummaryCardProps {
results: TestResults | null;
isLoading?: boolean;
}
const TestSummaryCard: React.FC<TestSummaryCardProps> = ({ results, isLoading }) => {
if (isLoading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<Activity className="w-5 h-5 animate-pulse text-blue-500" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Loading Test Results...
</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
))}
</div>
</div>
);
}
if (!results) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex flex-col items-center justify-center py-8 text-center">
<TestTube className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<h3 className="text-lg font-medium text-gray-500 dark:text-gray-400 mb-2">
No Test Results Available
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500">
Run tests to see detailed results and metrics
</p>
</div>
</div>
);
}
const { summary } = results;
const successRate = summary.total > 0 ? (summary.passed / summary.total) * 100 : 0;
const getHealthStatus = () => {
if (summary.failed === 0 && summary.passed > 0) return { text: 'All Tests Passing', color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' };
if (successRate >= 80) return { text: 'Mostly Passing', color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' };
return { text: 'Tests Failing', color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' };
};
const health = getHealthStatus();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow duration-200"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<TestTube className="w-6 h-6 text-blue-500" />
<h3 className="text-xl font-semibold text-gray-800 dark:text-white">
Test Summary
</h3>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${health.bg} ${health.color}`}>
{health.text}
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1 }}
className="text-center p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="text-2xl font-bold text-gray-800 dark:text-white mb-1">
{summary.total}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Tests</div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800"
>
<div className="text-2xl font-bold text-green-600 dark:text-green-400 mb-1 flex items-center justify-center gap-1">
<CheckCircle className="w-5 h-5" />
{summary.passed}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Passed</div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
>
<div className="text-2xl font-bold text-red-600 dark:text-red-400 mb-1 flex items-center justify-center gap-1">
<XCircle className="w-5 h-5" />
{summary.failed}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Failed</div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4 }}
className="text-center p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800"
>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400 mb-1 flex items-center justify-center gap-1">
<Clock className="w-5 h-5" />
{summary.skipped}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Skipped</div>
</motion.div>
</div>
{/* Success Rate Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Success Rate</span>
<span className="text-sm text-gray-600 dark:text-gray-400">{successRate.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${successRate}%` }}
transition={{ duration: 1, ease: "easeOut" }}
className={`h-3 rounded-full ${
successRate >= 90 ? 'bg-green-500' :
successRate >= 70 ? 'bg-yellow-500' : 'bg-red-500'
}`}
/>
</div>
</div>
{/* Additional Stats */}
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4" />
<span>Duration: {(summary.duration / 1000).toFixed(2)}s</span>
</div>
{results.timestamp && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Last run: {new Date(results.timestamp).toLocaleTimeString()}</span>
</div>
)}
</div>
{/* Failed Tests Alert */}
{summary.failed > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">
{summary.failed} test{summary.failed > 1 ? 's' : ''} failing - review errors below
</span>
</div>
</motion.div>
)}
</motion.div>
);
};
const FailedTestsList: React.FC<{ results: TestResults }> = ({ results }) => {
const failedSuites = results.suites.filter(suite => suite.failed > 0);
if (failedSuites.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-red-200 dark:border-red-800 shadow-sm"
>
<div className="flex items-center gap-3 mb-4">
<XCircle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Failed Tests ({results.summary.failed})
</h3>
</div>
<div className="space-y-4 max-h-96 overflow-y-auto">
{failedSuites.map((suite, suiteIndex) => (
<div key={suiteIndex} className="border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">
{suite.name.split('/').pop()}
</span>
<span className="text-xs text-red-600 dark:text-red-400">
{suite.failed} failed
</span>
</div>
</div>
{suite.failedTests && (
<div className="p-3 space-y-2">
{suite.failedTests.map((test, testIndex) => (
<div key={testIndex} className="pl-3 border-l-2 border-red-200 dark:border-red-800">
<div className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{test.name}
</div>
{test.error && (
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto">
{test.error.length > 300 ? `${test.error.substring(0, 300)}...` : test.error}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</motion.div>
);
};
export const TestResultDashboard: React.FC<TestResultDashboardProps> = ({
className = '',
compact = false,
showCoverage = true,
refreshInterval
}) => {
const [results, setResults] = useState<TestResults | null>(null);
const [coverage, setCoverage] = useState<CoverageData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const loadTestData = async () => {
setLoading(true);
setError(null);
try {
// Load test results and coverage data
const [testResults, coverageData] = await Promise.allSettled([
testService.getTestResults(),
showCoverage ? testService.getCoverageData() : Promise.resolve(null)
]);
if (testResults.status === 'fulfilled') {
setResults(testResults.value);
} else {
console.warn('Failed to load test results:', testResults.reason);
}
if (coverageData.status === 'fulfilled' && coverageData.value) {
setCoverage(coverageData.value);
} else if (showCoverage) {
console.warn('Failed to load coverage data:', coverageData.status === 'rejected' ? coverageData.reason : 'No data');
}
setLastRefresh(new Date());
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load test data';
setError(message);
console.error('Test data loading error:', err);
} finally {
setLoading(false);
}
};
// Initial load
useEffect(() => {
loadTestData();
}, [showCoverage]);
// Auto-refresh
useEffect(() => {
if (!refreshInterval) return;
const interval = setInterval(loadTestData, refreshInterval * 1000);
return () => clearInterval(interval);
}, [refreshInterval, showCoverage]);
return (
<div className={`space-y-6 ${className}`}>
{/* Header with refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Target className="w-6 h-6 text-blue-500" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
Test Results Dashboard
</h2>
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Last updated: {lastRefresh.toLocaleTimeString()}
</span>
)}
<button
onClick={loadTestData}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Error state */}
{error && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
>
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertTriangle className="w-5 h-5" />
<span className="font-medium">Failed to load test data: {error}</span>
</div>
</motion.div>
)}
{/* Main content */}
<div className={`grid gap-6 ${compact ? 'grid-cols-1' : 'grid-cols-1 xl:grid-cols-2'}`}>
{/* Test Summary */}
<div>
<TestSummaryCard results={results} isLoading={loading && !results} />
</div>
{/* Coverage Visualization */}
{showCoverage && (
<div>
<CoverageVisualization
coverage={coverage}
compact={compact}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-6"
/>
</div>
)}
</div>
{/* Failed Tests */}
{results && results.summary.failed > 0 && (
<FailedTestsList results={results} />
)}
</div>
);
};
export default TestResultDashboard;

View File

@@ -1,437 +0,0 @@
import { useEffect, useState } from 'react'
import { X, BarChart, AlertCircle, CheckCircle, XCircle, Activity, RefreshCw, ExternalLink, TestTube, Target, ChevronDown } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { CoverageVisualization, CoverageData } from './CoverageVisualization'
interface TestResults {
summary: {
total: number
passed: number
failed: number
skipped: number
duration: number
}
suites: Array<{
name: string
tests: number
passed: number
failed: number
skipped: number
duration: number
failedTests?: Array<{
name: string
error?: string
}>
}>
}
// Using CoverageData from CoverageVisualization component instead
type CoverageSummary = CoverageData
interface TestResultsModalProps {
isOpen: boolean
onClose: () => void
}
export function TestResultsModal({ isOpen, onClose }: TestResultsModalProps) {
const [testResults, setTestResults] = useState<TestResults | null>(null)
const [coverage, setCoverage] = useState<CoverageSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedSuites, setExpandedSuites] = useState<Set<number>>(new Set())
const fetchResults = async () => {
setLoading(true)
setError(null)
console.log('[TEST RESULTS MODAL] Fetching test results...')
// Add retry logic for file reading
const fetchWithRetry = async (url: string, retries = 3): Promise<Response | null> => {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url)
if (response.ok) {
const text = await response.text()
if (text && text.trim().length > 0) {
return response
}
}
// Wait a bit before retrying
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, 500))
}
} catch (err) {
console.log(`[TEST RESULTS MODAL] Attempt ${i + 1} failed for ${url}:`, err)
}
}
return null
}
try {
// Fetch test results JSON with retry
const testResponse = await fetchWithRetry('/test-results/test-results.json')
console.log('[TEST RESULTS MODAL] Test results response:', testResponse?.status, testResponse?.statusText)
if (testResponse && testResponse.ok) {
try {
const testData = await testResponse.json()
console.log('[TEST RESULTS MODAL] Test data loaded:', testData)
// Parse vitest results format - handle both full format and simplified format
const results: TestResults = {
summary: {
total: testData.numTotalTests || 0,
passed: testData.numPassedTests || 0,
failed: testData.numFailedTests || 0,
skipped: testData.numSkippedTests || testData.numPendingTests || 0,
duration: testData.testResults?.reduce((acc: number, suite: any) => {
const duration = suite.perfStats ?
(suite.perfStats.end - suite.perfStats.start) :
(suite.endTime - suite.startTime) || 0
return acc + duration
}, 0) || 0
},
suites: testData.testResults?.map((suite: any) => {
const suiteName = suite.name?.replace(process.cwd(), '') ||
suite.displayName ||
suite.testFilePath ||
'Unknown'
return {
name: suiteName,
tests: suite.numTotalTests || suite.assertionResults?.length || 0,
passed: suite.numPassedTests || suite.assertionResults?.filter((t: any) => t.status === 'passed').length || 0,
failed: suite.numFailedTests || suite.assertionResults?.filter((t: any) => t.status === 'failed').length || 0,
skipped: suite.numSkippedTests || suite.numPendingTests || suite.assertionResults?.filter((t: any) => t.status === 'skipped' || t.status === 'pending').length || 0,
duration: suite.perfStats ?
(suite.perfStats.end - suite.perfStats.start) :
(suite.endTime - suite.startTime) || 0,
failedTests: (suite.assertionResults || suite.testResults)?.filter((test: any) => test.status === 'failed')
.map((test: any) => ({
name: test.title || test.fullTitle || test.ancestorTitles?.join(' > ') || 'Unknown test',
error: test.failureMessages?.[0] || test.error?.message || test.message || 'No error message'
})) || []
}
}) || []
}
setTestResults(results)
} catch (parseError) {
console.error('[TEST RESULTS MODAL] JSON parse error:', parseError)
// Don't throw, just log and continue to coverage
}
}
// Fetch coverage data with retry
const coverageResponse = await fetchWithRetry('/test-results/coverage/coverage-summary.json')
console.log('[TEST RESULTS MODAL] Coverage response:', coverageResponse?.status, coverageResponse?.statusText)
if (coverageResponse && coverageResponse.ok) {
try {
const coverageData = await coverageResponse.json()
console.log('[TEST RESULTS MODAL] Coverage data loaded:', coverageData)
setCoverage(coverageData)
} catch (parseError) {
console.error('[TEST RESULTS MODAL] Coverage parse error:', parseError)
}
}
if (!testResponse && !coverageResponse) {
console.log('[TEST RESULTS MODAL] No data available - both requests failed')
throw new Error('No test results or coverage data available. Please run tests first.')
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load test results'
setError(message)
console.error('Test results fetch error:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
// Add a longer delay to ensure files are fully written
const timer = setTimeout(() => {
fetchResults()
}, 1000)
return () => clearTimeout(timer)
}
}, [isOpen])
const getHealthScore = () => {
if (!testResults || !coverage) return 0
const testScore = testResults.summary.total > 0
? (testResults.summary.passed / testResults.summary.total) * 100
: 0
const coverageScore = coverage.total.lines.pct
return Math.round((testScore + coverageScore) / 2)
}
const getHealthColor = (score: number) => {
if (score >= 80) return 'text-green-500'
if (score >= 60) return 'text-yellow-500'
return 'text-red-500'
}
const getHealthBg = (score: number) => {
if (score >= 80) return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
if (score >= 60) return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
}
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
if (!isOpen) return null
const healthScore = getHealthScore()
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<TestTube className="w-6 h-6 text-blue-500" />
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
Test Results Report
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{loading && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 animate-pulse text-blue-500" />
<span className="text-gray-600 dark:text-gray-400">Loading test results...</span>
</div>
</div>
)}
{error && !testResults && !coverage && (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<AlertCircle className="w-12 h-12 text-yellow-500" />
<div className="text-center">
<p className="text-gray-600 dark:text-gray-400 mb-2">No test results available</p>
<p className="text-sm text-gray-500 dark:text-gray-500">Run tests to generate the report</p>
</div>
</div>
)}
{(testResults || coverage) && (
<div className="space-y-6">
{/* Health Score */}
<div className={`p-6 rounded-lg border ${getHealthBg(healthScore)}`}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Target className={`w-8 h-8 ${getHealthColor(healthScore)}`} />
<div>
<h3 className="text-xl font-bold text-gray-800 dark:text-white">
Test Health Score
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Overall test and coverage quality
</p>
</div>
</div>
<div className="ml-auto text-right">
<div className={`text-4xl font-bold ${getHealthColor(healthScore)}`}>
{healthScore}%
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{healthScore >= 80 ? 'Excellent' : healthScore >= 60 ? 'Good' : 'Needs Work'}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Test Results Summary */}
{testResults && (
<div className="bg-gray-50 dark:bg-gray-900/50 p-6 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-4">
<TestTube className="w-5 h-5 text-blue-500" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Test Summary
</h3>
</div>
{/* Overall Stats */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{testResults.summary.passed}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Passed</div>
</div>
<div className="text-center p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{testResults.summary.failed}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Failed</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="text-lg font-semibold text-gray-700 dark:text-gray-300">
{testResults.summary.total}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Tests</div>
</div>
<div className="text-center p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="text-lg font-semibold text-gray-700 dark:text-gray-300">
{formatDuration(testResults.summary.duration)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Duration</div>
</div>
</div>
{/* Test Suites */}
<div className="space-y-2 max-h-60 overflow-y-auto">
{testResults.suites.map((suite, index) => (
<div key={index} className="bg-white dark:bg-gray-800 rounded border">
<div
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => {
if (suite.failed > 0) {
const newExpanded = new Set(expandedSuites)
if (newExpanded.has(index)) {
newExpanded.delete(index)
} else {
newExpanded.add(index)
}
setExpandedSuites(newExpanded)
}
}}
>
<div className="flex items-center gap-2">
{suite.failed > 0 ? (
<XCircle className="w-4 h-4 text-red-500" />
) : (
<CheckCircle className="w-4 h-4 text-green-500" />
)}
<span className="font-mono text-xs truncate max-w-[200px]" title={suite.name}>
{suite.name.split('/').pop()}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-green-600">{suite.passed}</span>
<span className="text-gray-400">/</span>
<span className="text-red-600">{suite.failed}</span>
<span className="text-gray-500">({formatDuration(suite.duration)})</span>
{suite.failed > 0 && (
<motion.div
animate={{ rotate: expandedSuites.has(index) ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-3 h-3 text-gray-400" />
</motion.div>
)}
</div>
</div>
{/* Expandable failed tests */}
<AnimatePresence>
{expandedSuites.has(index) && suite.failedTests && suite.failedTests.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div className="p-2 space-y-2 bg-red-50 dark:bg-red-900/10">
{suite.failedTests.map((test, testIndex) => (
<div key={testIndex} className="space-y-1">
<div className="flex items-start gap-2">
<XCircle className="w-3 h-3 text-red-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-red-700 dark:text-red-400">
{test.name}
</p>
{test.error && (
<pre className="mt-1 text-xs text-red-600 dark:text-red-500 whitespace-pre-wrap font-mono bg-red-100 dark:bg-red-900/20 p-2 rounded">
{test.error}
</pre>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
)}
{/* Coverage Visualization */}
{coverage && (
<div className="bg-gray-50 dark:bg-gray-900/50 p-6 rounded-lg border border-gray-200 dark:border-gray-700">
<CoverageVisualization
coverage={coverage}
compact={true}
showFileBreakdown={false}
/>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={fetchResults}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => window.open('/api/coverage/pytest/html/index.html', '_blank')}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
<ExternalLink className="w-4 h-4" />
Detailed Report
</button>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -43,22 +43,6 @@ export function getApiBasePath(): string {
return `${apiUrl}/api`;
}
// Get WebSocket URL for real-time connections
export function getWebSocketUrl(): string {
const apiUrl = getApiUrl();
// If using relative URLs, construct from current location
if (!apiUrl) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}`;
}
// Convert http/https to ws/wss
return apiUrl.replace(/^http/, 'ws');
}
// Export commonly used values
export const API_BASE_URL = '/api'; // Always use relative URL for API calls
export const API_FULL_URL = getApiUrl();
export const WS_URL = getWebSocketUrl();

View File

@@ -27,7 +27,8 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'info', duration = 4000) => {
const id = Date.now().toString();
// Use timestamp + random number to prevent duplicate keys
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newToast: Toast = { id, message, type, duration };
setToasts(prev => [...prev, newToast]);

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

Some files were not shown because too many files have changed in this diff Show More