Compare commits

...

17 Commits

Author SHA1 Message Date
Auto
910ca34eac Add Aurora Theme 2026-01-26 18:50:02 +02:00
Auto
9aae6769c9 Retro Arcade theme 2026-01-26 18:45:58 +02:00
Auto
c402736b92 feat(ui): add theme switching system with Twitter, Claude, and Neo Brutalism themes
Add a comprehensive theme system allowing users to switch between three
distinct visual themes, each supporting both light and dark modes:

- Twitter (default): Clean blue design with soft shadows
- Claude: Warm beige/cream tones with orange primary accents
- Neo Brutalism: Bold colors, hard shadows, 0px border radius

New files:
- ui/src/hooks/useTheme.ts: Theme state management hook with localStorage
  persistence for both theme selection and dark mode preference
- ui/src/components/ThemeSelector.tsx: Header dropdown with hover preview
  and color swatches for quick theme switching

Modified files:
- ui/src/styles/globals.css: Added CSS custom properties for Claude and
  Neo Brutalism themes with light/dark variants, shadow variables
  integrated into @theme inline block
- ui/src/App.tsx: Integrated useTheme hook and ThemeSelector component
- ui/src/components/SettingsModal.tsx: Added theme selection UI with
  preview swatches and dark mode toggle
- ui/index.html: Added DM Sans and Space Mono fonts for Neo Brutalism

Features:
- Independent theme and dark mode controls
- Smooth CSS transitions when switching themes
- Theme-specific shadow styles (soft vs hard)
- Theme-specific fonts and border radius
- Persisted preferences in localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:40:24 +02:00
Auto
c917582a64 refactor(ui): migrate to shadcn/ui components and fix scroll issues
Migrate UI component library from custom implementations to shadcn/ui:
- Add shadcn/ui primitives (Button, Card, Dialog, Input, etc.)
- Replace custom styles with Tailwind CSS v4 theme configuration
- Remove custom-theme.css in favor of globals.css with @theme directive

Fix scroll overflow issues in multiple components:
- ProjectSelector: "New Project" button no longer overlays project list
- FolderBrowser: folder list now scrolls properly within modal
- AgentCard: log modal content stays within bounds
- ConversationHistory: conversation list scrolls correctly
- KanbanColumn: feature cards scroll within fixed height
- ScheduleModal: schedule form content scrolls properly

Key technical changes:
- Replace ScrollArea component with native overflow-y-auto divs
- Add min-h-0 to flex containers to allow proper shrinking
- Restructure dropdown layouts with flex-col for fixed footers

New files:
- ui/components.json (shadcn/ui configuration)
- ui/src/components/ui/* (20 UI primitive components)
- ui/src/lib/utils.ts (cn utility for class merging)
- ui/tsconfig.app.json (app-specific TypeScript config)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:25:55 +02:00
Auto
e45b5b064e chore: remove unused import in test_security.py
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:34:48 +02:00
Auto
dd0a34a138 fix: address PR #93 review issues
- Remove translate-x/translate-y CSS selectors that broke layout utilities
  (AssistantPanel slide animation, DebugLogViewer resize handle)
- Add browser validation to get_playwright_browser() with warning for
  invalid values (matches get_playwright_headless() behavior)
- Remove phantom SQLite documentation from CUSTOM_UPDATES.md that
  described features not present in PR #93
- Update checklist and revert instructions to match actual changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:30:59 +02:00
Leon van Zyl
b6c7f05cee Merge pull request #93 from nioasoft/feat/twitter-ui-theme
feat: Twitter-style UI theme with custom theme override system
2026-01-26 16:29:03 +02:00
Leon van Zyl
ccfd1aa73e Merge pull request #97 from cabana8471-arch/fix/pydantic-datetime-serialization
fix: Pydantic datetime serialization for API endpoints
2026-01-26 16:07:03 +02:00
Leon van Zyl
d5e423b805 Merge pull request #98 from cabana8471-arch/fix/skip-priority-consistency
fix: use consistent priority increment when skipping features
2026-01-26 15:59:00 +02:00
Leon van Zyl
099577360e Merge pull request #99 from cabana8471-arch/fix/auto-stop-on-completion
fix: stop spawning testing agents after project completion
2026-01-26 15:53:06 +02:00
cabana8471
d6ba075ac4 style: align priority calculation pattern with rest of file
Address CodeRabbit feedback - use consistent conditional pattern:
`(max_priority.priority + 1) if max_priority else 1`

This matches the pattern used in create_feature and create_features_bulk.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:36:54 +01:00
cabana8471
33e9f7b4d0 fix: stop spawning testing agents after project completion (#66)
When all features pass, the orchestrator continued spawning testing
agents for 10+ minutes, wasting tokens on unnecessary regression
tests. Added a check for get_all_complete() to prevent this.

Fixes: leonvanzyl/autocoder#66

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:07:53 +01:00
cabana8471
6731ef44ea fix: use consistent priority increment when skipping features (#65)
The REST API skip endpoint was using max_priority + 1000, while the
MCP server used max_priority + 1. This caused priority inflation where
values could reach 10,000+ after multiple skips.

Changed to use + 1 for consistency with mcp_server/feature_mcp.py:345.

Fixes: leonvanzyl/autocoder#65

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:07:36 +01:00
nioasoft
84843459b4 fix: add keyboard accessibility and improve env var validation
Add focus-visible styles for keyboard navigation accessibility and
improve PLAYWRIGHT_HEADLESS environment variable validation to warn
users about invalid values instead of silently defaulting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 09:36:48 +02:00
cabana8471
43c37c52fe fix: Pydantic datetime serialization for API endpoints
Problem:
Several API endpoints return 500 Internal Server Error because datetime
objects are not serializable by Pydantic. The error occurs when:
- GET /agent/{project}/status
- GET /devserver/{project}/status
- GET /schedules/{project}/next

Root cause:
Pydantic models expect strings for Optional datetime fields, but the code
was passing raw datetime objects.

Solution:
Convert datetime objects to ISO 8601 strings using .isoformat() before
returning in Pydantic response models.

Changes:
- server/routers/agent.py: Fix started_at serialization
- server/routers/devserver.py: Fix started_at serialization
- server/routers/schedules.py: Fix next_start/next_end serialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 08:04:14 +01:00
nioasoft
813bb900fd feat: Twitter-style UI theme + Playwright optimization + documentation
UI Changes:
- Replace neobrutalism with clean Twitter/Supabase-style design
- Remove all shadows, use thin borders (1px)
- Single accent color (Twitter blue) for all status indicators
- Rounded corners (1.3rem base)
- Fix dark mode contrast and visibility
- Make KanbanColumn themeable via CSS classes

Backend Changes:
- Default Playwright browser changed to Firefox (lower CPU)
- Default Playwright mode changed to headless (saves resources)
- Add PLAYWRIGHT_BROWSER env var support

Documentation:
- Add CUSTOM_UPDATES.md with all customizations documented
- Update .env.example with new Playwright options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:47:47 +02:00
nioasoft
8bc4b25511 feat(ui): add custom theme override system
Create custom-theme.css for theme overrides that won't conflict
with upstream updates. The file loads after globals.css, so its
CSS variables take precedence.

This approach ensures:
- Zero merge conflicts on git pull (new file, not in upstream)
- Theme persists across upstream updates
- Easy to modify without touching upstream code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:47:47 +02:00
80 changed files with 6076 additions and 3887 deletions

View File

@@ -1,12 +1,19 @@
# Optional: N8N webhook for progress notifications # Optional: N8N webhook for progress notifications
# PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/... # PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/...
# Playwright Browser Mode # Playwright Browser Configuration
# Controls whether Playwright runs Chrome in headless mode (no visible browser window). #
# - true: Browser runs in background, invisible (recommended for using PC while agent works) # PLAYWRIGHT_BROWSER: Which browser to use for testing
# - firefox: Lower CPU usage, recommended (default)
# - chrome: Google Chrome
# - webkit: Safari engine
# - msedge: Microsoft Edge
# PLAYWRIGHT_BROWSER=firefox
#
# PLAYWRIGHT_HEADLESS: Run browser without visible window
# - true: Browser runs in background, saves CPU (default)
# - false: Browser opens a visible window (useful for debugging) # - false: Browser opens a visible window (useful for debugging)
# Defaults to 'false' if not specified # PLAYWRIGHT_HEADLESS=true
# PLAYWRIGHT_HEADLESS=false
# GLM/Alternative API Configuration (Optional) # GLM/Alternative API Configuration (Optional)
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables. # To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.

228
CUSTOM_UPDATES.md Normal file
View File

@@ -0,0 +1,228 @@
# Custom Updates - AutoCoder
This document tracks all customizations made to AutoCoder that deviate from the upstream repository. Reference this file before any updates to preserve these changes.
---
## Table of Contents
1. [UI Theme Customization](#1-ui-theme-customization)
2. [Playwright Browser Configuration](#2-playwright-browser-configuration)
3. [Update Checklist](#update-checklist)
---
## 1. UI Theme Customization
### Overview
The UI has been customized from the default **neobrutalism** style to a clean **Twitter/Supabase-style** design.
**Design Changes:**
- No shadows
- Thin borders (1px)
- Rounded corners (1.3rem base)
- Blue accent color (Twitter blue)
- Clean typography (Open Sans)
### Modified Files
#### `ui/src/styles/custom-theme.css`
**Purpose:** Main theme override file that replaces neo design with clean Twitter style.
**Key Changes:**
- All `--shadow-neo-*` variables set to `none`
- All status colors (`pending`, `progress`, `done`) use Twitter blue
- Rounded corners: `--radius-neo-lg: 1.3rem`
- Font: Open Sans
- Removed all transform effects on hover
- Dark mode with proper contrast
**CSS Variables (Light Mode):**
```css
--color-neo-accent: oklch(0.6723 0.1606 244.9955); /* Twitter blue */
--color-neo-pending: oklch(0.6723 0.1606 244.9955);
--color-neo-progress: oklch(0.6723 0.1606 244.9955);
--color-neo-done: oklch(0.6723 0.1606 244.9955);
```
**CSS Variables (Dark Mode):**
```css
--color-neo-bg: oklch(0.08 0 0);
--color-neo-card: oklch(0.16 0.005 250);
--color-neo-border: oklch(0.30 0 0);
```
**How to preserve:** This file should NOT be overwritten. It loads after `globals.css` and overrides it.
---
#### `ui/src/components/KanbanColumn.tsx`
**Purpose:** Modified to support themeable kanban columns without inline styles.
**Changes:**
1. **colorMap changed from inline colors to CSS classes:**
```tsx
// BEFORE (original):
const colorMap = {
pending: 'var(--color-neo-pending)',
progress: 'var(--color-neo-progress)',
done: 'var(--color-neo-done)',
}
// AFTER (customized):
const colorMap = {
pending: 'kanban-header-pending',
progress: 'kanban-header-progress',
done: 'kanban-header-done',
}
```
2. **Column div uses CSS class instead of inline style:**
```tsx
// BEFORE:
<div className="neo-card overflow-hidden" style={{ borderColor: colorMap[color] }}>
// AFTER:
<div className={`neo-card overflow-hidden kanban-column ${colorMap[color]}`}>
```
3. **Header div simplified (removed duplicate color class):**
```tsx
// BEFORE:
<div className={`... ${colorMap[color]}`} style={{ backgroundColor: colorMap[color] }}>
// AFTER:
<div className="kanban-header px-4 py-3 border-b border-[var(--color-neo-border)]">
```
4. **Title text color:**
```tsx
// BEFORE:
text-[var(--color-neo-text-on-bright)]
// AFTER:
text-[var(--color-neo-text)]
```
---
## 2. Playwright Browser Configuration
### Overview
Changed default Playwright settings for better performance:
- **Default browser:** Firefox (lower CPU usage)
- **Default mode:** Headless (saves resources)
### Modified Files
#### `client.py`
**Changes:**
```python
# BEFORE:
DEFAULT_PLAYWRIGHT_HEADLESS = False
# AFTER:
DEFAULT_PLAYWRIGHT_HEADLESS = True
DEFAULT_PLAYWRIGHT_BROWSER = "firefox"
```
**New function added:**
```python
def get_playwright_browser() -> str:
"""
Get the browser to use for Playwright.
Options: chrome, firefox, webkit, msedge
Firefox is recommended for lower CPU usage.
"""
return os.getenv("PLAYWRIGHT_BROWSER", DEFAULT_PLAYWRIGHT_BROWSER).lower()
```
**Playwright args updated:**
```python
playwright_args = [
"@playwright/mcp@latest",
"--viewport-size", "1280x720",
"--browser", browser, # NEW: configurable browser
]
```
---
#### `.env.example`
**Updated documentation:**
```bash
# PLAYWRIGHT_BROWSER: Which browser to use for testing
# - firefox: Lower CPU usage, recommended (default)
# - chrome: Google Chrome
# - webkit: Safari engine
# - msedge: Microsoft Edge
# PLAYWRIGHT_BROWSER=firefox
# PLAYWRIGHT_HEADLESS: Run browser without visible window
# - true: Browser runs in background, saves CPU (default)
# - false: Browser opens a visible window (useful for debugging)
# PLAYWRIGHT_HEADLESS=true
```
---
## 3. Update Checklist
When updating AutoCoder from upstream, verify these items:
### UI Changes
- [ ] `ui/src/styles/custom-theme.css` is preserved
- [ ] `ui/src/components/KanbanColumn.tsx` changes are preserved
- [ ] Run `npm run build` in `ui/` directory
- [ ] Test both light and dark modes
### Backend Changes
- [ ] `client.py` - Playwright browser/headless defaults preserved
- [ ] `.env.example` - Documentation updates preserved
### General
- [ ] Verify Playwright uses Firefox by default
- [ ] Check that browser runs headless by default
---
## Reverting to Defaults
### UI Only
```bash
rm ui/src/styles/custom-theme.css
git checkout ui/src/components/KanbanColumn.tsx
cd ui && npm run build
```
### Backend Only
```bash
git checkout client.py .env.example
```
---
## Files Summary
| File | Type | Change Description |
|------|------|-------------------|
| `ui/src/styles/custom-theme.css` | UI | Twitter-style theme |
| `ui/src/components/KanbanColumn.tsx` | UI | Themeable kanban columns |
| `ui/src/main.tsx` | UI | Imports custom theme |
| `client.py` | Backend | Firefox + headless defaults |
| `.env.example` | Config | Updated documentation |
---
## Last Updated
**Date:** January 2026
**PR:** #93 - Twitter-style UI theme with custom theme override system

View File

@@ -21,9 +21,14 @@ from security import bash_security_hook
load_dotenv() load_dotenv()
# Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var # Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var
# When True, browser runs invisibly in background # When True, browser runs invisibly in background (default - saves CPU)
# When False, browser window is visible (default - useful for monitoring agent progress) # When False, browser window is visible (useful for monitoring agent progress)
DEFAULT_PLAYWRIGHT_HEADLESS = False DEFAULT_PLAYWRIGHT_HEADLESS = True
# Default browser for Playwright - can be overridden via PLAYWRIGHT_BROWSER env var
# Options: chrome, firefox, webkit, msedge
# Firefox is recommended for lower CPU usage
DEFAULT_PLAYWRIGHT_BROWSER = "firefox"
# Environment variables to pass through to Claude CLI for API configuration # Environment variables to pass through to Claude CLI for API configuration
# These allow using alternative API endpoints (e.g., GLM via z.ai) without # These allow using alternative API endpoints (e.g., GLM via z.ai) without
@@ -42,12 +47,37 @@ def get_playwright_headless() -> bool:
""" """
Get the Playwright headless mode setting. Get the Playwright headless mode setting.
Reads from PLAYWRIGHT_HEADLESS environment variable, defaults to False. Reads from PLAYWRIGHT_HEADLESS environment variable, defaults to True.
Returns True for headless mode (invisible browser), False for visible browser. Returns True for headless mode (invisible browser), False for visible browser.
""" """
value = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower() value = os.getenv("PLAYWRIGHT_HEADLESS", str(DEFAULT_PLAYWRIGHT_HEADLESS).lower()).strip().lower()
# Accept various truthy/falsy values truthy = {"true", "1", "yes", "on"}
return value in ("true", "1", "yes", "on") falsy = {"false", "0", "no", "off"}
if value not in truthy | falsy:
print(f" - Warning: Invalid PLAYWRIGHT_HEADLESS='{value}', defaulting to {DEFAULT_PLAYWRIGHT_HEADLESS}")
return DEFAULT_PLAYWRIGHT_HEADLESS
return value in truthy
# Valid browsers supported by Playwright MCP
VALID_PLAYWRIGHT_BROWSERS = {"chrome", "firefox", "webkit", "msedge"}
def get_playwright_browser() -> str:
"""
Get the browser to use for Playwright.
Reads from PLAYWRIGHT_BROWSER environment variable, defaults to firefox.
Options: chrome, firefox, webkit, msedge
Firefox is recommended for lower CPU usage.
"""
value = os.getenv("PLAYWRIGHT_BROWSER", DEFAULT_PLAYWRIGHT_BROWSER).strip().lower()
if value not in VALID_PLAYWRIGHT_BROWSERS:
print(f" - Warning: Invalid PLAYWRIGHT_BROWSER='{value}', "
f"valid options: {', '.join(sorted(VALID_PLAYWRIGHT_BROWSERS))}. "
f"Defaulting to {DEFAULT_PLAYWRIGHT_BROWSER}")
return DEFAULT_PLAYWRIGHT_BROWSER
return value
# Feature MCP tools for feature/test management # Feature MCP tools for feature/test management
@@ -228,10 +258,16 @@ def create_client(
} }
if not yolo_mode: if not yolo_mode:
# Include Playwright MCP server for browser automation (standard mode only) # Include Playwright MCP server for browser automation (standard mode only)
# Headless mode is configurable via PLAYWRIGHT_HEADLESS environment variable # Browser and headless mode configurable via environment variables
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"] browser = get_playwright_browser()
playwright_args = [
"@playwright/mcp@latest",
"--viewport-size", "1280x720",
"--browser", browser,
]
if get_playwright_headless(): if get_playwright_headless():
playwright_args.append("--headless") playwright_args.append("--headless")
print(f" - Browser: {browser} (headless={get_playwright_headless()})")
# Browser isolation for parallel execution # Browser isolation for parallel execution
# Each agent gets its own isolated browser context to prevent tab conflicts # Each agent gets its own isolated browser context to prevent tab conflicts

View File

@@ -401,6 +401,10 @@ class ParallelOrchestrator:
if passing_count == 0: if passing_count == 0:
return return
# Don't spawn testing agents if all features are already complete
if self.get_all_complete():
return
# Spawn testing agents one at a time, re-checking limits each time # Spawn testing agents one at a time, re-checking limits each time
# This avoids TOCTOU race by holding lock during the decision # This avoids TOCTOU race by holding lock during the decision
while True: while True:

View File

@@ -93,7 +93,7 @@ async def get_agent_status(project_name: str):
return AgentStatus( return AgentStatus(
status=manager.status, status=manager.status,
pid=manager.pid, pid=manager.pid,
started_at=manager.started_at, started_at=manager.started_at.isoformat() if manager.started_at else None,
yolo_mode=manager.yolo_mode, yolo_mode=manager.yolo_mode,
model=manager.model, model=manager.model,
parallel_mode=manager.parallel_mode, parallel_mode=manager.parallel_mode,

View File

@@ -129,7 +129,7 @@ async def get_devserver_status(project_name: str) -> DevServerStatus:
pid=manager.pid, pid=manager.pid,
url=manager.detected_url, url=manager.detected_url,
command=manager._command, command=manager._command,
started_at=manager.started_at, started_at=manager.started_at.isoformat() if manager.started_at else None,
) )

View File

@@ -551,9 +551,9 @@ async def skip_feature(project_name: str, feature_id: int):
if not feature: if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
# Set priority to max + 1000 to push to end # Set priority to max + 1 to push to end (consistent with MCP server)
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first() max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
feature.priority = (max_priority.priority if max_priority else 0) + 1000 feature.priority = (max_priority.priority + 1) if max_priority else 1
session.commit() session.commit()

View File

@@ -256,8 +256,8 @@ async def get_next_scheduled_run(project_name: str):
return NextRunResponse( return NextRunResponse(
has_schedules=True, has_schedules=True,
next_start=next_start if active_count == 0 else None, next_start=next_start.isoformat() if (active_count == 0 and next_start) else None,
next_end=latest_end, next_end=latest_end.isoformat() if latest_end else None,
is_currently_running=active_count > 0, is_currently_running=active_count > 0,
active_schedule_count=active_count, active_schedule_count=active_count,
) )

View File

@@ -15,7 +15,6 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from security import ( from security import (
DEFAULT_PKILL_PROCESSES,
bash_security_hook, bash_security_hook,
extract_commands, extract_commands,
get_effective_commands, get_effective_commands,

22
ui/components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -7,7 +7,7 @@
<title>AutoCoder</title> <title>AutoCoder</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;700&family=Space+Mono:wght@400;700&family=Outfit:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

1025
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,37 +12,52 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-query": "^5.60.0", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.72.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"lucide-react": "^0.460.0", "lucide-react": "^0.475.0",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1" "react-dom": "^19.0.0",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.19.0",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.0.0-beta.4", "@tailwindcss/vite": "^4.1.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/react": "^18.3.12", "@types/node": "^22.12.0",
"@types/react-dom": "^18.3.1", "@types/react": "^19.0.0",
"@vitejs/plugin-react": "^4.3.3", "@types/react-dom": "^19.0.0",
"eslint": "^9.13.0", "@vitejs/plugin-react": "^4.4.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint": "^9.19.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.11.0", "eslint-plugin-react-refresh": "^0.4.19",
"tailwindcss": "^4.0.0-beta.4", "globals": "^15.14.0",
"typescript": "~5.6.2", "tailwindcss": "^4.1.0",
"typescript-eslint": "^8.11.0", "tw-animate-css": "^1.4.0",
"vite": "^5.4.10" "typescript": "~5.7.3",
"typescript-eslint": "^8.23.0",
"vite": "^7.3.0"
} }
} }

View File

@@ -4,6 +4,7 @@ import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/u
import { useProjectWebSocket } from './hooks/useWebSocket' import { useProjectWebSocket } from './hooks/useWebSocket'
import { useFeatureSound } from './hooks/useFeatureSound' import { useFeatureSound } from './hooks/useFeatureSound'
import { useCelebration } from './hooks/useCelebration' import { useCelebration } from './hooks/useCelebration'
import { useTheme } from './hooks/useTheme'
import { ProjectSelector } from './components/ProjectSelector' import { ProjectSelector } from './components/ProjectSelector'
import { KanbanBoard } from './components/KanbanBoard' import { KanbanBoard } from './components/KanbanBoard'
import { AgentControl } from './components/AgentControl' import { AgentControl } from './components/AgentControl'
@@ -24,12 +25,15 @@ import { DevServerControl } from './components/DevServerControl'
import { ViewToggle, type ViewMode } from './components/ViewToggle' import { ViewToggle, type ViewMode } from './components/ViewToggle'
import { DependencyGraph } from './components/DependencyGraph' import { DependencyGraph } from './components/DependencyGraph'
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp' import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
import { ThemeSelector } from './components/ThemeSelector'
import { getDependencyGraph } from './lib/api' import { getDependencyGraph } from './lib/api'
import { Loader2, Settings, Moon, Sun } from 'lucide-react' import { Loader2, Settings, Moon, Sun } from 'lucide-react'
import type { Feature } from './lib/types' import type { Feature } from './lib/types'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const STORAGE_KEY = 'autocoder-selected-project' const STORAGE_KEY = 'autocoder-selected-project'
const DARK_MODE_KEY = 'autocoder-dark-mode'
const VIEW_MODE_KEY = 'autocoder-view-mode' const VIEW_MODE_KEY = 'autocoder-view-mode'
function App() { function App() {
@@ -53,13 +57,6 @@ function App() {
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false) const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false)
const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban
const [darkMode, setDarkMode] = useState(() => {
try {
return localStorage.getItem(DARK_MODE_KEY) === 'true'
} catch {
return false
}
})
const [viewMode, setViewMode] = useState<ViewMode>(() => { const [viewMode, setViewMode] = useState<ViewMode>(() => {
try { try {
const stored = localStorage.getItem(VIEW_MODE_KEY) const stored = localStorage.getItem(VIEW_MODE_KEY)
@@ -75,6 +72,7 @@ function App() {
const { data: settings } = useSettings() const { data: settings } = useSettings()
useAgentStatus(selectedProject) // Keep polling for status updates useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject) const wsState = useProjectWebSocket(selectedProject)
const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme()
// Get has_spec from the selected project // Get has_spec from the selected project
const selectedProjectData = projects?.find(p => p.name === selectedProject) const selectedProjectData = projects?.find(p => p.name === selectedProject)
@@ -88,20 +86,6 @@ function App() {
refetchInterval: 5000, // Refresh every 5 seconds refetchInterval: 5000, // Refresh every 5 seconds
}) })
// Apply dark mode class to document
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
try {
localStorage.setItem(DARK_MODE_KEY, String(darkMode))
} catch {
// localStorage not available
}
}, [darkMode])
// Persist view mode to localStorage // Persist view mode to localStorage
useEffect(() => { useEffect(() => {
try { try {
@@ -256,9 +240,9 @@ function App() {
} }
return ( return (
<div className="min-h-screen bg-neo-bg"> <div className="min-h-screen bg-background">
{/* Header */} {/* Header */}
<header className="bg-neo-card text-neo-text border-b-4 border-neo-border"> <header className="bg-card text-foreground border-b-2 border-border">
<div className="max-w-7xl mx-auto px-4 py-4"> <div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Logo and Title */} {/* Logo and Title */}
@@ -289,47 +273,56 @@ function App() {
url={wsState.devServerUrl} url={wsState.devServerUrl}
/> />
<button <Button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="neo-btn text-sm py-2 px-3" variant="outline"
size="sm"
title="Settings (,)" title="Settings (,)"
aria-label="Open Settings" aria-label="Open Settings"
> >
<Settings size={18} /> <Settings size={18} />
</button> </Button>
{/* Ollama Mode Indicator */} {/* Ollama Mode Indicator */}
{settings?.ollama_mode && ( {settings?.ollama_mode && (
<div <div
className="flex items-center gap-1.5 px-2 py-1 bg-white rounded border-2 border-neo-border shadow-neo-sm" className="flex items-center gap-1.5 px-2 py-1 bg-card rounded border-2 border-border shadow-sm"
title="Using Ollama local models (configured via .env)" title="Using Ollama local models (configured via .env)"
> >
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" /> <img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
<span className="text-xs font-bold text-neo-text">Ollama</span> <span className="text-xs font-bold text-foreground">Ollama</span>
</div> </div>
)} )}
{/* GLM Mode Badge */} {/* GLM Mode Badge */}
{settings?.glm_mode && ( {settings?.glm_mode && (
<span <Badge
className="px-2 py-1 text-xs font-bold bg-[var(--color-neo-glm)] text-white rounded border-2 border-neo-border shadow-neo-sm" className="bg-purple-500 text-white hover:bg-purple-600"
title="Using GLM API (configured via .env)" title="Using GLM API (configured via .env)"
> >
GLM GLM
</span> </Badge>
)} )}
</> </>
)} )}
{/* Theme selector */}
<ThemeSelector
themes={themes}
currentTheme={theme}
onThemeChange={setTheme}
/>
{/* Dark mode toggle - always visible */} {/* Dark mode toggle - always visible */}
<button <Button
onClick={() => setDarkMode(!darkMode)} onClick={toggleDarkMode}
className="neo-btn text-sm py-2 px-3" variant="outline"
size="sm"
title="Toggle dark mode" title="Toggle dark mode"
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
> >
{darkMode ? <Sun size={18} /> : <Moon size={18} />} {darkMode ? <Sun size={18} /> : <Moon size={18} />}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -341,11 +334,11 @@ function App() {
style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : undefined }} style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : undefined }}
> >
{!selectedProject ? ( {!selectedProject ? (
<div className="neo-empty-state mt-12"> <div className="text-center mt-12">
<h2 className="font-display text-2xl font-bold mb-2"> <h2 className="font-display text-2xl font-bold mb-2">
Welcome to AutoCoder Welcome to AutoCoder
</h2> </h2>
<p className="text-neo-text-secondary mb-4"> <p className="text-muted-foreground mb-4">
Select a project from the dropdown above or create a new one to get started. Select a project from the dropdown above or create a new one to get started.
</p> </p>
</div> </div>
@@ -381,15 +374,17 @@ function App() {
features.in_progress.length === 0 && features.in_progress.length === 0 &&
features.done.length === 0 && features.done.length === 0 &&
wsState.agentStatus === 'running' && ( wsState.agentStatus === 'running' && (
<div className="neo-card p-8 text-center"> <Card className="p-8 text-center">
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-neo-progress" /> <CardContent className="p-0">
<h3 className="font-display font-bold text-xl mb-2"> <Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
Initializing Features... <h3 className="font-display font-bold text-xl mb-2">
</h3> Initializing Features...
<p className="text-neo-text-secondary"> </h3>
The agent is reading your spec and creating features. This may take a moment. <p className="text-muted-foreground">
</p> The agent is reading your spec and creating features. This may take a moment.
</div> </p>
</CardContent>
</Card>
)} )}
{/* View Toggle - only show when there are features */} {/* View Toggle - only show when there are features */}
@@ -411,7 +406,7 @@ function App() {
hasSpec={hasSpec} hasSpec={hasSpec}
/> />
) : ( ) : (
<div className="neo-card overflow-hidden" style={{ height: '600px' }}> <Card className="overflow-hidden" style={{ height: '600px' }}>
{graphData ? ( {graphData ? (
<DependencyGraph <DependencyGraph
graphData={graphData} graphData={graphData}
@@ -420,10 +415,10 @@ function App() {
/> />
) : ( ) : (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<Loader2 size={32} className="animate-spin text-neo-progress" /> <Loader2 size={32} className="animate-spin text-primary" />
</div> </div>
)} )}
</div> </Card>
)} )}
</div> </div>
)} )}
@@ -461,7 +456,7 @@ function App() {
{/* Spec Creation Chat - for creating spec from empty kanban */} {/* Spec Creation Chat - for creating spec from empty kanban */}
{showSpecChat && selectedProject && ( {showSpecChat && selectedProject && (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]"> <div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat <SpecCreationChat
projectName={selectedProject} projectName={selectedProject}
onComplete={() => { onComplete={() => {
@@ -508,14 +503,10 @@ function App() {
)} )}
{/* Settings Modal */} {/* Settings Modal */}
{showSettings && ( <SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<SettingsModal onClose={() => setShowSettings(false)} />
)}
{/* Keyboard Shortcuts Help */} {/* Keyboard Shortcuts Help */}
{showKeyboardHelp && ( <KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
<KeyboardShortcutsHelp onClose={() => setShowKeyboardHelp(false)} />
)}
{/* Celebration Overlay - shows when a feature is completed by an agent */} {/* Celebration Overlay - shows when a feature is completed by an agent */}
{wsState.celebration && ( {wsState.celebration && (

View File

@@ -1,6 +1,7 @@
import { Activity } from 'lucide-react' import { Activity } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types' import type { AgentMascot } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
interface ActivityItem { interface ActivityItem {
agentName: string agentName: string
@@ -38,8 +39,8 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac
<div> <div>
{showHeader && ( {showHeader && (
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Activity size={14} className="text-neo-text-secondary" /> <Activity size={14} className="text-muted-foreground" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity Recent Activity
</span> </span>
</div> </div>
@@ -47,34 +48,36 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac
<div className="space-y-2"> <div className="space-y-2">
{displayedActivities.map((activity) => ( {displayedActivities.map((activity) => (
<div <Card
key={`${activity.featureId}-${activity.timestamp}-${activity.thought.slice(0, 20)}`} key={`${activity.featureId}-${activity.timestamp}-${activity.thought.slice(0, 20)}`}
className="flex items-start gap-2 py-1.5 px-2 rounded bg-[var(--color-neo-bg)] border border-neo-border/20" className="py-1.5"
> >
<AgentAvatar <CardContent className="p-2 flex items-start gap-2">
name={activity.agentName as AgentMascot} <AgentAvatar
state="working" name={activity.agentName as AgentMascot}
size="sm" state="working"
/> size="sm"
<div className="flex-1 min-w-0"> />
<div className="flex items-center gap-2"> <div className="flex-1 min-w-0">
<span className="text-xs font-bold" style={{ <div className="flex items-center gap-2">
color: getMascotColor(activity.agentName as AgentMascot) <span className="text-xs font-semibold" style={{
}}> color: getMascotColor(activity.agentName as AgentMascot)
{activity.agentName} }}>
</span> {activity.agentName}
<span className="text-[10px] text-neo-text-muted"> </span>
#{activity.featureId} <span className="text-[10px] text-muted-foreground">
</span> #{activity.featureId}
<span className="text-[10px] text-neo-text-muted ml-auto"> </span>
{formatTimestamp(activity.timestamp)} <span className="text-[10px] text-muted-foreground ml-auto">
</span> {formatTimestamp(activity.timestamp)}
</span>
</div>
<p className="text-xs text-muted-foreground truncate" title={activity.thought}>
{activity.thought}
</p>
</div> </div>
<p className="text-xs text-neo-text-secondary truncate" title={activity.thought}> </CardContent>
{activity.thought} </Card>
</p>
</div>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,18 @@
import { useState, useId } from 'react' import { useState, useId } from 'react'
import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
import { useCreateFeature } from '../hooks/useProjects' import { useCreateFeature } from '../hooks/useProjects'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface Step { interface Step {
id: string id: string
@@ -65,149 +77,135 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) {
const isValid = category.trim() && name.trim() && description.trim() const isValid = category.trim() && name.trim() && description.trim()
return ( return (
<div className="neo-modal-backdrop" onClick={onClose}> <Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<div <DialogContent className="sm:max-w-2xl">
className="neo-modal w-full max-w-2xl" <DialogHeader>
onClick={(e) => e.stopPropagation()} <DialogTitle>Add Feature</DialogTitle>
> </DialogHeader>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
<h2 className="font-display text-2xl font-bold">
Add Feature
</h2>
<button
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={24} />
</button>
</div>
{/* Form */} <form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)]"> <Alert variant="destructive">
<AlertCircle size={20} /> <AlertCircle className="h-4 w-4" />
<span>{error}</span> <AlertDescription className="flex items-center justify-between">
<button <span>{error}</span>
type="button" <Button
onClick={() => setError(null)} type="button"
className="ml-auto hover:opacity-70 transition-opacity" variant="ghost"
> size="icon-xs"
<X size={16} /> onClick={() => setError(null)}
</button> >
</div> <X size={14} />
</Button>
</AlertDescription>
</Alert>
)} )}
{/* Category & Priority Row */} {/* Category & Priority Row */}
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"> <div className="flex-1 space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="category">Category</Label>
Category <Input
</label> id="category"
<input
type="text" type="text"
value={category} value={category}
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategory(e.target.value)}
placeholder="e.g., Authentication, UI, API" placeholder="e.g., Authentication, UI, API"
className="neo-input"
required required
/> />
</div> </div>
<div className="w-32"> <div className="w-32 space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="priority">Priority</Label>
Priority <Input
</label> id="priority"
<input
type="number" type="number"
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
placeholder="Auto" placeholder="Auto"
min="1" min="1"
className="neo-input"
/> />
</div> </div>
</div> </div>
{/* Name */} {/* Name */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="name">Feature Name</Label>
Feature Name <Input
</label> id="name"
<input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g., User login form" placeholder="e.g., User login form"
className="neo-input"
required required
/> />
</div> </div>
{/* Description */} {/* Description */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="description">Description</Label>
Description <Textarea
</label> id="description"
<textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this feature should do..." placeholder="Describe what this feature should do..."
className="neo-input min-h-[100px] resize-y" className="min-h-[100px] resize-y"
required required
/> />
</div> </div>
{/* Steps */} {/* Steps */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label>Test Steps (Optional)</Label>
Test Steps (Optional)
</label>
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step, index) => ( {steps.map((step, index) => (
<div key={step.id} className="flex gap-2 items-center"> <div key={step.id} className="flex gap-2 items-center">
<span <span className="w-10 h-10 flex-shrink-0 flex items-center justify-center font-mono font-semibold text-sm border rounded-md bg-muted text-muted-foreground">
className="w-10 h-10 flex-shrink-0 flex items-center justify-center font-mono font-bold text-sm border-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)] text-[var(--color-neo-text-secondary)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
{index + 1} {index + 1}
</span> </span>
<input <Input
type="text" type="text"
value={step.value} value={step.value}
onChange={(e) => handleStepChange(step.id, e.target.value)} onChange={(e) => handleStepChange(step.id, e.target.value)}
placeholder="Describe this step..." placeholder="Describe this step..."
className="neo-input flex-1" className="flex-1"
/> />
{steps.length > 1 && ( {steps.length > 1 && (
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveStep(step.id)} onClick={() => handleRemoveStep(step.id)}
className="neo-btn neo-btn-ghost p-2"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </Button>
)} )}
</div> </div>
))} ))}
</div> </div>
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
onClick={handleAddStep} onClick={handleAddStep}
className="neo-btn neo-btn-ghost mt-2 text-sm"
> >
<Plus size={16} /> <Plus size={16} />
Add Step Add Step
</button> </Button>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]"> <DialogFooter className="pt-4 border-t">
<button <Button
type="button"
variant="outline"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit" type="submit"
disabled={!isValid || createFeature.isPending} disabled={!isValid || createFeature.isPending}
className="neo-btn neo-btn-success flex-1"
> >
{createFeature.isPending ? ( {createFeature.isPending ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
@@ -217,17 +215,10 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) {
Create Feature Create Feature
</> </>
)} )}
</button> </Button>
<button </DialogFooter>
type="button"
onClick={onClose}
className="neo-btn neo-btn-ghost"
>
Cancel
</button>
</div>
</form> </form>
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -606,7 +606,7 @@ export function AgentAvatar({ name, state, size = 'md', showName = false }: Agen
<SvgComponent colors={colors} size={svgSize} /> <SvgComponent colors={colors} size={svgSize} />
</div> </div>
{showName && ( {showName && (
<span className={`${font} font-bold text-neo-text`} style={{ color: colors.primary }}> <span className={`${font} font-bold text-foreground`} style={{ color: colors.primary }}>
{name} {name}
</span> </span>
)} )}

View File

@@ -3,6 +3,9 @@ import { useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import type { ActiveAgent, AgentLogEntry, AgentType } from '../lib/types' import type { ActiveAgent, AgentLogEntry, AgentType } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface AgentCardProps { interface AgentCardProps {
agent: ActiveAgent agent: ActiveAgent
@@ -31,22 +34,22 @@ function getStateText(state: ActiveAgent['state']): string {
} }
} }
// Get state color // Get state color class
function getStateColor(state: ActiveAgent['state']): string { function getStateColor(state: ActiveAgent['state']): string {
switch (state) { switch (state) {
case 'success': case 'success':
return 'text-neo-done' return 'text-primary'
case 'error': case 'error':
return 'text-neo-pending' // Yellow - just pivoting, not a real error return 'text-yellow-600'
case 'struggling': case 'struggling':
return 'text-orange-500' // Orange - working hard, being persistent return 'text-orange-500'
case 'working': case 'working':
case 'testing': case 'testing':
return 'text-neo-progress' return 'text-primary'
case 'thinking': case 'thinking':
return 'text-neo-pending' return 'text-yellow-600'
default: default:
return 'text-neo-text-secondary' return 'text-muted-foreground'
} }
} }
@@ -55,14 +58,13 @@ function getAgentTypeBadge(agentType: AgentType): { label: string; className: st
if (agentType === 'testing') { if (agentType === 'testing') {
return { return {
label: 'TEST', label: 'TEST',
className: 'bg-purple-100 text-purple-700 border-purple-300', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
icon: FlaskConical, icon: FlaskConical,
} }
} }
// Default to coding
return { return {
label: 'CODE', label: 'CODE',
className: 'bg-blue-100 text-blue-700 border-blue-300', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
icon: Code, icon: Code,
} }
} }
@@ -74,75 +76,66 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
const TypeIcon = typeBadge.icon const TypeIcon = typeBadge.icon
return ( return (
<div <Card className={`min-w-[180px] max-w-[220px] py-3 ${isActive ? 'animate-pulse' : ''}`}>
className={` <CardContent className="p-3 space-y-2">
neo-card p-3 min-w-[180px] max-w-[220px] {/* Agent type badge */}
${isActive ? 'animate-pulse-neo' : ''} <div className="flex justify-end">
transition-all duration-300 <Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
`} <TypeIcon size={10} />
> {typeBadge.label}
{/* Agent type badge */} </Badge>
<div className="flex justify-end mb-1"> </div>
<span
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold
uppercase tracking-wide rounded border
${typeBadge.className}
`}
>
<TypeIcon size={10} />
{typeBadge.label}
</span>
</div>
{/* Header with avatar and name */} {/* Header with avatar and name */}
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" /> <AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-display font-bold text-sm truncate"> <div className="font-semibold text-sm truncate">
{agent.agentName} {agent.agentName}
</div>
<div className={`text-xs ${getStateColor(agent.state)}`}>
{getStateText(agent.state)}
</div>
</div> </div>
<div className={`text-xs ${getStateColor(agent.state)}`}> {/* Log button */}
{getStateText(agent.state)} {hasLogs && onShowLogs && (
</div> <Button
</div> variant="ghost"
{/* Log button */} size="icon-xs"
{hasLogs && onShowLogs && ( onClick={() => onShowLogs(agent.agentIndex)}
<button title={`View logs (${agent.logs?.length || 0} entries)`}
onClick={() => onShowLogs(agent.agentIndex)}
className="p-1 hover:bg-neo-bg-secondary rounded transition-colors"
title={`View logs (${agent.logs?.length || 0} entries)`}
>
<ScrollText size={14} className="text-neo-text-secondary" />
</button>
)}
</div>
{/* Feature info */}
<div className="mb-2">
<div className="text-xs text-neo-text-secondary mb-0.5">
Feature #{agent.featureId}
</div>
<div className="text-sm font-medium truncate" title={agent.featureName}>
{agent.featureName}
</div>
</div>
{/* Thought bubble */}
{agent.thought && (
<div className="relative mt-2 pt-2 border-t-2 border-neo-border/30">
<div className="flex items-start gap-1.5">
<MessageCircle size={14} className="text-neo-progress shrink-0 mt-0.5" />
<p
className="text-xs text-neo-text-secondary line-clamp-2 italic"
title={agent.thought}
> >
{agent.thought} <ScrollText size={14} className="text-muted-foreground" />
</p> </Button>
)}
</div>
{/* Feature info */}
<div>
<div className="text-xs text-muted-foreground mb-0.5">
Feature #{agent.featureId}
</div>
<div className="text-sm font-medium truncate" title={agent.featureName}>
{agent.featureName}
</div> </div>
</div> </div>
)}
</div> {/* Thought bubble */}
{agent.thought && (
<div className="pt-2 border-t border-border/50">
<div className="flex items-start gap-1.5">
<MessageCircle size={14} className="text-primary shrink-0 mt-0.5" />
<p
className="text-xs text-muted-foreground line-clamp-2 italic"
title={agent.thought}
>
{agent.thought}
</p>
</div>
</div>
)}
</CardContent>
</Card>
) )
} }
@@ -170,91 +163,76 @@ export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {
const getLogColor = (type: AgentLogEntry['type']) => { const getLogColor = (type: AgentLogEntry['type']) => {
switch (type) { switch (type) {
case 'error': case 'error':
return 'text-neo-danger' return 'text-destructive'
case 'state_change': case 'state_change':
return 'text-neo-progress' return 'text-primary'
default: default:
return 'text-neo-text' return 'text-foreground'
} }
} }
// Use portal to render modal at document body level (avoids overflow:hidden issues)
return createPortal( return createPortal(
<div <div
className="fixed inset-0 flex items-center justify-center p-4 bg-black/50" className="fixed inset-0 flex items-center justify-center p-4 bg-black/50"
style={{ zIndex: 9999 }} style={{ zIndex: 9999 }}
onClick={(e) => { onClick={(e) => {
// Close when clicking backdrop
if (e.target === e.currentTarget) onClose() if (e.target === e.currentTarget) onClose()
}} }}
> >
<div className="neo-card w-full max-w-4xl max-h-[80vh] flex flex-col bg-neo-bg"> <Card className="w-full max-w-4xl max-h-[80vh] flex flex-col py-0">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-neo-border"> <div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" /> <AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-display font-bold text-lg"> <h2 className="font-semibold text-lg">
{agent.agentName} Logs {agent.agentName} Logs
</h2> </h2>
<span <Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold
uppercase tracking-wide rounded border
${typeBadge.className}
`}
>
<TypeIcon size={10} /> <TypeIcon size={10} />
{typeBadge.label} {typeBadge.label}
</span> </Badge>
</div> </div>
<p className="text-sm text-neo-text-secondary"> <p className="text-sm text-muted-foreground">
Feature #{agent.featureId}: {agent.featureName} Feature #{agent.featureId}: {agent.featureName}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <Button variant="outline" size="sm" onClick={handleCopy}>
onClick={handleCopy}
className="neo-button neo-button-sm flex items-center gap-1"
title="Copy all logs"
>
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'} {copied ? 'Copied!' : 'Copy'}
</button> </Button>
<button <Button variant="ghost" size="icon-sm" onClick={onClose}>
onClick={onClose}
className="p-2 hover:bg-neo-bg-secondary rounded transition-colors"
>
<X size={20} /> <X size={20} />
</button> </Button>
</div> </div>
</div> </div>
{/* Log content */} {/* Log content */}
<div className="flex-1 overflow-auto p-4 bg-neo-bg-secondary font-mono text-xs"> <div className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/50">
{logs.length === 0 ? ( <div className="font-mono text-xs space-y-1">
<p className="text-neo-text-secondary italic">No logs available</p> {logs.length === 0 ? (
) : ( <p className="text-muted-foreground italic">No logs available</p>
<div className="space-y-1"> ) : (
{logs.map((log, idx) => ( logs.map((log, idx) => (
<div key={idx} className={`${getLogColor(log.type)} whitespace-pre-wrap break-all`}> <div key={idx} className={`${getLogColor(log.type)} whitespace-pre-wrap break-all`}>
<span className="text-neo-muted"> <span className="text-muted-foreground">
[{new Date(log.timestamp).toLocaleTimeString()}] [{new Date(log.timestamp).toLocaleTimeString()}]
</span>{' '} </span>{' '}
{log.line} {log.line}
</div> </div>
))} ))
</div> )}
)} </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="p-3 border-t-2 border-neo-border/30 text-xs text-neo-text-secondary"> <div className="p-3 border-t text-xs text-muted-foreground">
{logs.length} log entries {logs.length} log entries
</div> </div>
</div> </Card>
</div>, </div>,
document.body document.body
) )

View File

@@ -9,6 +9,8 @@ import { useNextScheduledRun } from '../hooks/useSchedules'
import { formatNextRun, formatEndTime } from '../lib/timeUtils' import { formatNextRun, formatEndTime } from '../lib/timeUtils'
import { ScheduleModal } from './ScheduleModal' import { ScheduleModal } from './ScheduleModal'
import type { AgentStatus } from '../lib/types' import type { AgentStatus } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface AgentControlProps { interface AgentControlProps {
projectName: string projectName: string
@@ -30,18 +32,17 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
const isLoading = startAgent.isPending || stopAgent.isPending const isLoading = startAgent.isPending || stopAgent.isPending
const isRunning = status === 'running' || status === 'paused' const isRunning = status === 'running' || status === 'paused'
const isLoadingStatus = status === 'loading' // Status unknown, waiting for WebSocket const isLoadingStatus = status === 'loading'
const isParallel = concurrency > 1 const isParallel = concurrency > 1
const handleStart = () => startAgent.mutate({ const handleStart = () => startAgent.mutate({
yoloMode, yoloMode,
parallelMode: isParallel, parallelMode: isParallel,
maxConcurrency: concurrency, // Always pass concurrency (1-5) maxConcurrency: concurrency,
testingAgentRatio: settings?.testing_agent_ratio, testingAgentRatio: settings?.testing_agent_ratio,
}) })
const handleStop = () => stopAgent.mutate() const handleStop = () => stopAgent.mutate()
// Simplified: either show Start (when stopped/crashed), Stop (when running/paused), or loading spinner
const isStopped = status === 'stopped' || status === 'crashed' const isStopped = status === 'stopped' || status === 'crashed'
return ( return (
@@ -50,7 +51,7 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
{/* Concurrency slider - visible when stopped */} {/* Concurrency slider - visible when stopped */}
{isStopped && ( {isStopped && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} /> <GitBranch size={16} className={isParallel ? 'text-primary' : 'text-muted-foreground'} />
<input <input
type="range" type="range"
min={1} min={1}
@@ -58,11 +59,11 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
value={concurrency} value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))} onChange={(e) => setConcurrency(Number(e.target.value))}
disabled={isLoading} disabled={isLoading}
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer" className="w-16 h-2 accent-primary cursor-pointer"
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`} title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
aria-label="Set number of concurrent agents" aria-label="Set number of concurrent agents"
/> />
<span className="text-xs font-bold min-w-[1.5rem] text-center"> <span className="text-xs font-semibold min-w-[1.5rem] text-center">
{concurrency}x {concurrency}x
</span> </span>
</div> </div>
@@ -70,80 +71,71 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
{/* Show concurrency indicator when running with multiple agents */} {/* Show concurrency indicator when running with multiple agents */}
{isRunning && isParallel && ( {isRunning && isParallel && (
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold"> <Badge variant="secondary" className="gap-1">
<GitBranch size={14} /> <GitBranch size={14} />
<span>{concurrency}x</span> {concurrency}x
</div> </Badge>
)} )}
{/* Schedule status display */} {/* Schedule status display */}
{nextRun?.is_currently_running && nextRun.next_end && ( {nextRun?.is_currently_running && nextRun.next_end && (
<div className="flex items-center gap-2 text-sm text-[var(--color-neo-done)] font-bold"> <Badge variant="default" className="gap-1">
<Clock size={16} className="flex-shrink-0" /> <Clock size={14} />
<span>Running until {formatEndTime(nextRun.next_end)}</span> Running until {formatEndTime(nextRun.next_end)}
</div> </Badge>
)} )}
{!nextRun?.is_currently_running && nextRun?.next_start && ( {!nextRun?.is_currently_running && nextRun?.next_start && (
<div className="flex items-center gap-2 text-sm text-gray-900 dark:text-white font-bold"> <Badge variant="secondary" className="gap-1">
<Clock size={16} className="flex-shrink-0" /> <Clock size={14} />
<span>Next: {formatNextRun(nextRun.next_start)}</span> Next: {formatNextRun(nextRun.next_start)}
</div> </Badge>
)} )}
{/* Start/Stop button */} {/* Start/Stop button */}
{isLoadingStatus ? ( {isLoadingStatus ? (
<button <Button disabled variant="outline" size="sm">
disabled
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
title="Loading agent status..."
aria-label="Loading agent status"
>
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
</button> </Button>
) : isStopped ? ( ) : isStopped ? (
<button <Button
onClick={handleStart} onClick={handleStart}
disabled={isLoading} disabled={isLoading}
className={`neo-btn text-sm py-2 px-3 ${ variant={yoloMode ? 'secondary' : 'default'}
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success' size="sm"
}`}
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'} title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
) : ( ) : (
<Play size={18} /> <Play size={18} />
)} )}
</button> </Button>
) : ( ) : (
<button <Button
onClick={handleStop} onClick={handleStop}
disabled={isLoading} disabled={isLoading}
className={`neo-btn text-sm py-2 px-3 ${ variant="destructive"
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger' size="sm"
}`}
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'} title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
) : ( ) : (
<Square size={18} /> <Square size={18} />
)} )}
</button> </Button>
)} )}
{/* Clock button to open schedule modal */} {/* Clock button to open schedule modal */}
<button <Button
variant="outline"
size="sm"
onClick={() => setShowScheduleModal(true)} onClick={() => setShowScheduleModal(true)}
className="neo-btn text-sm py-2 px-3"
title="Manage schedules" title="Manage schedules"
aria-label="Manage agent schedules"
> >
<Clock size={18} /> <Clock size={18} />
</button> </Button>
</div> </div>
{/* Schedule Modal */} {/* Schedule Modal */}

View File

@@ -4,6 +4,9 @@ import { AgentCard, AgentLogModal } from './AgentCard'
import { ActivityFeed } from './ActivityFeed' import { ActivityFeed } from './ActivityFeed'
import { OrchestratorStatusCard } from './OrchestratorStatusCard' import { OrchestratorStatusCard } from './OrchestratorStatusCard'
import type { ActiveAgent, AgentLogEntry, OrchestratorStatus } from '../lib/types' import type { ActiveAgent, AgentLogEntry, OrchestratorStatus } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed' const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
@@ -35,7 +38,6 @@ export function AgentMissionControl({
return false return false
} }
}) })
// State for log modal
const [selectedAgentForLogs, setSelectedAgentForLogs] = useState<ActiveAgent | null>(null) const [selectedAgentForLogs, setSelectedAgentForLogs] = useState<ActiveAgent | null>(null)
const toggleActivityCollapsed = () => { const toggleActivityCollapsed = () => {
@@ -54,18 +56,18 @@ export function AgentMissionControl({
} }
return ( return (
<div className="neo-card mb-6 overflow-hidden"> <Card className="mb-6 overflow-hidden py-0">
{/* Header */} {/* Header */}
<button <button
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--color-neo-progress)] hover:brightness-105 transition-all" className="w-full flex items-center justify-between px-4 py-3 bg-primary hover:bg-primary/90 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rocket size={20} className="text-neo-text-on-bright" /> <Rocket size={20} className="text-primary-foreground" />
<span className="font-display font-bold text-neo-text-on-bright uppercase tracking-wide"> <span className="font-semibold text-primary-foreground uppercase tracking-wide">
Mission Control Mission Control
</span> </span>
<span className="neo-badge neo-badge-sm bg-white text-neo-text ml-2"> <Badge variant="secondary" className="ml-2">
{agents.length > 0 {agents.length > 0
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active` ? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
: orchestratorStatus?.state === 'initializing' : orchestratorStatus?.state === 'initializing'
@@ -74,12 +76,12 @@ export function AgentMissionControl({
? 'Complete' ? 'Complete'
: 'Orchestrating' : 'Orchestrating'
} }
</span> </Badge>
</div> </div>
{isExpanded ? ( {isExpanded ? (
<ChevronUp size={20} className="text-neo-text-on-bright" /> <ChevronUp size={20} className="text-primary-foreground" />
) : ( ) : (
<ChevronDown size={20} className="text-neo-text-on-bright" /> <ChevronDown size={20} className="text-primary-foreground" />
)} )}
</button> </button>
@@ -90,7 +92,7 @@ export function AgentMissionControl({
${isExpanded ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'} ${isExpanded ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'}
`} `}
> >
<div className="p-4"> <CardContent className="p-4">
{/* Orchestrator Status Card */} {/* Orchestrator Status Card */}
{orchestratorStatus && ( {orchestratorStatus && (
<OrchestratorStatusCard status={orchestratorStatus} /> <OrchestratorStatusCard status={orchestratorStatus} />
@@ -98,7 +100,7 @@ export function AgentMissionControl({
{/* Agent Cards Row */} {/* Agent Cards Row */}
{agents.length > 0 && ( {agents.length > 0 && (
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin"> <div className="flex gap-4 overflow-x-auto pb-4">
{agents.map((agent) => ( {agents.map((agent) => (
<AgentCard <AgentCard
key={`agent-${agent.agentIndex}`} key={`agent-${agent.agentIndex}`}
@@ -116,24 +118,26 @@ export function AgentMissionControl({
{/* Collapsible Activity Feed */} {/* Collapsible Activity Feed */}
{recentActivity.length > 0 && ( {recentActivity.length > 0 && (
<div className="mt-4 pt-4 border-t-2 border-neo-border/30"> <div className="mt-4 pt-4 border-t">
<button <Button
variant="ghost"
size="sm"
onClick={toggleActivityCollapsed} onClick={toggleActivityCollapsed}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity" className="gap-2 mb-2 h-auto p-1"
> >
<Activity size={14} className="text-neo-text-secondary" /> <Activity size={14} className="text-muted-foreground" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide"> <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity Recent Activity
</span> </span>
<span className="text-xs text-neo-muted"> <span className="text-xs text-muted-foreground">
({recentActivity.length}) ({recentActivity.length})
</span> </span>
{activityCollapsed ? ( {activityCollapsed ? (
<ChevronDown size={14} className="text-neo-text-secondary" /> <ChevronDown size={14} className="text-muted-foreground" />
) : ( ) : (
<ChevronUp size={14} className="text-neo-text-secondary" /> <ChevronUp size={14} className="text-muted-foreground" />
)} )}
</button> </Button>
<div <div
className={` className={`
transition-all duration-200 ease-out overflow-hidden transition-all duration-200 ease-out overflow-hidden
@@ -144,7 +148,7 @@ export function AgentMissionControl({
</div> </div>
</div> </div>
)} )}
</div> </CardContent>
</div> </div>
{/* Log Modal */} {/* Log Modal */}
@@ -155,6 +159,6 @@ export function AgentMissionControl({
onClose={() => setSelectedAgentForLogs(null)} onClose={() => setSelectedAgentForLogs(null)}
/> />
)} )}
</div> </Card>
) )
} }

View File

@@ -1,6 +1,7 @@
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
import { Brain, Sparkles } from 'lucide-react' import { Brain, Sparkles } from 'lucide-react'
import type { AgentStatus } from '../lib/types' import type { AgentStatus } from '../lib/types'
import { Card } from '@/components/ui/card'
interface AgentThoughtProps { interface AgentThoughtProps {
logs: Array<{ line: string; timestamp: string }> logs: Array<{ line: string; timestamp: string }>
@@ -105,38 +106,25 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
${shouldShow ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'} ${shouldShow ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'}
`} `}
> >
<div <Card className={`relative px-4 py-3 flex items-center gap-3 ${isRunning ? 'animate-pulse' : ''}`}>
className={`
relative
bg-[var(--color-neo-card)]
border-3 border-[var(--color-neo-border)]
shadow-[var(--shadow-neo-sm)]
px-4 py-3
flex items-center gap-3
${isRunning ? 'animate-pulse-neo' : ''}
`}
>
{/* Brain Icon with subtle glow */} {/* Brain Icon with subtle glow */}
<div className="relative shrink-0"> <div className="relative shrink-0">
<Brain <Brain
size={22} size={22}
className="text-[var(--color-neo-progress)]" className="text-primary"
strokeWidth={2.5} strokeWidth={2.5}
/> />
{isRunning && ( {isRunning && (
<Sparkles <Sparkles
size={10} size={10}
className="absolute -top-1 -right-1 text-[var(--color-neo-pending)] animate-pulse" className="absolute -top-1 -right-1 text-yellow-500 animate-pulse"
/> />
)} )}
</div> </div>
{/* Thought text with fade transition + shimmer effect when running */} {/* Thought text with fade transition */}
<p <p
className={` className="font-mono text-sm truncate transition-all duration-150 ease-out text-foreground"
font-mono text-sm truncate transition-all duration-150 ease-out
${isRunning ? 'animate-shimmer' : 'text-[var(--color-neo-text)]'}
`}
style={{ style={{
opacity: textVisible ? 1 : 0, opacity: textVisible ? 1 : 0,
transform: textVisible ? 'translateY(0)' : 'translateY(-4px)', transform: textVisible ? 'translateY(0)' : 'translateY(-4px)',
@@ -147,14 +135,11 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
{/* Subtle running indicator bar */} {/* Subtle running indicator bar */}
{isRunning && ( {isRunning && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-neo-progress)] opacity-50"> <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary/50">
<div <div className="h-full bg-primary animate-pulse" style={{ width: '100%' }} />
className="h-full bg-[var(--color-neo-progress)] animate-pulse"
style={{ width: '100%' }}
/>
</div> </div>
)} )}
</div> </Card>
</div> </div>
) )
} }

View File

@@ -12,6 +12,8 @@ import { useAssistantChat } from '../hooks/useAssistantChat'
import { ChatMessage as ChatMessageComponent } from './ChatMessage' import { ChatMessage as ChatMessageComponent } from './ChatMessage'
import { ConversationHistory } from './ConversationHistory' import { ConversationHistory } from './ConversationHistory'
import type { ChatMessage } from '../lib/types' import type { ChatMessage } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
interface AssistantChatProps { interface AssistantChatProps {
projectName: string projectName: string
@@ -167,28 +169,28 @@ export function AssistantChat({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header with actions and connection status */} {/* Header with actions and connection status */}
<div className="flex items-center justify-between px-4 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-background">
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-1 relative"> <div className="flex items-center gap-1 relative">
<button <Button
variant="ghost"
size="icon"
onClick={handleNewChat} onClick={handleNewChat}
className="neo-btn neo-btn-ghost p-1.5 text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]" className="h-8 w-8"
title="New conversation" title="New conversation"
disabled={isLoading} disabled={isLoading}
> >
<Plus size={16} /> <Plus size={16} />
</button> </Button>
<button <Button
variant={showHistory ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setShowHistory(!showHistory)} onClick={() => setShowHistory(!showHistory)}
className={`neo-btn neo-btn-ghost p-1.5 ${ className="h-8 w-8"
showHistory
? 'text-[var(--color-neo-text)] bg-[var(--color-neo-pending)]'
: 'text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]'
}`}
title="Conversation history" title="Conversation history"
> >
<History size={16} /> <History size={16} />
</button> </Button>
{/* History dropdown */} {/* History dropdown */}
<ConversationHistory <ConversationHistory
@@ -204,34 +206,34 @@ export function AssistantChat({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{connectionStatus === 'connected' ? ( {connectionStatus === 'connected' ? (
<> <>
<Wifi size={14} className="text-[var(--color-neo-done)]" /> <Wifi size={14} className="text-green-500" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connected</span> <span className="text-xs text-muted-foreground">Connected</span>
</> </>
) : connectionStatus === 'connecting' ? ( ) : connectionStatus === 'connecting' ? (
<> <>
<Loader2 size={14} className="text-[var(--color-neo-progress)] animate-spin" /> <Loader2 size={14} className="text-primary animate-spin" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connecting...</span> <span className="text-xs text-muted-foreground">Connecting...</span>
</> </>
) : ( ) : (
<> <>
<WifiOff size={14} className="text-[var(--color-neo-danger)]" /> <WifiOff size={14} className="text-destructive" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Disconnected</span> <span className="text-xs text-muted-foreground">Disconnected</span>
</> </>
)} )}
</div> </div>
</div> </div>
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto bg-[var(--color-neo-bg)]"> <div className="flex-1 overflow-y-auto bg-background">
{isLoadingConversation ? ( {isLoadingConversation ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm"> <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span>Loading conversation...</span> <span>Loading conversation...</span>
</div> </div>
</div> </div>
) : displayMessages.length === 0 ? ( ) : displayMessages.length === 0 ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-secondary)] text-sm"> <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
@@ -253,12 +255,12 @@ export function AssistantChat({
{/* Loading indicator */} {/* Loading indicator */}
{isLoading && displayMessages.length > 0 && ( {isLoading && displayMessages.length > 0 && (
<div className="px-4 py-2 border-t-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <div className="px-4 py-2 border-t border-border bg-background">
<div className="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm"> <div className="flex items-center gap-2 text-muted-foreground text-sm">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div> </div>
<span>Thinking...</span> <span>Thinking...</span>
</div> </div>
@@ -266,33 +268,21 @@ export function AssistantChat({
)} )}
{/* Input area */} {/* Input area */}
<div className="border-t-3 border-[var(--color-neo-border)] p-4 bg-[var(--color-neo-card)]"> <div className="border-t border-border p-4 bg-card">
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <Textarea
ref={inputRef} ref={inputRef}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..." placeholder="Ask about the codebase..."
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'} disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
className=" className="flex-1 resize-none min-h-[44px] max-h-[120px]"
flex-1
neo-input
resize-none
min-h-[44px]
max-h-[120px]
py-2.5
"
rows={1} rows={1}
/> />
<button <Button
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'} disabled={!inputValue.trim() || isLoading || isLoadingConversation || connectionStatus !== 'connected'}
className="
neo-btn neo-btn-primary
px-4
disabled:opacity-50 disabled:cursor-not-allowed
"
title="Send message" title="Send message"
> >
{isLoading ? ( {isLoading ? (
@@ -300,9 +290,9 @@ export function AssistantChat({
) : ( ) : (
<Send size={18} /> <Send size={18} />
)} )}
</button> </Button>
</div> </div>
<p className="text-xs text-[var(--color-neo-text-secondary)] mt-2"> <p className="text-xs text-muted-foreground mt-2">
Press Enter to send, Shift+Enter for new line Press Enter to send, Shift+Enter for new line
</p> </p>
</div> </div>

View File

@@ -3,6 +3,7 @@
*/ */
import { MessageCircle, X } from 'lucide-react' import { MessageCircle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface AssistantFABProps { interface AssistantFABProps {
onClick: () => void onClick: () => void
@@ -11,24 +12,14 @@ interface AssistantFABProps {
export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) { export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) {
return ( return (
<button <Button
onClick={onClick} onClick={onClick}
className={` size="icon"
fixed bottom-6 right-6 z-50 className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0.5"
w-14 h-14
flex items-center justify-center
bg-[var(--color-neo-progress)] text-[var(--color-neo-text-on-bright)]
border-3 border-[var(--color-neo-border)]
shadow-neo-md
transition-all duration-200
hover:shadow-neo-lg hover:-translate-y-0.5
active:shadow-neo-sm active:translate-y-0.5
${isOpen ? 'rotate-0' : ''}
`}
title={isOpen ? 'Close Assistant (Press A)' : 'Open Assistant (Press A)'} title={isOpen ? 'Close Assistant (Press A)' : 'Open Assistant (Press A)'}
aria-label={isOpen ? 'Close Assistant' : 'Open Assistant'} aria-label={isOpen ? 'Close Assistant' : 'Open Assistant'}
> >
{isOpen ? <X size={24} /> : <MessageCircle size={24} />} {isOpen ? <X size={24} /> : <MessageCircle size={24} />}
</button> </Button>
) )
} }

View File

@@ -11,6 +11,7 @@ import { X, Bot } from 'lucide-react'
import { AssistantChat } from './AssistantChat' import { AssistantChat } from './AssistantChat'
import { useConversation } from '../hooks/useConversations' import { useConversation } from '../hooks/useConversations'
import type { ChatMessage } from '../lib/types' import type { ChatMessage } from '../lib/types'
import { Button } from '@/components/ui/button'
interface AssistantPanelProps { interface AssistantPanelProps {
projectName: string projectName: string
@@ -103,45 +104,37 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
className={` className={`
fixed right-0 top-0 bottom-0 z-50 fixed right-0 top-0 bottom-0 z-50
w-[400px] max-w-[90vw] w-[400px] max-w-[90vw]
bg-neo-card bg-card
border-l-4 border-[var(--color-neo-border)] border-l border-border
transform transition-transform duration-300 ease-out transform transition-transform duration-300 ease-out
flex flex-col flex flex-col shadow-xl
${isOpen ? 'translate-x-0' : 'translate-x-full'} ${isOpen ? 'translate-x-0' : 'translate-x-full'}
`} `}
style={{ boxShadow: 'var(--shadow-neo-left-lg)' }}
role="dialog" role="dialog"
aria-label="Project Assistant" aria-label="Project Assistant"
aria-hidden={!isOpen} aria-hidden={!isOpen}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b-3 border-neo-border bg-neo-progress"> <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-primary text-primary-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div className="bg-card text-foreground border border-border p-1.5 rounded">
className="bg-neo-card border-2 border-neo-border p-1.5"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<Bot size={18} /> <Bot size={18} />
</div> </div>
<div> <div>
<h2 className="font-display font-bold text-neo-text-on-bright">Project Assistant</h2> <h2 className="font-semibold">Project Assistant</h2>
<p className="text-xs text-neo-text-on-bright opacity-80 font-mono">{projectName}</p> <p className="text-xs opacity-80 font-mono">{projectName}</p>
</div> </div>
</div> </div>
<button <Button
variant="ghost"
size="icon"
onClick={onClose} onClick={onClose}
className=" className="text-primary-foreground hover:bg-primary-foreground/20"
neo-btn neo-btn-ghost
p-2
bg-[var(--color-neo-card)] border-[var(--color-neo-border)]
hover:bg-[var(--color-neo-bg)]
text-[var(--color-neo-text)]
"
title="Close Assistant (Press A)" title="Close Assistant (Press A)"
aria-label="Close Assistant" aria-label="Close Assistant"
> >
<X size={18} /> <X size={18} />
</button> </Button>
</div> </div>
{/* Chat area */} {/* Chat area */}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Sparkles, PartyPopper } from 'lucide-react' import { Sparkles, PartyPopper } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types' import type { AgentMascot } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
interface CelebrationOverlayProps { interface CelebrationOverlayProps {
agentName: AgentMascot | 'Unknown' agentName: AgentMascot | 'Unknown'
@@ -80,17 +81,18 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb
</div> </div>
{/* Celebration card - click to dismiss */} {/* Celebration card - click to dismiss */}
<button <Card
type="button"
onClick={dismiss} onClick={dismiss}
className="neo-card p-6 bg-[var(--color-neo-done)] animate-bounce-in pointer-events-auto cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-neo-accent" className="p-6 bg-green-500 border-green-600 animate-bounce-in pointer-events-auto cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-primary"
role="button"
tabIndex={0}
> >
<div className="flex flex-col items-center gap-4"> <CardContent className="p-0 flex flex-col items-center gap-4">
{/* Icons */} {/* Icons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles size={24} className="text-neo-pending animate-pulse" /> <Sparkles size={24} className="text-yellow-300 animate-pulse" />
<PartyPopper size={28} className="text-neo-accent" /> <PartyPopper size={28} className="text-white" />
<Sparkles size={24} className="text-neo-pending animate-pulse" /> <Sparkles size={24} className="text-yellow-300 animate-pulse" />
</div> </div>
{/* Avatar celebrating */} {/* Avatar celebrating */}
@@ -98,23 +100,23 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb
{/* Message */} {/* Message */}
<div className="text-center"> <div className="text-center">
<h3 className="font-display text-lg font-bold text-neo-text-on-bright mb-1"> <h3 className="font-display text-lg font-bold text-white mb-1">
Feature Complete! Feature Complete!
</h3> </h3>
<p className="text-sm text-neo-text-on-bright/80 max-w-[200px] truncate"> <p className="text-sm text-white/80 max-w-[200px] truncate">
{featureName} {featureName}
</p> </p>
<p className="text-xs text-neo-text-on-bright/60 mt-2"> <p className="text-xs text-white/60 mt-2">
Great job, {agentName}! Great job, {agentName}!
</p> </p>
</div> </div>
{/* Dismiss hint */} {/* Dismiss hint */}
<p className="text-xs text-neo-text-on-bright/40 mt-1"> <p className="text-xs text-white/40 mt-1">
Click or press Esc to dismiss Click or press Esc to dismiss
</p> </p>
</div> </CardContent>
</button> </Card>
</div> </div>
) )
} }

View File

@@ -2,12 +2,13 @@
* Chat Message Component * Chat Message Component
* *
* Displays a single message in the spec creation chat. * Displays a single message in the spec creation chat.
* Supports user, assistant, and system messages with neobrutalism styling. * Supports user, assistant, and system messages with clean styling.
*/ */
import { memo } from 'react' import { memo } from 'react'
import { Bot, User, Info } from 'lucide-react' import { Bot, User, Info } from 'lucide-react'
import type { ChatMessage as ChatMessageType } from '../lib/types' import type { ChatMessage as ChatMessageType } from '../lib/types'
import { Card } from '@/components/ui/card'
interface ChatMessageProps { interface ChatMessageProps {
message: ChatMessageType message: ChatMessageType
@@ -25,37 +26,34 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
minute: '2-digit', minute: '2-digit',
}) })
// Role-specific styling using CSS variables for theme consistency // Role-specific styling
const roleConfig = { const roleConfig = {
user: { user: {
icon: User, icon: User,
bgColor: 'bg-[var(--color-neo-pending)]', bgColor: 'bg-primary',
textColor: 'text-[var(--color-neo-text-on-bright)]', textColor: 'text-primary-foreground',
borderColor: 'border-[var(--color-neo-border)]',
align: 'justify-end', align: 'justify-end',
bubbleAlign: 'items-end', bubbleAlign: 'items-end',
iconBg: 'bg-[var(--color-neo-pending)]', iconBg: 'bg-primary',
shadow: 'var(--shadow-neo-md)', iconColor: 'text-primary-foreground',
}, },
assistant: { assistant: {
icon: Bot, icon: Bot,
bgColor: 'bg-[var(--color-neo-card)]', bgColor: 'bg-muted',
textColor: 'text-[var(--color-neo-text)]', textColor: 'text-foreground',
borderColor: 'border-[var(--color-neo-border)]',
align: 'justify-start', align: 'justify-start',
bubbleAlign: 'items-start', bubbleAlign: 'items-start',
iconBg: 'bg-[var(--color-neo-progress)]', iconBg: 'bg-secondary',
shadow: 'var(--shadow-neo-md)', iconColor: 'text-secondary-foreground',
}, },
system: { system: {
icon: Info, icon: Info,
bgColor: 'bg-[var(--color-neo-done)]', bgColor: 'bg-green-100 dark:bg-green-900/30',
textColor: 'text-[var(--color-neo-text-on-bright)]', textColor: 'text-green-900 dark:text-green-100',
borderColor: 'border-[var(--color-neo-border)]',
align: 'justify-center', align: 'justify-center',
bubbleAlign: 'items-center', bubbleAlign: 'items-center',
iconBg: 'bg-[var(--color-neo-done)]', iconBg: 'bg-green-500',
shadow: 'var(--shadow-neo-sm)', iconColor: 'text-white',
}, },
} }
@@ -66,15 +64,7 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
if (role === 'system') { if (role === 'system') {
return ( return (
<div className={`flex ${config.align} px-4 py-2`}> <div className={`flex ${config.align} px-4 py-2`}>
<div <div className={`${config.bgColor} border border-border rounded-lg px-4 py-2 text-sm font-mono ${config.textColor}`}>
className={`
${config.bgColor}
border-2 ${config.borderColor}
px-4 py-2
text-sm font-mono text-[var(--color-neo-text-on-bright)]
`}
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Icon size={14} /> <Icon size={14} />
{content} {content}
@@ -90,28 +80,12 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{/* Message bubble */} {/* Message bubble */}
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{role === 'assistant' && ( {role === 'assistant' && (
<div <div className={`${config.iconBg} p-1.5 rounded flex-shrink-0`}>
className={` <Icon size={16} className={config.iconColor} />
${config.iconBg}
border-2 border-[var(--color-neo-border)]
p-1.5
flex-shrink-0
`}
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<Icon size={16} className="text-[var(--color-neo-text-on-bright)]" />
</div> </div>
)} )}
<div <Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
className={`
${config.bgColor}
border-3 ${config.borderColor}
px-4 py-3
${isStreaming ? 'animate-pulse-neo' : ''}
`}
style={{ boxShadow: config.shadow }}
>
{/* Parse content for basic markdown-like formatting */} {/* Parse content for basic markdown-like formatting */}
{content && ( {content && (
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}> <div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
@@ -152,19 +126,15 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}> <div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}>
{attachments.map((attachment) => ( {attachments.map((attachment) => (
<div <div key={attachment.id} className="border border-border rounded p-1 bg-card">
key={attachment.id}
className="border-2 border-[var(--color-neo-border)] p-1 bg-[var(--color-neo-card)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
className="max-w-48 max-h-48 object-contain cursor-pointer hover:opacity-90 transition-opacity" className="max-w-48 max-h-48 object-contain cursor-pointer hover:opacity-90 transition-opacity rounded"
onClick={() => window.open(attachment.previewUrl, '_blank')} onClick={() => window.open(attachment.previewUrl, '_blank')}
title={`${attachment.filename} (click to enlarge)`} title={`${attachment.filename} (click to enlarge)`}
/> />
<span className="text-xs text-[var(--color-neo-text-secondary)] block mt-1 text-center"> <span className="text-xs text-muted-foreground block mt-1 text-center">
{attachment.filename} {attachment.filename}
</span> </span>
</div> </div>
@@ -174,27 +144,19 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{/* Streaming indicator */} {/* Streaming indicator */}
{isStreaming && ( {isStreaming && (
<span className="inline-block w-2 h-4 bg-[var(--color-neo-accent)] ml-1 animate-pulse" /> <span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded" />
)} )}
</div> </Card>
{role === 'user' && ( {role === 'user' && (
<div <div className={`${config.iconBg} p-1.5 rounded flex-shrink-0`}>
className={` <Icon size={16} className={config.iconColor} />
${config.iconBg}
border-2 border-[var(--color-neo-border)]
p-1.5
flex-shrink-0
`}
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<Icon size={16} className="text-[var(--color-neo-text-on-bright)]" />
</div> </div>
)} )}
</div> </div>
{/* Timestamp */} {/* Timestamp */}
<span className="text-xs text-[var(--color-neo-text-secondary)] font-mono px-2"> <span className="text-xs text-muted-foreground font-mono px-2">
{timeString} {timeString}
</span> </span>
</div> </div>

View File

@@ -1,12 +1,21 @@
/** /**
* ConfirmDialog Component * ConfirmDialog Component
* *
* A reusable confirmation dialog following the neobrutalism design system. * A reusable confirmation dialog using ShadCN Dialog components.
* Used to confirm destructive actions like deleting projects. * Used to confirm destructive actions like deleting projects.
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AlertTriangle, X } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
@@ -31,74 +40,39 @@ export function ConfirmDialog({
onConfirm, onConfirm,
onCancel, onCancel,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
if (!isOpen) return null
const variantColors = {
danger: {
icon: 'var(--color-neo-danger)',
button: 'neo-btn-danger',
},
warning: {
icon: 'var(--color-neo-pending)',
button: 'neo-btn-warning',
},
}
const colors = variantColors[variant]
return ( return (
<div className="neo-modal-backdrop" onClick={onCancel}> <Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<div <DialogContent className="sm:max-w-md">
className="neo-modal w-full max-w-md" <DialogHeader>
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className="p-2 border-2 border-[var(--color-neo-border)]" className={`p-2 rounded-lg ${
style={{ boxShadow: 'var(--shadow-neo-sm)', backgroundColor: colors.icon }} variant === 'danger'
? 'bg-destructive/10 text-destructive'
: 'bg-primary/10 text-primary'
}`}
> >
<AlertTriangle size={20} className="text-[var(--color-neo-text-on-bright)]" /> <AlertTriangle size={20} />
</div> </div>
<h2 className="font-display font-bold text-lg text-[var(--color-neo-text)]"> <DialogTitle>{title}</DialogTitle>
{title}
</h2>
</div> </div>
<button </DialogHeader>
onClick={onCancel} <DialogDescription asChild>
className="neo-btn neo-btn-ghost p-2" <div className="text-muted-foreground">{message}</div>
</DialogDescription>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
{cancelLabel}
</Button>
<Button
variant={variant === 'danger' ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isLoading} disabled={isLoading}
> >
<X size={20} /> {isLoading ? 'Deleting...' : confirmLabel}
</button> </Button>
</div> </DialogFooter>
</DialogContent>
{/* Content */} </Dialog>
<div className="p-6">
<div className="text-[var(--color-neo-text-secondary)] mb-6">
{message}
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="neo-btn"
disabled={isLoading}
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`neo-btn ${colors.button}`}
disabled={isLoading}
>
{isLoading ? 'Deleting...' : confirmLabel}
</button>
</div>
</div>
</div>
</div>
) )
} }

View File

@@ -10,6 +10,8 @@ import { MessageSquare, Trash2, Loader2, AlertCircle } from 'lucide-react'
import { useConversations, useDeleteConversation } from '../hooks/useConversations' import { useConversations, useDeleteConversation } from '../hooks/useConversations'
import { ConfirmDialog } from './ConfirmDialog' import { ConfirmDialog } from './ConfirmDialog'
import type { AssistantConversation } from '../lib/types' import type { AssistantConversation } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
interface ConversationHistoryProps { interface ConversationHistoryProps {
projectName: string projectName: string
@@ -116,78 +118,73 @@ export function ConversationHistory({
/> />
{/* Dropdown */} {/* Dropdown */}
<div <Card className="absolute top-full left-0 mt-2 z-50 w-[320px] max-w-[calc(100vw-2rem)] shadow-lg">
className="absolute top-full left-0 mt-2 neo-dropdown z-50 w-[320px] max-w-[calc(100vw-2rem)]"
style={{ boxShadow: 'var(--shadow-neo)' }}
>
{/* Header */} {/* Header */}
<div className="px-3 py-2 border-b-2 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <CardHeader className="p-3 border-b border-border">
<h3 className="font-bold text-sm">Conversation History</h3> <h3 className="font-bold text-sm">Conversation History</h3>
</div> </CardHeader>
{/* Content */} {/* Content */}
{isLoading ? ( <CardContent className="p-0">
<div className="p-4 flex items-center justify-center"> {isLoading ? (
<Loader2 size={20} className="animate-spin text-[var(--color-neo-text-secondary)]" /> <div className="p-4 flex items-center justify-center">
</div> <Loader2 size={20} className="animate-spin text-muted-foreground" />
) : !conversations || conversations.length === 0 ? ( </div>
<div className="p-4 text-center text-[var(--color-neo-text-secondary)] text-sm"> ) : !conversations || conversations.length === 0 ? (
No conversations yet <div className="p-4 text-center text-muted-foreground text-sm">
</div> No conversations yet
) : ( </div>
<div className="max-h-[300px] overflow-auto"> ) : (
{conversations.map((conversation) => { <div className="max-h-[300px] overflow-y-auto">
const isCurrent = conversation.id === currentConversationId {conversations.map((conversation) => {
const isCurrent = conversation.id === currentConversationId
return ( return (
<div <div
key={conversation.id} key={conversation.id}
className={`flex items-center group ${ className={`flex items-center group ${
isCurrent isCurrent ? 'bg-primary/10' : 'hover:bg-muted'
? 'bg-[var(--color-neo-pending)] text-[var(--color-neo-text-on-bright)]' }`}
: ''
}`}
>
<button
onClick={() => handleSelectConversation(conversation.id)}
className="flex-1 neo-dropdown-item text-left"
disabled={isCurrent}
> >
<div className="flex items-start gap-2"> <button
<MessageSquare size={16} className="mt-0.5 flex-shrink-0" /> onClick={() => handleSelectConversation(conversation.id)}
<div className="flex-1 min-w-0"> className="flex-1 px-3 py-2 text-left"
<div className="font-medium truncate"> disabled={isCurrent}
{conversation.title || 'Untitled conversation'} >
</div> <div className="flex items-start gap-2">
<div className={`text-xs flex items-center gap-2 ${ <MessageSquare size={16} className="mt-0.5 flex-shrink-0 text-muted-foreground" />
isCurrent <div className="flex-1 min-w-0">
? 'text-[var(--color-neo-text-on-bright)] opacity-80' <div className="font-medium truncate text-foreground">
: 'text-[var(--color-neo-text-secondary)]' {conversation.title || 'Untitled conversation'}
}`}> </div>
<span>{conversation.message_count} messages</span> <div className="text-xs flex items-center gap-2 text-muted-foreground">
<span>|</span> <span>{conversation.message_count} messages</span>
<span>{formatRelativeTime(conversation.updated_at)}</span> <span>|</span>
<span>{formatRelativeTime(conversation.updated_at)}</span>
</div>
</div> </div>
</div> </div>
</div> </button>
</button> <Button
<button variant="ghost"
onClick={(e) => handleDeleteClick(e, conversation)} size="icon"
className={`p-2 mr-2 transition-colors rounded ${ onClick={(e) => handleDeleteClick(e, conversation)}
isCurrent className={`h-8 w-8 mr-2 ${
? 'text-[var(--color-neo-text-on-bright)] opacity-60 hover:opacity-100 hover:bg-[var(--color-neo-danger)]/20' isCurrent
: 'text-[var(--color-neo-text-secondary)] opacity-0 group-hover:opacity-100 hover:text-[var(--color-neo-danger)] hover:bg-[var(--color-neo-danger)]/10' ? 'opacity-60 hover:opacity-100'
}`} : 'opacity-0 group-hover:opacity-100'
title="Delete conversation" } hover:text-destructive hover:bg-destructive/10`}
> title="Delete conversation"
<Trash2 size={14} /> >
</button> <Trash2 size={14} />
</div> </Button>
) </div>
})} )
</div> })}
)} </div>
</div> )}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<ConfirmDialog <ConfirmDialog
@@ -197,7 +194,7 @@ export function ConversationHistory({
deleteError ? ( deleteError ? (
<div className="space-y-3"> <div className="space-y-3">
<p>{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}</p> <p>{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}</p>
<div className="flex items-center gap-2 p-2 bg-[var(--color-neo-danger)]/10 border border-[var(--color-neo-danger)] rounded text-sm text-[var(--color-neo-danger)]"> <div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive rounded text-sm text-destructive">
<AlertCircle size={16} className="flex-shrink-0" /> <AlertCircle size={16} className="flex-shrink-0" />
<span>{deleteError}</span> <span>{deleteError}</span>
</div> </div>

View File

@@ -12,6 +12,8 @@ import { Terminal } from './Terminal'
import { TerminalTabs } from './TerminalTabs' import { TerminalTabs } from './TerminalTabs'
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api' import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
import type { TerminalInfo } from '@/lib/types' import type { TerminalInfo } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const MIN_HEIGHT = 150 const MIN_HEIGHT = 150
const MAX_HEIGHT = 600 const MAX_HEIGHT = 600
@@ -273,18 +275,18 @@ export function DebugLogViewer({
return 'info' return 'info'
} }
// Get color class for log level using theme CSS variables // Get color class for log level
const getLogColor = (level: LogLevel): string => { const getLogColor = (level: LogLevel): string => {
switch (level) { switch (level) {
case 'error': case 'error':
return 'text-[var(--color-neo-log-error)]' return 'text-red-500'
case 'warn': case 'warn':
return 'text-[var(--color-neo-log-warning)]' return 'text-yellow-500'
case 'debug': case 'debug':
return 'text-[var(--color-neo-log-debug)]' return 'text-blue-400'
case 'info': case 'info':
default: default:
return 'text-[var(--color-neo-log-info)]' return 'text-foreground'
} }
} }
@@ -316,89 +318,83 @@ export function DebugLogViewer({
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize group flex items-center justify-center -translate-y-1/2 z-50" className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize group flex items-center justify-center -translate-y-1/2 z-50"
onMouseDown={handleResizeStart} onMouseDown={handleResizeStart}
> >
<div className="w-16 h-1.5 bg-[var(--color-neo-border)] rounded-full group-hover:bg-[var(--color-neo-text-secondary)] transition-colors flex items-center justify-center"> <div className="w-16 h-1.5 bg-border rounded-full group-hover:bg-muted-foreground transition-colors flex items-center justify-center">
<GripHorizontal size={12} className="text-[var(--color-neo-text-muted)] group-hover:text-[var(--color-neo-text-secondary)]" /> <GripHorizontal size={12} className="text-muted-foreground group-hover:text-foreground" />
</div> </div>
</div> </div>
)} )}
{/* Header bar */} {/* Header bar */}
<div <div
className="flex items-center justify-between h-10 px-4 bg-[var(--color-neo-border)] border-t-3 border-[var(--color-neo-text)]" className="flex items-center justify-between h-10 px-4 bg-muted border-t border-border"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Collapse/expand toggle */} {/* Collapse/expand toggle */}
<button <button
onClick={onToggle} onClick={onToggle}
className="flex items-center gap-2 hover:bg-[var(--color-neo-hover-subtle)] px-2 py-1 rounded transition-colors cursor-pointer" className="flex items-center gap-2 hover:bg-accent px-2 py-1 rounded transition-colors cursor-pointer"
> >
<TerminalIcon size={16} className="text-[var(--color-neo-done)]" /> <TerminalIcon size={16} className="text-green-500" />
<span className="font-mono text-sm text-[var(--color-neo-bg)] font-bold"> <span className="font-mono text-sm text-foreground font-bold">
Debug Debug
</span> </span>
<span className="px-1.5 py-0.5 text-xs font-mono bg-[var(--color-neo-card)] text-[var(--color-neo-text-muted)] rounded" title="Toggle debug panel"> <Badge variant="secondary" className="text-xs font-mono" title="Toggle debug panel">
D D
</span> </Badge>
</button> </button>
{/* Tabs - only visible when open */} {/* Tabs - only visible when open */}
{isOpen && ( {isOpen && (
<div className="flex items-center gap-1 ml-4"> <div className="flex items-center gap-1 ml-4">
<button <Button
variant={activeTab === 'agent' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setActiveTab('agent') setActiveTab('agent')
}} }}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${ className="h-7 text-xs font-mono gap-1.5"
activeTab === 'agent'
? 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)]'
: 'text-[var(--color-neo-text-muted)] hover:text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
}`}
> >
<Cpu size={12} /> <Cpu size={12} />
Agent Agent
{logs.length > 0 && ( {logs.length > 0 && (
<span className="px-1.5 py-0.5 text-[10px] bg-[var(--color-neo-text-secondary)] text-[var(--color-neo-bg)] rounded"> <Badge variant="default" className="h-4 px-1.5 text-[10px]">
{logs.length} {logs.length}
</span> </Badge>
)} )}
</button> </Button>
<button <Button
variant={activeTab === 'devserver' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setActiveTab('devserver') setActiveTab('devserver')
}} }}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${ className="h-7 text-xs font-mono gap-1.5"
activeTab === 'devserver'
? 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)]'
: 'text-[var(--color-neo-text-muted)] hover:text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
}`}
> >
<Server size={12} /> <Server size={12} />
Dev Server Dev Server
{devLogs.length > 0 && ( {devLogs.length > 0 && (
<span className="px-1.5 py-0.5 text-[10px] bg-[var(--color-neo-text-secondary)] text-[var(--color-neo-bg)] rounded"> <Badge variant="default" className="h-4 px-1.5 text-[10px]">
{devLogs.length} {devLogs.length}
</span> </Badge>
)} )}
</button> </Button>
<button <Button
variant={activeTab === 'terminal' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setActiveTab('terminal') setActiveTab('terminal')
}} }}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${ className="h-7 text-xs font-mono gap-1.5"
activeTab === 'terminal'
? 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)]'
: 'text-[var(--color-neo-text-muted)] hover:text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
}`}
> >
<TerminalIcon size={12} /> <TerminalIcon size={12} />
Terminal Terminal
<span className="px-1.5 py-0.5 text-[10px] bg-[var(--color-neo-text-secondary)] text-[var(--color-neo-text-muted)] rounded" title="Toggle terminal"> <Badge variant="outline" className="h-4 px-1.5 text-[10px]" title="Toggle terminal">
T T
</span> </Badge>
</button> </Button>
</div> </div>
)} )}
@@ -406,14 +402,14 @@ export function DebugLogViewer({
{isOpen && activeTab !== 'terminal' && ( {isOpen && activeTab !== 'terminal' && (
<> <>
{getCurrentLogCount() > 0 && ( {getCurrentLogCount() > 0 && (
<span className="px-2 py-0.5 text-xs font-mono bg-[var(--color-neo-card)] text-[var(--color-neo-text-secondary)] rounded ml-2"> <Badge variant="secondary" className="ml-2 font-mono">
{getCurrentLogCount()} {getCurrentLogCount()}
</span> </Badge>
)} )}
{isAutoScrollPaused() && ( {isAutoScrollPaused() && (
<span className="px-2 py-0.5 text-xs font-mono bg-[var(--color-neo-pending)] text-[var(--color-neo-text-on-bright)] rounded"> <Badge variant="default" className="bg-yellow-500 text-yellow-950">
Paused Paused
</span> </Badge>
)} )}
</> </>
)} )}
@@ -422,22 +418,24 @@ export function DebugLogViewer({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Clear button - only for log tabs */} {/* Clear button - only for log tabs */}
{isOpen && activeTab !== 'terminal' && ( {isOpen && activeTab !== 'terminal' && (
<button <Button
variant="ghost"
size="icon"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleClear() handleClear()
}} }}
className="p-1.5 hover:bg-[var(--color-neo-hover-subtle)] rounded transition-colors" className="h-7 w-7"
title="Clear logs" title="Clear logs"
> >
<Trash2 size={14} className="text-[var(--color-neo-text-muted)]" /> <Trash2 size={14} className="text-muted-foreground" />
</button> </Button>
)} )}
<div className="p-1"> <div className="p-1">
{isOpen ? ( {isOpen ? (
<ChevronDown size={16} className="text-[var(--color-neo-text-muted)]" /> <ChevronDown size={16} className="text-muted-foreground" />
) : ( ) : (
<ChevronUp size={16} className="text-[var(--color-neo-text-muted)]" /> <ChevronUp size={16} className="text-muted-foreground" />
)} )}
</div> </div>
</div> </div>
@@ -445,7 +443,7 @@ export function DebugLogViewer({
{/* Content area */} {/* Content area */}
{isOpen && ( {isOpen && (
<div className="h-[calc(100%-2.5rem)] bg-[var(--color-neo-border)]"> <div className="h-[calc(100%-2.5rem)] bg-card">
{/* Agent Logs Tab */} {/* Agent Logs Tab */}
{activeTab === 'agent' && ( {activeTab === 'agent' && (
<div <div
@@ -454,7 +452,7 @@ export function DebugLogViewer({
className="h-full overflow-y-auto p-2 font-mono text-sm" className="h-full overflow-y-auto p-2 font-mono text-sm"
> >
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-muted)]"> <div className="flex items-center justify-center h-full text-muted-foreground">
No logs yet. Start the agent to see output. No logs yet. Start the agent to see output.
</div> </div>
) : ( ) : (
@@ -467,9 +465,9 @@ export function DebugLogViewer({
return ( return (
<div <div
key={`${log.timestamp}-${index}`} key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-[var(--color-neo-hover-subtle)] px-1 py-0.5 rounded" className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
> >
<span className="text-[var(--color-neo-text-muted)] select-none shrink-0"> <span className="text-muted-foreground select-none shrink-0">
{timestamp} {timestamp}
</span> </span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}> <span className={`${colorClass} whitespace-pre-wrap break-all`}>
@@ -491,7 +489,7 @@ export function DebugLogViewer({
className="h-full overflow-y-auto p-2 font-mono text-sm" className="h-full overflow-y-auto p-2 font-mono text-sm"
> >
{devLogs.length === 0 ? ( {devLogs.length === 0 ? (
<div className="flex items-center justify-center h-full text-[var(--color-neo-text-muted)]"> <div className="flex items-center justify-center h-full text-muted-foreground">
No dev server logs yet. No dev server logs yet.
</div> </div>
) : ( ) : (
@@ -504,9 +502,9 @@ export function DebugLogViewer({
return ( return (
<div <div
key={`${log.timestamp}-${index}`} key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-[var(--color-neo-hover-subtle)] px-1 py-0.5 rounded" className="flex gap-2 hover:bg-muted px-1 py-0.5 rounded"
> >
<span className="text-[var(--color-neo-text-muted)] select-none shrink-0"> <span className="text-muted-foreground select-none shrink-0">
{timestamp} {timestamp}
</span> </span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}> <span className={`${colorClass} whitespace-pre-wrap break-all`}>
@@ -538,11 +536,11 @@ export function DebugLogViewer({
{/* Terminal content - render all terminals and show/hide to preserve buffers */} {/* Terminal content - render all terminals and show/hide to preserve buffers */}
<div className="flex-1 min-h-0 relative"> <div className="flex-1 min-h-0 relative">
{isLoadingTerminals ? ( {isLoadingTerminals ? (
<div className="h-full flex items-center justify-center text-[var(--color-neo-text-muted)] font-mono text-sm"> <div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
Loading terminals... Loading terminals...
</div> </div>
) : terminals.length === 0 ? ( ) : terminals.length === 0 ? (
<div className="h-full flex items-center justify-center text-[var(--color-neo-text-muted)] font-mono text-sm"> <div className="h-full flex items-center justify-center text-muted-foreground font-mono text-sm">
No terminal available No terminal available
</div> </div>
) : ( ) : (

View File

@@ -1,5 +1,6 @@
import { AlertTriangle, GitBranch, Check } from 'lucide-react' import { AlertTriangle, GitBranch, Check } from 'lucide-react'
import type { Feature } from '../lib/types' import type { Feature } from '../lib/types'
import { Badge } from '@/components/ui/badge'
interface DependencyBadgeProps { interface DependencyBadgeProps {
feature: Feature feature: Feature
@@ -38,14 +39,13 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }:
if (compact) { if (compact) {
// Compact view for card displays // Compact view for card displays
return ( return (
<div <Badge
className={` variant="outline"
inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono className={`gap-1 font-mono text-xs ${
${isBlocked isBlocked
? 'bg-neo-danger/20 text-neo-danger' ? 'bg-destructive/10 text-destructive border-destructive/30'
: 'bg-neo-neutral-200 text-neo-text-secondary' : 'bg-muted text-muted-foreground'
} }`}
`}
title={isBlocked title={isBlocked
? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}` ? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}`
: `${satisfiedCount}/${dependencies.length} dependencies satisfied` : `${satisfiedCount}/${dependencies.length} dependencies satisfied`
@@ -62,7 +62,7 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }:
<span>{satisfiedCount}/{dependencies.length}</span> <span>{satisfiedCount}/{dependencies.length}</span>
</> </>
)} )}
</div> </Badge>
) )
} }
@@ -70,15 +70,15 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }:
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isBlocked ? ( {isBlocked ? (
<div className="flex items-center gap-1.5 text-sm text-neo-danger"> <div className="flex items-center gap-1.5 text-sm text-destructive">
<AlertTriangle size={14} /> <AlertTriangle size={14} />
<span className="font-medium"> <span className="font-medium">
Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'} Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'}
</span> </span>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1.5 text-sm text-neo-text-secondary"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Check size={14} className="text-neo-done" /> <Check size={14} className="text-primary" />
<span> <span>
All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied
</span> </span>
@@ -102,7 +102,7 @@ export function DependencyIndicator({ feature }: { feature: Feature }) {
if (isBlocked) { if (isBlocked) {
return ( return (
<span <span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-danger/20 text-neo-danger" className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-destructive/10 text-destructive"
title={`Blocked by ${feature.blocking_dependencies?.length || 0} dependencies`} title={`Blocked by ${feature.blocking_dependencies?.length || 0} dependencies`}
> >
<AlertTriangle size={12} /> <AlertTriangle size={12} />
@@ -112,7 +112,7 @@ export function DependencyIndicator({ feature }: { feature: Feature }) {
return ( return (
<span <span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-neutral-200 text-neo-text-secondary" className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-muted text-muted-foreground"
title={`${dependencies.length} dependencies (all satisfied)`} title={`${dependencies.length} dependencies (all satisfied)`}
> >
<GitBranch size={12} /> <GitBranch size={12} />

View File

@@ -18,6 +18,8 @@ import dagre from 'dagre'
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react' import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types' import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
// Node dimensions // Node dimensions
@@ -69,20 +71,17 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="h-full w-full flex items-center justify-center bg-neo-neutral-100"> <div className="h-full w-full flex items-center justify-center bg-muted">
<div className="text-center p-6"> <div className="text-center p-6">
<AlertTriangle size={48} className="mx-auto mb-4 text-neo-warning" /> <AlertTriangle size={48} className="mx-auto mb-4 text-yellow-500" />
<div className="text-neo-text font-bold mb-2">Graph rendering error</div> <div className="text-foreground font-bold mb-2">Graph rendering error</div>
<div className="text-sm text-neo-text-secondary mb-4"> <div className="text-sm text-muted-foreground mb-4">
The dependency graph encountered an issue. The dependency graph encountered an issue.
</div> </div>
<button <Button onClick={this.handleReset} className="gap-2">
onClick={this.handleReset}
className="inline-flex items-center gap-2 px-4 py-2 bg-neo-accent text-white rounded border-2 border-neo-border shadow-neo-sm hover:shadow-neo-md transition-all"
>
<RefreshCw size={16} /> <RefreshCw size={16} />
Reload Graph Reload Graph
</button> </Button>
</div> </div>
</div> </div>
) )
@@ -95,32 +94,39 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
// Custom node component // Custom node component
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) { function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
const statusColors = { const statusColors = {
pending: 'bg-neo-pending border-neo-border', pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
in_progress: 'bg-neo-progress border-neo-border', in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
done: 'bg-neo-done border-neo-border', done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
blocked: 'bg-neo-danger/20 border-neo-danger', blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
}
const textColors = {
pending: 'text-yellow-900 dark:text-yellow-100',
in_progress: 'text-cyan-900 dark:text-cyan-100',
done: 'text-green-900 dark:text-green-100',
blocked: 'text-red-900 dark:text-red-100',
} }
const StatusIcon = () => { const StatusIcon = () => {
switch (data.status) { switch (data.status) {
case 'done': case 'done':
return <CheckCircle2 size={16} className="text-neo-text-on-bright" /> return <CheckCircle2 size={16} className={textColors[data.status]} />
case 'in_progress': case 'in_progress':
return <Loader2 size={16} className="text-neo-text-on-bright animate-spin" /> return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
case 'blocked': case 'blocked':
return <AlertTriangle size={16} className="text-neo-danger" /> return <AlertTriangle size={16} className="text-destructive" />
default: default:
return <Circle size={16} className="text-neo-text-on-bright" /> return <Circle size={16} className={textColors[data.status]} />
} }
} }
return ( return (
<> <>
<Handle type="target" position={Position.Left} className="!bg-neo-border !w-2 !h-2" /> <Handle type="target" position={Position.Left} className="!bg-border !w-2 !h-2" />
<div <div
className={` className={`
px-4 py-3 rounded-lg border-2 cursor-pointer px-4 py-3 rounded-lg border-2 cursor-pointer
transition-all hover:shadow-neo-md relative transition-all hover:shadow-md relative
${statusColors[data.status]} ${statusColors[data.status]}
`} `}
onClick={data.onClick} onClick={data.onClick}
@@ -129,31 +135,31 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
{/* Agent avatar badge - positioned at top right */} {/* Agent avatar badge - positioned at top right */}
{data.agent && ( {data.agent && (
<div className="absolute -top-3 -right-3 z-10"> <div className="absolute -top-3 -right-3 z-10">
<div className="rounded-full border-2 border-neo-border bg-white shadow-neo-sm"> <div className="rounded-full border-2 border-border bg-background shadow-sm">
<AgentAvatar name={data.agent.name} state={data.agent.state} size="sm" /> <AgentAvatar name={data.agent.name} state={data.agent.state} size="sm" />
</div> </div>
</div> </div>
)} )}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<StatusIcon /> <StatusIcon />
<span className="text-xs font-mono text-neo-text-on-bright/70"> <span className={`text-xs font-mono ${textColors[data.status]} opacity-70`}>
#{data.priority} #{data.priority}
</span> </span>
{/* Show agent name inline if present */} {/* Show agent name inline if present */}
{data.agent && ( {data.agent && (
<span className="text-xs font-bold text-neo-text-on-bright ml-auto"> <span className={`text-xs font-bold ${textColors[data.status]} ml-auto`}>
{data.agent.name} {data.agent.name}
</span> </span>
)} )}
</div> </div>
<div className="font-bold text-sm text-neo-text-on-bright truncate" title={data.name}> <div className={`font-bold text-sm ${textColors[data.status]} truncate`} title={data.name}>
{data.name} {data.name}
</div> </div>
<div className="text-xs text-neo-text-on-bright/70 truncate" title={data.category}> <div className={`text-xs ${textColors[data.status]} opacity-70 truncate`} title={data.category}>
{data.category} {data.category}
</div> </div>
</div> </div>
<Handle type="source" position={Position.Right} className="!bg-neo-border !w-2 !h-2" /> <Handle type="source" position={Position.Right} className="!bg-border !w-2 !h-2" />
</> </>
) )
} }
@@ -249,10 +255,10 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
target: String(edge.target), target: String(edge.target),
type: 'smoothstep', type: 'smoothstep',
animated: false, animated: false,
style: { stroke: 'var(--color-neo-border)', strokeWidth: 2 }, style: { stroke: '#a1a1aa', strokeWidth: 2 },
markerEnd: { markerEnd: {
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
color: 'var(--color-neo-border)', color: '#a1a1aa',
}, },
})) }))
@@ -308,22 +314,22 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
const status = (node.data as unknown as GraphNode).status const status = (node.data as unknown as GraphNode).status
switch (status) { switch (status) {
case 'done': case 'done':
return 'var(--color-neo-done)' return '#22c55e' // green-500
case 'in_progress': case 'in_progress':
return 'var(--color-neo-progress)' return '#06b6d4' // cyan-500
case 'blocked': case 'blocked':
return 'var(--color-neo-danger)' return '#ef4444' // red-500
default: default:
return 'var(--color-neo-pending)' return '#eab308' // yellow-500
} }
}, []) }, [])
if (graphData.nodes.length === 0) { if (graphData.nodes.length === 0) {
return ( return (
<div className="h-full w-full flex items-center justify-center bg-neo-neutral-100"> <div className="h-full w-full flex items-center justify-center bg-muted">
<div className="text-center"> <div className="text-center">
<div className="text-neo-text-secondary mb-2">No features to display</div> <div className="text-muted-foreground mb-2">No features to display</div>
<div className="text-sm text-neo-text-muted"> <div className="text-sm text-muted-foreground/70">
Create features to see the dependency graph Create features to see the dependency graph
</div> </div>
</div> </div>
@@ -332,57 +338,49 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
} }
return ( return (
<div className="h-full w-full relative bg-neo-neutral-50"> <div className="h-full w-full relative bg-background">
{/* Layout toggle */} {/* Layout toggle */}
<div className="absolute top-4 left-4 z-10 flex gap-2"> <div className="absolute top-4 left-4 z-10 flex gap-2">
<button <Button
variant={direction === 'LR' ? 'default' : 'outline'}
size="sm"
onClick={() => onLayout('LR')} onClick={() => onLayout('LR')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'LR'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
> >
Horizontal Horizontal
</button> </Button>
<button <Button
variant={direction === 'TB' ? 'default' : 'outline'}
size="sm"
onClick={() => onLayout('TB')} onClick={() => onLayout('TB')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'TB'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
> >
Vertical Vertical
</button> </Button>
</div> </div>
{/* Legend */} {/* Legend */}
<div className="absolute top-4 right-4 z-10 bg-white border-2 border-neo-border rounded-lg p-3 shadow-neo-sm"> <Card className="absolute top-4 right-4 z-10">
<div className="text-xs font-bold mb-2">Status</div> <CardContent className="p-3">
<div className="space-y-1.5"> <div className="text-xs font-bold mb-2">Status</div>
<div className="flex items-center gap-2 text-xs"> <div className="space-y-1.5">
<div className="w-3 h-3 rounded bg-neo-pending border border-neo-border" /> <div className="flex items-center gap-2 text-xs">
<span>Pending</span> <div className="w-3 h-3 rounded bg-yellow-400 border border-yellow-500" />
<span>Pending</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-cyan-400 border border-cyan-500" />
<span>In Progress</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-green-400 border border-green-500" />
<span>Done</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-red-100 border border-red-400" />
<span>Blocked</span>
</div>
</div> </div>
<div className="flex items-center gap-2 text-xs"> </CardContent>
<div className="w-3 h-3 rounded bg-neo-progress border border-neo-border" /> </Card>
<span>In Progress</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-done border border-neo-border" />
<span>Done</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-danger/20 border border-neo-danger" />
<span>Blocked</span>
</div>
</div>
</div>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
@@ -397,14 +395,14 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
minZoom={0.1} minZoom={0.1}
maxZoom={2} maxZoom={2}
> >
<Background color="var(--color-neo-neutral-300)" gap={20} size={1} /> <Background color="#d4d4d8" gap={20} size={1} />
<Controls <Controls
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm" className="!bg-card !border !border-border !rounded-lg !shadow-sm"
showInteractive={false} showInteractive={false}
/> />
<MiniMap <MiniMap
nodeColor={nodeColor} nodeColor={nodeColor}
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm" className="!bg-card !border !border-border !rounded-lg !shadow-sm"
maskColor="rgba(0, 0, 0, 0.1)" maskColor="rgba(0, 0, 0, 0.1)"
/> />
</ReactFlow> </ReactFlow>

View File

@@ -2,6 +2,7 @@ import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-reac
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { DevServerStatus } from '../lib/types' import type { DevServerStatus } from '../lib/types'
import { startDevServer, stopDevServer } from '../lib/api' import { startDevServer, stopDevServer } from '../lib/api'
import { Button } from '@/components/ui/button'
// Re-export DevServerStatus from lib/types for consumers that import from here // Re-export DevServerStatus from lib/types for consumers that import from here
export type { DevServerStatus } export type { DevServerStatus }
@@ -86,14 +87,11 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isStopped ? ( {isStopped ? (
<button <Button
onClick={handleStart} onClick={handleStart}
disabled={isLoading} disabled={isLoading}
className="neo-btn text-sm py-2 px-3" variant={isCrashed ? "destructive" : "outline"}
style={isCrashed ? { size="sm"
backgroundColor: 'var(--color-neo-danger)',
color: 'var(--color-neo-text-on-bright)',
} : undefined}
title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"} title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"}
aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"} aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"}
> >
@@ -104,16 +102,13 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
) : ( ) : (
<Globe size={18} /> <Globe size={18} />
)} )}
</button> </Button>
) : ( ) : (
<button <Button
onClick={handleStop} onClick={handleStop}
disabled={isLoading} disabled={isLoading}
className="neo-btn text-sm py-2 px-3" size="sm"
style={{ className="bg-primary text-primary-foreground hover:bg-primary/90"
backgroundColor: 'var(--color-neo-progress)',
color: 'var(--color-neo-text-on-bright)',
}}
title="Stop Dev Server" title="Stop Dev Server"
aria-label="Stop Dev Server" aria-label="Stop Dev Server"
> >
@@ -122,31 +117,31 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP
) : ( ) : (
<Square size={18} /> <Square size={18} />
)} )}
</button> </Button>
)} )}
{/* Show URL as clickable link when server is running */} {/* Show URL as clickable link when server is running */}
{isRunning && url && ( {isRunning && url && (
<a <Button
href={url} asChild
target="_blank" size="sm"
rel="noopener noreferrer" className="bg-primary text-primary-foreground hover:bg-primary/90 gap-1"
className="neo-btn text-sm py-2 px-3 gap-1"
style={{
backgroundColor: 'var(--color-neo-progress)',
color: 'var(--color-neo-text-on-bright)',
textDecoration: 'none',
}}
title={`Open ${url} in new tab`}
> >
<span className="font-mono text-xs">{url}</span> <a
<ExternalLink size={14} /> href={url}
</a> target="_blank"
rel="noopener noreferrer"
title={`Open ${url} in new tab`}
>
<span className="font-mono text-xs">{url}</span>
<ExternalLink size={14} />
</a>
</Button>
)} )}
{/* Error display */} {/* Error display */}
{(startDevServerMutation.error || stopDevServerMutation.error) && ( {(startDevServerMutation.error || stopDevServerMutation.error) && (
<span className="text-xs font-mono text-[var(--color-neo-danger)] ml-2"> <span className="text-xs font-mono text-destructive ml-2">
{String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')} {String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')}
</span> </span>
)} )}

View File

@@ -2,6 +2,18 @@ import { useState, useId } from 'react'
import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
import { useUpdateFeature } from '../hooks/useProjects' import { useUpdateFeature } from '../hooks/useProjects'
import type { Feature } from '../lib/types' import type { Feature } from '../lib/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface Step { interface Step {
id: string id: string
@@ -83,149 +95,135 @@ export function EditFeatureForm({ feature, projectName, onClose, onSaved }: Edit
JSON.stringify(currentSteps) !== JSON.stringify(feature.steps) JSON.stringify(currentSteps) !== JSON.stringify(feature.steps)
return ( return (
<div className="neo-modal-backdrop" onClick={onClose}> <Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<div <DialogContent className="sm:max-w-2xl">
className="neo-modal w-full max-w-2xl" <DialogHeader>
onClick={(e) => e.stopPropagation()} <DialogTitle>Edit Feature</DialogTitle>
> </DialogHeader>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
<h2 className="font-display text-2xl font-bold">
Edit Feature
</h2>
<button
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={24} />
</button>
</div>
{/* Form */} <form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)]"> <Alert variant="destructive">
<AlertCircle size={20} /> <AlertCircle className="h-4 w-4" />
<span>{error}</span> <AlertDescription className="flex items-center justify-between">
<button <span>{error}</span>
type="button" <Button
onClick={() => setError(null)} type="button"
className="ml-auto hover:opacity-70 transition-opacity" variant="ghost"
> size="icon-xs"
<X size={16} /> onClick={() => setError(null)}
</button> >
</div> <X size={14} />
</Button>
</AlertDescription>
</Alert>
)} )}
{/* Category & Priority Row */} {/* Category & Priority Row */}
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1"> <div className="flex-1 space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="category">Category</Label>
Category <Input
</label> id="category"
<input
type="text" type="text"
value={category} value={category}
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategory(e.target.value)}
placeholder="e.g., Authentication, UI, API" placeholder="e.g., Authentication, UI, API"
className="neo-input"
required required
/> />
</div> </div>
<div className="w-32"> <div className="w-32 space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="priority">Priority</Label>
Priority <Input
</label> id="priority"
<input
type="number" type="number"
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value)} onChange={(e) => setPriority(e.target.value)}
min="1" min="1"
className="neo-input"
required required
/> />
</div> </div>
</div> </div>
{/* Name */} {/* Name */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="name">Feature Name</Label>
Feature Name <Input
</label> id="name"
<input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g., User login form" placeholder="e.g., User login form"
className="neo-input"
required required
/> />
</div> </div>
{/* Description */} {/* Description */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label htmlFor="description">Description</Label>
Description <Textarea
</label> id="description"
<textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this feature should do..." placeholder="Describe what this feature should do..."
className="neo-input min-h-[100px] resize-y" className="min-h-[100px] resize-y"
required required
/> />
</div> </div>
{/* Steps */} {/* Steps */}
<div> <div className="space-y-2">
<label className="block font-display font-bold mb-2 uppercase text-sm"> <Label>Test Steps</Label>
Test Steps
</label>
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step, index) => ( {steps.map((step, index) => (
<div key={step.id} className="flex gap-2 items-center"> <div key={step.id} className="flex gap-2 items-center">
<span <span className="w-10 h-10 flex-shrink-0 flex items-center justify-center font-mono font-semibold text-sm border rounded-md bg-muted text-muted-foreground">
className="w-10 h-10 flex-shrink-0 flex items-center justify-center font-mono font-bold text-sm border-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)] text-[var(--color-neo-text-secondary)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
{index + 1} {index + 1}
</span> </span>
<input <Input
type="text" type="text"
value={step.value} value={step.value}
onChange={(e) => handleStepChange(step.id, e.target.value)} onChange={(e) => handleStepChange(step.id, e.target.value)}
placeholder="Describe this step..." placeholder="Describe this step..."
className="neo-input flex-1" className="flex-1"
/> />
{steps.length > 1 && ( {steps.length > 1 && (
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveStep(step.id)} onClick={() => handleRemoveStep(step.id)}
className="neo-btn neo-btn-ghost p-2"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </Button>
)} )}
</div> </div>
))} ))}
</div> </div>
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
onClick={handleAddStep} onClick={handleAddStep}
className="neo-btn neo-btn-ghost mt-2 text-sm"
> >
<Plus size={16} /> <Plus size={16} />
Add Step Add Step
</button> </Button>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]"> <DialogFooter className="pt-4 border-t">
<button <Button
type="button"
variant="outline"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit" type="submit"
disabled={!isValid || !hasChanges || updateFeature.isPending} disabled={!isValid || !hasChanges || updateFeature.isPending}
className="neo-btn neo-btn-success flex-1"
> >
{updateFeature.isPending ? ( {updateFeature.isPending ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
@@ -235,17 +233,10 @@ export function EditFeatureForm({ feature, projectName, onClose, onSaved }: Edit
Save Changes Save Changes
</> </>
)} )}
</button> </Button>
<button </DialogFooter>
type="button"
onClick={onClose}
className="neo-btn neo-btn-ghost"
>
Cancel
</button>
</div>
</form> </form>
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -11,6 +11,10 @@ import { useExpandChat } from '../hooks/useExpandChat'
import { ChatMessage } from './ChatMessage' import { ChatMessage } from './ChatMessage'
import { TypingIndicator } from './TypingIndicator' import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types' import type { ImageAttachment } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants // Image upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
@@ -152,28 +156,28 @@ export function ExpandProjectChat({
switch (connectionStatus) { switch (connectionStatus) {
case 'connected': case 'connected':
return ( return (
<span className="flex items-center gap-1 text-xs text-neo-done"> <span className="flex items-center gap-1 text-xs text-green-500">
<Wifi size={12} /> <Wifi size={12} />
Connected Connected
</span> </span>
) )
case 'connecting': case 'connecting':
return ( return (
<span className="flex items-center gap-1 text-xs text-neo-pending"> <span className="flex items-center gap-1 text-xs text-yellow-500">
<Wifi size={12} className="animate-pulse" /> <Wifi size={12} className="animate-pulse" />
Connecting... Connecting...
</span> </span>
) )
case 'error': case 'error':
return ( return (
<span className="flex items-center gap-1 text-xs text-neo-danger"> <span className="flex items-center gap-1 text-xs text-destructive">
<WifiOff size={12} /> <WifiOff size={12} />
Error Error
</span> </span>
) )
default: default:
return ( return (
<span className="flex items-center gap-1 text-xs text-neo-text-secondary"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
<WifiOff size={12} /> <WifiOff size={12} />
Disconnected Disconnected
</span> </span>
@@ -182,16 +186,16 @@ export function ExpandProjectChat({
} }
return ( return (
<div className="flex flex-col h-full bg-neo-bg"> <div className="flex flex-col h-full bg-background">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-neo-border bg-neo-card"> <div className="flex items-center justify-between p-4 border-b-2 border-border bg-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="font-display font-bold text-lg text-neo-text"> <h2 className="font-display font-bold text-lg text-foreground">
Expand Project: {projectName} Expand Project: {projectName}
</h2> </h2>
<ConnectionIndicator /> <ConnectionIndicator />
{featuresCreated > 0 && ( {featuresCreated > 0 && (
<span className="flex items-center gap-1 text-sm text-neo-done font-bold"> <span className="flex items-center gap-1 text-sm text-green-500 font-bold">
<Plus size={14} /> <Plus size={14} />
{featuresCreated} added {featuresCreated} added
</span> </span>
@@ -200,57 +204,63 @@ export function ExpandProjectChat({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isComplete && ( {isComplete && (
<span className="flex items-center gap-1 text-sm text-neo-done font-bold"> <span className="flex items-center gap-1 text-sm text-green-500 font-bold">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
Complete Complete
</span> </span>
)} )}
<button <Button
onClick={onCancel} onClick={onCancel}
className="neo-btn neo-btn-ghost p-2" variant="ghost"
size="icon"
title="Close" title="Close"
> >
<X size={20} /> <X size={20} />
</button> </Button>
</div> </div>
</div> </div>
{/* Error banner */} {/* Error banner */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-neo-error-bg text-neo-error-text border-b-3 border-neo-error-border"> <Alert variant="destructive" className="rounded-none border-x-0 border-t-0">
<AlertCircle size={16} /> <AlertCircle size={16} />
<span className="flex-1 text-sm">{error}</span> <AlertDescription className="flex-1">{error}</AlertDescription>
<button <Button
onClick={() => setError(null)} onClick={() => setError(null)}
className="p-1 hover:opacity-70 transition-opacity rounded" variant="ghost"
size="icon"
className="h-6 w-6"
> >
<X size={14} /> <X size={14} />
</button> </Button>
</div> </Alert>
)} )}
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
{messages.length === 0 && !isLoading && ( {messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center p-8"> <div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="neo-card p-6 max-w-md"> <Card className="p-6 max-w-md">
<h3 className="font-display font-bold text-lg mb-2"> <CardContent className="p-0">
Starting Project Expansion <h3 className="font-display font-bold text-lg mb-2">
</h3> Starting Project Expansion
<p className="text-sm text-neo-text-secondary"> </h3>
Connecting to Claude to help you add new features to your project... <p className="text-sm text-muted-foreground">
</p> Connecting to Claude to help you add new features to your project...
{connectionStatus === 'error' && ( </p>
<button {connectionStatus === 'error' && (
onClick={start} <Button
className="neo-btn neo-btn-primary mt-4 text-sm" onClick={start}
> className="mt-4"
<RotateCcw size={14} /> size="sm"
Retry Connection >
</button> <RotateCcw size={14} />
)} Retry Connection
</div> </Button>
)}
</CardContent>
</Card>
</div> </div>
)} )}
@@ -268,7 +278,7 @@ export function ExpandProjectChat({
{/* Input area */} {/* Input area */}
{!isComplete && ( {!isComplete && (
<div <div
className="p-4 border-t-3 border-neo-border bg-neo-card" className="p-4 border-t-2 border-border bg-card"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
> >
@@ -278,22 +288,21 @@ export function ExpandProjectChat({
{pendingAttachments.map((attachment) => ( {pendingAttachments.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
className="relative group border-2 border-neo-border p-1 bg-neo-card" className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
> >
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
className="w-16 h-16 object-cover" className="w-16 h-16 object-cover rounded"
/> />
<button <button
onClick={() => handleRemoveAttachment(attachment.id)} onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-neo-danger text-neo-text-on-bright rounded-full p-0.5 border-2 border-neo-border hover:scale-110 transition-transform" className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
title="Remove attachment" title="Remove attachment"
> >
<X size={12} /> <X size={12} />
</button> </button>
<span className="text-xs truncate block max-w-16 mt-1 text-center"> <span className="text-xs truncate block max-w-16 mt-1 text-center text-muted-foreground">
{attachment.filename.length > 10 {attachment.filename.length > 10
? `${attachment.filename.substring(0, 7)}...` ? `${attachment.filename.substring(0, 7)}...`
: attachment.filename} : attachment.filename}
@@ -315,16 +324,17 @@ export function ExpandProjectChat({
/> />
{/* Attach button */} {/* Attach button */}
<button <Button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={connectionStatus !== 'connected'} disabled={connectionStatus !== 'connected'}
className="neo-btn neo-btn-ghost p-3" variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)" title="Attach image (JPEG, PNG - max 5MB)"
> >
<Paperclip size={18} /> <Paperclip size={18} />
</button> </Button>
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={input} value={input}
@@ -335,24 +345,24 @@ export function ExpandProjectChat({
? 'Add a message with your image(s)...' ? 'Add a message with your image(s)...'
: 'Describe the features you want to add...' : 'Describe the features you want to add...'
} }
className="neo-input flex-1" className="flex-1"
disabled={isLoading || connectionStatus !== 'connected'} disabled={isLoading || connectionStatus !== 'connected'}
/> />
<button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={ disabled={
(!input.trim() && pendingAttachments.length === 0) || (!input.trim() && pendingAttachments.length === 0) ||
isLoading || isLoading ||
connectionStatus !== 'connected' connectionStatus !== 'connected'
} }
className="neo-btn neo-btn-primary px-6" className="px-6"
> >
<Send size={18} /> <Send size={18} />
</button> </Button>
</div> </div>
{/* Help text */} {/* Help text */}
<p className="text-xs text-neo-text-secondary mt-2"> <p className="text-xs text-muted-foreground mt-2">
Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images. Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images.
</p> </p>
</div> </div>
@@ -360,7 +370,7 @@ export function ExpandProjectChat({
{/* Completion footer */} {/* Completion footer */}
{isComplete && ( {isComplete && (
<div className="p-4 border-t-3 border-neo-border bg-neo-done text-neo-text-on-bright"> <div className="p-4 border-t-2 border-border bg-green-500 text-white">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle2 size={20} /> <CheckCircle2 size={20} />
@@ -368,12 +378,12 @@ export function ExpandProjectChat({
Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}! Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}!
</span> </span>
</div> </div>
<button <Button
onClick={() => onComplete(featuresCreated)} onClick={() => onComplete(featuresCreated)}
className="neo-btn bg-neo-card" variant="secondary"
> >
Close Close
</button> </Button>
</div> </div>
</div> </div>
)} )}

View File

@@ -30,7 +30,7 @@ export function ExpandProjectModal({
} }
return ( return (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]"> <div className="fixed inset-0 z-50 bg-background">
<ExpandProjectChat <ExpandProjectChat
projectName={projectName} projectName={projectName}
onComplete={handleComplete} onComplete={handleComplete}

View File

@@ -2,26 +2,27 @@ import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types' import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge' import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface FeatureCardProps { interface FeatureCardProps {
feature: Feature feature: Feature
onClick: () => void onClick: () => void
isInProgress?: boolean isInProgress?: boolean
allFeatures?: Feature[] allFeatures?: Feature[]
activeAgent?: ActiveAgent // Agent working on this feature activeAgent?: ActiveAgent
} }
// Generate consistent color for category using CSS variable references // Generate consistent color for category
// These map to the --color-neo-category-* variables defined in globals.css
function getCategoryColor(category: string): string { function getCategoryColor(category: string): string {
const colors = [ const colors = [
'var(--color-neo-category-pink)', 'bg-pink-500',
'var(--color-neo-category-cyan)', 'bg-cyan-500',
'var(--color-neo-category-green)', 'bg-green-500',
'var(--color-neo-category-yellow)', 'bg-yellow-500',
'var(--color-neo-category-orange)', 'bg-orange-500',
'var(--color-neo-category-purple)', 'bg-purple-500',
'var(--color-neo-category-blue)', 'bg-blue-500',
] ]
let hash = 0 let hash = 0
@@ -38,86 +39,85 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
const hasActiveAgent = !!activeAgent const hasActiveAgent = !!activeAgent
return ( return (
<button <Card
onClick={onClick} onClick={onClick}
className={` className={`
w-full text-left neo-card p-4 cursor-pointer relative cursor-pointer transition-all hover:border-primary py-3
${isInProgress ? 'animate-pulse-neo' : ''} ${isInProgress ? 'animate-pulse' : ''}
${feature.passes ? 'border-neo-done' : ''} ${feature.passes ? 'border-primary/50' : ''}
${isBlocked && !feature.passes ? 'border-neo-danger opacity-80' : ''} ${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-neo-progress ring-offset-2' : ''} ${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
`} `}
> >
{/* Header */} <CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2 mb-2"> {/* Header */}
<div className="flex items-center gap-2"> <div className="flex items-start justify-between gap-2">
<span <div className="flex items-center gap-2">
className="neo-badge" <Badge className={`${categoryColor} text-white`}>
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }} {feature.category}
> </Badge>
{feature.category} <DependencyBadge feature={feature} allFeatures={allFeatures} compact />
</span>
<DependencyBadge feature={feature} allFeatures={allFeatures} compact />
</div>
<span className="font-mono text-sm text-neo-text-secondary">
#{feature.priority}
</span>
</div>
{/* Name */}
<h3 className="font-display font-bold mb-1 line-clamp-2">
{feature.name}
</h3>
{/* Description */}
<p className="text-sm text-neo-text-secondary line-clamp-2 mb-3">
{feature.description}
</p>
{/* Agent working on this feature */}
{activeAgent && (
<div className="flex items-center gap-2 mb-3 py-2 px-2 rounded bg-[var(--color-neo-progress)]/10 border border-[var(--color-neo-progress)]/30">
<AgentAvatar name={activeAgent.agentName} state={activeAgent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-neo-progress">
{activeAgent.agentName} is working on this!
</div>
{activeAgent.thought && (
<div className="flex items-center gap-1 mt-0.5">
<MessageCircle size={10} className="text-neo-text-secondary shrink-0" />
<p className="text-[10px] text-neo-text-secondary truncate italic">
{activeAgent.thought}
</p>
</div>
)}
</div> </div>
<span className="font-mono text-sm text-muted-foreground">
#{feature.priority}
</span>
</div> </div>
)}
{/* Status */} {/* Name */}
<div className="flex items-center gap-2 text-sm"> <h3 className="font-semibold line-clamp-2">
{isInProgress ? ( {feature.name}
<> </h3>
<Loader2 size={16} className="animate-spin text-neo-progress" />
<span className="text-neo-progress font-bold">Processing...</span> {/* Description */}
</> <p className="text-sm text-muted-foreground line-clamp-2">
) : feature.passes ? ( {feature.description}
<> </p>
<CheckCircle2 size={16} className="text-neo-done" />
<span className="text-neo-done font-bold">Complete</span> {/* Agent working on this feature */}
</> {activeAgent && (
) : isBlocked ? ( <div className="flex items-center gap-2 py-2 px-2 rounded-md bg-primary/10 border border-primary/30">
<> <AgentAvatar name={activeAgent.agentName} state={activeAgent.state} size="sm" />
<Circle size={16} className="text-neo-danger" /> <div className="flex-1 min-w-0">
<span className="text-neo-danger">Blocked</span> <div className="text-xs font-semibold text-primary">
</> {activeAgent.agentName} is working on this!
) : ( </div>
<> {activeAgent.thought && (
<Circle size={16} className="text-neo-text-secondary" /> <div className="flex items-center gap-1 mt-0.5">
<span className="text-neo-text-secondary">Pending</span> <MessageCircle size={10} className="text-muted-foreground shrink-0" />
</> <p className="text-[10px] text-muted-foreground truncate italic">
{activeAgent.thought}
</p>
</div>
)}
</div>
</div>
)} )}
</div>
</button> {/* Status */}
<div className="flex items-center gap-2 text-sm">
{isInProgress ? (
<>
<Loader2 size={16} className="animate-spin text-primary" />
<span className="text-primary font-medium">Processing...</span>
</>
) : feature.passes ? (
<>
<CheckCircle2 size={16} className="text-primary" />
<span className="text-primary font-medium">Complete</span>
</>
) : isBlocked ? (
<>
<Circle size={16} className="text-destructive" />
<span className="text-destructive">Blocked</span>
</>
) : (
<>
<Circle size={16} className="text-muted-foreground" />
<span className="text-muted-foreground">Pending</span>
</>
)}
</div>
</CardContent>
</Card>
) )
} }

View File

@@ -3,17 +3,28 @@ import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pen
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects' import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm' import { EditFeatureForm } from './EditFeatureForm'
import type { Feature } from '../lib/types' import type { Feature } from '../lib/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
// Generate consistent color for category (matches FeatureCard pattern) // Generate consistent color for category
function getCategoryColor(category: string): string { function getCategoryColor(category: string): string {
const colors = [ const colors = [
'#ff006e', // pink (accent) 'bg-pink-500',
'#00b4d8', // cyan (progress) 'bg-cyan-500',
'#70e000', // green (done) 'bg-green-500',
'#ffd60a', // yellow (pending) 'bg-yellow-500',
'#ff5400', // orange (danger) 'bg-orange-500',
'#8338ec', // purple 'bg-purple-500',
'#3a86ff', // blue 'bg-blue-500',
] ]
let hash = 0 let hash = 0
@@ -90,109 +101,91 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
} }
return ( return (
<div className="neo-modal-backdrop" onClick={onClose}> <Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<div <DialogContent className="sm:max-w-2xl p-0 gap-0">
className="neo-modal w-full max-w-2xl p-0"
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between p-6 border-b-3 border-[var(--color-neo-border)]"> <DialogHeader className="p-6 pb-4">
<div> <div className="flex items-start gap-3">
<span <Badge className={`${getCategoryColor(feature.category)} text-white`}>
className="neo-badge mb-2"
style={{ backgroundColor: getCategoryColor(feature.category), color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category} {feature.category}
</span> </Badge>
<h2 className="font-display text-2xl font-bold">
{feature.name}
</h2>
</div> </div>
<button <DialogTitle className="text-xl mt-2">{feature.name}</DialogTitle>
onClick={onClose} </DialogHeader>
className="neo-btn neo-btn-ghost p-2"
> <Separator />
<X size={24} />
</button>
</div>
{/* Content */} {/* Content */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)]"> <Alert variant="destructive">
<AlertCircle size={20} /> <AlertCircle className="h-4 w-4" />
<span>{error}</span> <AlertDescription className="flex items-center justify-between">
<button <span>{error}</span>
onClick={() => setError(null)} <Button
className="ml-auto hover:opacity-70 transition-opacity" variant="ghost"
> size="icon-xs"
<X size={16} /> onClick={() => setError(null)}
</button> >
</div> <X size={14} />
</Button>
</AlertDescription>
</Alert>
)} )}
{/* Status */} {/* Status */}
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]"> <div className="flex items-center gap-3 p-4 bg-muted rounded-lg">
{feature.passes ? ( {feature.passes ? (
<> <>
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" /> <CheckCircle2 size={24} className="text-primary" />
<span className="font-display font-bold text-[var(--color-neo-done)]"> <span className="font-semibold text-primary">COMPLETE</span>
COMPLETE
</span>
</> </>
) : ( ) : (
<> <>
<Circle size={24} className="text-[var(--color-neo-text-secondary)]" /> <Circle size={24} className="text-muted-foreground" />
<span className="font-display font-bold text-[var(--color-neo-text-secondary)]"> <span className="font-semibold text-muted-foreground">PENDING</span>
PENDING
</span>
</> </>
)} )}
<span className="ml-auto font-mono text-sm"> <span className="ml-auto font-mono text-sm text-muted-foreground">
Priority: #{feature.priority} Priority: #{feature.priority}
</span> </span>
</div> </div>
{/* Description */} {/* Description */}
<div> <div>
<h3 className="font-display font-bold mb-2 uppercase text-sm"> <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
Description Description
</h3> </h3>
<p className="text-[var(--color-neo-text-secondary)]"> <p className="text-foreground">{feature.description}</p>
{feature.description}
</p>
</div> </div>
{/* Blocked By Warning */} {/* Blocked By Warning */}
{blockingDeps.length > 0 && ( {blockingDeps.length > 0 && (
<div className="p-4 bg-[var(--color-neo-warning-bg)] border-3 border-[var(--color-neo-warning-border)]"> <Alert variant="destructive" className="border-orange-500 bg-orange-50 dark:bg-orange-950/20">
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2 text-[var(--color-neo-warning-text)]"> <AlertTriangle className="h-4 w-4 text-orange-600" />
<AlertTriangle size={16} /> <AlertDescription>
Blocked By <h4 className="font-semibold mb-1 text-orange-700 dark:text-orange-400">Blocked By</h4>
</h3> <p className="text-sm text-orange-600 dark:text-orange-300 mb-2">
<p className="text-sm text-[var(--color-neo-warning-text)] mb-2"> This feature cannot start until the following dependencies are complete:
This feature cannot start until the following dependencies are complete: </p>
</p> <ul className="space-y-1">
<ul className="space-y-1"> {blockingDeps.map(dep => (
{blockingDeps.map(dep => ( <li key={dep.id} className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-300">
<li <Circle size={14} />
key={dep.id} <span className="font-mono text-xs">#{dep.id}</span>
className="flex items-center gap-2 text-sm" <span>{dep.name}</span>
> </li>
<Circle size={14} className="text-[var(--color-neo-warning-text)]" /> ))}
<span className="font-mono text-xs text-[var(--color-neo-warning-text)]">#{dep.id}</span> </ul>
<span className="text-[var(--color-neo-warning-text)]">{dep.name}</span> </AlertDescription>
</li> </Alert>
))}
</ul>
</div>
)} )}
{/* Dependencies */} {/* Dependencies */}
{dependencies.length > 0 && ( {dependencies.length > 0 && (
<div> <div>
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2"> <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<Link2 size={16} /> <Link2 size={16} />
Depends On Depends On
</h3> </h3>
@@ -200,15 +193,15 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{dependencies.map(dep => ( {dependencies.map(dep => (
<li <li
key={dep.id} key={dep.id}
className="flex items-center gap-2 p-2 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)]" className="flex items-center gap-2 p-2 bg-muted rounded-md text-sm"
> >
{dep.passes ? ( {dep.passes ? (
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" /> <CheckCircle2 size={16} className="text-primary" />
) : ( ) : (
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" /> <Circle size={16} className="text-muted-foreground" />
)} )}
<span className="font-mono text-xs text-[var(--color-neo-text-secondary)]">#{dep.id}</span> <span className="font-mono text-xs text-muted-foreground">#{dep.id}</span>
<span className={dep.passes ? 'text-[var(--color-neo-done)]' : ''}>{dep.name}</span> <span className={dep.passes ? 'text-primary' : ''}>{dep.name}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -218,14 +211,14 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{/* Steps */} {/* Steps */}
{feature.steps.length > 0 && ( {feature.steps.length > 0 && (
<div> <div>
<h3 className="font-display font-bold mb-2 uppercase text-sm"> <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
Test Steps Test Steps
</h3> </h3>
<ol className="list-decimal list-inside space-y-2"> <ol className="list-decimal list-inside space-y-2">
{feature.steps.map((step, index) => ( {feature.steps.map((step, index) => (
<li <li
key={index} key={index}
className="p-3 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]" className="p-3 bg-muted rounded-md text-sm"
> >
{step} {step}
</li> </li>
@@ -237,69 +230,76 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{/* Actions */} {/* Actions */}
{!feature.passes && ( {!feature.passes && (
<div className="p-6 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <>
{showDeleteConfirm ? ( <Separator />
<div className="space-y-4"> <DialogFooter className="p-4 bg-muted/50">
<p className="font-bold text-center"> {showDeleteConfirm ? (
Are you sure you want to delete this feature? <div className="w-full space-y-4">
</p> <p className="font-medium text-center">
<div className="flex gap-3"> Are you sure you want to delete this feature?
<button </p>
onClick={handleDelete} <div className="flex gap-3">
disabled={deleteFeature.isPending} <Button
className="neo-btn neo-btn-danger flex-1" variant="destructive"
onClick={handleDelete}
disabled={deleteFeature.isPending}
className="flex-1"
>
{deleteFeature.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
'Yes, Delete'
)}
</Button>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteFeature.isPending}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex gap-3 w-full">
<Button
onClick={() => setShowEdit(true)}
disabled={skipFeature.isPending}
className="flex-1"
> >
{deleteFeature.isPending ? ( <Pencil size={18} />
Edit
</Button>
<Button
variant="secondary"
onClick={handleSkip}
disabled={skipFeature.isPending}
className="flex-1"
>
{skipFeature.isPending ? (
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
) : ( ) : (
'Yes, Delete' <>
<SkipForward size={18} />
Skip
</>
)} )}
</button> </Button>
<button <Button
onClick={() => setShowDeleteConfirm(false)} variant="destructive"
disabled={deleteFeature.isPending} size="icon"
className="neo-btn neo-btn-ghost flex-1" onClick={() => setShowDeleteConfirm(true)}
disabled={skipFeature.isPending}
> >
Cancel <Trash2 size={18} />
</button> </Button>
</div> </div>
</div> )}
) : ( </DialogFooter>
<div className="flex gap-3"> </>
<button
onClick={() => setShowEdit(true)}
disabled={skipFeature.isPending}
className="neo-btn neo-btn-primary flex-1"
>
<Pencil size={18} />
Edit
</button>
<button
onClick={handleSkip}
disabled={skipFeature.isPending}
className="neo-btn neo-btn-warning flex-1"
>
{skipFeature.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<SkipForward size={18} />
Skip
</>
)}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={skipFeature.isPending}
className="neo-btn neo-btn-danger"
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
)} )}
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -19,6 +19,9 @@ import {
} from 'lucide-react' } from 'lucide-react'
import * as api from '../lib/api' import * as api from '../lib/api'
import type { DirectoryEntry, DriveInfo } from '../lib/types' import type { DirectoryEntry, DriveInfo } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
interface FolderBrowserProps { interface FolderBrowserProps {
onSelect: (path: string) => void onSelect: (path: string) => void
@@ -139,37 +142,36 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
return ( return (
<div className="flex flex-col h-full max-h-[70vh]"> <div className="flex flex-col h-full max-h-[70vh]">
{/* Header with breadcrumb navigation */} {/* Header with breadcrumb navigation */}
<div className="flex-shrink-0 p-4 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-card)]"> <div className="flex-shrink-0 p-4 border-b bg-card">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Folder size={20} className="text-[var(--color-neo-progress)]" /> <Folder size={20} className="text-primary" />
<span className="font-bold text-[var(--color-neo-text)]">Select Project Folder</span> <span className="font-semibold">Select Project Folder</span>
</div> </div>
{/* Breadcrumb navigation */} {/* Breadcrumb navigation */}
<div className="flex items-center gap-1 flex-wrap text-sm"> <div className="flex items-center gap-1 flex-wrap text-sm">
{directoryData?.parent_path && ( {directoryData?.parent_path && (
<button <Button
variant="ghost"
size="icon-sm"
onClick={handleNavigateUp} onClick={handleNavigateUp}
className="neo-btn neo-btn-ghost p-1"
title="Go up" title="Go up"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </Button>
)} )}
{breadcrumbs.map((crumb, index) => ( {breadcrumbs.map((crumb, index) => (
<div key={crumb.path} className="flex items-center"> <div key={crumb.path} className="flex items-center">
{index > 0 && <ChevronRight size={14} className="text-[var(--color-neo-text-muted)] mx-1" />} {index > 0 && <ChevronRight size={14} className="text-muted-foreground mx-1" />}
<button <Button
variant="ghost"
size="sm"
onClick={() => handleNavigate(crumb.path)} onClick={() => handleNavigate(crumb.path)}
className={` className={index === breadcrumbs.length - 1 ? 'font-semibold' : ''}
px-2 py-1 rounded text-[var(--color-neo-text)]
hover:bg-[var(--color-neo-bg)]
${index === breadcrumbs.length - 1 ? 'font-bold' : ''}
`}
> >
{crumb.name} {crumb.name}
</button> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -177,162 +179,161 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
{/* Drive selector (Windows only) */} {/* Drive selector (Windows only) */}
{directoryData?.drives && directoryData.drives.length > 0 && ( {directoryData?.drives && directoryData.drives.length > 0 && (
<div className="flex-shrink-0 p-3 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]"> <div className="flex-shrink-0 p-3 border-b bg-muted/50">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--color-neo-text-secondary)]">Drives:</span> <span className="text-sm font-medium text-muted-foreground">Drives:</span>
{directoryData.drives.map((drive) => ( {directoryData.drives.map((drive) => (
<button <Button
key={drive.letter} key={drive.letter}
variant={currentPath?.startsWith(drive.letter) ? 'default' : 'outline'}
size="sm"
onClick={() => handleDriveSelect(drive)} onClick={() => handleDriveSelect(drive)}
className={`
neo-btn neo-btn-ghost py-1 px-2 text-sm
flex items-center gap-1
${currentPath?.startsWith(drive.letter) ? 'bg-[var(--color-neo-progress)] text-[var(--color-neo-text-on-bright)]' : ''}
`}
> >
<HardDrive size={14} /> <HardDrive size={14} />
{drive.letter}: {drive.label && `(${drive.label})`} {drive.letter}: {drive.label && `(${drive.label})`}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Directory listing */} {/* Directory listing */}
<div className="flex-1 overflow-y-auto p-2 bg-[var(--color-neo-card)]"> <div className="flex-1 min-h-0 overflow-y-auto bg-card">
{isLoading ? ( <div className="p-2">
<div className="flex items-center justify-center p-8"> {isLoading ? (
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" /> <div className="flex items-center justify-center p-8">
</div> <Loader2 size={24} className="animate-spin text-primary" />
) : error ? (
<div className="p-4 text-center">
<AlertCircle size={32} className="mx-auto mb-2 text-[var(--color-neo-danger)]" />
<p className="text-[var(--color-neo-danger)]">
{error instanceof Error ? error.message : 'Failed to load directory'}
</p>
<button onClick={() => refetch()} className="neo-btn neo-btn-ghost mt-2">
Retry
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-1">
{/* Directory entries - only show directories */}
{directoryData?.entries
.filter((entry) => entry.is_directory)
.map((entry) => (
<button
key={entry.path}
onClick={() => handleEntryClick(entry)}
onDoubleClick={() => handleNavigate(entry.path)}
className={`
w-full text-left p-2 rounded
flex items-center gap-2
hover:bg-[var(--color-neo-bg)]
border-2 border-transparent
text-[var(--color-neo-text)]
${selectedPath === entry.path ? 'bg-[var(--color-neo-progress)] bg-opacity-10 border-[var(--color-neo-progress)]' : ''}
`}
>
{selectedPath === entry.path ? (
<FolderOpen size={18} className="text-[var(--color-neo-progress)] flex-shrink-0" />
) : (
<Folder size={18} className="text-[var(--color-neo-pending)] flex-shrink-0" />
)}
<span className="truncate flex-1 text-[var(--color-neo-text)]">{entry.name}</span>
{entry.has_children && (
<ChevronRight size={14} className="ml-auto text-[var(--color-neo-text-muted)] flex-shrink-0" />
)}
</button>
))}
{/* Empty state */}
{directoryData?.entries.filter((e) => e.is_directory).length === 0 && (
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
<Folder size={32} className="mx-auto mb-2 opacity-50" />
<p>No subfolders</p>
<p className="text-sm">You can create a new folder or select this directory.</p>
</div>
)}
</div>
)}
{/* New folder creation */}
{isCreatingFolder && (
<div className="mt-2 p-3 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)] rounded">
<div className="flex items-center gap-2">
<FolderPlus size={18} className="text-[var(--color-neo-progress)]" />
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="neo-input flex-1 py-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}
}}
/>
<button onClick={handleCreateFolder} className="neo-btn neo-btn-primary py-1 px-3">
Create
</button>
<button
onClick={() => {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}}
className="neo-btn neo-btn-ghost py-1 px-2"
>
Cancel
</button>
</div> </div>
{createError && ( ) : error ? (
<p className="text-sm text-[var(--color-neo-danger)] mt-1">{createError}</p> <div className="p-4 text-center">
)} <AlertCircle size={32} className="mx-auto mb-2 text-destructive" />
</div> <p className="text-destructive">
)} {error instanceof Error ? error.message : 'Failed to load directory'}
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-2">
Retry
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-1">
{/* Directory entries - only show directories */}
{directoryData?.entries
.filter((entry) => entry.is_directory)
.map((entry) => (
<button
key={entry.path}
onClick={() => handleEntryClick(entry)}
onDoubleClick={() => handleNavigate(entry.path)}
className={`
w-full text-left p-2 rounded-md
flex items-center gap-2
hover:bg-muted
border-2 border-transparent transition-colors
${selectedPath === entry.path ? 'bg-primary/10 border-primary' : ''}
`}
>
{selectedPath === entry.path ? (
<FolderOpen size={18} className="text-primary flex-shrink-0" />
) : (
<Folder size={18} className="text-muted-foreground flex-shrink-0" />
)}
<span className="truncate flex-1">{entry.name}</span>
{entry.has_children && (
<ChevronRight size={14} className="ml-auto text-muted-foreground flex-shrink-0" />
)}
</button>
))}
{/* Empty state */}
{directoryData?.entries.filter((e) => e.is_directory).length === 0 && (
<div className="p-4 text-center text-muted-foreground">
<Folder size={32} className="mx-auto mb-2 opacity-50" />
<p>No subfolders</p>
<p className="text-sm">You can create a new folder or select this directory.</p>
</div>
)}
</div>
)}
{/* New folder creation */}
{isCreatingFolder && (
<Card className="mt-2">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<FolderPlus size={18} className="text-primary" />
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}
}}
/>
<Button onClick={handleCreateFolder} size="sm">
Create
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsCreatingFolder(false)
setNewFolderName('')
setCreateError(null)
}}
>
Cancel
</Button>
</div>
{createError && (
<p className="text-sm text-destructive mt-1">{createError}</p>
)}
</CardContent>
</Card>
)}
</div>
</div> </div>
{/* Footer with selected path and actions */} {/* Footer with selected path and actions */}
<div className="flex-shrink-0 p-4 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-card)]"> <div className="flex-shrink-0 p-4 border-t bg-card">
{/* Selected path display */} {/* Selected path display */}
<div className="mb-3 p-2 bg-[var(--color-neo-bg)] rounded border-2 border-[var(--color-neo-border)]"> <Card className="mb-3">
<div className="text-xs text-[var(--color-neo-text-secondary)] mb-1">Selected path:</div> <CardContent className="p-2">
<div className="font-mono text-sm truncate text-[var(--color-neo-text)]">{selectedPath || 'No folder selected'}</div> <div className="text-xs text-muted-foreground mb-1">Selected path:</div>
{selectedPath && ( <div className="font-mono text-sm truncate">{selectedPath || 'No folder selected'}</div>
<div className="text-xs text-[var(--color-neo-text-secondary)] mt-2 italic"> {selectedPath && (
This folder will contain all project files <div className="text-xs text-muted-foreground mt-2 italic">
</div> This folder will contain all project files
)} </div>
</div> )}
</CardContent>
</Card>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <Button
variant="outline"
onClick={() => setIsCreatingFolder(true)} onClick={() => setIsCreatingFolder(true)}
className="neo-btn neo-btn-ghost"
disabled={isCreatingFolder} disabled={isCreatingFolder}
> >
<FolderPlus size={16} /> <FolderPlus size={16} />
New Folder New Folder
</button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={onCancel} className="neo-btn neo-btn-ghost"> <Button variant="outline" onClick={onCancel}>
Cancel Cancel
</button> </Button>
<button <Button onClick={handleSelect} disabled={!selectedPath}>
onClick={handleSelect}
className="neo-btn neo-btn-primary"
disabled={!selectedPath}
>
Select This Folder Select This Folder
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { KanbanColumn } from './KanbanColumn' import { KanbanColumn } from './KanbanColumn'
import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types' import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
interface KanbanBoardProps { interface KanbanBoardProps {
features: FeatureListResponse | undefined features: FeatureListResponse | undefined
@@ -7,8 +8,8 @@ interface KanbanBoardProps {
onAddFeature?: () => void onAddFeature?: () => void
onExpandProject?: () => void onExpandProject?: () => void
activeAgents?: ActiveAgent[] activeAgents?: ActiveAgent[]
onCreateSpec?: () => void // Callback to start spec creation onCreateSpec?: () => void
hasSpec?: boolean // Whether the project has a spec hasSpec?: boolean
} }
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) { export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
@@ -23,14 +24,16 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['Pending', 'In Progress', 'Done'].map(title => ( {['Pending', 'In Progress', 'Done'].map(title => (
<div key={title} className="neo-card p-4"> <Card key={title} className="py-4">
<div className="h-8 bg-[var(--color-neo-bg)] animate-pulse mb-4" /> <CardContent className="p-4">
<div className="space-y-3"> <div className="h-8 bg-muted animate-pulse rounded mb-4" />
{[1, 2, 3].map(i => ( <div className="space-y-3">
<div key={i} className="h-24 bg-[var(--color-neo-bg)] animate-pulse" /> {[1, 2, 3].map(i => (
))} <div key={i} className="h-24 bg-muted animate-pulse rounded" />
</div> ))}
</div> </div>
</CardContent>
</Card>
))} ))}
</div> </div>
) )

View File

@@ -1,26 +1,29 @@
import { FeatureCard } from './FeatureCard' import { FeatureCard } from './FeatureCard'
import { Plus, Sparkles, Wand2 } from 'lucide-react' import { Plus, Sparkles, Wand2 } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types' import type { Feature, ActiveAgent } from '../lib/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface KanbanColumnProps { interface KanbanColumnProps {
title: string title: string
count: number count: number
features: Feature[] features: Feature[]
allFeatures?: Feature[] // For dependency status calculation allFeatures?: Feature[]
activeAgents?: ActiveAgent[] // Active agents for showing which agent is working on a feature activeAgents?: ActiveAgent[]
color: 'pending' | 'progress' | 'done' color: 'pending' | 'progress' | 'done'
onFeatureClick: (feature: Feature) => void onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void onAddFeature?: () => void
onExpandProject?: () => void onExpandProject?: () => void
showExpandButton?: boolean showExpandButton?: boolean
onCreateSpec?: () => void // Callback to start spec creation onCreateSpec?: () => void
showCreateSpec?: boolean // Show "Create Spec" button when project has no spec showCreateSpec?: boolean
} }
const colorMap = { const colorMap = {
pending: 'var(--color-neo-pending)', pending: 'border-t-4 border-t-muted',
progress: 'var(--color-neo-progress)', progress: 'border-t-4 border-t-primary',
done: 'var(--color-neo-done)', done: 'border-t-4 border-t-primary',
} }
export function KanbanColumn({ export function KanbanColumn({
@@ -41,83 +44,78 @@ export function KanbanColumn({
const agentByFeatureId = new Map( const agentByFeatureId = new Map(
activeAgents.map(agent => [agent.featureId, agent]) activeAgents.map(agent => [agent.featureId, agent])
) )
return (
<div
className="neo-card overflow-hidden"
style={{ borderColor: colorMap[color] }}
>
{/* Header */}
<div
className="px-4 py-3 border-b-3 border-[var(--color-neo-border)]"
style={{ backgroundColor: colorMap[color] }}
>
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-bold uppercase flex items-center gap-2 text-[var(--color-neo-text-on-bright)]">
{title}
<span className="neo-badge bg-[var(--color-neo-card)] text-[var(--color-neo-text)]">{count}</span>
</h2>
{(onAddFeature || onExpandProject) && (
<div className="flex items-center gap-2">
{onAddFeature && (
<button
onClick={onAddFeature}
className="neo-btn neo-btn-primary text-sm py-1.5 px-2"
title="Add new feature (N)"
>
<Plus size={16} />
</button>
)}
{onExpandProject && showExpandButton && (
<button
onClick={onExpandProject}
className="neo-btn bg-[var(--color-neo-progress)] text-[var(--color-neo-text-on-bright)] text-sm py-1.5 px-2"
title="Expand project with AI (E)"
>
<Sparkles size={16} />
</button>
)}
</div>
)}
</div>
</div>
{/* Cards */} return (
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]"> <Card className={`overflow-hidden ${colorMap[color]} py-0`}>
{features.length === 0 ? ( {/* Header */}
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]"> <CardHeader className="px-4 py-3 border-b flex-row items-center justify-between space-y-0">
{showCreateSpec && onCreateSpec ? ( <CardTitle className="text-lg font-semibold flex items-center gap-2">
<div className="space-y-4"> {title}
<p>No spec created yet</p> <Badge variant="secondary">{count}</Badge>
<button </CardTitle>
onClick={onCreateSpec} {(onAddFeature || onExpandProject) && (
className="neo-btn neo-btn-primary inline-flex items-center gap-2" <div className="flex items-center gap-2">
> {onAddFeature && (
<Wand2 size={18} /> <Button
Create Spec with AI onClick={onAddFeature}
</button> size="icon-sm"
</div> title="Add new feature (N)"
) : ( >
'No features' <Plus size={16} />
</Button>
)}
{onExpandProject && showExpandButton && (
<Button
onClick={onExpandProject}
size="icon-sm"
variant="secondary"
title="Expand project with AI (E)"
>
<Sparkles size={16} />
</Button>
)} )}
</div> </div>
) : (
features.map((feature, index) => (
<div
key={feature.id}
className="animate-slide-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<FeatureCard
feature={feature}
onClick={() => onFeatureClick(feature)}
isInProgress={color === 'progress'}
allFeatures={allFeatures}
activeAgent={agentByFeatureId.get(feature.id)}
/>
</div>
))
)} )}
</div> </CardHeader>
</div>
{/* Cards */}
<CardContent className="p-0">
<div className="h-[600px] overflow-y-auto">
<div className="p-4 space-y-3">
{features.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{showCreateSpec && onCreateSpec ? (
<div className="space-y-4">
<p>No spec created yet</p>
<Button onClick={onCreateSpec}>
<Wand2 size={18} />
Create Spec with AI
</Button>
</div>
) : (
'No features'
)}
</div>
) : (
features.map((feature, index) => (
<div
key={feature.id}
className="animate-slide-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<FeatureCard
feature={feature}
onClick={() => onFeatureClick(feature)}
isInProgress={color === 'progress'}
allFeatures={allFeatures}
activeAgent={agentByFeatureId.get(feature.id)}
/>
</div>
))
)}
</div>
</div>
</CardContent>
</Card>
) )
} }

View File

@@ -1,5 +1,12 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { X, Keyboard } from 'lucide-react' import { Keyboard } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
interface Shortcut { interface Shortcut {
key: string key: string
@@ -20,10 +27,11 @@ const shortcuts: Shortcut[] = [
] ]
interface KeyboardShortcutsHelpProps { interface KeyboardShortcutsHelpProps {
isOpen: boolean
onClose: () => void onClose: () => void
} }
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) { export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === '?') { if (e.key === 'Escape' || e.key === '?') {
@@ -35,59 +43,49 @@ export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
) )
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown) if (isOpen) {
return () => window.removeEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
}, [handleKeyDown]) return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleKeyDown])
return ( return (
<div <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" <DialogContent className="sm:max-w-md">
onClick={onClose} <DialogHeader>
> <DialogTitle className="flex items-center gap-2">
<div <Keyboard size={20} className="text-primary" />
className="neo-card p-6 max-w-md w-full mx-4" Keyboard Shortcuts
onClick={(e) => e.stopPropagation()} </DialogTitle>
> </DialogHeader>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Keyboard size={20} className="text-neo-accent" />
<h2 className="font-display text-lg font-bold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="neo-btn p-1.5"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shortcuts list */} {/* Shortcuts list */}
<ul className="space-y-2"> <ul className="space-y-1">
{shortcuts.map((shortcut) => ( {shortcuts.map((shortcut) => (
<li <li
key={shortcut.key} key={shortcut.key}
className="flex items-center justify-between py-2 border-b border-neo-border/30 last:border-0" className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<kbd className="px-2 py-1 text-sm font-mono bg-neo-bg rounded border border-neo-border shadow-neo-sm min-w-[2rem] text-center"> <kbd className="px-2 py-1 text-xs font-mono bg-muted rounded border border-border min-w-[2rem] text-center">
{shortcut.key} {shortcut.key}
</kbd> </kbd>
<span className="text-neo-text">{shortcut.description}</span> <span className="text-sm">{shortcut.description}</span>
</div> </div>
{shortcut.context && ( {shortcut.context && (
<span className="text-xs text-neo-muted">{shortcut.context}</span> <Badge variant="secondary" className="text-xs">
{shortcut.context}
</Badge>
)} )}
</li> </li>
))} ))}
</ul> </ul>
{/* Footer */} {/* Footer */}
<p className="text-xs text-neo-muted text-center mt-6"> <p className="text-xs text-muted-foreground text-center pt-2">
Press ? or Esc to close Press ? or Esc to close
</p> </p>
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -10,11 +10,25 @@
*/ */
import { useState } from 'react' import { useState } from 'react'
import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react' import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
import { useCreateProject } from '../hooks/useProjects' import { useCreateProject } from '../hooks/useProjects'
import { SpecCreationChat } from './SpecCreationChat' import { SpecCreationChat } from './SpecCreationChat'
import { FolderBrowser } from './FolderBrowser' import { FolderBrowser } from './FolderBrowser'
import { startAgent } from '../lib/api' import { startAgent } from '../lib/api'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
type InitializerStatus = 'idle' | 'starting' | 'error' type InitializerStatus = 'idle' | 'starting' | 'error'
@@ -75,7 +89,7 @@ export function NewProjectModal({
} }
const handleFolderSelect = (path: string) => { const handleFolderSelect = (path: string) => {
setProjectPath(path) // Use selected path directly - no subfolder creation setProjectPath(path)
changeStep('method') changeStep('method')
} }
@@ -189,7 +203,7 @@ export function NewProjectModal({
// Full-screen chat view // Full-screen chat view
if (step === 'chat') { if (step === 'chat') {
return ( return (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]"> <div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat <SpecCreationChat
projectName={projectName.trim()} projectName={projectName.trim()}
onComplete={handleSpecComplete} onComplete={handleSpecComplete}
@@ -206,31 +220,20 @@ export function NewProjectModal({
// Folder step uses larger modal // Folder step uses larger modal
if (step === 'folder') { if (step === 'folder') {
return ( return (
<div className="neo-modal-backdrop" onClick={handleClose}> <Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<div <DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col p-0">
className="neo-modal w-full max-w-3xl max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]"> <DialogHeader className="p-6 pb-4 border-b">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Folder size={24} className="text-[var(--color-neo-progress)]" /> <Folder size={24} className="text-primary" />
<div> <div>
<h2 className="font-display font-bold text-xl text-[var(--color-neo-text)]"> <DialogTitle>Select Project Location</DialogTitle>
Select Project Location <DialogDescription>
</h2> Select the folder to use for project <span className="font-semibold font-mono">{projectName}</span>. Create a new folder or choose an existing one.
<p className="text-sm text-[var(--color-neo-text-secondary)]"> </DialogDescription>
Select the folder to use for project <span className="font-bold font-mono">{projectName}</span>. Create a new folder or choose an existing one.
</p>
</div> </div>
</div> </div>
<button </DialogHeader>
onClick={handleClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={20} />
</button>
</div>
{/* Folder Browser */} {/* Folder Browser */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
@@ -239,193 +242,151 @@ export function NewProjectModal({
onCancel={handleFolderCancel} onCancel={handleFolderCancel}
/> />
</div> </div>
</div> </DialogContent>
</div> </Dialog>
) )
} }
return ( return (
<div className="neo-modal-backdrop" onClick={handleClose}> <Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<div <DialogContent className="sm:max-w-lg">
className="neo-modal w-full max-w-lg" <DialogHeader>
onClick={(e) => e.stopPropagation()} <DialogTitle>
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
<h2 className="font-display font-bold text-xl text-[var(--color-neo-text)]">
{step === 'name' && 'Create New Project'} {step === 'name' && 'Create New Project'}
{step === 'method' && 'Choose Setup Method'} {step === 'method' && 'Choose Setup Method'}
{step === 'complete' && 'Project Created!'} {step === 'complete' && 'Project Created!'}
</h2> </DialogTitle>
<button </DialogHeader>
onClick={handleClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={20} />
</button>
</div>
{/* Content */} {/* Step 1: Project Name */}
<div className="p-6"> {step === 'name' && (
{/* Step 1: Project Name */} <form onSubmit={handleNameSubmit} className="space-y-4">
{step === 'name' && ( <div className="space-y-2">
<form onSubmit={handleNameSubmit}> <Label htmlFor="project-name">Project Name</Label>
<div className="mb-6"> <Input
<label className="block font-bold mb-2 text-[var(--color-neo-text)]"> id="project-name"
Project Name type="text"
</label> value={projectName}
<input onChange={(e) => setProjectName(e.target.value)}
type="text" placeholder="my-awesome-app"
value={projectName} pattern="^[a-zA-Z0-9_-]+$"
onChange={(e) => setProjectName(e.target.value)} autoFocus
placeholder="my-awesome-app" />
className="neo-input" <p className="text-sm text-muted-foreground">
pattern="^[a-zA-Z0-9_-]+$" Use letters, numbers, hyphens, and underscores only.
autoFocus
/>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-2">
Use letters, numbers, hyphens, and underscores only.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] text-sm border-3 border-[var(--color-neo-error-border)]">
{error}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
className="neo-btn neo-btn-primary"
disabled={!projectName.trim()}
>
Next
<ArrowRight size={16} />
</button>
</div>
</form>
)}
{/* Step 2: Spec Method */}
{step === 'method' && (
<div>
<p className="text-[var(--color-neo-text-secondary)] mb-6">
How would you like to define your project?
</p> </p>
</div>
<div className="space-y-4"> {error && (
{/* Claude option */} <Alert variant="destructive">
<button <AlertDescription>{error}</AlertDescription>
onClick={() => handleMethodSelect('claude')} </Alert>
disabled={createProject.isPending} )}
className="
w-full text-left p-4 <DialogFooter>
hover:translate-x-[-2px] hover:translate-y-[-2px] <Button type="submit" disabled={!projectName.trim()}>
transition-all duration-150 Next
disabled:opacity-50 disabled:cursor-not-allowed <ArrowRight size={16} />
neo-card </Button>
" </DialogFooter>
> </form>
)}
{/* Step 2: Spec Method */}
{step === 'method' && (
<div className="space-y-4">
<DialogDescription>
How would you like to define your project?
</DialogDescription>
<div className="space-y-3">
{/* Claude option */}
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => !createProject.isPending && handleMethodSelect('claude')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div className="p-2 bg-primary/10 rounded-lg">
className="p-2 bg-[var(--color-neo-progress)] border-2 border-[var(--color-neo-border)]" <Bot size={24} className="text-primary" />
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<Bot size={24} className="text-[var(--color-neo-text-on-bright)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-bold text-lg text-[var(--color-neo-text)]">Create with Claude</span> <span className="font-semibold">Create with Claude</span>
<span className="neo-badge bg-[var(--color-neo-done)] text-[var(--color-neo-text-on-bright)] text-xs"> <Badge>Recommended</Badge>
Recommended
</span>
</div> </div>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1"> <p className="text-sm text-muted-foreground mt-1">
Interactive conversation to define features and generate your app specification automatically. Interactive conversation to define features and generate your app specification automatically.
</p> </p>
</div> </div>
</div> </div>
</button> </CardContent>
</Card>
{/* Manual option */} {/* Manual option */}
<button <Card
onClick={() => handleMethodSelect('manual')} className="cursor-pointer hover:border-primary transition-colors"
disabled={createProject.isPending} onClick={() => !createProject.isPending && handleMethodSelect('manual')}
className=" >
w-full text-left p-4 <CardContent className="p-4">
hover:translate-x-[-2px] hover:translate-y-[-2px]
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
neo-card
"
>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div className="p-2 bg-secondary rounded-lg">
className="p-2 bg-[var(--color-neo-pending)] border-2 border-[var(--color-neo-border)]" <FileEdit size={24} className="text-secondary-foreground" />
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<FileEdit size={24} className="text-[var(--color-neo-text-on-bright)]" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<span className="font-bold text-lg text-[var(--color-neo-text)]">Edit Templates Manually</span> <span className="font-semibold">Edit Templates Manually</span>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1"> <p className="text-sm text-muted-foreground mt-1">
Edit the template files directly. Best for developers who want full control. Edit the template files directly. Best for developers who want full control.
</p> </p>
</div> </div>
</div> </div>
</button> </CardContent>
</div> </Card>
{error && (
<div className="mt-4 p-3 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] text-sm border-3 border-[var(--color-neo-error-border)]">
{error}
</div>
)}
{createProject.isPending && (
<div className="mt-4 flex items-center justify-center gap-2 text-[var(--color-neo-text-secondary)]">
<Loader2 size={16} className="animate-spin" />
<span>Creating project...</span>
</div>
)}
<div className="flex justify-start mt-6">
<button
onClick={handleBack}
className="neo-btn neo-btn-ghost"
disabled={createProject.isPending}
>
<ArrowLeft size={16} />
Back
</button>
</div>
</div> </div>
)}
{/* Step 3: Complete */} {error && (
{step === 'complete' && ( <Alert variant="destructive">
<div className="text-center py-8"> <AlertDescription>{error}</AlertDescription>
<div </Alert>
className="inline-flex items-center justify-center w-16 h-16 bg-[var(--color-neo-done)] border-3 border-[var(--color-neo-border)] mb-4" )}
style={{ boxShadow: 'var(--shadow-neo-md)' }}
> {createProject.isPending && (
<CheckCircle2 size={32} className="text-[var(--color-neo-text-on-bright)]" /> <div className="flex items-center justify-center gap-2 text-muted-foreground">
</div>
<h3 className="font-display font-bold text-xl mb-2">
{projectName}
</h3>
<p className="text-[var(--color-neo-text-secondary)]">
Your project has been created successfully!
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span className="text-sm">Redirecting...</span> <span>Creating project...</span>
</div> </div>
)}
<DialogFooter className="sm:justify-start">
<Button
variant="ghost"
onClick={handleBack}
disabled={createProject.isPending}
>
<ArrowLeft size={16} />
Back
</Button>
</DialogFooter>
</div>
)}
{/* Step 3: Complete */}
{step === 'complete' && (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-full mb-4">
<CheckCircle2 size={32} className="text-primary" />
</div> </div>
)} <h3 className="font-semibold text-xl mb-2">{projectName}</h3>
</div> <p className="text-muted-foreground">
</div> Your project has been created successfully!
</div> </p>
<div className="mt-4 flex items-center justify-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm text-muted-foreground">Redirecting...</span>
</div>
</div>
)}
</DialogContent>
</Dialog>
) )
} }

View File

@@ -2,6 +2,9 @@ import { useState } from 'react'
import { ChevronDown, ChevronUp, Code, FlaskConical, Clock, Lock, Sparkles } from 'lucide-react' import { ChevronDown, ChevronUp, Code, FlaskConical, Clock, Lock, Sparkles } from 'lucide-react'
import { OrchestratorAvatar } from './OrchestratorAvatar' import { OrchestratorAvatar } from './OrchestratorAvatar'
import type { OrchestratorStatus, OrchestratorState } from '../lib/types' import type { OrchestratorStatus, OrchestratorState } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface OrchestratorStatusCardProps { interface OrchestratorStatusCardProps {
status: OrchestratorStatus status: OrchestratorStatus
@@ -31,16 +34,16 @@ function getStateText(state: OrchestratorState): string {
function getStateColor(state: OrchestratorState): string { function getStateColor(state: OrchestratorState): string {
switch (state) { switch (state) {
case 'complete': case 'complete':
return 'text-neo-done' return 'text-primary'
case 'spawning': case 'spawning':
return 'text-[#7C3AED]' // Violet return 'text-violet-600 dark:text-violet-400'
case 'scheduling': case 'scheduling':
case 'monitoring': case 'monitoring':
return 'text-neo-progress' return 'text-primary'
case 'initializing': case 'initializing':
return 'text-neo-pending' return 'text-yellow-600 dark:text-yellow-400'
default: default:
return 'text-neo-text-secondary' return 'text-muted-foreground'
} }
} }
@@ -62,91 +65,95 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps)
const [showEvents, setShowEvents] = useState(false) const [showEvents, setShowEvents] = useState(false)
return ( return (
<div className="neo-card p-4 bg-gradient-to-r from-[#EDE9FE] to-[#F3E8FF] border-[#7C3AED]/30 mb-4"> <Card className="mb-4 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30 border-violet-200 dark:border-violet-800/50 py-4">
<div className="flex items-start gap-4"> <CardContent className="p-4">
{/* Avatar */} <div className="flex items-start gap-4">
<OrchestratorAvatar state={status.state} size="md" /> {/* Avatar */}
<OrchestratorAvatar state={status.state} size="md" />
{/* Main content */} {/* Main content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Header row */} {/* Header row */}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-display font-bold text-lg text-[#7C3AED]"> <span className="font-semibold text-lg text-violet-700 dark:text-violet-300">
Maestro Maestro
</span> </span>
<span className={`text-sm font-medium ${getStateColor(status.state)}`}> <span className={`text-sm font-medium ${getStateColor(status.state)}`}>
{getStateText(status.state)} {getStateText(status.state)}
</span> </span>
</div>
{/* Current message */}
<p className="text-sm text-foreground mb-3 line-clamp-2">
{status.message}
</p>
{/* Status badges row */}
<div className="flex flex-wrap items-center gap-2">
{/* Coding agents badge */}
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700">
<Code size={12} />
Coding: {status.codingAgents}
</Badge>
{/* Testing agents badge */}
<Badge variant="outline" className="bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700">
<FlaskConical size={12} />
Testing: {status.testingAgents}
</Badge>
{/* Ready queue badge */}
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-300 dark:bg-green-900/30 dark:text-green-300 dark:border-green-700">
<Clock size={12} />
Ready: {status.readyCount}
</Badge>
{/* Blocked badge (only show if > 0) */}
{status.blockedCount > 0 && (
<Badge variant="outline" className="bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-700">
<Lock size={12} />
Blocked: {status.blockedCount}
</Badge>
)}
</div>
</div> </div>
{/* Current message */} {/* Recent events toggle */}
<p className="text-sm text-neo-text mb-3 line-clamp-2"> {status.recentEvents.length > 0 && (
{status.message} <Button
</p> variant="ghost"
size="sm"
{/* Status badges row */} onClick={() => setShowEvents(!showEvents)}
<div className="flex flex-wrap items-center gap-2"> className="text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-900/30"
{/* Coding agents badge */} >
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-blue-100 text-blue-700 rounded border border-blue-300 text-xs font-bold"> <Sparkles size={12} />
<Code size={12} /> Activity
<span>Coding: {status.codingAgents}</span> {showEvents ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</div> </Button>
)}
{/* Testing agents badge */}
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-purple-100 text-purple-700 rounded border border-purple-300 text-xs font-bold">
<FlaskConical size={12} />
<span>Testing: {status.testingAgents}</span>
</div>
{/* Ready queue badge */}
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-green-100 text-green-700 rounded border border-green-300 text-xs font-bold">
<Clock size={12} />
<span>Ready: {status.readyCount}</span>
</div>
{/* Blocked badge (only show if > 0) */}
{status.blockedCount > 0 && (
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 rounded border border-amber-300 text-xs font-bold">
<Lock size={12} />
<span>Blocked: {status.blockedCount}</span>
</div>
)}
</div>
</div> </div>
{/* Recent events toggle */} {/* Collapsible recent events */}
{status.recentEvents.length > 0 && ( {showEvents && status.recentEvents.length > 0 && (
<button <div className="mt-3 pt-3 border-t border-violet-200 dark:border-violet-800/50">
onClick={() => setShowEvents(!showEvents)} <div className="space-y-1.5">
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-[#7C3AED] hover:bg-[#7C3AED]/10 rounded transition-colors" {status.recentEvents.map((event, idx) => (
> <div
<Sparkles size={12} /> key={`${event.timestamp}-${idx}`}
<span>Activity</span> className="flex items-start gap-2 text-xs"
{showEvents ? <ChevronUp size={14} /> : <ChevronDown size={14} />} >
</button> <span className="text-violet-500 dark:text-violet-400 shrink-0 font-mono">
{formatRelativeTime(event.timestamp)}
</span>
<span className="text-foreground">
{event.message}
</span>
</div>
))}
</div>
</div>
)} )}
</div> </CardContent>
</Card>
{/* Collapsible recent events */}
{showEvents && status.recentEvents.length > 0 && (
<div className="mt-3 pt-3 border-t border-[#7C3AED]/20">
<div className="space-y-1.5">
{status.recentEvents.map((event, idx) => (
<div
key={`${event.timestamp}-${idx}`}
className="flex items-start gap-2 text-xs"
>
<span className="text-[#A78BFA] shrink-0 font-mono">
{formatRelativeTime(event.timestamp)}
</span>
<span className="text-neo-text">
{event.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
) )
} }

View File

@@ -1,4 +1,6 @@
import { Wifi, WifiOff } from 'lucide-react' import { Wifi, WifiOff } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface ProgressDashboardProps { interface ProgressDashboardProps {
passing: number passing: number
@@ -14,66 +16,68 @@ export function ProgressDashboard({
isConnected, isConnected,
}: ProgressDashboardProps) { }: ProgressDashboardProps) {
return ( return (
<div className="neo-card p-6"> <Card>
<div className="flex items-center justify-between mb-4"> <CardHeader className="flex-row items-center justify-between space-y-0 pb-4">
<h2 className="font-display text-xl font-bold uppercase"> <CardTitle className="text-xl uppercase tracking-wide">
Progress Progress
</h2> </CardTitle>
<div className="flex items-center gap-2"> <Badge variant={isConnected ? 'default' : 'destructive'} className="gap-1">
{isConnected ? ( {isConnected ? (
<> <>
<Wifi size={16} className="text-[var(--color-neo-done)]" /> <Wifi size={14} />
<span className="text-sm text-[var(--color-neo-done)]">Live</span> Live
</> </>
) : ( ) : (
<> <>
<WifiOff size={16} className="text-[var(--color-neo-danger)]" /> <WifiOff size={14} />
<span className="text-sm text-[var(--color-neo-danger)]">Offline</span> Offline
</> </>
)} )}
</div> </Badge>
</div> </CardHeader>
{/* Large Percentage */} <CardContent>
<div className="text-center mb-6"> {/* Large Percentage */}
<span className="inline-flex items-baseline"> <div className="text-center mb-6">
<span className="font-display text-6xl font-bold"> <span className="inline-flex items-baseline">
{percentage.toFixed(1)} <span className="text-6xl font-bold tabular-nums">
</span> {percentage.toFixed(1)}
<span className="font-display text-3xl font-bold text-[var(--color-neo-text-secondary)]"> </span>
% <span className="text-3xl font-semibold text-muted-foreground">
</span> %
</span> </span>
</div>
{/* Progress Bar */}
<div className="neo-progress mb-4">
<div
className="neo-progress-fill"
style={{ width: `${percentage}%` }}
/>
</div>
{/* Stats */}
<div className="flex justify-center gap-8 text-center">
<div>
<span className="font-mono text-3xl font-bold text-[var(--color-neo-done)]">
{passing}
</span>
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
Passing
</span> </span>
</div> </div>
<div className="text-4xl text-[var(--color-neo-text-secondary)]">/</div>
<div> {/* Progress Bar */}
<span className="font-mono text-3xl font-bold"> <div className="h-3 bg-muted rounded-full overflow-hidden mb-6">
{total} <div
</span> className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase"> style={{ width: `${percentage}%` }}
Total />
</span>
</div> </div>
</div>
</div> {/* Stats */}
<div className="flex justify-center gap-8 text-center">
<div>
<span className="font-mono text-3xl font-bold text-primary">
{passing}
</span>
<span className="block text-sm text-muted-foreground uppercase">
Passing
</span>
</div>
<div className="text-4xl text-muted-foreground">/</div>
<div>
<span className="font-mono text-3xl font-bold">
{total}
</span>
<span className="block text-sm text-muted-foreground uppercase">
Total
</span>
</div>
</div>
</CardContent>
</Card>
) )
} }

View File

@@ -4,6 +4,15 @@ import type { ProjectSummary } from '../lib/types'
import { NewProjectModal } from './NewProjectModal' import { NewProjectModal } from './NewProjectModal'
import { ConfirmDialog } from './ConfirmDialog' import { ConfirmDialog } from './ConfirmDialog'
import { useDeleteProject } from '../hooks/useProjects' import { useDeleteProject } from '../hooks/useProjects'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
interface ProjectSelectorProps { interface ProjectSelectorProps {
projects: ProjectSummary[] projects: ProjectSummary[]
@@ -32,8 +41,8 @@ export function ProjectSelector({
} }
const handleDeleteClick = (e: React.MouseEvent, projectName: string) => { const handleDeleteClick = (e: React.MouseEvent, projectName: string) => {
// Prevent the click from selecting the project
e.stopPropagation() e.stopPropagation()
e.preventDefault()
setProjectToDelete(projectName) setProjectToDelete(projectName)
} }
@@ -42,13 +51,11 @@ export function ProjectSelector({
try { try {
await deleteProject.mutateAsync(projectToDelete) await deleteProject.mutateAsync(projectToDelete)
// If the deleted project was selected, clear the selection
if (selectedProject === projectToDelete) { if (selectedProject === projectToDelete) {
onSelectProject(null) onSelectProject(null)
} }
setProjectToDelete(null) setProjectToDelete(null)
} catch (error) { } catch (error) {
// Error is handled by the mutation, just close the dialog
console.error('Failed to delete project:', error) console.error('Failed to delete project:', error)
setProjectToDelete(null) setProjectToDelete(null)
} }
@@ -62,106 +69,86 @@ export function ProjectSelector({
return ( return (
<div className="relative"> <div className="relative">
{/* Dropdown Trigger */} <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<button <DropdownMenuTrigger asChild>
onClick={() => setIsOpen(!isOpen)} <Button
className="neo-btn bg-[var(--color-neo-card)] text-[var(--color-neo-text)] min-w-[200px] justify-between" variant="outline"
disabled={isLoading} className="min-w-[200px] justify-between"
> disabled={isLoading}
{isLoading ? ( >
<Loader2 size={18} className="animate-spin" /> {isLoading ? (
) : selectedProject ? ( <Loader2 size={18} className="animate-spin" />
<> ) : selectedProject ? (
<span className="flex items-center gap-2"> <>
<FolderOpen size={18} /> <span className="flex items-center gap-2">
{selectedProject} <FolderOpen size={18} />
</span> {selectedProject}
{selectedProjectData && selectedProjectData.stats.total > 0 && ( </span>
<span className="neo-badge bg-[var(--color-neo-done)] ml-2"> {selectedProjectData && selectedProjectData.stats.total > 0 && (
{selectedProjectData.stats.percentage}% <Badge className="ml-2">{selectedProjectData.stats.percentage}%</Badge>
</span> )}
)} </>
</>
) : (
<span className="text-[var(--color-neo-text-secondary)]">
Select Project
</span>
)}
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Menu */}
<div className="absolute top-full left-0 mt-2 w-full neo-dropdown z-50 min-w-[280px]">
{projects.length > 0 ? (
<div className="max-h-[300px] overflow-auto">
{projects.map(project => (
<div
key={project.name}
className={`flex items-center ${
project.name === selectedProject
? 'bg-[var(--color-neo-pending)] text-[var(--color-neo-text-on-bright)]'
: ''
}`}
>
<button
onClick={() => {
onSelectProject(project.name)
setIsOpen(false)
}}
className="flex-1 neo-dropdown-item flex items-center justify-between"
>
<span className="flex items-center gap-2">
<FolderOpen size={16} />
{project.name}
</span>
{project.stats.total > 0 && (
<span className="text-sm font-mono">
{project.stats.passing}/{project.stats.total}
</span>
)}
</button>
<button
onClick={(e) => handleDeleteClick(e, project.name)}
className="p-2 mr-2 text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-danger)] hover:bg-[var(--color-neo-danger)]/10 transition-colors rounded"
title={`Delete ${project.name}`}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
) : ( ) : (
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]"> <span className="text-muted-foreground">Select Project</span>
No projects yet
</div>
)} )}
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</Button>
</DropdownMenuTrigger>
{/* Divider */} <DropdownMenuContent align="start" className="w-[280px] p-0 flex flex-col">
<div className="border-t-3 border-[var(--color-neo-border)]" /> {projects.length > 0 ? (
<div className="max-h-[300px] overflow-y-auto p-1">
{projects.map(project => (
<DropdownMenuItem
key={project.name}
className={`flex items-center justify-between cursor-pointer ${
project.name === selectedProject ? 'bg-primary/10' : ''
}`}
onSelect={() => {
onSelectProject(project.name)
}}
>
<span className="flex items-center gap-2 flex-1">
<FolderOpen size={16} />
{project.name}
{project.stats.total > 0 && (
<span className="text-sm font-mono text-muted-foreground ml-auto">
{project.stats.passing}/{project.stats.total}
</span>
)}
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => handleDeleteClick(e, project.name)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</Button>
</DropdownMenuItem>
))}
</div>
) : (
<div className="p-4 text-center text-muted-foreground">
No projects yet
</div>
)}
{/* Create New */} <DropdownMenuSeparator className="my-0" />
<button
onClick={() => { <div className="p-1">
<DropdownMenuItem
onSelect={() => {
setShowNewProjectModal(true) setShowNewProjectModal(true)
setIsOpen(false)
}} }}
className="w-full neo-dropdown-item flex items-center gap-2 font-bold" className="cursor-pointer font-semibold"
> >
<Plus size={16} /> <Plus size={16} />
New Project New Project
</button> </DropdownMenuItem>
</div> </div>
</> </DropdownMenuContent>
)} </DropdownMenu>
{/* New Project Modal */} {/* New Project Modal */}
<NewProjectModal <NewProjectModal

View File

@@ -2,12 +2,16 @@
* Question Options Component * Question Options Component
* *
* Renders structured questions from AskUserQuestion tool. * Renders structured questions from AskUserQuestion tool.
* Shows clickable option buttons in neobrutalism style. * Shows clickable option buttons.
*/ */
import { useState } from 'react' import { useState } from 'react'
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import type { SpecQuestion } from '../lib/types' import type { SpecQuestion } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface QuestionOptionsProps { interface QuestionOptionsProps {
questions: SpecQuestion[] questions: SpecQuestion[]
@@ -91,165 +95,126 @@ export function QuestionOptions({
return ( return (
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
{questions.map((q, questionIdx) => ( {questions.map((q, questionIdx) => (
<div <Card key={questionIdx}>
key={questionIdx} <CardContent className="p-4">
className="neo-card p-4 bg-[var(--color-neo-card)]" {/* Question header */}
> <div className="flex items-center gap-3 mb-4">
{/* Question header */} <Badge>{q.header}</Badge>
<div className="flex items-center gap-3 mb-4"> <span className="font-bold text-foreground">
<span className="neo-badge bg-[var(--color-neo-accent)] text-[var(--color-neo-text-on-bright)]"> {q.question}
{q.header}
</span>
<span className="font-bold text-[var(--color-neo-text)]">
{q.question}
</span>
{q.multiSelect && (
<span className="text-xs text-[var(--color-neo-text-secondary)] font-mono">
(select multiple)
</span> </span>
)} {q.multiSelect && (
</div> <span className="text-xs text-muted-foreground font-mono">
(select multiple)
{/* Options grid */} </span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> )}
{q.options.map((opt, optIdx) => {
const isSelected = isOptionSelected(questionIdx, opt.label, q.multiSelect)
return (
<button
key={optIdx}
onClick={() => handleOptionClick(questionIdx, opt.label, q.multiSelect)}
disabled={disabled}
className={`
text-left p-4
border-3 border-[var(--color-neo-border)]
transition-all duration-150
${
isSelected
? 'bg-[var(--color-neo-pending)] translate-x-[1px] translate-y-[1px]'
: 'bg-[var(--color-neo-card)] hover:translate-x-[-1px] hover:translate-y-[-1px]'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
style={{
boxShadow: isSelected ? 'var(--shadow-neo-sm)' : 'var(--shadow-neo-md)',
}}
onMouseEnter={(e) => {
if (!isSelected && !disabled) {
e.currentTarget.style.boxShadow = 'var(--shadow-neo-lg)'
}
}}
onMouseLeave={(e) => {
if (!isSelected && !disabled) {
e.currentTarget.style.boxShadow = 'var(--shadow-neo-md)'
}
}}
>
<div className="flex items-start gap-2">
{/* Checkbox/Radio indicator */}
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5
border-2 border-[var(--color-neo-border)]
flex items-center justify-center
${q.multiSelect ? '' : 'rounded-full'}
${isSelected ? 'bg-[var(--color-neo-done)]' : 'bg-[var(--color-neo-card)]'}
`}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-[var(--color-neo-text)]">
{opt.label}
</div>
<div className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
{opt.description}
</div>
</div>
</div>
</button>
)
})}
{/* "Other" option */}
<button
onClick={() => handleOptionClick(questionIdx, 'Other', q.multiSelect)}
disabled={disabled}
className={`
text-left p-4
border-3 border-[var(--color-neo-border)]
transition-all duration-150
${
showCustomInput[String(questionIdx)]
? 'bg-[var(--color-neo-pending)] translate-x-[1px] translate-y-[1px]'
: 'bg-[var(--color-neo-card)] hover:translate-x-[-1px] hover:translate-y-[-1px]'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
style={{
boxShadow: showCustomInput[String(questionIdx)] ? 'var(--shadow-neo-sm)' : 'var(--shadow-neo-md)',
}}
onMouseEnter={(e) => {
if (!showCustomInput[String(questionIdx)] && !disabled) {
e.currentTarget.style.boxShadow = 'var(--shadow-neo-lg)'
}
}}
onMouseLeave={(e) => {
if (!showCustomInput[String(questionIdx)] && !disabled) {
e.currentTarget.style.boxShadow = 'var(--shadow-neo-md)'
}
}}
>
<div className="flex items-start gap-2">
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5
border-2 border-[var(--color-neo-border)]
flex items-center justify-center
${q.multiSelect ? '' : 'rounded-full'}
${showCustomInput[String(questionIdx)] ? 'bg-[var(--color-neo-done)]' : 'bg-[var(--color-neo-card)]'}
`}
>
{showCustomInput[String(questionIdx)] && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-[var(--color-neo-text)]">Other</div>
<div className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
Provide a custom answer
</div>
</div>
</div>
</button>
</div>
{/* Custom input field */}
{showCustomInput[String(questionIdx)] && (
<div className="mt-4">
<input
type="text"
value={customInputs[String(questionIdx)] || ''}
onChange={(e) => handleCustomInputChange(questionIdx, e.target.value)}
placeholder="Type your answer..."
className="neo-input"
autoFocus
disabled={disabled}
/>
</div> </div>
)}
</div> {/* Options grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{q.options.map((opt, optIdx) => {
const isSelected = isOptionSelected(questionIdx, opt.label, q.multiSelect)
return (
<button
key={optIdx}
onClick={() => handleOptionClick(questionIdx, opt.label, q.multiSelect)}
disabled={disabled}
className={`
text-left p-4 rounded-lg border-2 transition-all duration-150
${
isSelected
? 'bg-primary/10 border-primary'
: 'bg-card border-border hover:border-primary/50 hover:bg-muted'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-2">
{/* Checkbox/Radio indicator */}
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5 border-2 flex items-center justify-center
${q.multiSelect ? 'rounded' : 'rounded-full'}
${isSelected ? 'bg-primary border-primary text-primary-foreground' : 'border-border bg-background'}
`}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-foreground">
{opt.label}
</div>
<div className="text-sm text-muted-foreground mt-1">
{opt.description}
</div>
</div>
</div>
</button>
)
})}
{/* "Other" option */}
<button
onClick={() => handleOptionClick(questionIdx, 'Other', q.multiSelect)}
disabled={disabled}
className={`
text-left p-4 rounded-lg border-2 transition-all duration-150
${
showCustomInput[String(questionIdx)]
? 'bg-primary/10 border-primary'
: 'bg-card border-border hover:border-primary/50 hover:bg-muted'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-2">
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5 border-2 flex items-center justify-center
${q.multiSelect ? 'rounded' : 'rounded-full'}
${showCustomInput[String(questionIdx)] ? 'bg-primary border-primary text-primary-foreground' : 'border-border bg-background'}
`}
>
{showCustomInput[String(questionIdx)] && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-foreground">Other</div>
<div className="text-sm text-muted-foreground mt-1">
Provide a custom answer
</div>
</div>
</div>
</button>
</div>
{/* Custom input field */}
{showCustomInput[String(questionIdx)] && (
<div className="mt-4">
<Input
type="text"
value={customInputs[String(questionIdx)] || ''}
onChange={(e) => handleCustomInputChange(questionIdx, e.target.value)}
placeholder="Type your answer..."
autoFocus
disabled={disabled}
/>
</div>
)}
</CardContent>
</Card>
))} ))}
{/* Submit button */} {/* Submit button */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={disabled || !allQuestionsAnswered} disabled={disabled || !allQuestionsAnswered}
className="neo-btn neo-btn-primary"
> >
Continue Continue
</button> </Button>
</div> </div>
</div> </div>
) )

View File

@@ -2,11 +2,10 @@
* Schedule Modal Component * Schedule Modal Component
* *
* Modal for managing agent schedules (create, edit, delete). * Modal for managing agent schedules (create, edit, delete).
* Follows neobrutalism design patterns from SettingsModal.
*/ */
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Clock, GitBranch, Trash2, X } from 'lucide-react' import { Clock, GitBranch, Trash2 } from 'lucide-react'
import { import {
useSchedules, useSchedules,
useCreateSchedule, useCreateSchedule,
@@ -23,6 +22,20 @@ import {
toggleDay, toggleDay,
} from '../lib/timeUtils' } from '../lib/timeUtils'
import type { ScheduleCreate } from '../lib/types' import type { ScheduleCreate } from '../lib/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
interface ScheduleModalProps { interface ScheduleModalProps {
projectName: string projectName: string
@@ -60,38 +73,6 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
} }
}, [isOpen]) }, [isOpen])
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return
if (e.key === 'Escape') {
onClose()
}
if (e.key === 'Tab' && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
if (!isOpen) return null
const schedules = schedulesData?.schedules || [] const schedules = schedulesData?.schedules || []
const handleCreateSchedule = async () => { const handleCreateSchedule = async () => {
@@ -114,8 +95,6 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
const { time: utcTime, dayShift } = localToUTCWithDayShift(newSchedule.start_time) const { time: utcTime, dayShift } = localToUTCWithDayShift(newSchedule.start_time)
// Adjust days_of_week based on day shift // Adjust days_of_week based on day shift
// If UTC is on the next day (dayShift = 1), shift days forward
// If UTC is on the previous day (dayShift = -1), shift days backward
const adjustedDays = adjustDaysForDayShift(newSchedule.days_of_week, dayShift) const adjustedDays = adjustDaysForDayShift(newSchedule.days_of_week, dayShift)
const scheduleToCreate = { const scheduleToCreate = {
@@ -169,287 +148,256 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
} }
return ( return (
<div <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
className="neo-modal-backdrop" <DialogContent ref={modalRef} className="sm:max-w-[650px] max-h-[80vh] flex flex-col p-0">
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div ref={modalRef} className="neo-modal p-6" style={{ maxWidth: '650px', maxHeight: '80vh' }}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <DialogHeader className="p-6 pb-4">
<div className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Clock size={24} className="text-[var(--color-neo-progress)]" /> <Clock size={24} className="text-primary" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Agent Schedules</h2> Agent Schedules
</div> </DialogTitle>
<button </DialogHeader>
ref={firstFocusableRef}
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
{/* Error display */} <div className="flex-1 min-h-0 overflow-y-auto px-6">
{error && ( {/* Error display */}
<div className="mb-4 p-3 border-2 border-red-500 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 rounded"> {error && (
{error} <Alert variant="destructive" className="mb-4">
</div> <AlertDescription>{error}</AlertDescription>
)} </Alert>
)}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-center py-8 text-gray-600 dark:text-gray-300"> <div className="text-center py-8 text-muted-foreground">
Loading schedules... Loading schedules...
</div>
)}
{/* Existing schedules */}
{!isLoading && schedules.length > 0 && (
<div className="space-y-3 mb-6 max-h-[300px] overflow-y-auto">
{schedules.map((schedule) => {
// Convert UTC time to local and get day shift for display
const { time: localTime, dayShift } = utcToLocalWithDayShift(schedule.start_time)
const duration = formatDuration(schedule.duration_minutes)
// Adjust displayed days: if local is next day (dayShift=1), shift forward
// if local is prev day (dayShift=-1), shift backward
const displayDays = adjustDaysForDayShift(schedule.days_of_week, dayShift)
return (
<div
key={schedule.id}
className="neo-card p-4 flex items-start justify-between gap-4"
>
<div className="flex-1">
{/* Time and duration */}
<div className="flex items-baseline gap-2 mb-2">
<span className="text-lg font-bold text-gray-900 dark:text-white">{localTime}</span>
<span className="text-sm text-gray-600 dark:text-gray-300">
for {duration}
</span>
</div>
{/* Days */}
<div className="flex gap-1 mb-2">
{DAYS.map((day) => {
const isActive = isDayActive(displayDays, day.bit)
return (
<span
key={day.label}
className={`text-xs px-2 py-1 rounded border-2 ${
isActive
? 'border-[var(--color-neo-progress)] bg-[var(--color-neo-progress)] text-white font-bold'
: 'border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500'
}`}
>
{day.label}
</span>
)
})}
</div>
{/* Metadata */}
<div className="flex gap-3 text-xs text-gray-600 dark:text-gray-300">
{schedule.yolo_mode && (
<span className="font-bold text-yellow-600"> YOLO mode</span>
)}
<span className="flex items-center gap-1">
<GitBranch size={12} />
{schedule.max_concurrency}x
</span>
{schedule.model && <span>Model: {schedule.model}</span>}
{schedule.crash_count > 0 && (
<span className="text-red-600">Crashes: {schedule.crash_count}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Enable/disable toggle */}
<button
onClick={() => handleToggleSchedule(schedule.id, schedule.enabled)}
className={`neo-btn neo-btn-ghost px-3 py-1 text-xs font-bold ${
schedule.enabled
? 'text-[var(--color-neo-done)]'
: 'text-[var(--color-neo-text-secondary)]'
}`}
disabled={toggleSchedule.isPending}
>
{schedule.enabled ? 'Enabled' : 'Disabled'}
</button>
{/* Delete button */}
<button
onClick={() => handleDeleteSchedule(schedule.id)}
className="neo-btn neo-btn-ghost p-2 text-red-600 hover:bg-red-50"
disabled={deleteSchedule.isPending}
aria-label="Delete schedule"
>
<Trash2 size={16} />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Empty state */}
{!isLoading && schedules.length === 0 && (
<div className="text-center py-6 text-gray-600 dark:text-gray-300 mb-6">
<Clock size={48} className="mx-auto mb-2 opacity-50 text-gray-400 dark:text-gray-500" />
<p>No schedules configured yet</p>
</div>
)}
{/* Divider */}
<div className="border-t-2 border-gray-200 dark:border-gray-700 my-6"></div>
{/* Add new schedule form */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Add New Schedule</h3>
{/* Time and duration */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">Start Time (Local)</label>
<input
type="time"
value={newSchedule.start_time}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, start_time: e.target.value }))
}
className="neo-input w-full"
/>
</div> </div>
<div> )}
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">Duration (minutes)</label>
<input {/* Existing schedules */}
type="number" {!isLoading && schedules.length > 0 && (
min="1" <div className="space-y-3 mb-6">
max="1440" {schedules.map((schedule) => {
value={newSchedule.duration_minutes} // Convert UTC time to local and get day shift for display
onChange={(e) => { const { time: localTime, dayShift } = utcToLocalWithDayShift(schedule.start_time)
const parsed = parseInt(e.target.value, 10) const duration = formatDuration(schedule.duration_minutes)
const value = isNaN(parsed) ? 1 : Math.max(1, Math.min(1440, parsed)) const displayDays = adjustDaysForDayShift(schedule.days_of_week, dayShift)
setNewSchedule((prev) => ({
...prev,
duration_minutes: value,
}))
}}
className="neo-input w-full"
/>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{formatDuration(newSchedule.duration_minutes)}
</div>
</div>
</div>
{/* Days of week */}
<div className="mb-4">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">Days</label>
<div className="flex gap-2">
{DAYS.map((day) => {
const isActive = isDayActive(newSchedule.days_of_week, day.bit)
return ( return (
<button <Card key={schedule.id}>
key={day.label} <CardContent className="p-4">
onClick={() => handleToggleDay(day.bit)} <div className="flex items-start justify-between gap-4">
className={`neo-btn px-3 py-2 text-sm ${ <div className="flex-1">
isActive {/* Time and duration */}
? 'bg-[var(--color-neo-progress)] text-white border-[var(--color-neo-progress)]' <div className="flex items-baseline gap-2 mb-2">
: 'neo-btn-ghost' <span className="text-lg font-semibold">{localTime}</span>
}`} <span className="text-sm text-muted-foreground">
> for {duration}
{day.label} </span>
</button> </div>
{/* Days */}
<div className="flex gap-1 mb-2">
{DAYS.map((day) => {
const isActive = isDayActive(displayDays, day.bit)
return (
<span
key={day.label}
className={`text-xs px-2 py-1 rounded border ${
isActive
? 'border-primary bg-primary text-primary-foreground font-medium'
: 'border-border text-muted-foreground'
}`}
>
{day.label}
</span>
)
})}
</div>
{/* Metadata */}
<div className="flex gap-3 text-xs text-muted-foreground">
{schedule.yolo_mode && (
<span className="font-semibold text-yellow-600">YOLO mode</span>
)}
<span className="flex items-center gap-1">
<GitBranch size={12} />
{schedule.max_concurrency}x
</span>
{schedule.model && <span>Model: {schedule.model}</span>}
{schedule.crash_count > 0 && (
<span className="text-destructive">Crashes: {schedule.crash_count}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Enable/disable toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleSchedule(schedule.id, schedule.enabled)}
disabled={toggleSchedule.isPending}
className={schedule.enabled ? 'text-primary' : 'text-muted-foreground'}
>
{schedule.enabled ? 'Enabled' : 'Disabled'}
</Button>
{/* Delete button */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDeleteSchedule(schedule.id)}
disabled={deleteSchedule.isPending}
className="text-destructive hover:text-destructive"
>
<Trash2 size={16} />
</Button>
</div>
</div>
</CardContent>
</Card>
) )
})} })}
</div> </div>
</div> )}
{/* YOLO mode toggle */} {/* Empty state */}
<div className="mb-4"> {!isLoading && schedules.length === 0 && (
<label className="flex items-center gap-2 cursor-pointer"> <div className="text-center py-6 text-muted-foreground mb-6">
<input <Clock size={48} className="mx-auto mb-2 opacity-50" />
type="checkbox" <p>No schedules configured yet</p>
</div>
)}
<Separator className="my-6" />
{/* Add new schedule form */}
<div className="pb-6">
<h3 className="text-lg font-semibold mb-4">Add New Schedule</h3>
{/* Time and duration */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<Label>Start Time (Local)</Label>
<Input
type="time"
value={newSchedule.start_time}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, start_time: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Duration (minutes)</Label>
<Input
type="number"
min="1"
max="1440"
value={newSchedule.duration_minutes}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10)
const value = isNaN(parsed) ? 1 : Math.max(1, Math.min(1440, parsed))
setNewSchedule((prev) => ({
...prev,
duration_minutes: value,
}))
}}
/>
<p className="text-xs text-muted-foreground">
{formatDuration(newSchedule.duration_minutes)}
</p>
</div>
</div>
{/* Days of week */}
<div className="mb-4 space-y-2">
<Label>Days</Label>
<div className="flex gap-2">
{DAYS.map((day) => {
const isActive = isDayActive(newSchedule.days_of_week, day.bit)
return (
<Button
key={day.label}
variant={isActive ? 'default' : 'outline'}
size="sm"
onClick={() => handleToggleDay(day.bit)}
>
{day.label}
</Button>
)
})}
</div>
</div>
{/* YOLO mode toggle */}
<div className="mb-4 flex items-center space-x-2">
<Checkbox
id="yolo-mode"
checked={newSchedule.yolo_mode} checked={newSchedule.yolo_mode}
onChange={(e) => onCheckedChange={(checked) =>
setNewSchedule((prev) => ({ ...prev, yolo_mode: e.target.checked })) setNewSchedule((prev) => ({ ...prev, yolo_mode: checked === true }))
} }
className="w-4 h-4"
/> />
<span className="text-sm font-bold text-gray-700 dark:text-gray-200">YOLO Mode (skip testing)</span> <Label htmlFor="yolo-mode" className="font-normal">
</label> YOLO Mode (skip testing)
</div> </Label>
</div>
{/* Concurrency slider */} {/* Concurrency slider */}
<div className="mb-4"> <div className="mb-4 space-y-2">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2"> <Label>Concurrent Agents (1-5)</Label>
Concurrent Agents (1-5) <div className="flex items-center gap-3">
</label> <GitBranch
<div className="flex items-center gap-3"> size={16}
<GitBranch className={newSchedule.max_concurrency > 1 ? 'text-primary' : 'text-muted-foreground'}
size={16} />
className={newSchedule.max_concurrency > 1 ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} <input
/> type="range"
<input min={1}
type="range" max={5}
min={1} value={newSchedule.max_concurrency}
max={5} onChange={(e) =>
value={newSchedule.max_concurrency} setNewSchedule((prev) => ({ ...prev, max_concurrency: Number(e.target.value) }))
}
className="flex-1 h-2 accent-primary cursor-pointer"
/>
<span className="text-sm font-medium min-w-[2rem] text-center">
{newSchedule.max_concurrency}x
</span>
</div>
<p className="text-xs text-muted-foreground">
Run {newSchedule.max_concurrency} agent{newSchedule.max_concurrency > 1 ? 's' : ''} in parallel for faster feature completion
</p>
</div>
{/* Model selection (optional) */}
<div className="mb-4 space-y-2">
<Label>Model (optional, defaults to global setting)</Label>
<Input
placeholder="e.g., claude-3-5-sonnet-20241022"
value={newSchedule.model || ''}
onChange={(e) => onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, max_concurrency: Number(e.target.value) })) setNewSchedule((prev) => ({ ...prev, model: e.target.value || null }))
} }
className="flex-1 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
title={`${newSchedule.max_concurrency} concurrent agent${newSchedule.max_concurrency > 1 ? 's' : ''}`}
aria-label="Set number of concurrent agents"
/> />
<span className="text-sm font-bold min-w-[2rem] text-center text-gray-900 dark:text-white">
{newSchedule.max_concurrency}x
</span>
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Run {newSchedule.max_concurrency} agent{newSchedule.max_concurrency > 1 ? 's' : ''} in parallel for faster feature completion
</div>
</div>
{/* Model selection (optional) */}
<div className="mb-6">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">
Model (optional, defaults to global setting)
</label>
<input
type="text"
placeholder="e.g., claude-3-5-sonnet-20241022"
value={newSchedule.model || ''}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, model: e.target.value || null }))
}
className="neo-input w-full"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="neo-btn neo-btn-ghost">
Close
</button>
<button
onClick={handleCreateSchedule}
disabled={createSchedule.isPending || newSchedule.days_of_week === 0}
className="neo-btn neo-btn-primary"
>
{createSchedule.isPending ? 'Creating...' : 'Create Schedule'}
</button>
</div> </div>
</div> </div>
</div>
</div> {/* Actions */}
<DialogFooter className="p-6 pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Close
</Button>
<Button
onClick={handleCreateSchedule}
disabled={createSchedule.isPending || newSchedule.days_of_week === 0}
>
{createSchedule.isPending ? 'Creating...' : 'Create Schedule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) )
} }

View File

@@ -1,62 +1,27 @@
import { useEffect, useRef } from 'react' import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react'
import { X, Loader2, AlertCircle } from 'lucide-react'
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects' import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
import { useTheme, THEMES } from '../hooks/useTheme'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean
onClose: () => void onClose: () => void
} }
export function SettingsModal({ onClose }: SettingsModalProps) { export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { data: settings, isLoading, isError, refetch } = useSettings() const { data: settings, isLoading, isError, refetch } = useSettings()
const { data: modelsData } = useAvailableModels() const { data: modelsData } = useAvailableModels()
const updateSettings = useUpdateSettings() const updateSettings = useUpdateSettings()
const modalRef = useRef<HTMLDivElement>(null) const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
const closeButtonRef = useRef<HTMLButtonElement>(null)
// Focus trap - keep focus within modal
useEffect(() => {
const modal = modalRef.current
if (!modal) return
// Focus the close button when modal opens
closeButtonRef.current?.focus()
const focusableElements = modal.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleTabKey)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleTabKey)
document.removeEventListener('keydown', handleEscape)
}
}, [onClose])
const handleYoloToggle = () => { const handleYoloToggle = () => {
if (settings && !updateSettings.isPending) { if (settings && !updateSettings.isPending) {
@@ -80,36 +45,14 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
const isSaving = updateSettings.isPending const isSaving = updateSettings.isPending
return ( return (
<div <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
className="neo-modal-backdrop" <DialogContent className="sm:max-w-sm">
onClick={onClose} <DialogHeader>
role="presentation" <DialogTitle className="flex items-center gap-2">
>
<div
ref={modalRef}
className="neo-modal w-full max-w-sm p-6"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="settings-title"
aria-modal="true"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 id="settings-title" className="font-display text-xl font-bold">
Settings Settings
{isSaving && ( {isSaving && <Loader2 className="animate-spin" size={16} />}
<Loader2 className="inline-block ml-2 animate-spin" size={16} /> </DialogTitle>
)} </DialogHeader>
</h2>
<button
ref={closeButtonRef}
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
aria-label="Close settings"
>
<X size={20} />
</button>
</div>
{/* Loading State */} {/* Loading State */}
{isLoading && ( {isLoading && (
@@ -121,82 +64,126 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
{/* Error State */} {/* Error State */}
{isError && ( {isError && (
<div className="p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)] mb-4"> <Alert variant="destructive">
<div className="flex items-center gap-2"> <AlertCircle className="h-4 w-4" />
<AlertCircle size={18} /> <AlertDescription>
<span>Failed to load settings</span> Failed to load settings
</div> <Button
<button variant="link"
onClick={() => refetch()} onClick={() => refetch()}
className="mt-2 underline text-sm hover:opacity-70 transition-opacity" className="ml-2 p-0 h-auto"
> >
Retry Retry
</button> </Button>
</div> </AlertDescription>
</Alert>
)} )}
{/* Settings Content */} {/* Settings Content */}
{settings && !isLoading && ( {settings && !isLoading && (
<div className="space-y-6"> <div className="space-y-6">
{/* YOLO Mode Toggle */} {/* Theme Selection */}
<div> <div className="space-y-3">
<div className="flex items-center justify-between"> <Label className="font-medium">Theme</Label>
<div> <div className="grid gap-2">
<label {THEMES.map((themeOption) => (
id="yolo-label" <button
className="font-display font-bold text-base" key={themeOption.id}
> onClick={() => setTheme(themeOption.id)}
YOLO Mode className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${
</label> theme === themeOption.id
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1"> ? 'border-primary bg-primary/5'
Skip testing for rapid prototyping : 'border-border hover:border-primary/50 hover:bg-muted/50'
</p>
</div>
<button
onClick={handleYoloToggle}
disabled={isSaving}
className={`relative w-14 h-8 rounded-none border-3 border-[var(--color-neo-border)] transition-colors ${
settings.yolo_mode
? 'bg-[var(--color-neo-pending)]'
: 'bg-[var(--color-neo-card)]'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
role="switch"
aria-checked={settings.yolo_mode}
aria-labelledby="yolo-label"
>
<span
className={`absolute top-1 w-5 h-5 bg-[var(--color-neo-border)] transition-transform ${
settings.yolo_mode ? 'left-7' : 'left-1'
}`} }`}
/> >
</button> {/* Color swatches */}
<div className="flex gap-0.5 shrink-0">
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.background }}
/>
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.primary }}
/>
<div
className="w-5 h-5 rounded-sm border border-border/50"
style={{ backgroundColor: themeOption.previewColors.accent }}
/>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{themeOption.name}</div>
<div className="text-xs text-muted-foreground">
{themeOption.description}
</div>
</div>
{/* Checkmark */}
{theme === themeOption.id && (
<Check size={18} className="text-primary shrink-0" />
)}
</button>
))}
</div> </div>
</div> </div>
{/* Model Selection - Radio Group */} {/* Dark Mode Toggle */}
<div> <div className="flex items-center justify-between">
<label <div className="space-y-0.5">
id="model-label" <Label htmlFor="dark-mode" className="font-medium">
className="font-display font-bold text-base block mb-2" Dark Mode
> </Label>
Model <p className="text-sm text-muted-foreground">
</label> Switch between light and dark appearance
<div </p>
className="flex border-3 border-[var(--color-neo-border)]" </div>
role="radiogroup" <Button
aria-labelledby="model-label" id="dark-mode"
variant="outline"
size="sm"
onClick={toggleDarkMode}
className="gap-2"
> >
{darkMode ? <Sun size={16} /> : <Moon size={16} />}
{darkMode ? 'Light' : 'Dark'}
</Button>
</div>
<hr className="border-border" />
{/* YOLO Mode Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="yolo-mode" className="font-medium">
YOLO Mode
</Label>
<p className="text-sm text-muted-foreground">
Skip testing for rapid prototyping
</p>
</div>
<Switch
id="yolo-mode"
checked={settings.yolo_mode}
onCheckedChange={handleYoloToggle}
disabled={isSaving}
/>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label className="font-medium">Model</Label>
<div className="flex rounded-lg border overflow-hidden">
{models.map((model) => ( {models.map((model) => (
<button <button
key={model.id} key={model.id}
onClick={() => handleModelChange(model.id)} onClick={() => handleModelChange(model.id)}
disabled={isSaving} disabled={isSaving}
role="radio" className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
aria-checked={settings.model === model.id}
className={`flex-1 py-3 px-4 font-display font-bold text-sm transition-colors ${
settings.model === model.id settings.model === model.id
? 'bg-[var(--color-neo-accent)] text-[var(--color-neo-text-on-bright)]' ? 'bg-primary text-primary-foreground'
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]' : 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{model.name} {model.name}
@@ -206,32 +193,21 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
</div> </div>
{/* Regression Agents */} {/* Regression Agents */}
<div> <div className="space-y-2">
<label <Label className="font-medium">Regression Agents</Label>
id="testing-ratio-label" <p className="text-sm text-muted-foreground">
className="font-display font-bold text-base block mb-1"
>
Regression Agents
</label>
<p className="text-sm text-[var(--color-neo-text-secondary)] mb-2">
Number of regression testing agents (0 = disabled) Number of regression testing agents (0 = disabled)
</p> </p>
<div <div className="flex rounded-lg border overflow-hidden">
className="flex border-3 border-[var(--color-neo-border)]"
role="radiogroup"
aria-labelledby="testing-ratio-label"
>
{[0, 1, 2, 3].map((ratio) => ( {[0, 1, 2, 3].map((ratio) => (
<button <button
key={ratio} key={ratio}
onClick={() => handleTestingRatioChange(ratio)} onClick={() => handleTestingRatioChange(ratio)}
disabled={isSaving} disabled={isSaving}
role="radio" className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
aria-checked={settings.testing_agent_ratio === ratio}
className={`flex-1 py-2 px-3 font-display font-bold text-sm transition-colors ${
settings.testing_agent_ratio === ratio settings.testing_agent_ratio === ratio
? 'bg-[var(--color-neo-progress)] text-[var(--color-neo-text)]' ? 'bg-primary text-primary-foreground'
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]' : 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
{ratio} {ratio}
@@ -242,13 +218,15 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
{/* Update Error */} {/* Update Error */}
{updateSettings.isError && ( {updateSettings.isError && (
<div className="p-3 bg-[var(--color-neo-error-bg)] border-3 border-[var(--color-neo-error-border)] text-[var(--color-neo-error-text)] text-sm"> <Alert variant="destructive">
Failed to save settings. Please try again. <AlertDescription>
</div> Failed to save settings. Please try again.
</AlertDescription>
</Alert>
)} )}
</div> </div>
)} )}
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -1,6 +1,9 @@
import { useEffect, useCallback } from 'react' import { useEffect, useCallback } from 'react'
import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react' import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react'
import { useSetupStatus, useHealthCheck } from '../hooks/useProjects' import { useSetupStatus, useHealthCheck } from '../hooks/useProjects'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
interface SetupWizardProps { interface SetupWizardProps {
onComplete: () => void onComplete: () => void
@@ -26,98 +29,100 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
}, [checkAndComplete]) }, [checkAndComplete])
return ( return (
<div className="min-h-screen bg-[var(--color-neo-bg)] flex items-center justify-center p-4"> <div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="neo-card w-full max-w-lg p-8"> <Card className="w-full max-w-lg">
<h1 className="font-display text-3xl font-bold text-center mb-2"> <CardContent className="p-8">
Setup Wizard <h1 className="font-display text-3xl font-bold text-center mb-2">
</h1> Setup Wizard
<p className="text-center text-[var(--color-neo-text-secondary)] mb-8"> </h1>
Let's make sure everything is ready to go <p className="text-center text-muted-foreground mb-8">
</p> Let's make sure everything is ready to go
</p>
<div className="space-y-4"> <div className="space-y-4">
{/* API Health */} {/* API Health */}
<SetupItem <SetupItem
label="Backend Server" label="Backend Server"
description="FastAPI server is running" description="FastAPI server is running"
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'} status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
/> />
{/* Claude CLI */} {/* Claude CLI */}
<SetupItem <SetupItem
label="Claude CLI" label="Claude CLI"
description="Claude Code CLI is installed" description="Claude Code CLI is installed"
status={ status={
setupLoading setupLoading
? 'loading' ? 'loading'
: setupError : setupError
? 'error' ? 'error'
: setupStatus?.claude_cli : setupStatus?.claude_cli
? 'success' ? 'success'
: 'error' : 'error'
} }
helpLink="https://docs.anthropic.com/claude/claude-code" helpLink="https://docs.anthropic.com/claude/claude-code"
helpText="Install Claude Code" helpText="Install Claude Code"
/> />
{/* Credentials */} {/* Credentials */}
<SetupItem <SetupItem
label="Anthropic Credentials" label="Anthropic Credentials"
description="API credentials are configured" description="API credentials are configured"
status={ status={
setupLoading setupLoading
? 'loading' ? 'loading'
: setupError : setupError
? 'error' ? 'error'
: setupStatus?.credentials : setupStatus?.credentials
? 'success' ? 'success'
: 'error' : 'error'
} }
helpLink="https://console.anthropic.com/account/keys" helpLink="https://console.anthropic.com/account/keys"
helpText="Get API Key" helpText="Get API Key"
/> />
{/* Node.js */} {/* Node.js */}
<SetupItem <SetupItem
label="Node.js" label="Node.js"
description="Node.js is installed (for UI dev)" description="Node.js is installed (for UI dev)"
status={ status={
setupLoading setupLoading
? 'loading' ? 'loading'
: setupError : setupError
? 'error' ? 'error'
: setupStatus?.node : setupStatus?.node
? 'success' ? 'success'
: 'warning' : 'warning'
} }
helpLink="https://nodejs.org" helpLink="https://nodejs.org"
helpText="Install Node.js" helpText="Install Node.js"
optional optional
/> />
</div>
{/* Continue Button */}
{isReady && (
<button
onClick={onComplete}
className="neo-btn neo-btn-success w-full mt-8"
>
Continue to Dashboard
</button>
)}
{/* Error Message */}
{(healthError || setupError) && (
<div className="mt-6 p-4 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-3 border-[var(--color-neo-error-border)]">
<p className="font-bold mb-2">Setup Error</p>
<p className="text-sm">
{healthError
? 'Cannot connect to the backend server. Make sure to run start_ui.py first.'
: 'Failed to check setup status.'}
</p>
</div> </div>
)}
</div> {/* Continue Button */}
{isReady && (
<Button
onClick={onComplete}
className="w-full mt-8 bg-green-500 hover:bg-green-600 text-white"
>
Continue to Dashboard
</Button>
)}
{/* Error Message */}
{(healthError || setupError) && (
<Alert variant="destructive" className="mt-6">
<AlertTitle>Setup Error</AlertTitle>
<AlertDescription>
{healthError
? 'Cannot connect to the backend server. Make sure to run start_ui.py first.'
: 'Failed to check setup status.'}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -140,31 +145,31 @@ function SetupItem({
optional, optional,
}: SetupItemProps) { }: SetupItemProps) {
return ( return (
<div className="flex items-start gap-4 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]"> <div className="flex items-start gap-4 p-4 bg-background border-2 border-border rounded-lg">
{/* Status Icon */} {/* Status Icon */}
<div className="flex-shrink-0 mt-1"> <div className="flex-shrink-0 mt-1">
{status === 'success' ? ( {status === 'success' ? (
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" /> <CheckCircle2 size={24} className="text-green-500" />
) : status === 'error' ? ( ) : status === 'error' ? (
<XCircle size={24} className="text-[var(--color-neo-danger)]" /> <XCircle size={24} className="text-destructive" />
) : status === 'warning' ? ( ) : status === 'warning' ? (
<XCircle size={24} className="text-[var(--color-neo-pending)]" /> <XCircle size={24} className="text-yellow-500" />
) : ( ) : (
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" /> <Loader2 size={24} className="animate-spin text-primary" />
)} )}
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-display font-bold">{label}</span> <span className="font-display font-bold text-foreground">{label}</span>
{optional && ( {optional && (
<span className="text-xs text-[var(--color-neo-text-secondary)]"> <span className="text-xs text-muted-foreground">
(optional) (optional)
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-[var(--color-neo-text-secondary)]"> <p className="text-sm text-muted-foreground">
{description} {description}
</p> </p>
{(status === 'error' || status === 'warning') && helpLink && ( {(status === 'error' || status === 'warning') && helpLink && (
@@ -172,7 +177,7 @@ function SetupItem({
href={helpLink} href={helpLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 text-sm text-[var(--color-neo-accent)] hover:underline" className="inline-flex items-center gap-1 mt-2 text-sm text-primary hover:underline"
> >
{helpText} <ExternalLink size={12} /> {helpText} <ExternalLink size={12} />
</a> </a>

View File

@@ -12,6 +12,10 @@ import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions' import { QuestionOptions } from './QuestionOptions'
import { TypingIndicator } from './TypingIndicator' import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types' import type { ImageAttachment } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
// Image upload validation constants // Image upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
@@ -193,28 +197,28 @@ export function SpecCreationChat({
switch (connectionStatus) { switch (connectionStatus) {
case 'connected': case 'connected':
return ( return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-done)]"> <span className="flex items-center gap-1 text-xs text-green-500">
<Wifi size={12} /> <Wifi size={12} />
Connected Connected
</span> </span>
) )
case 'connecting': case 'connecting':
return ( return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-pending)]"> <span className="flex items-center gap-1 text-xs text-yellow-500">
<Wifi size={12} className="animate-pulse" /> <Wifi size={12} className="animate-pulse" />
Connecting... Connecting...
</span> </span>
) )
case 'error': case 'error':
return ( return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-danger)]"> <span className="flex items-center gap-1 text-xs text-destructive">
<WifiOff size={12} /> <WifiOff size={12} />
Error Error
</span> </span>
) )
default: default:
return ( return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-text-secondary)]"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
<WifiOff size={12} /> <WifiOff size={12} />
Disconnected Disconnected
</span> </span>
@@ -223,11 +227,11 @@ export function SpecCreationChat({
} }
return ( return (
<div className="flex flex-col h-full bg-[var(--color-neo-bg)]"> <div className="flex flex-col h-full bg-background">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-card)]"> <div className="flex items-center justify-between p-4 border-b-2 border-border bg-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="font-display font-bold text-lg text-[var(--color-neo-text)]"> <h2 className="font-display font-bold text-lg text-foreground">
Create Spec: {projectName} Create Spec: {projectName}
</h2> </h2>
<ConnectionIndicator /> <ConnectionIndicator />
@@ -235,14 +239,14 @@ export function SpecCreationChat({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isComplete && ( {isComplete && (
<span className="flex items-center gap-1 text-sm text-[var(--color-neo-done)] font-bold"> <span className="flex items-center gap-1 text-sm text-green-500 font-bold">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
Complete Complete
</span> </span>
)} )}
{/* Load Sample Prompt */} {/* Load Sample Prompt */}
<button <Button
onClick={() => { onClick={() => {
setInput(SAMPLE_PROMPT) setInput(SAMPLE_PROMPT)
// Also resize the textarea to fit content // Also resize the textarea to fit content
@@ -251,68 +255,76 @@ export function SpecCreationChat({
inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 200)}px` inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 200)}px`
} }
}} }}
className="neo-btn neo-btn-ghost text-sm py-2" variant="ghost"
size="sm"
title="Load sample prompt (Simple Todo app)" title="Load sample prompt (Simple Todo app)"
> >
<FileText size={16} /> <FileText size={16} />
Load Sample Load Sample
</button> </Button>
{/* Exit to Project - always visible escape hatch */} {/* Exit to Project - always visible escape hatch */}
<button <Button
onClick={onExitToProject} onClick={onExitToProject}
className="neo-btn neo-btn-ghost text-sm py-2" variant="ghost"
size="sm"
title="Exit chat and go to project (you can start the agent manually)" title="Exit chat and go to project (you can start the agent manually)"
> >
<ExternalLink size={16} /> <ExternalLink size={16} />
Exit to Project Exit to Project
</button> </Button>
<button <Button
onClick={onCancel} onClick={onCancel}
className="neo-btn neo-btn-ghost p-2" variant="ghost"
size="icon"
title="Cancel" title="Cancel"
> >
<X size={20} /> <X size={20} />
</button> </Button>
</div> </div>
</div> </div>
{/* Error banner */} {/* Error banner */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-[var(--color-neo-error-bg)] text-[var(--color-neo-error-text)] border-b-3 border-[var(--color-neo-error-border)]"> <Alert variant="destructive" className="rounded-none border-x-0 border-t-0">
<AlertCircle size={16} /> <AlertCircle size={16} />
<span className="flex-1 text-sm">{error}</span> <AlertDescription className="flex-1">{error}</AlertDescription>
<button <Button
onClick={() => setError(null)} onClick={() => setError(null)}
className="p-1 hover:opacity-70 transition-opacity rounded" variant="ghost"
size="icon"
className="h-6 w-6"
> >
<X size={14} /> <X size={14} />
</button> </Button>
</div> </Alert>
)} )}
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
{messages.length === 0 && !isLoading && ( {messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center p-8"> <div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="neo-card p-6 max-w-md"> <Card className="p-6 max-w-md">
<h3 className="font-display font-bold text-lg mb-2"> <CardContent className="p-0">
Starting Spec Creation <h3 className="font-display font-bold text-lg mb-2">
</h3> Starting Spec Creation
<p className="text-sm text-[var(--color-neo-text-secondary)]"> </h3>
Connecting to Claude to help you create your app specification... <p className="text-sm text-muted-foreground">
</p> Connecting to Claude to help you create your app specification...
{connectionStatus === 'error' && ( </p>
<button {connectionStatus === 'error' && (
onClick={start} <Button
className="neo-btn neo-btn-primary mt-4 text-sm" onClick={start}
> className="mt-4"
<RotateCcw size={14} /> size="sm"
Retry Connection >
</button> <RotateCcw size={14} />
)} Retry Connection
</div> </Button>
)}
</CardContent>
</Card>
</div> </div>
)} )}
@@ -339,7 +351,7 @@ export function SpecCreationChat({
{/* Input area */} {/* Input area */}
{!isComplete && ( {!isComplete && (
<div <div
className="p-4 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-card)]" className="p-4 border-t-2 border-border bg-card"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
> >
@@ -349,22 +361,21 @@ export function SpecCreationChat({
{pendingAttachments.map((attachment) => ( {pendingAttachments.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
className="relative group border-2 border-[var(--color-neo-border)] p-1 bg-[var(--color-neo-card)]" className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
> >
<img <img
src={attachment.previewUrl} src={attachment.previewUrl}
alt={attachment.filename} alt={attachment.filename}
className="w-16 h-16 object-cover" className="w-16 h-16 object-cover rounded"
/> />
<button <button
onClick={() => handleRemoveAttachment(attachment.id)} onClick={() => handleRemoveAttachment(attachment.id)}
className="absolute -top-2 -right-2 bg-[var(--color-neo-danger)] text-[var(--color-neo-text-on-bright)] rounded-full p-0.5 border-2 border-[var(--color-neo-border)] hover:scale-110 transition-transform" className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5 border-2 border-border hover:scale-110 transition-transform"
title="Remove attachment" title="Remove attachment"
> >
<X size={12} /> <X size={12} />
</button> </button>
<span className="text-xs truncate block max-w-16 mt-1 text-center"> <span className="text-xs truncate block max-w-16 mt-1 text-center text-muted-foreground">
{attachment.filename.length > 10 {attachment.filename.length > 10
? `${attachment.filename.substring(0, 7)}...` ? `${attachment.filename.substring(0, 7)}...`
: attachment.filename} : attachment.filename}
@@ -386,16 +397,17 @@ export function SpecCreationChat({
/> />
{/* Attach button */} {/* Attach button */}
<button <Button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={connectionStatus !== 'connected'} disabled={connectionStatus !== 'connected'}
className="neo-btn neo-btn-ghost p-3" variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)" title="Attach image (JPEG, PNG - max 5MB)"
> >
<Paperclip size={18} /> <Paperclip size={18} />
</button> </Button>
<textarea <Textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => { onChange={(e) => {
@@ -412,25 +424,25 @@ export function SpecCreationChat({
? 'Add a message with your image(s)...' ? 'Add a message with your image(s)...'
: 'Type your response... (or /exit to go to project)' : 'Type your response... (or /exit to go to project)'
} }
className="neo-input flex-1 resize-none min-h-[46px] max-h-[200px] overflow-y-auto" className="flex-1 resize-none min-h-[46px] max-h-[200px] overflow-y-auto"
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'} disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
rows={1} rows={1}
/> />
<button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={ disabled={
(!input.trim() && pendingAttachments.length === 0) || (!input.trim() && pendingAttachments.length === 0) ||
(isLoading && !currentQuestions) || (isLoading && !currentQuestions) ||
connectionStatus !== 'connected' connectionStatus !== 'connected'
} }
className="neo-btn neo-btn-primary px-6" className="px-6"
> >
<Send size={18} /> <Send size={18} />
</button> </Button>
</div> </div>
{/* Help text */} {/* Help text */}
<p className="text-xs text-[var(--color-neo-text-secondary)] mt-2"> <p className="text-xs text-muted-foreground mt-2">
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB). Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
</p> </p>
</div> </div>
@@ -438,64 +450,63 @@ export function SpecCreationChat({
{/* Completion footer */} {/* Completion footer */}
{isComplete && ( {isComplete && (
<div className={`p-4 border-t-3 border-[var(--color-neo-border)] ${ <div className={`p-4 border-t-2 border-border ${
initializerStatus === 'error' ? 'bg-[var(--color-neo-danger)]' : 'bg-[var(--color-neo-done)]' initializerStatus === 'error' ? 'bg-destructive' : 'bg-green-500'
}`}> }`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{initializerStatus === 'starting' ? ( {initializerStatus === 'starting' ? (
<> <>
<Loader2 size={20} className="animate-spin text-[var(--color-neo-text-on-bright)]" /> <Loader2 size={20} className="animate-spin text-white" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]"> <span className="font-bold text-white">
Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}... Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...
</span> </span>
</> </>
) : initializerStatus === 'error' ? ( ) : initializerStatus === 'error' ? (
<> <>
<AlertCircle size={20} className="text-[var(--color-neo-text-on-bright)]" /> <AlertCircle size={20} className="text-white" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]"> <span className="font-bold text-white">
{initializerError || 'Failed to start agent'} {initializerError || 'Failed to start agent'}
</span> </span>
</> </>
) : ( ) : (
<> <>
<CheckCircle2 size={20} className="text-[var(--color-neo-text-on-bright)]" /> <CheckCircle2 size={20} className="text-white" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]">Specification created successfully!</span> <span className="font-bold text-white">Specification created successfully!</span>
</> </>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{initializerStatus === 'error' && onRetryInitializer && ( {initializerStatus === 'error' && onRetryInitializer && (
<button <Button
onClick={onRetryInitializer} onClick={onRetryInitializer}
className="neo-btn bg-[var(--color-neo-card)]" variant="secondary"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
Retry Retry
</button> </Button>
)} )}
{initializerStatus === 'idle' && ( {initializerStatus === 'idle' && (
<> <>
{/* YOLO Mode Toggle */} {/* YOLO Mode Toggle */}
<button <Button
onClick={() => setYoloEnabled(!yoloEnabled)} onClick={() => setYoloEnabled(!yoloEnabled)}
className={`neo-btn text-sm py-2 px-3 ${ variant={yoloEnabled ? "default" : "secondary"}
yoloEnabled ? 'neo-btn-warning' : 'bg-[var(--color-neo-card)]' size="sm"
}`} className={yoloEnabled ? 'bg-yellow-500 hover:bg-yellow-600 text-yellow-900' : ''}
title="YOLO Mode: Skip testing for rapid prototyping" title="YOLO Mode: Skip testing for rapid prototyping"
> >
<Zap size={16} className={yoloEnabled ? 'text-yellow-900' : ''} /> <Zap size={16} />
<span className={yoloEnabled ? 'text-yellow-900 font-bold' : ''}> <span className={yoloEnabled ? 'font-bold' : ''}>
YOLO YOLO
</span> </span>
</button> </Button>
<button <Button
onClick={() => onComplete('', yoloEnabled)} onClick={() => onComplete('', yoloEnabled)}
className="neo-btn neo-btn-primary"
> >
Continue to Project Continue to Project
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </Button>
</> </>
)} )}
</div> </div>

View File

@@ -40,29 +40,29 @@ interface TerminalExitMessage {
type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage
// Neobrutalism theme colors for xterm // Clean terminal theme colors
const TERMINAL_THEME = { const TERMINAL_THEME = {
background: '#1a1a1a', background: '#09090b', // zinc-950
foreground: '#ffffff', foreground: '#fafafa', // zinc-50
cursor: '#ff006e', // --color-neo-accent cursor: '#3b82f6', // blue-500
cursorAccent: '#1a1a1a', cursorAccent: '#09090b',
selectionBackground: 'rgba(255, 0, 110, 0.3)', selectionBackground: 'rgba(59, 130, 246, 0.3)',
selectionForeground: '#ffffff', selectionForeground: '#ffffff',
black: '#1a1a1a', black: '#09090b',
red: '#ff5400', red: '#ef4444',
green: '#70e000', green: '#22c55e',
yellow: '#ffd60a', yellow: '#eab308',
blue: '#00b4d8', blue: '#3b82f6',
magenta: '#ff006e', magenta: '#a855f7',
cyan: '#00b4d8', cyan: '#06b6d4',
white: '#ffffff', white: '#fafafa',
brightBlack: '#4a4a4a', brightBlack: '#52525b',
brightRed: '#ff7733', brightRed: '#f87171',
brightGreen: '#8fff00', brightGreen: '#4ade80',
brightYellow: '#ffe44d', brightYellow: '#facc15',
brightBlue: '#33c7e6', brightBlue: '#60a5fa',
brightMagenta: '#ff4d94', brightMagenta: '#c084fc',
brightCyan: '#33c7e6', brightCyan: '#22d3ee',
brightWhite: '#ffffff', brightWhite: '#ffffff',
} }
@@ -552,17 +552,17 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
}, [projectName, terminalId, isActive]) }, [projectName, terminalId, isActive])
return ( return (
<div className="relative h-full w-full bg-[#1a1a1a]"> <div className="relative h-full w-full bg-zinc-950">
{/* Connection status indicator */} {/* Connection status indicator */}
<div className="absolute top-2 right-2 z-10 flex items-center gap-2"> <div className="absolute top-2 right-2 z-10 flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-neo-done' : 'bg-neo-danger' isConnected ? 'bg-green-500' : 'bg-destructive'
}`} }`}
title={isConnected ? 'Connected' : 'Disconnected'} title={isConnected ? 'Connected' : 'Disconnected'}
/> />
{!isConnected && !hasExited && ( {!isConnected && !hasExited && (
<span className="text-xs font-mono text-gray-500">Connecting...</span> <span className="text-xs font-mono text-muted-foreground">Connecting...</span>
)} )}
{hasExited && exitCode !== null && ( {hasExited && exitCode !== null && (
<span className="text-xs font-mono text-yellow-500"> <span className="text-xs font-mono text-yellow-500">

View File

@@ -8,6 +8,8 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import type { TerminalInfo } from '@/lib/types' import type { TerminalInfo } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface TerminalTabsProps { interface TerminalTabsProps {
terminals: TerminalInfo[] terminals: TerminalInfo[]
@@ -154,18 +156,18 @@ export function TerminalTabs({
) )
return ( return (
<div className="flex items-center gap-1 px-2 py-1 bg-[#2a2a2a] border-b-2 border-black overflow-x-auto"> <div className="flex items-center gap-1 px-2 py-1 bg-zinc-900 border-b border-border overflow-x-auto">
{/* Terminal tabs */} {/* Terminal tabs */}
{terminals.map((terminal) => ( {terminals.map((terminal) => (
<div <div
key={terminal.id} key={terminal.id}
className={` className={`
group flex items-center gap-1 px-3 py-1 border-2 border-black cursor-pointer group flex items-center gap-1 px-3 py-1 rounded cursor-pointer
transition-colors duration-100 select-none min-w-0 transition-colors duration-100 select-none min-w-0
${ ${
activeTerminalId === terminal.id activeTerminalId === terminal.id
? 'bg-neo-progress text-black' ? 'bg-primary text-primary-foreground'
: 'bg-[#3a3a3a] text-white hover:bg-[var(--color-neo-hover-subtle)]' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
} }
`} `}
onClick={() => onSelect(terminal.id)} onClick={() => onSelect(terminal.id)}
@@ -173,14 +175,14 @@ export function TerminalTabs({
onContextMenu={(e) => handleContextMenu(e, terminal.id)} onContextMenu={(e) => handleContextMenu(e, terminal.id)}
> >
{editingId === terminal.id ? ( {editingId === terminal.id ? (
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={editValue} value={editValue}
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onBlur={submitEdit} onBlur={submitEdit}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="bg-neo-card text-neo-text px-1 py-0 text-sm font-mono border-2 border-black w-24 outline-none" className="h-6 px-1 py-0 text-sm font-mono w-24"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
@@ -210,31 +212,33 @@ export function TerminalTabs({
))} ))}
{/* Add new terminal button */} {/* Add new terminal button */}
<button <Button
variant="ghost"
size="icon"
onClick={onCreate} onClick={onCreate}
className="flex items-center justify-center w-8 h-8 border-2 border-black bg-[#3a3a3a] text-white hover:bg-[var(--color-neo-hover-subtle)] transition-colors" className="h-8 w-8 bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
title="New terminal" title="New terminal"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</button> </Button>
{/* Context menu */} {/* Context menu */}
{contextMenu.visible && ( {contextMenu.visible && (
<div <div
ref={contextMenuRef} ref={contextMenuRef}
className="fixed z-50 bg-neo-card border-2 border-[var(--color-neo-border)] py-1 min-w-[120px]" className="fixed z-50 bg-popover border border-border rounded-md py-1 min-w-[120px] shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-neo-md)' }} style={{ left: contextMenu.x, top: contextMenu.y }}
> >
<button <button
onClick={handleContextMenuRename} onClick={handleContextMenuRename}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-progress hover:text-black transition-colors" className="w-full px-3 py-1.5 text-left text-sm font-mono hover:bg-accent transition-colors"
> >
Rename Rename
</button> </button>
{terminals.length > 1 && ( {terminals.length > 1 && (
<button <button
onClick={handleContextMenuClose} onClick={handleContextMenuClose}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-danger hover:text-white transition-colors" className="w-full px-3 py-1.5 text-left text-sm font-mono text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors"
> >
Close Close
</button> </button>

View File

@@ -0,0 +1,163 @@
import { useState, useRef, useEffect } from 'react'
import { Palette, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { ThemeId, ThemeOption } from '../hooks/useTheme'
interface ThemeSelectorProps {
themes: ThemeOption[]
currentTheme: ThemeId
onThemeChange: (theme: ThemeId) => void
}
export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
setPreviewTheme(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Apply preview theme temporarily
useEffect(() => {
if (previewTheme) {
const root = document.documentElement
root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora')
if (previewTheme === 'claude') {
root.classList.add('theme-claude')
} else if (previewTheme === 'neo-brutalism') {
root.classList.add('theme-neo-brutalism')
} else if (previewTheme === 'retro-arcade') {
root.classList.add('theme-retro-arcade')
} else if (previewTheme === 'aurora') {
root.classList.add('theme-aurora')
}
}
// Cleanup: restore current theme when preview ends
return () => {
if (previewTheme) {
const root = document.documentElement
root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora')
if (currentTheme === 'claude') {
root.classList.add('theme-claude')
} else if (currentTheme === 'neo-brutalism') {
root.classList.add('theme-neo-brutalism')
} else if (currentTheme === 'retro-arcade') {
root.classList.add('theme-retro-arcade')
} else if (currentTheme === 'aurora') {
root.classList.add('theme-aurora')
}
}
}
}, [previewTheme, currentTheme])
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setIsOpen(true)
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setIsOpen(false)
setPreviewTheme(null)
}, 150)
}
const handleThemeHover = (themeId: ThemeId) => {
setPreviewTheme(themeId)
}
const handleThemeClick = (themeId: ThemeId) => {
onThemeChange(themeId)
setPreviewTheme(null)
setIsOpen(false)
}
return (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Button
variant="outline"
size="sm"
title="Theme"
aria-label="Select theme"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Palette size={18} />
</Button>
{/* Dropdown */}
{isOpen && (
<div
className="absolute right-0 top-full mt-2 w-56 bg-popover border-2 border-border rounded-lg shadow-lg z-50 animate-slide-in-down overflow-hidden"
role="menu"
aria-orientation="vertical"
>
<div className="p-2 space-y-1">
{themes.map((theme) => (
<button
key={theme.id}
onClick={() => handleThemeClick(theme.id)}
onMouseEnter={() => handleThemeHover(theme.id)}
onMouseLeave={() => setPreviewTheme(null)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
currentTheme === theme.id
? 'bg-primary/10 text-foreground'
: 'hover:bg-muted text-foreground'
}`}
role="menuitem"
>
{/* Color swatches */}
<div className="flex gap-0.5 shrink-0">
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.background }}
/>
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.primary }}
/>
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.accent }}
/>
</div>
{/* Theme name and description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{theme.name}</div>
<div className="text-xs text-muted-foreground truncate">
{theme.description}
</div>
</div>
{/* Checkmark for current theme */}
{currentTheme === theme.id && (
<Check size={16} className="text-primary shrink-0" />
)}
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -2,7 +2,6 @@
* Typing Indicator Component * Typing Indicator Component
* *
* Shows animated dots to indicate Claude is typing/thinking. * Shows animated dots to indicate Claude is typing/thinking.
* Styled in neobrutalism aesthetic.
*/ */
export function TypingIndicator() { export function TypingIndicator() {
@@ -10,19 +9,19 @@ export function TypingIndicator() {
<div className="flex items-center gap-2 p-4"> <div className="flex items-center gap-2 p-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: '0ms' }} style={{ animationDelay: '0ms' }}
/> />
<span <span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: '150ms' }} style={{ animationDelay: '150ms' }}
/> />
<span <span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce" className="w-2 h-2 bg-primary rounded-full animate-bounce"
style={{ animationDelay: '300ms' }} style={{ animationDelay: '300ms' }}
/> />
</div> </div>
<span className="text-sm font-mono animate-shimmer"> <span className="text-sm font-mono text-muted-foreground">
Claude is thinking... Claude is thinking...
</span> </span>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { LayoutGrid, GitBranch } from 'lucide-react' import { LayoutGrid, GitBranch } from 'lucide-react'
import { Button } from '@/components/ui/button'
export type ViewMode = 'kanban' | 'graph' export type ViewMode = 'kanban' | 'graph'
@@ -12,35 +13,25 @@ interface ViewToggleProps {
*/ */
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) { export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
return ( return (
<div className="inline-flex rounded-lg border-2 border-neo-border p-1 bg-white shadow-neo-sm"> <div className="inline-flex rounded-lg border p-1 bg-background">
<button <Button
variant={viewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('kanban')} onClick={() => onViewModeChange('kanban')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'kanban'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Kanban View" title="Kanban View"
> >
<LayoutGrid size={16} /> <LayoutGrid size={16} />
<span>Kanban</span> Kanban
</button> </Button>
<button <Button
variant={viewMode === 'graph' ? 'default' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('graph')} onClick={() => onViewModeChange('graph')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'graph'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Dependency Graph View" title="Dependency Graph View"
> >
<GitBranch size={16} /> <GitBranch size={16} />
<span>Graph</span> Graph
</button> </Button>
</div> </div>
) )
} }

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,87 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,89 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

140
ui/src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,140 @@
import { useState, useEffect, useCallback } from 'react'
export type ThemeId = 'twitter' | 'claude' | 'neo-brutalism' | 'retro-arcade' | 'aurora'
export interface ThemeOption {
id: ThemeId
name: string
description: string
previewColors: {
primary: string
background: string
accent: string
}
}
export const THEMES: ThemeOption[] = [
{
id: 'twitter',
name: 'Twitter',
description: 'Clean and modern blue design',
previewColors: { primary: '#4a9eff', background: '#ffffff', accent: '#e8f4ff' }
},
{
id: 'claude',
name: 'Claude',
description: 'Warm beige tones with orange accents',
previewColors: { primary: '#c75b2a', background: '#faf6f0', accent: '#f5ede4' }
},
{
id: 'neo-brutalism',
name: 'Neo Brutalism',
description: 'Bold colors with hard shadows',
previewColors: { primary: '#ff4d00', background: '#ffffff', accent: '#ffeb00' }
},
{
id: 'retro-arcade',
name: 'Retro Arcade',
description: 'Vibrant pink and teal pixel vibes',
previewColors: { primary: '#e8457c', background: '#f0e6d3', accent: '#4eb8a5' }
},
{
id: 'aurora',
name: 'Aurora',
description: 'Deep violet and teal, like northern lights',
previewColors: { primary: '#8b5cf6', background: '#faf8ff', accent: '#2dd4bf' }
}
]
const THEME_STORAGE_KEY = 'autocoder-theme'
const DARK_MODE_STORAGE_KEY = 'autocoder-dark-mode'
function getThemeClass(themeId: ThemeId): string {
switch (themeId) {
case 'twitter':
return '' // Default, no class needed
case 'claude':
return 'theme-claude'
case 'neo-brutalism':
return 'theme-neo-brutalism'
case 'retro-arcade':
return 'theme-retro-arcade'
case 'aurora':
return 'theme-aurora'
default:
return ''
}
}
export function useTheme() {
const [theme, setThemeState] = useState<ThemeId>(() => {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'twitter' || stored === 'claude' || stored === 'neo-brutalism' || stored === 'retro-arcade' || stored === 'aurora') {
return stored
}
} catch {
// localStorage not available
}
return 'twitter'
})
const [darkMode, setDarkModeState] = useState(() => {
try {
return localStorage.getItem(DARK_MODE_STORAGE_KEY) === 'true'
} catch {
return false
}
})
// Apply theme and dark mode classes to document
useEffect(() => {
const root = document.documentElement
// Remove all theme classes
root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora')
// Add current theme class (if not twitter/default)
const themeClass = getThemeClass(theme)
if (themeClass) {
root.classList.add(themeClass)
}
// Handle dark mode
if (darkMode) {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
// Persist to localStorage
try {
localStorage.setItem(THEME_STORAGE_KEY, theme)
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(darkMode))
} catch {
// localStorage not available
}
}, [theme, darkMode])
const setTheme = useCallback((newTheme: ThemeId) => {
setThemeState(newTheme)
}, [])
const setDarkMode = useCallback((enabled: boolean) => {
setDarkModeState(enabled)
}, [])
const toggleDarkMode = useCallback(() => {
setDarkModeState(prev => !prev)
}, [])
return {
theme,
setTheme,
darkMode,
setDarkMode,
toggleDarkMode,
themes: THEMES,
currentTheme: THEMES.find(t => t.id === theme) ?? THEMES[0]
}
}

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App' import App from './App'
import './styles/globals.css' import './styles/globals.css'
// Note: Custom theme removed - using shadcn/ui theming instead
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {

File diff suppressed because it is too large Load Diff

30
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,30 +1,17 @@
{ {
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": { "compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, }
"include": ["src"]
} }