Compare commits

..

85 Commits

Author SHA1 Message Date
Auto
79d02a1410 refactor: improve Vertex AI model conversion and add tests
- Rename compute_mode -> convert_model_for_vertex for clarity
- Move `import re` to module top-level (stdlib convention)
- Use greedy regex quantifier for more readable pattern matching
- Restore PEP 8 double blank line between top-level definitions
- Add test_client.py with 10 unit tests covering:
  - Vertex disabled (env unset, "0", empty)
  - Standard conversions (Opus, Sonnet, Haiku)
  - Edge cases (already-converted, non-Claude, no date suffix, empty)

Follow-up improvements from PR #129 review.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:20:03 +02:00
Leon van Zyl
813fcde18b Merge pull request #129 from derhally/zd/vertex-support
feat: Adds Vertex AI support for Claude models
2026-01-30 10:16:18 +02:00
Auto
b693de2999 fix: improve parallel orchestrator agent tracking clarity and cleanup
- Add comment on running_coding_agents explaining why feature_id keying
  is safe (start_feature checks for duplicates before spawning), since
  the sister dict running_testing_agents required PID keying to avoid
  overwrites from concurrent same-feature testing
- Clear running_testing_agents dict in stop_all() after killing
  processes so get_status() doesn't report stale agent counts while
  _on_agent_complete callbacks are still in flight

Follow-up to PR #130 (runaway testing agent spawn fix).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:07:40 +02:00
Leon van Zyl
21fe28f51d Merge pull request #130 from ipodishima/fix/too_many_agents_spawned
fix: prevent runaway testing agent spawning (critical)
2026-01-30 09:03:52 +02:00
Marian Paul
80b6af7b2b fix: prevent runaway testing agent spawning (critical)
running_testing_agents was keyed by feature_id, so when multiple agents
tested the same feature, each spawn overwrote the previous dict entry.
The count stayed at 1 regardless of how many processes were actually
running, causing the maintain loop to spawn agents indefinitely (~130+).

Re-key the dict by PID so each agent gets a unique entry and the
existing max-agent guards work correctly. Also check the return value
of _spawn_testing_agent() to break the loop on failure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:02:08 +01:00
Zeid Derhally
099f52b19c Add support for using vertex AI with claude models 2026-01-29 06:13:34 -05:00
Auto
3edb380b58 docs: update security test count from 136 to 163
Update the documented test count in CLAUDE.md to reflect the current
state after merging PR #100 which added diagnostic warnings for config
loading failures. The test suite now includes additional tests for:
- Empty command name validation in project configs
- Config loading diagnostic warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:08:28 +02:00
Leon van Zyl
3256072793 Merge pull request #100 from cabana8471-arch/fix/config-loading-diagnostics
fix: add diagnostic warnings for config loading failures
2026-01-29 11:03:55 +02:00
Leon van Zyl
9f67d7ffe4 Merge pull request #95 from cabana8471-arch/fix/infrastructure-features-mock-prevention
fix: Prevent mock data implementations with infrastructure features
2026-01-29 11:00:13 +02:00
Auto
8ae6189c0f fix: apply Windows subprocess fixes to testing agent and initializer
Follow-up to PR #89 - apply the same popen_kwargs pattern with
stdin=DEVNULL and CREATE_NO_WINDOW to _spawn_testing_agent() and
_run_initializer() for consistent Windows behavior.

Also fixes typo: _kill_process_tree -> kill_process_tree

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:56:02 +02:00
Leon van Zyl
a4a33e612e Merge pull request #89 from mmereu/master
fix: prevent agent subprocess blocking on Windows
2026-01-29 10:54:12 +02:00
Auto
cf62885e83 feat: add project reset functionality with quick and full reset options
Add the ability to reset a project to its initial state with two options:
- Quick Reset: Clears features.db, assistant.db, and settings files while
  preserving app spec and prompts
- Full Reset: Deletes everything including prompts directory, triggering
  the setup wizard for project reconfiguration

Backend changes:
- Add POST /{name}/reset endpoint to projects router with full_reset query param
- Validate agent lock file to prevent reset while agent is running (409 Conflict)
- Dispose database engines before deleting files to release Windows file locks
- Add engine caching to api/database.py for better connection management
- Add dispose_engine() functions to both database modules
- Delete WAL mode journal files (*.db-wal, *.db-shm) during reset

Frontend changes:
- Add ResetProjectModal component with toggle between Quick/Full reset modes
- Add ProjectSetupRequired component shown when has_spec is false
- Add resetProject API function and useResetProject React Query hook
- Integrate reset button in header (disabled when agent running)
- Add 'R' keyboard shortcut to open reset modal
- Show ProjectSetupRequired when project needs setup after full reset

This implements the feature from PR #4 directly on master to avoid merge
conflicts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:42:05 +02:00
cabana8471
0a46eda5e8 test: add empty command name validation test for project config
Adds a test case to verify that empty command names are rejected
in project-level allowed_commands.yaml, matching the behavior already
tested for org-level config. Updates test count to 163.

Addresses review feedback from leonvanzyl.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:40:47 +01:00
cabana8471
06c0bf4fd3 fix: add diagnostic warnings for pkill_processes validation failures
Per CodeRabbit feedback, add logger.warning calls when pkill_processes
validation fails in both load_org_config and load_project_commands.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:40:47 +01:00
cabana8471
1d67fff9e0 fix: add diagnostic warnings for config loading failures (#91)
When config files have errors, users had no way to know why their
settings weren't being applied. Added logging.warning() calls to
diagnose:
- Empty config files
- Missing 'version' field
- Invalid structure (not a dict)
- Invalid command entries
- Exceeding 100 command limit
- YAML parse errors
- File read errors

Also added .resolve() to project path to handle symlinks correctly.

Fixes: leonvanzyl/autocoder#91

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:40:47 +01:00
cabana8471
4cec4e63a4 fix: standardize tier naming from 'Complex' to 'Advanced' for consistency
Per CodeRabbit review - aligns with create-spec.md terminology.
2026-01-29 08:35:17 +01:00
Leon van Zyl
836bc8ae16 Merge pull request #115 from ipodishima/fix/build
Fix latest build issues from master
2026-01-29 09:22:19 +02:00
Auto
ce6da81a34 feat(security): add audit logging for shlex fallback parser
- Add debug logging when shlex fallback extraction is used, capturing
  both successful extractions and failures for security auditing
- Add test case for docker nested quotes that trigger fallback parser
- Remove redundant comment about re import (already at module level)

Follow-up improvements from PR #127 code review:
- Enables tracking of malformed command patterns in production logs
- Verifies fallback parser handles the exact docker exec case reported

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:19:48 +02:00
Leon van Zyl
77b91caa85 Merge pull request #127 from cabana8471-arch/fix/security-shlex-fallback
fix: add shlex fallback parser and heredoc warning
2026-01-29 09:17:35 +02:00
Leon van Zyl
51dc1bba5b Merge pull request #105 from nioasoft/fix/assistant-conversation-404-handling
fix: handle 404 errors for deleted assistant conversations
2026-01-29 09:14:03 +02:00
Auto
f6ddffa6e2 feat: persist concurrent agents slider at project level
Add `default_concurrency` column to the projects table in the registry
database, allowing each project to remember its preferred concurrency
setting (1-5 agents). The value persists across page refreshes and
app restarts.

Backend changes:
- Add `default_concurrency` column to Project model in registry.py
- Add database migration for existing databases (ALTER TABLE)
- Add get/set_project_concurrency() CRUD functions
- Add ProjectSettingsUpdate schema with validation
- Add PATCH /{name}/settings endpoint in projects router
- Include default_concurrency in ProjectSummary/ProjectDetail responses

Frontend changes:
- Add default_concurrency to ProjectSummary TypeScript interface
- Add ProjectSettingsUpdate type and updateProjectSettings API function
- Add useUpdateProjectSettings React Query mutation hook
- Update AgentControl to accept defaultConcurrency prop
- Sync local state when project changes via useEffect
- Debounce slider changes (500ms) before saving to backend
- Pass defaultConcurrency from selectedProjectData in App.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 09:08:17 +02:00
cabana8471
d47028d97a fix: add shlex fallback parser and heredoc warning
- Add _extract_primary_command() fallback when shlex.split() fails on complex nested quotes (e.g., docker exec with PHP)

- Returns primary command instead of empty list, allowing valid commands to proceed

- Add heredoc warning to coding prompt - sandbox blocks /tmp access for here documents

- All 162 security tests pass
2026-01-29 08:04:01 +01:00
Auto
a12e4aa3b8 refactor(ui): extract keyboard utilities and add padding constant
- Create shared `isSubmitEnter()` utility in `ui/src/lib/keyboard.ts`
  for IME-aware Enter key handling across all input components
- Extract magic number 48 to named constant `COLLAPSED_DEBUG_PANEL_CLEARANCE`
  with explanatory comment (40px panel header + 8px margin)
- Update 5 components to use the new utility:
  - AssistantChat.tsx
  - ExpandProjectChat.tsx
  - SpecCreationChat.tsx
  - FolderBrowser.tsx
  - TerminalTabs.tsx

This follows up on PR #121 which added IME composition checks. The
refactoring centralizes the logic for easier maintenance and documents
the padding value that prevents Kanban cards from being cut off when
the debug panel is collapsed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:28:48 +02:00
Auto
51d7d79695 feat(ui): make header sticky with glassy backdrop blur
Add sticky positioning and glassmorphism effect to the top navigation:
- sticky top-0 z-50 for fixed positioning above content
- bg-card/80 for 80% opacity background
- backdrop-blur-md for frosted glass effect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:24:39 +02:00
Leon van Zyl
3b905cf3d7 Merge pull request #121 from nogataka/fix/ime-support-and-padding
fix: Prevent accidental IME submission and add bottom padding for Kanban cards
2026-01-29 08:22:44 +02:00
Leon van Zyl
868a90ab03 Merge pull request #122 from nogataka/feature/business-theme
feat(theme): Add Business theme with deep navy and concrete gray palette
2026-01-29 08:12:47 +02:00
Auto
52331d126f fix(ui): resolve tw-animate-css import resolution error
Change CSS import from bare module specifier to url() syntax to fix
Vite/Tailwind CSS resolution issues on some systems.

- Changed `@import "tw-animate-css"` to `@import url("tw-animate-css")`
- The url() wrapper ensures proper package resolution across platforms
- Fixes "Can't resolve 'tw-animate-css'" build error

The bare import syntax failed because Vite's CSS processing didn't
properly resolve the package exports. Using url() bypasses this issue
while still correctly resolving the npm package from node_modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:09:59 +02:00
Auto
7a6b7f8f9c fix: align security_settings with permission_mode + add dependency tests
- Fix settings inconsistency in ExpandChatSession: security_settings
  now uses "bypassPermissions" to match permission_mode parameter
- Add comprehensive tests for dependency resolver (12 tests):
  - Cycle detection in compute_scheduling_scores (critical fix from PR #124)
  - Self-reference handling
  - Diamond dependency patterns
  - would_create_circular_dependency validation
  - Dependency satisfaction checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:04:01 +02:00
Leon van Zyl
5f64ae36f2 Merge pull request #124 from rudiheydra/fix/expand-session-mcp-and-scheduling
fix: expand session MCP wiring + scheduling infinite loop
2026-01-29 08:00:27 +02:00
Auto
5ae7f4cffa security: harden EXTRA_READ_PATHS with validation and blocklist
Add security controls to the EXTRA_READ_PATHS feature (PR #126) to prevent
path traversal attacks and accidental exposure of sensitive directories.

Changes:
- Add EXTRA_READ_PATHS_BLOCKLIST constant blocking credential directories
  (.ssh, .aws, .azure, .kube, .gnupg, .docker, .npmrc, .pypirc, .netrc)
- Create get_extra_read_paths() function with comprehensive validation:
  - Path canonicalization via Path.resolve() to prevent .. traversal
  - Validates paths are absolute (rejects relative paths)
  - Validates paths exist and are directories
  - Blocks paths that are/contain sensitive directories
  - Blocks paths that would expose sensitive dirs (e.g., home dir)
- Update create_client() to use validated getter function
- Improve logging to show validated paths instead of raw input
- Document security controls in CLAUDE.md under Security Model section

Security considerations:
- Addresses path traversal risk similar to CVE-2025-54794
- Prevents accidental exposure of SSH keys, cloud credentials, etc.
- All validation happens before permissions are granted to the agent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 07:54:55 +02:00
Auto
56f260cb79 feat(ui): use theme-aware colors for Maestro status card
Replace hardcoded violet/purple colors in OrchestratorStatusCard with
standard primary color CSS variables to ensure proper theming across
all theme variants (light/dark mode, Twitter, Claude, Neo Brutalism,
Aurora, Retro Arcade).

Changes:
- Card background: bg-primary/10 with border-primary/30
- Maestro title and state text: text-primary
- Activity button: text-primary hover:bg-primary/10
- Events border and timestamps: use primary color variants

Also includes:
- Enhanced review-pr command with vision alignment checks
- CLAUDE.md improvements: prerequisites, testing section, React 19 update
- Simplified parallel mode documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 07:51:42 +02:00
Leon van Zyl
f286c93ca3 Merge pull request #126 from nogataka/feature/extra-read-paths
feat: add EXTRA_READ_PATHS for read-only external file access
2026-01-29 07:50:09 +02:00
nogataka
3588dc8df7 feat: add EXTRA_READ_PATHS for read-only external file access
Allow agents to read files from directories outside the project folder
via the EXTRA_READ_PATHS environment variable.

Changes:
- Add EXTRA_READ_PATHS_VAR constant in client.py
- Parse comma-separated paths and add Read/Glob/Grep permissions
- Log configured extra read paths on agent startup
- Document the feature in .env.example

Usage:
  EXTRA_READ_PATHS=/path/to/docs,/path/to/libs

Security: External paths are read-only (no Write/Edit permissions)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:55:49 +09:00
rudiheydra
3161c1260a fix: wire MCP server into ExpandChatSession for feature creation
Replace direct-DB feature creation with MCP tool path. The expand
session now configures the feature MCP server and allows
feature_create_bulk tool calls, matching how AssistantChatSession
already works. Removes duplicated _create_features_bulk() method
and <features_to_create> regex parsing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:09:40 +11:00
rudiheydra
d68d70c800 fix: prevent infinite loop in compute_scheduling_scores with circular deps
Add visited set to BFS algorithm to handle circular dependencies gracefully.
Previously, cycles in the dependency graph caused the orchestrator to hang
at 100% CPU indefinitely during startup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:09:40 +11:00
nogataka
76475d1fb6 style(theme): Change Business theme background to concrete blue-gray
- Replace warm off-white with cool blue-gray concrete tone
- More corporate and industrial aesthetic
- Sidebar background adjusted to match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:12:33 +09:00
nogataka
f10ad59cf5 refactor(theme): Update Business theme with deep navy and gray palette
- Change primary color to deep navy #000e4e
- Replace teal accent with gray monochrome scale
- Add warm off-white background
- Enhance card shadows for modern depth (2026 UI trend)
- Update chart colors to navy-gray gradient scale

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:09:52 +09:00
nogataka
b2bfc4cb3b feat: Add Business theme with professional navy and gray palette
Add a new "Business" theme designed for corporate/professional use cases.
The theme features a sophisticated navy and gray color palette that conveys
trust and professionalism while maintaining excellent readability.

Key characteristics:
- Deep navy primary color for trust and professionalism
- Off-white/charcoal backgrounds (avoiding harsh pure white/black)
- Teal accent for CTAs and highlights
- Soft, professional shadows
- System fonts for native feel
- High contrast ratios (WCAG AA compliant)

Files changed:
- globals.css: Added .theme-business light and dark mode variables
- useTheme.ts: Added 'business' to ThemeId and THEMES array
- ThemeSelector.tsx: Added business theme class handling
2026-01-28 14:50:34 +09:00
cabana8471
11cefec85b fix: add test file exclusions to mock data grep checks
The comment said "excluding test files" but the grep commands didn't
actually exclude them. Added common test file exclusion patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:40:34 +01:00
nogataka
9e097b3fc8 fix: Ensure consistent main content padding when debug panel is closed
Add minimum bottom padding (48px) when the debug panel is closed to
prevent Kanban cards from being cut off at the bottom of the viewport.
2026-01-28 12:59:26 +09:00
nogataka
80c15a534d fix: Prevent accidental message submission during IME composition
Add isComposing check to prevent Enter key from submitting messages
while Japanese (or other) IME input is in progress.

Affected components:
- AssistantChat
- ExpandProjectChat
- SpecCreationChat
- FolderBrowser
- TerminalTabs
2026-01-28 12:59:14 +09:00
Marian Paul
0072951221 Fix latest build issues from master 2026-01-27 10:04:58 +01:00
cabana8471
d652b18587 fix: add language tags to fenced code blocks per CodeRabbit/markdownlint
Added 'text' language identifier to all fenced code blocks in the
Infrastructure Feature Descriptions section to satisfy MD040.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:00:30 +01:00
cabana8471
03504b3c1a fix: use port-based process killing for cross-platform safety
Addresses reviewer feedback:
1. Windows Compatibility: Added Windows alternative using netstat/taskkill
2. Safer Process Killing: Changed from `pkill -f "node"` to port-based
   killing (`lsof -ti :$PORT`) to avoid killing unrelated Node processes
   like VS Code, Claude Code, or other development tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:26:24 +01:00
cabana8471
d1233ad104 fix: Expand Map/Set grep search to entire src/ directory
- Changed grep for "new Map()/new Set()" to search all of src/
- Previously only searched src/lib/, src/store/, src/data/
- Now consistent with other grep patterns that search entire src/
- Applied to both coding_prompt and initializer_prompt templates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:29:24 +01:00
cabana8471
cd9f5b76cf fix: Address Leon's review - safer process killing and cross-platform support
Changes:
- Replace pkill -f "node" with port-based killing (lsof -ti :PORT)
  - Safer: only kills dev server, not VS Code/Claude Code/other Node apps
  - More specific: targets exact port instead of all Node processes
- Add Windows alternative commands (commented, for reference)
- Use ${PORT:-3000} variable instead of hardcoded port 3000
- Update health check and API verification to use PORT variable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 20:49:35 +01:00
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
Leon van Zyl
95c3cafecd Merge pull request #101 from cabana8471-arch/feat/extensible-pkill-processes
feat: allow extending pkill process names via config
2026-01-26 13:05:09 +02:00
nioasoft
2b07625ce4 fix: improve 404 detection for deleted conversations
- Check for 'not found' message (server response) in addition to '404'
- Only clear stored conversation ID on actual 404 errors
- Prevent unnecessary retries for deleted conversations
- Don't clear conversation on transient network errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:47:21 +02:00
Auto
f1c529e1a7 Merge branch 'master' of https://github.com/leonvanzyl/autonomous-coding-ui 2026-01-26 12:41:06 +02:00
Auto
fe5f58cf45 add a pr review command 2026-01-26 12:41:01 +02:00
nioasoft
468e59f86c fix: handle 404 errors for deleted assistant conversations
When a stored conversation ID no longer exists (e.g., database was reset
or conversation was deleted), the UI would repeatedly try to fetch it,
causing endless 404 errors in the console.

This fix:
- Stops retrying on 404 errors (conversation doesn't exist)
- Automatically clears the stored conversation ID from localStorage
  when a 404 is received, allowing the user to start fresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:26:06 +02:00
Leon van Zyl
a437af7f96 Merge pull request #102 from cabana8471-arch/fix/websocket-project-isolation
fix: prevent cross-project UI contamination
2026-01-26 10:32:06 +02:00
Leon van Zyl
0ef6cf7d62 Merge pull request #103 from cabana8471-arch/feat/webui-remote-access
feat: add --host argument for WebUI remote access
2026-01-26 10:27:05 +02:00
Leon van Zyl
aa9e8b1ab7 Merge pull request #104 from leonvanzyl/ollama-support
add ollama support
2026-01-26 09:50:22 +02:00
Auto
2dc12061fa chore: remove duplicate asset and gitignore local settings
- Remove assets/ollama.png (duplicate of ui/public/ollama.png)
- Remove .claude/settings.local.json from tracking
- Add .claude/settings.local.json to .gitignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 09:49:21 +02:00
Auto
095d248a66 add ollama support 2026-01-26 09:42:01 +02:00
cabana8471
34b9b5f5b2 security: validate all pkill patterns for BSD compatibility
pkill on BSD systems accepts multiple pattern operands. Previous code
only validated args[-1], allowing disallowed processes to slip through
when combined with allowed ones (e.g., "pkill node sshd" would only
check "sshd").

Now validates every non-flag argument to ensure no disallowed process
can be targeted. Added tests for multiple pattern scenarios.

Addresses CodeRabbit feedback on PR #101.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:12:54 +01:00
cabana8471
95b0dfac83 fix: Health check now fails script on server startup failure
Changed from warning-only to proper error handling:
- if server doesn't respond after restart, exit with error
- prevents false negatives when server fails to start

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:11:06 +01:00
cabana8471
fed2516f08 security: validate pkill process names against safe character set
Address CodeRabbit security feedback - restrict pkill_processes entries
to alphanumeric names with dots, underscores, and hyphens only.

This prevents potential exploitation through regex metacharacters like
'.*' being registered as process names.

Changes:
- Added VALID_PROCESS_NAME_PATTERN regex constant
- Updated both org and project config validation to:
  - Normalize (trim whitespace) process names
  - Reject names with regex metacharacters
  - Reject names with spaces
- Added 3 new tests for regex validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 16:34:56 +01: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
be20c8a3ef feat: add --host argument for WebUI remote access (#81)
Users can now access the WebUI remotely (e.g., via VS Code tunnels,
remote servers) by specifying a host address:

    python start_ui.py --host 0.0.0.0
    python start_ui.py --host 0.0.0.0 --port 8888

Changes:
- Added --host and --port CLI arguments to start_ui.py
- Security warning displayed when remote access is enabled
- AUTOCODER_ALLOW_REMOTE env var passed to server
- server/main.py conditionally disables localhost middleware
- CORS updated to allow all origins when remote access is enabled
- Browser auto-open disabled for remote hosts

Security considerations documented in warning:
- File system access to project directories
- API can start/stop agents and modify files
- Recommend firewall or VPN for protection

Fixes: leonvanzyl/autocoder#81

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:14:23 +01:00
cabana8471
32c7778ee5 fix: prevent cross-project UI contamination (#71)
When running multiple projects simultaneously, UI would show mixed data
because the manager registry used only project_name as key. Projects with
the same name but different paths shared the same manager instance.

Changed manager registries to use composite key (project_name, resolved_path):
- server/services/process_manager.py: AgentProcessManager registry
- server/services/dev_server_manager.py: DevServerProcessManager registry

This ensures that:
- /old/my-app and /new/my-app get separate managers
- Multiple browser tabs viewing different projects stay isolated
- Project renames don't cause callback contamination

Fixes: leonvanzyl/autocoder#71
Also fixes: leonvanzyl/autocoder#62 (progress bar sync)
Also fixes: leonvanzyl/autocoder#61 (features missing in kanban)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:12:38 +01:00
cabana8471
dbbc7d5ce5 feat: allow extending pkill process names via config (#85)
Previously, pkill was limited to a hardcoded set of process names
(node, npm, npx, vite, next). Users building Python/Ruby/Go apps
couldn't kill their dev servers.

Changes:
- Added pkill_processes config option to org config (~/.autocoder/config.yaml)
- Added pkill_processes config option to project config (.autocoder/allowed_commands.yaml)
- Modified validate_pkill_command() to accept extra_processes parameter
- Added get_effective_pkill_processes() to merge default + org + project processes
- Updated bash_security_hook to pass configured processes to validator

Example usage:
```yaml
# ~/.autocoder/config.yaml
version: 1
pkill_processes:
  - python
  - uvicorn
  - gunicorn
```

Fixes: leonvanzyl/autocoder#85

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:11:58 +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
cabana8471
e756486515 fix: Address remaining CodeRabbit feedback
- Escape parentheses in grep patterns: new Map\(\) and new Set\(\)
- Add --include="*.js" to all grep commands for complete coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:50:35 +01:00
cabana8471
dae16c3cca fix: Address CodeRabbit review feedback
- Fix math error in category totals (155→165, 255→265)
- Fix example JSON to include [0,1,2,3,4] dependencies for all features
- Add more robust server shutdown (SIGTERM then SIGKILL)
- Add health check after server restart
- Align grep patterns between templates (add .js, testData, TODO/STUB/MOCK)
- Add package.json check for mock backend libraries
- Reference STEP 5.6 instead of duplicating grep commands

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 11:43:54 +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
cabana8471
8e23fee094 fix: Prevent mock data implementations with infrastructure features
Problem:
The coding agent can implement in-memory storage (e.g., `dev-store.ts` with
`globalThis`) instead of a real database. These implementations pass all tests
because data persists during runtime, but data is lost on server restart.

This is a root cause for #68 - agent "passes" features that don't actually work.

Solution:
1. Add 5 mandatory Infrastructure Features (indices 0-4) that run first:
   - Feature 0: Database connection established
   - Feature 1: Database schema applied correctly
   - Feature 2: Data persists across server restart (CRITICAL)
   - Feature 3: No mock data patterns in codebase
   - Feature 4: Backend API queries real database

2. Add STEP 5.7: Server Restart Persistence Test to coding prompt:
   - Create test data, stop server, restart, verify data still exists

3. Extend grep patterns for mock detection in STEP 5.6:
   - globalThis., devStore, dev-store, mockData, fakeData
   - TODO.*real, STUB, MOCK, new Map() as data stores

Changes:
- .claude/templates/initializer_prompt.template.md - Infrastructure features
- .claude/templates/coding_prompt.template.md - STEP 5.6/5.7 enhancements
- .claude/commands/create-spec.md - Phase 3b database question

Backwards Compatible:
- Works with YOLO mode (uses bash/grep, not browser automation)
- Stateless apps can skip database features via create-spec question

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 08:01:30 +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
mmereu
795bd5f92a fix: kill process tree on agent completion to prevent zombies
Added _kill_process_tree call in _read_output finally block to ensure
child processes (Claude CLI) are cleaned up when agents complete or fail.
This prevents accumulation of zombie processes that was causing 78+
Python processes when max concurrency was set to 5.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:18:28 +01:00
mmereu
45289ef0d2 Merge remote-tracking branch 'origin/master'
Resolved conflicts by combining:
- stdin=DEVNULL and CREATE_NO_WINDOW (blocking fix)
- PYTHONUNBUFFERED env var (output buffering fix)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:43:38 +01:00
mmereu
d48fb0a6fc fix: prevent agent subprocess blocking on Windows
- Add stdin=subprocess.DEVNULL to prevent blocking on stdin reads
- Add CREATE_NO_WINDOW flag on Windows to prevent console pop-ups
- Remove trailing pause from start_ui.bat

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:40:47 +01:00
111 changed files with 8899 additions and 4276 deletions

View File

@@ -95,6 +95,27 @@ Ask the user about their involvement preference:
**For Detailed Mode users**, ask specific tech questions about frontend, backend, database, etc.
### Phase 3b: Database Requirements (MANDATORY)
**Always ask this question regardless of mode:**
> "One foundational question about data storage:
>
> **Does this application need to store user data persistently?**
>
> 1. **Yes, needs a database** - Users create, save, and retrieve data (most apps)
> 2. **No, stateless** - Pure frontend, no data storage needed (calculators, static sites)
> 3. **Not sure** - Let me describe what I need and you decide"
**Branching logic:**
- **If "Yes" or "Not sure"**: Continue normally. The spec will include database in tech stack and the initializer will create 5 mandatory Infrastructure features (indices 0-4) to verify database connectivity and persistence.
- **If "No, stateless"**: Note this in the spec. Skip database from tech stack. Infrastructure features will be simplified (no database persistence tests). Mark this clearly:
```xml
<database>none - stateless application</database>
```
## Phase 4: Features (THE MAIN PHASE)
This is where you spend most of your time. Ask questions in plain language that anyone can answer.
@@ -207,12 +228,23 @@ After gathering all features, **you** (the agent) should tally up the testable f
**Typical ranges for reference:**
- **Simple apps** (todo list, calculator, notes): ~20-50 features
- **Medium apps** (blog, task manager with auth): ~100 features
- **Advanced apps** (e-commerce, CRM, full SaaS): ~150-200 features
- **Simple apps** (todo list, calculator, notes): ~25-55 features (includes 5 infrastructure)
- **Medium apps** (blog, task manager with auth): ~105 features (includes 5 infrastructure)
- **Advanced apps** (e-commerce, CRM, full SaaS): ~155-205 features (includes 5 infrastructure)
These are just reference points - your actual count should come from the requirements discussed.
**MANDATORY: Infrastructure Features**
If the app requires a database (Phase 3b answer was "Yes" or "Not sure"), you MUST include 5 Infrastructure features (indices 0-4):
1. Database connection established
2. Database schema applied correctly
3. Data persists across server restart
4. No mock data patterns in codebase
5. Backend API queries real database
These features ensure the coding agent implements a real database, not mock data or in-memory storage.
**How to count features:**
For each feature area discussed, estimate the number of discrete, testable behaviors:
@@ -225,17 +257,20 @@ For each feature area discussed, estimate the number of discrete, testable behav
> "Based on what we discussed, here's my feature breakdown:
>
> - **Infrastructure (required)**: 5 features (database setup, persistence verification)
> - [Category 1]: ~X features
> - [Category 2]: ~Y features
> - [Category 3]: ~Z features
> - ...
>
> **Total: ~N features**
> **Total: ~N features** (including 5 infrastructure)
>
> Does this seem right, or should I adjust?"
Let the user confirm or adjust. This becomes your `feature_count` for the spec.
**Important:** The first 5 features (indices 0-4) created by the initializer MUST be the Infrastructure category with no dependencies. All other features depend on these.
## Phase 5: Technical Details (DERIVED OR DISCUSSED)
**For Quick Mode users:**

View File

@@ -0,0 +1,54 @@
---
description: Review pull requests
---
Pull request(s): $ARGUMENTS
- If no PR numbers are provided, ask the user to provide PR number(s).
- At least 1 PR is required.
## TASKS
1. **Retrieve PR Details**
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
2. **Assess PR Complexity**
After retrieving PR details, assess complexity based on:
- Number of files changed
- Lines added/removed
- Number of contributors/commits
- Whether changes touch core/architectural files
### Complexity Tiers
**Simple** (no deep dive agents needed):
- ≤5 files changed AND ≤100 lines changed AND single author
- Review directly without spawning agents
**Medium** (1-2 deep dive agents):
- 6-15 files changed, OR 100-500 lines, OR 2 contributors
- Spawn 1 agent for focused areas, 2 if changes span multiple domains
**Complex** (up to 3 deep dive agents):
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
3. **Analyze Codebase Impact**
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
- For Simple PRs: analyze directly without spawning agents
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
4. **Vision Alignment Check**
- Read the project's README.md and CLAUDE.md to understand the application's core purpose
- Assess whether this PR aligns with the application's intended functionality
- If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review
- This is not a blocker, but should be flagged for the reviewer's consideration
5. **Safety Assessment**
- Provide a review on whether the PR is safe to merge as-is
- Provide any feedback in terms of risk level
6. **Improvements**
- Propose any improvements in terms of importance and complexity

View File

@@ -156,6 +156,9 @@ Use browser automation tools:
- [ ] Deleted the test data - verified it's gone everywhere
- [ ] NO unexplained data appeared (would indicate mock data)
- [ ] Dashboard/counts reflect real numbers after my changes
- [ ] **Ran extended mock data grep (STEP 5.6) - no hits in src/ (excluding tests)**
- [ ] **Verified no globalThis, devStore, or dev-store patterns**
- [ ] **Server restart test passed (STEP 5.7) - data persists across restart**
#### Navigation Verification
@@ -174,10 +177,92 @@ Use browser automation tools:
### STEP 5.6: MOCK DATA DETECTION (Before marking passing)
1. **Search code:** `grep -r "mockData\|fakeData\|TODO\|STUB" --include="*.ts" --include="*.tsx"`
2. **Runtime test:** Create unique data (e.g., "TEST_12345") → verify in UI → delete → verify gone
3. **Check database:** All displayed data must come from real DB queries
4. If unexplained data appears, it's mock data - fix before marking passing.
**Run ALL these grep checks. Any hits in src/ (excluding test files) require investigation:**
```bash
# Common exclusions for test files
EXCLUDE="--exclude=*.test.* --exclude=*.spec.* --exclude=*__test__* --exclude=*__mocks__*"
# 1. In-memory storage patterns (CRITICAL - catches dev-store)
grep -r "globalThis\." --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/
grep -r "dev-store\|devStore\|DevStore\|mock-db\|mockDb" --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/
# 2. Mock data variables
grep -r "mockData\|fakeData\|sampleData\|dummyData\|testData" --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/
# 3. TODO/incomplete markers
grep -r "TODO.*real\|TODO.*database\|TODO.*API\|STUB\|MOCK" --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/
# 4. Development-only conditionals
grep -r "isDevelopment\|isDev\|process\.env\.NODE_ENV.*development" --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/
# 5. In-memory collections as data stores
grep -r "new Map\(\)\|new Set\(\)" --include="*.ts" --include="*.tsx" --include="*.js" $EXCLUDE src/ 2>/dev/null
```
**Rule:** If ANY grep returns results in production code → investigate → FIX before marking passing.
**Runtime verification:**
1. Create unique data (e.g., "TEST_12345") → verify in UI → delete → verify gone
2. Check database directly - all displayed data must come from real DB queries
3. If unexplained data appears, it's mock data - fix before marking passing.
### STEP 5.7: SERVER RESTART PERSISTENCE TEST (MANDATORY for data features)
**When required:** Any feature involving CRUD operations or data persistence.
**This test is NON-NEGOTIABLE. It catches in-memory storage implementations that pass all other tests.**
**Steps:**
1. Create unique test data via UI or API (e.g., item named "RESTART_TEST_12345")
2. Verify data appears in UI and API response
3. **STOP the server completely:**
```bash
# Kill by port (safer - only kills the dev server, not VS Code/Claude Code/etc.)
# Unix/macOS:
lsof -ti :${PORT:-3000} | xargs kill -TERM 2>/dev/null || true
sleep 3
lsof -ti :${PORT:-3000} | xargs kill -9 2>/dev/null || true
sleep 2
# Windows alternative (use if lsof not available):
# netstat -ano | findstr :${PORT:-3000} | findstr LISTENING
# taskkill /F /PID <pid_from_above> 2>nul
# Verify server is stopped
if lsof -ti :${PORT:-3000} > /dev/null 2>&1; then
echo "ERROR: Server still running on port ${PORT:-3000}!"
exit 1
fi
```
4. **RESTART the server:**
```bash
./init.sh &
sleep 15 # Allow server to fully start
# Verify server is responding
if ! curl -f http://localhost:${PORT:-3000}/api/health && ! curl -f http://localhost:${PORT:-3000}; then
echo "ERROR: Server failed to start after restart"
exit 1
fi
```
5. **Query for test data - it MUST still exist**
- Via UI: Navigate to data location, verify data appears
- Via API: `curl http://localhost:${PORT:-3000}/api/items` - verify data in response
6. **If data is GONE:** Implementation uses in-memory storage → CRITICAL FAIL
- Run all grep commands from STEP 5.6 to identify the mock pattern
- You MUST fix the in-memory storage implementation before proceeding
- Replace in-memory storage with real database queries
7. **Clean up test data** after successful verification
**Why this test exists:** In-memory stores like `globalThis.devStore` pass all other tests because data persists during a single server run. Only a full server restart reveals this bug. Skipping this step WILL allow dev-store implementations to slip through.
**YOLO Mode Note:** Even in YOLO mode, this verification is MANDATORY for data features. Use curl instead of browser automation.
### STEP 6: UPDATE FEATURE STATUS (CAREFULLY!)
@@ -202,17 +287,23 @@ Use the feature_mark_passing tool with feature_id=42
### STEP 7: COMMIT YOUR PROGRESS
Make a descriptive git commit:
Make a descriptive git commit.
**Git Commit Rules:**
- ALWAYS use simple `-m` flag for commit messages
- NEVER use heredocs (`cat <<EOF` or `<<'EOF'`) - they fail in sandbox mode with "can't create temp file for here document: operation not permitted"
- For multi-line messages, use multiple `-m` flags:
```bash
git add .
git commit -m "Implement [feature name] - verified end-to-end
git commit -m "Implement [feature name] - verified end-to-end" -m "- Added [specific changes]" -m "- Tested with browser automation" -m "- Marked feature #X as passing"
```
- Added [specific changes]
- Tested with browser automation
- Marked feature #X as passing
- Screenshots in verification/ directory
"
Or use a single descriptive message:
```bash
git add .
git commit -m "feat: implement [feature name] with browser verification"
```
### STEP 8: UPDATE PROGRESS NOTES

View File

@@ -36,9 +36,9 @@ Use the feature_create_bulk tool to add all features at once. You can create fea
- Feature count must match the `feature_count` specified in app_spec.txt
- Reference tiers for other projects:
- **Simple apps**: ~150 tests
- **Medium apps**: ~250 tests
- **Complex apps**: ~400+ tests
- **Simple apps**: ~165 tests (includes 5 infrastructure)
- **Medium apps**: ~265 tests (includes 5 infrastructure)
- **Advanced apps**: ~405+ tests (includes 5 infrastructure)
- Both "functional" and "style" categories
- Mix of narrow tests (2-5 steps) and comprehensive tests (10+ steps)
- At least 25 tests MUST have 10+ steps each (more for complex apps)
@@ -60,8 +60,9 @@ Dependencies enable **parallel execution** of independent features. When specifi
2. **Can only depend on EARLIER features** (index must be less than current position)
3. **No circular dependencies** allowed
4. **Maximum 20 dependencies** per feature
5. **Foundation features (index 0-9)** should have NO dependencies
6. **60% of features after index 10** should have at least one dependency
5. **Infrastructure features (indices 0-4)** have NO dependencies - they run FIRST
6. **ALL features after index 4** MUST depend on `[0, 1, 2, 3, 4]` (infrastructure)
7. **60% of features after index 10** should have additional dependencies beyond infrastructure
### Dependency Types
@@ -82,30 +83,113 @@ Create WIDE dependency graphs, not linear chains:
```json
[
// FOUNDATION TIER (indices 0-2, no dependencies) - run first
{ "name": "App loads without errors", "category": "functional" },
{ "name": "Navigation bar displays", "category": "style" },
{ "name": "Homepage renders correctly", "category": "functional" },
// INFRASTRUCTURE TIER (indices 0-4, no dependencies) - MUST run first
{ "name": "Database connection established", "category": "functional" },
{ "name": "Database schema applied correctly", "category": "functional" },
{ "name": "Data persists across server restart", "category": "functional" },
{ "name": "No mock data patterns in codebase", "category": "functional" },
{ "name": "Backend API queries real database", "category": "functional" },
// AUTH TIER (indices 3-5, depend on foundation) - run in parallel
{ "name": "User can register", "depends_on_indices": [0] },
{ "name": "User can login", "depends_on_indices": [0, 3] },
{ "name": "User can logout", "depends_on_indices": [4] },
// FOUNDATION TIER (indices 5-7, depend on infrastructure)
{ "name": "App loads without errors", "category": "functional", "depends_on_indices": [0, 1, 2, 3, 4] },
{ "name": "Navigation bar displays", "category": "style", "depends_on_indices": [0, 1, 2, 3, 4] },
{ "name": "Homepage renders correctly", "category": "functional", "depends_on_indices": [0, 1, 2, 3, 4] },
// CORE CRUD TIER (indices 6-9) - WIDE GRAPH: all 4 depend on login
// All 4 start as soon as login passes!
{ "name": "User can create todo", "depends_on_indices": [4] },
{ "name": "User can view todos", "depends_on_indices": [4] },
{ "name": "User can edit todo", "depends_on_indices": [4, 6] },
{ "name": "User can delete todo", "depends_on_indices": [4, 6] },
// AUTH TIER (indices 8-10, depend on foundation + infrastructure)
{ "name": "User can register", "depends_on_indices": [0, 1, 2, 3, 4, 5] },
{ "name": "User can login", "depends_on_indices": [0, 1, 2, 3, 4, 5, 8] },
{ "name": "User can logout", "depends_on_indices": [0, 1, 2, 3, 4, 9] },
// ADVANCED TIER (indices 10-11) - both depend on view, not each other
{ "name": "User can filter todos", "depends_on_indices": [7] },
{ "name": "User can search todos", "depends_on_indices": [7] }
// CORE CRUD TIER (indices 11-14) - WIDE GRAPH: all 4 depend on login
{ "name": "User can create todo", "depends_on_indices": [0, 1, 2, 3, 4, 9] },
{ "name": "User can view todos", "depends_on_indices": [0, 1, 2, 3, 4, 9] },
{ "name": "User can edit todo", "depends_on_indices": [0, 1, 2, 3, 4, 9, 11] },
{ "name": "User can delete todo", "depends_on_indices": [0, 1, 2, 3, 4, 9, 11] },
// ADVANCED TIER (indices 15-16) - both depend on view, not each other
{ "name": "User can filter todos", "depends_on_indices": [0, 1, 2, 3, 4, 12] },
{ "name": "User can search todos", "depends_on_indices": [0, 1, 2, 3, 4, 12] }
]
```
**Result:** With 3 parallel agents, this 12-feature project completes in ~5-6 cycles instead of 12 sequential cycles.
**Result:** With 3 parallel agents, this project completes efficiently with proper database validation first.
---
## MANDATORY INFRASTRUCTURE FEATURES (Indices 0-4)
**CRITICAL:** Create these FIRST, before any functional features. These features ensure the application uses a real database, not mock data or in-memory storage.
| Index | Name | Test Steps |
|-------|------|------------|
| 0 | Database connection established | Start server → check logs for DB connection → health endpoint returns DB status |
| 1 | Database schema applied correctly | Connect to DB directly → list tables → verify schema matches spec |
| 2 | Data persists across server restart | Create via API → STOP server completely → START server → query API → data still exists |
| 3 | No mock data patterns in codebase | Run grep for prohibited patterns → must return empty |
| 4 | Backend API queries real database | Check server logs → SQL/DB queries appear for API calls |
**ALL other features MUST depend on indices [0, 1, 2, 3, 4].**
### Infrastructure Feature Descriptions
**Feature 0 - Database connection established:**
```text
Steps:
1. Start the development server
2. Check server logs for database connection message
3. Call health endpoint (e.g., GET /api/health)
4. Verify response includes database status: connected
```
**Feature 1 - Database schema applied correctly:**
```text
Steps:
1. Connect to database directly (sqlite3, psql, etc.)
2. List all tables in the database
3. Verify tables match what's defined in app_spec.txt
4. Verify key columns exist on each table
```
**Feature 2 - Data persists across server restart (CRITICAL):**
```text
Steps:
1. Create unique test data via API (e.g., POST /api/items with name "RESTART_TEST_12345")
2. Verify data appears in API response (GET /api/items)
3. STOP the server completely (kill by port to avoid killing unrelated Node processes):
- Unix/macOS: lsof -ti :$PORT | xargs kill -9 2>/dev/null || true && sleep 5
- Windows: FOR /F "tokens=5" %a IN ('netstat -aon ^| find ":$PORT"') DO taskkill /F /PID %a 2>nul
- Note: Replace $PORT with actual port (e.g., 3000)
4. Verify server is stopped: lsof -ti :$PORT returns nothing (or netstat on Windows)
5. RESTART the server: ./init.sh & sleep 15
6. Query API again: GET /api/items
7. Verify "RESTART_TEST_12345" still exists
8. If data is GONE → CRITICAL FAILURE (in-memory storage detected)
9. Clean up test data
```
**Feature 3 - No mock data patterns in codebase:**
```text
Steps:
1. Run: grep -r "globalThis\." --include="*.ts" --include="*.tsx" --include="*.js" src/
2. Run: grep -r "dev-store\|devStore\|DevStore\|mock-db\|mockDb" --include="*.ts" --include="*.tsx" --include="*.js" src/
3. Run: grep -r "mockData\|testData\|fakeData\|sampleData\|dummyData" --include="*.ts" --include="*.tsx" --include="*.js" src/
4. Run: grep -r "TODO.*real\|TODO.*database\|TODO.*API\|STUB\|MOCK" --include="*.ts" --include="*.tsx" --include="*.js" src/
5. Run: grep -r "isDevelopment\|isDev\|process\.env\.NODE_ENV.*development" --include="*.ts" --include="*.tsx" --include="*.js" src/
6. Run: grep -r "new Map\(\)\|new Set\(\)" --include="*.ts" --include="*.tsx" --include="*.js" src/ 2>/dev/null
7. Run: grep -E "json-server|miragejs|msw" package.json
8. ALL grep commands must return empty (exit code 1)
9. If any returns results → investigate and fix before passing
```
**Feature 4 - Backend API queries real database:**
```text
Steps:
1. Start server with verbose logging
2. Make API call (e.g., GET /api/items)
3. Check server logs
4. Verify SQL query appears (SELECT, INSERT, etc.) or ORM query log
5. If no DB queries in logs → implementation is using mock data
```
---
@@ -115,8 +199,9 @@ The feature_list.json **MUST** include tests from ALL 20 categories. Minimum cou
### Category Distribution by Complexity Tier
| Category | Simple | Medium | Complex |
| Category | Simple | Medium | Advanced |
| -------------------------------- | ------- | ------- | -------- |
| **0. Infrastructure (REQUIRED)** | 5 | 5 | 5 |
| A. Security & Access Control | 5 | 20 | 40 |
| B. Navigation Integrity | 15 | 25 | 40 |
| C. Real Data Verification | 20 | 30 | 50 |
@@ -137,12 +222,14 @@ The feature_list.json **MUST** include tests from ALL 20 categories. Minimum cou
| R. Concurrency & Race Conditions | 5 | 8 | 15 |
| S. Export/Import | 5 | 6 | 10 |
| T. Performance | 5 | 5 | 10 |
| **TOTAL** | **150** | **250** | **400+** |
| **TOTAL** | **165** | **265** | **405+** |
---
### Category Descriptions
**0. Infrastructure (REQUIRED - Priority 0)** - Database connectivity, schema existence, data persistence across server restart, absence of mock patterns. These features MUST pass before any functional features can begin. All tiers require exactly 5 infrastructure features (indices 0-4).
**A. Security & Access Control** - Test unauthorized access blocking, permission enforcement, session management, role-based access, and data isolation between users.
**B. Navigation Integrity** - Test all buttons, links, menus, breadcrumbs, deep links, back button behavior, 404 handling, and post-login/logout redirects.
@@ -205,6 +292,16 @@ The feature_list.json must include tests that **actively verify real data** and
- `setTimeout` simulating API delays with static data
- Static returns instead of database queries
**Additional prohibited patterns (in-memory stores):**
- `globalThis.` (in-memory storage pattern)
- `dev-store`, `devStore`, `DevStore` (development stores)
- `json-server`, `mirage`, `msw` (mock backends)
- `Map()` or `Set()` used as primary data store
- Environment checks like `if (process.env.NODE_ENV === 'development')` for data routing
**Why this matters:** In-memory stores (like `globalThis.devStore`) will pass simple tests because data persists during a single server run. But data is LOST on server restart, which is unacceptable for production. The Infrastructure features (0-4) specifically test for this by requiring data to survive a full server restart.
---
**CRITICAL INSTRUCTION:**

View File

@@ -1,12 +1,38 @@
# Optional: N8N webhook for progress notifications
# PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/...
# Playwright Browser Mode
# 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 Configuration
#
# 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)
# Defaults to 'false' if not specified
# PLAYWRIGHT_HEADLESS=false
# PLAYWRIGHT_HEADLESS=true
# Extra Read Paths (Optional)
# Comma-separated list of absolute paths for read-only access to external directories.
# The agent can read files from these paths but cannot write to them.
# Useful for referencing documentation, shared libraries, or other projects.
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
# EXTRA_READ_PATHS=
# Google Cloud Vertex AI Configuration (Optional)
# To use Claude via Vertex AI on Google Cloud Platform, uncomment and set these variables.
# Requires: gcloud CLI installed and authenticated (run: gcloud auth application-default login)
# Note: Use @ instead of - in model names (e.g., claude-opus-4-5@20251101)
#
# CLAUDE_CODE_USE_VERTEX=1
# CLOUD_ML_REGION=us-east5
# ANTHROPIC_VERTEX_PROJECT_ID=your-gcp-project-id
# ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4-5@20251101
# ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4-5@20250929
# ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-3-5-haiku@20241022
# GLM/Alternative API Configuration (Optional)
# To use Zhipu AI's GLM models instead of Claude, uncomment and set these variables.
@@ -19,3 +45,20 @@
# ANTHROPIC_DEFAULT_SONNET_MODEL=glm-4.7
# ANTHROPIC_DEFAULT_OPUS_MODEL=glm-4.7
# ANTHROPIC_DEFAULT_HAIKU_MODEL=glm-4.5-air
# Ollama Local Model Configuration (Optional)
# To use local models via Ollama instead of Claude, uncomment and set these variables.
# Requires Ollama v0.14.0+ with Anthropic API compatibility.
# See: https://ollama.com/blog/claude
#
# ANTHROPIC_BASE_URL=http://localhost:11434
# ANTHROPIC_AUTH_TOKEN=ollama
# API_TIMEOUT_MS=3000000
# ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
# ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
# ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
#
# Model recommendations:
# - For best results, use a capable coding model like qwen3-coder or deepseek-coder-v2
# - You can use the same model for all tiers, or different models per tier
# - Larger models (70B+) work best for Opus tier, smaller (7B-20B) for Haiku

5
.gitignore vendored
View File

@@ -76,6 +76,11 @@ ui/playwright-report/
.dmypy.json
dmypy.json
# ===================
# Claude Code
# ===================
.claude/settings.local.json
# ===================
# IDE / Editors
# ===================

167
CLAUDE.md
View File

@@ -2,6 +2,12 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Prerequisites
- Python 3.11+
- Node.js 20+ (for UI development)
- Claude Code CLI
## Project Overview
This is an autonomous coding agent system with a React-based UI. It uses the Claude Agent SDK to build complete applications over multiple sessions using a two-agent pattern:
@@ -86,6 +92,33 @@ npm run lint # Run ESLint
**Note:** The `start_ui.bat` script serves the pre-built UI from `ui/dist/`. After making UI changes, run `npm run build` in the `ui/` directory.
## Testing
### Python
```bash
ruff check . # Lint
mypy . # Type check
python test_security.py # Security unit tests (163 tests)
python test_security_integration.py # Integration tests (9 tests)
```
### React UI
```bash
cd ui
npm run lint # ESLint
npm run build # Type check + build
npm run test:e2e # Playwright end-to-end tests
npm run test:e2e:ui # Playwright tests with UI
```
### Code Quality
Configuration in `pyproject.toml`:
- ruff: Line length 120, Python 3.11 target
- mypy: Strict return type checking, ignores missing imports
## Architecture
### Core Python Modules
@@ -141,7 +174,7 @@ MCP tools available to the agent:
### React UI (ui/)
- Tech stack: React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI, dagre (graph layout)
- Tech stack: React 19, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI, dagre (graph layout)
- `src/App.tsx` - Main app with project selection, kanban board, agent controls
- `src/hooks/useWebSocket.ts` - Real-time updates via WebSocket (progress, agent status, logs, agent updates)
- `src/hooks/useProjects.ts` - React Query hooks for API calls
@@ -178,6 +211,46 @@ Defense-in-depth approach configured in `client.py`:
2. Filesystem restricted to project directory only
3. Bash commands validated using hierarchical allowlist system
#### Extra Read Paths (Cross-Project File Access)
The agent can optionally read files from directories outside the project folder via the `EXTRA_READ_PATHS` environment variable. This enables referencing documentation, shared libraries, or other projects.
**Configuration:**
```bash
# Single path
EXTRA_READ_PATHS=/Users/me/docs
# Multiple paths (comma-separated)
EXTRA_READ_PATHS=/Users/me/docs,/opt/shared-libs,/Volumes/Data/reference
```
**Security Controls:**
All paths are validated before being granted read access:
- Must be absolute paths (not relative)
- Must exist and be directories
- Paths are canonicalized via `Path.resolve()` to prevent `..` traversal attacks
- Sensitive directories are blocked (see blocklist below)
- Only Read, Glob, and Grep operations are allowed (no Write/Edit)
**Blocked Sensitive Directories:**
The following directories (relative to home) are always blocked:
- `.ssh`, `.aws`, `.azure`, `.kube` - Cloud/SSH credentials
- `.gnupg`, `.gpg`, `.password-store` - Encryption keys
- `.docker`, `.config/gcloud` - Container/cloud configs
- `.npmrc`, `.pypirc`, `.netrc` - Package manager credentials
**Example Output:**
```
Created security settings at /path/to/project/.claude_settings.json
- Sandbox enabled (OS-level bash isolation)
- Filesystem restricted to: /path/to/project
- Extra read paths (validated): /Users/me/docs, /opt/shared-libs
```
#### Per-Project Allowed Commands
The agent's bash command access is controlled through a hierarchical configuration system:
@@ -237,15 +310,6 @@ blocked_commands:
- Blocklisted commands (sudo, dd, shutdown, etc.) can NEVER be allowed
- Org-level blocked commands cannot be overridden by project configs
**Testing:**
```bash
# Unit tests (136 tests - fast)
python test_security.py
# Integration tests (9 tests - uses real hooks)
python test_security_integration.py
```
**Files:**
- `security.py` - Command validation logic and hardcoded blocklist
- `test_security.py` - Unit tests for security system (136 tests)
@@ -256,6 +320,39 @@ python test_security_integration.py
- `examples/README.md` - Comprehensive guide with use cases, testing, and troubleshooting
- `PHASE3_SPEC.md` - Specification for mid-session approval feature (future enhancement)
### Ollama Local Models (Optional)
Run coding agents using local models via Ollama v0.14.0+:
1. Install Ollama: https://ollama.com
2. Start Ollama: `ollama serve`
3. Pull a coding model: `ollama pull qwen3-coder`
4. Configure `.env`:
```
ANTHROPIC_BASE_URL=http://localhost:11434
ANTHROPIC_AUTH_TOKEN=ollama
API_TIMEOUT_MS=3000000
ANTHROPIC_DEFAULT_SONNET_MODEL=qwen3-coder
ANTHROPIC_DEFAULT_OPUS_MODEL=qwen3-coder
ANTHROPIC_DEFAULT_HAIKU_MODEL=qwen3-coder
```
5. Run autocoder normally - it will use your local Ollama models
**Recommended coding models:**
- `qwen3-coder` - Good balance of speed and capability
- `deepseek-coder-v2` - Strong coding performance
- `codellama` - Meta's code-focused model
**Model tier mapping:**
- Use the same model for all tiers, or map different models per capability level
- Larger models (70B+) work best for Opus tier
- Smaller models (7B-20B) work well for Haiku tier
**Known limitations:**
- Smaller context windows than Claude (model-dependent)
- Extended context beta disabled (not supported by Ollama)
- Performance depends on local hardware (GPU recommended)
## Claude Code Integration
- `.claude/commands/create-spec.md` - `/create-spec` slash command for interactive spec creation
@@ -301,55 +398,7 @@ The orchestrator enforces strict bounds on concurrent processes:
- `MAX_PARALLEL_AGENTS = 5` - Maximum concurrent coding agents
- `MAX_TOTAL_AGENTS = 10` - Hard limit on total agents (coding + testing)
- Testing agents are capped at `max_concurrency` (same as coding agents)
**Expected process count during normal operation:**
- 1 orchestrator process
- Up to 5 coding agents
- Up to 5 testing agents
- Total: never exceeds 11 Python processes
**Stress Test Verification:**
```bash
# Windows - verify process bounds
# 1. Note baseline count
tasklist | findstr python | find /c /v ""
# 2. Start parallel agent (max concurrency)
python autonomous_agent_demo.py --project-dir test --parallel --max-concurrency 5
# 3. During run - should NEVER exceed baseline + 11
tasklist | findstr python | find /c /v ""
# 4. After stop via UI - should return to baseline
tasklist | findstr python | find /c /v ""
```
```bash
# macOS/Linux - verify process bounds
# 1. Note baseline count
pgrep -c python
# 2. Start parallel agent
python autonomous_agent_demo.py --project-dir test --parallel --max-concurrency 5
# 3. During run - should NEVER exceed baseline + 11
pgrep -c python
# 4. After stop - should return to baseline
pgrep -c python
```
**Log Verification:**
```bash
# Check spawn vs completion balance
grep "Started testing agent" orchestrator_debug.log | wc -l
grep "Testing agent.*completed\|failed" orchestrator_debug.log | wc -l
# Watch for cap enforcement messages
grep "at max testing agents\|At max total agents" orchestrator_debug.log
```
- Total process count never exceeds 11 Python processes (1 orchestrator + 5 coding + 5 testing)
### Design System

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

@@ -336,12 +336,20 @@ def create_database(project_dir: Path) -> tuple:
"""
Create database and return engine + session maker.
Uses a cache to avoid creating new engines for each request, which improves
performance by reusing database connections.
Args:
project_dir: Directory containing the project
Returns:
Tuple of (engine, SessionLocal)
"""
cache_key = project_dir.as_posix()
if cache_key in _engine_cache:
return _engine_cache[cache_key]
db_url = get_database_url(project_dir)
engine = create_engine(db_url, connect_args={
"check_same_thread": False,
@@ -369,12 +377,39 @@ def create_database(project_dir: Path) -> tuple:
_migrate_add_schedules_tables(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Cache the engine and session maker
_engine_cache[cache_key] = (engine, SessionLocal)
return engine, SessionLocal
def dispose_engine(project_dir: Path) -> bool:
"""Dispose of and remove the cached engine for a project.
This closes all database connections, releasing file locks on Windows.
Should be called before deleting the database file.
Returns:
True if an engine was disposed, False if no engine was cached.
"""
cache_key = project_dir.as_posix()
if cache_key in _engine_cache:
engine, _ = _engine_cache.pop(cache_key)
engine.dispose()
return True
return False
# Global session maker - will be set when server starts
_session_maker: Optional[sessionmaker] = None
# Engine cache to avoid creating new engines for each request
# Key: project directory path (as posix string), Value: (engine, SessionLocal)
_engine_cache: dict[str, tuple] = {}
def set_session_maker(session_maker: sessionmaker) -> None:
"""Set the global session maker."""

View File

@@ -300,15 +300,20 @@ def compute_scheduling_scores(features: list[dict]) -> dict[int, float]:
parents[f["id"]].append(dep_id)
# Calculate depths via BFS from roots
# Use visited set to prevent infinite loops from circular dependencies
depths: dict[int, int] = {}
visited: set[int] = set()
roots = [f["id"] for f in features if not parents[f["id"]]]
queue = [(root, 0) for root in roots]
while queue:
node_id, depth = queue.pop(0)
if node_id not in depths or depth > depths[node_id]:
depths[node_id] = depth
if node_id in visited:
continue # Skip already visited nodes (handles cycles)
visited.add(node_id)
depths[node_id] = depth
for child_id in children[node_id]:
queue.append((child_id, depth + 1))
if child_id not in visited:
queue.append((child_id, depth + 1))
# Handle orphaned nodes (shouldn't happen but be safe)
for f in features:

214
client.py
View File

@@ -7,6 +7,7 @@ Functions for creating and configuring the Claude Agent SDK client.
import json
import os
import re
import shutil
import sys
from pathlib import Path
@@ -21,12 +22,17 @@ from security import bash_security_hook
load_dotenv()
# Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var
# When True, browser runs invisibly in background
# When False, browser window is visible (default - useful for monitoring agent progress)
DEFAULT_PLAYWRIGHT_HEADLESS = False
# When True, browser runs invisibly in background (default - saves CPU)
# When False, browser window is visible (useful for monitoring agent progress)
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
# 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, Vertex AI) without
# affecting the user's global Claude Code settings
API_ENV_VARS = [
"ANTHROPIC_BASE_URL", # Custom API endpoint (e.g., https://api.z.ai/api/anthropic)
@@ -35,19 +41,172 @@ API_ENV_VARS = [
"ANTHROPIC_DEFAULT_SONNET_MODEL", # Model override for Sonnet
"ANTHROPIC_DEFAULT_OPUS_MODEL", # Model override for Opus
"ANTHROPIC_DEFAULT_HAIKU_MODEL", # Model override for Haiku
# Vertex AI configuration
"CLAUDE_CODE_USE_VERTEX", # Enable Vertex AI mode (set to "1")
"CLOUD_ML_REGION", # GCP region (e.g., us-east5)
"ANTHROPIC_VERTEX_PROJECT_ID", # GCP project ID
]
# Extra read paths for cross-project file access (read-only)
# Set EXTRA_READ_PATHS environment variable with comma-separated absolute paths
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
EXTRA_READ_PATHS_VAR = "EXTRA_READ_PATHS"
# Sensitive directories that should never be allowed via EXTRA_READ_PATHS
# These contain credentials, keys, or system-critical files
EXTRA_READ_PATHS_BLOCKLIST = {
".ssh",
".aws",
".azure",
".kube",
".gnupg",
".gpg",
".password-store",
".docker",
".config/gcloud",
".npmrc",
".pypirc",
".netrc",
}
def convert_model_for_vertex(model: str) -> str:
"""
Convert model name format for Vertex AI compatibility.
Vertex AI uses @ to separate model name from version (e.g., claude-opus-4-5@20251101)
while the Anthropic API uses - (e.g., claude-opus-4-5-20251101).
Args:
model: Model name in Anthropic format (with hyphens)
Returns:
Model name in Vertex AI format (with @ before date) if Vertex AI is enabled,
otherwise returns the model unchanged.
"""
# Only convert if Vertex AI is enabled
if os.getenv("CLAUDE_CODE_USE_VERTEX") != "1":
return model
# Pattern: claude-{name}-{version}-{date} -> claude-{name}-{version}@{date}
# Example: claude-opus-4-5-20251101 -> claude-opus-4-5@20251101
# The date is always 8 digits at the end
match = re.match(r'^(claude-.+)-(\d{8})$', model)
if match:
base_name, date = match.groups()
return f"{base_name}@{date}"
# If already in @ format or doesn't match expected pattern, return as-is
return model
def get_playwright_headless() -> bool:
"""
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.
"""
value = os.getenv("PLAYWRIGHT_HEADLESS", "false").lower()
# Accept various truthy/falsy values
return value in ("true", "1", "yes", "on")
value = os.getenv("PLAYWRIGHT_HEADLESS", str(DEFAULT_PLAYWRIGHT_HEADLESS).lower()).strip().lower()
truthy = {"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
def get_extra_read_paths() -> list[Path]:
"""
Get extra read-only paths from EXTRA_READ_PATHS environment variable.
Parses comma-separated absolute paths and validates each one:
- Must be an absolute path
- Must exist and be a directory
- Cannot be or contain sensitive directories (e.g., .ssh, .aws)
Returns:
List of validated, canonicalized Path objects.
"""
raw_value = os.getenv(EXTRA_READ_PATHS_VAR, "").strip()
if not raw_value:
return []
validated_paths: list[Path] = []
home_dir = Path.home()
for path_str in raw_value.split(","):
path_str = path_str.strip()
if not path_str:
continue
# Parse and canonicalize the path
try:
path = Path(path_str).resolve()
except (OSError, ValueError) as e:
print(f" - Warning: Invalid EXTRA_READ_PATHS path '{path_str}': {e}")
continue
# Must be absolute (resolve() makes it absolute, but check original input)
if not Path(path_str).is_absolute():
print(f" - Warning: EXTRA_READ_PATHS requires absolute paths, skipping: {path_str}")
continue
# Must exist
if not path.exists():
print(f" - Warning: EXTRA_READ_PATHS path does not exist, skipping: {path_str}")
continue
# Must be a directory
if not path.is_dir():
print(f" - Warning: EXTRA_READ_PATHS path is not a directory, skipping: {path_str}")
continue
# Check against sensitive directory blocklist
is_blocked = False
for sensitive in EXTRA_READ_PATHS_BLOCKLIST:
sensitive_path = (home_dir / sensitive).resolve()
try:
# Block if path IS the sensitive dir or is INSIDE it
if path == sensitive_path or path.is_relative_to(sensitive_path):
print(f" - Warning: EXTRA_READ_PATHS blocked sensitive path: {path_str}")
is_blocked = True
break
# Also block if sensitive dir is INSIDE the requested path
if sensitive_path.is_relative_to(path):
print(f" - Warning: EXTRA_READ_PATHS path contains sensitive directory ({sensitive}): {path_str}")
is_blocked = True
break
except (OSError, ValueError):
# is_relative_to can raise on some edge cases
continue
if is_blocked:
continue
validated_paths.append(path)
return validated_paths
# Feature MCP tools for feature/test management
@@ -172,6 +331,16 @@ def create_client(
# Allow Feature MCP tools for feature management
*FEATURE_MCP_TOOLS,
]
# Add extra read paths from environment variable (read-only access)
# Paths are validated, canonicalized, and checked against sensitive blocklist
extra_read_paths = get_extra_read_paths()
for path in extra_read_paths:
# Add read-only permissions for each validated path
permissions_list.append(f"Read({path}/**)")
permissions_list.append(f"Glob({path}/**)")
permissions_list.append(f"Grep({path}/**)")
if not yolo_mode:
# Allow Playwright MCP tools for browser automation (standard mode only)
permissions_list.extend(PLAYWRIGHT_TOOLS)
@@ -198,6 +367,8 @@ def create_client(
print(f"Created security settings at {settings_file}")
print(" - Sandbox enabled (OS-level bash isolation)")
print(f" - Filesystem restricted to: {project_dir.resolve()}")
if extra_read_paths:
print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}")
print(" - Bash commands restricted to allowlist (see security.py)")
if yolo_mode:
print(" - MCP servers: features (database) - YOLO MODE (no Playwright)")
@@ -228,10 +399,16 @@ def create_client(
}
if not yolo_mode:
# Include Playwright MCP server for browser automation (standard mode only)
# Headless mode is configurable via PLAYWRIGHT_HEADLESS environment variable
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"]
# Browser and headless mode configurable via environment variables
browser = get_playwright_browser()
playwright_args = [
"@playwright/mcp@latest",
"--viewport-size", "1280x720",
"--browser", browser,
]
if get_playwright_headless():
playwright_args.append("--headless")
print(f" - Browser: {browser} (headless={get_playwright_headless()})")
# Browser isolation for parallel execution
# Each agent gets its own isolated browser context to prevent tab conflicts
@@ -257,9 +434,21 @@ def create_client(
if value:
sdk_env[var] = value
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
is_vertex = sdk_env.get("CLAUDE_CODE_USE_VERTEX") == "1"
is_alternative_api = bool(base_url) or is_vertex
is_ollama = "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
model = convert_model_for_vertex(model)
if sdk_env:
print(f" - API overrides: {', '.join(sdk_env.keys())}")
if "ANTHROPIC_BASE_URL" in sdk_env:
if is_vertex:
project_id = sdk_env.get("ANTHROPIC_VERTEX_PROJECT_ID", "unknown")
region = sdk_env.get("CLOUD_ML_REGION", "unknown")
print(f" - Vertex AI Mode: Using GCP project '{project_id}' with model '{model}' in region '{region}'")
elif is_ollama:
print(" - Ollama Mode: Using local models")
elif "ANTHROPIC_BASE_URL" in sdk_env:
print(f" - GLM Mode: Using {sdk_env['ANTHROPIC_BASE_URL']}")
# Create a wrapper for bash_security_hook that passes project_dir via context
@@ -336,7 +525,8 @@ def create_client(
# Enable extended context beta for better handling of long sessions.
# This provides up to 1M tokens of context with automatic compaction.
# See: https://docs.anthropic.com/en/api/beta-headers
betas=["context-1m-2025-08-07"],
# Disabled for alternative APIs (Ollama, GLM, Vertex AI) as they don't support this beta.
betas=[] if is_alternative_api else ["context-1m-2025-08-07"],
# Note on context management:
# The Claude Agent SDK handles context management automatically through the
# underlying Claude Code CLI. When context approaches limits, the CLI

View File

@@ -169,9 +169,11 @@ class ParallelOrchestrator:
# Thread-safe state
self._lock = threading.Lock()
# Coding agents: feature_id -> process
# Safe to key by feature_id because start_feature() checks for duplicates before spawning
self.running_coding_agents: dict[int, subprocess.Popen] = {}
# Testing agents: feature_id -> process (feature being tested)
self.running_testing_agents: dict[int, subprocess.Popen] = {}
# Testing agents: pid -> (feature_id, process)
# Keyed by PID (not feature_id) because multiple agents can test the same feature
self.running_testing_agents: dict[int, tuple[int, subprocess.Popen]] = {}
# Legacy alias for backward compatibility
self.running_agents = self.running_coding_agents
self.abort_events: dict[int, threading.Event] = {}
@@ -401,6 +403,10 @@ class ParallelOrchestrator:
if passing_count == 0:
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
# This avoids TOCTOU race by holding lock during the decision
while True:
@@ -425,7 +431,10 @@ class ParallelOrchestrator:
# Spawn outside lock (I/O bound operation)
print(f"[DEBUG] Spawning testing agent ({spawn_index}/{desired})", flush=True)
self._spawn_testing_agent()
success, msg = self._spawn_testing_agent()
if not success:
debug_log.log("TESTING", f"Spawn failed, stopping: {msg}")
return
def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, str]:
"""Start a single coding agent for a feature.
@@ -500,14 +509,20 @@ class ParallelOrchestrator:
cmd.append("--yolo")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(AUTOCODER_ROOT),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
# stdin=DEVNULL prevents blocking on stdin reads
popen_kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
"cwd": str(AUTOCODER_ROOT), # Run from autocoder root for proper imports
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.Popen(cmd, **popen_kwargs)
except Exception as e:
# Reset in_progress on failure
session = self.get_session()
@@ -583,20 +598,27 @@ class ParallelOrchestrator:
cmd.extend(["--model", self.model])
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(AUTOCODER_ROOT),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
# stdin=DEVNULL prevents blocking on stdin reads
popen_kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
"cwd": str(AUTOCODER_ROOT),
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.Popen(cmd, **popen_kwargs)
except Exception as e:
debug_log.log("TESTING", f"FAILED to spawn testing agent: {e}")
return False, f"Failed to start testing agent: {e}"
# Register process with feature ID (same pattern as coding agents)
self.running_testing_agents[feature_id] = proc
# Register process by PID (not feature_id) to avoid overwrites
# when multiple agents test the same feature
self.running_testing_agents[proc.pid] = (feature_id, proc)
testing_count = len(self.running_testing_agents)
# Start output reader thread with feature ID (same as coding agents)
@@ -634,14 +656,20 @@ class ParallelOrchestrator:
print("Running initializer agent...", flush=True)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(AUTOCODER_ROOT),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
# stdin=DEVNULL prevents blocking on stdin reads
popen_kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"text": True,
"cwd": str(AUTOCODER_ROOT),
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.Popen(cmd, **popen_kwargs)
debug_log.log("INIT", "Initializer subprocess started", pid=proc.pid)
@@ -699,6 +727,12 @@ class ParallelOrchestrator:
print(f"[Feature #{feature_id}] {line}", flush=True)
proc.wait()
finally:
# CRITICAL: Kill the process tree to clean up any child processes (e.g., Claude CLI)
# This prevents zombie processes from accumulating
try:
kill_process_tree(proc, timeout=2.0)
except Exception as e:
debug_log.log("CLEANUP", f"Error killing process tree for {agent_type} agent", error=str(e))
self._on_agent_complete(feature_id, proc.returncode, agent_type, proc)
def _signal_agent_completed(self):
@@ -767,11 +801,8 @@ class ParallelOrchestrator:
"""
if agent_type == "testing":
with self._lock:
# Remove from dict by finding the feature_id for this proc
for fid, p in list(self.running_testing_agents.items()):
if p is proc:
del self.running_testing_agents[fid]
break
# Remove by PID
self.running_testing_agents.pop(proc.pid, None)
status = "completed" if return_code == 0 else "failed"
print(f"Feature #{feature_id} testing {status}", flush=True)
@@ -870,12 +901,17 @@ class ParallelOrchestrator:
with self._lock:
testing_items = list(self.running_testing_agents.items())
for feature_id, proc in testing_items:
for pid, (feature_id, proc) in testing_items:
result = kill_process_tree(proc, timeout=5.0)
debug_log.log("STOP", f"Killed testing agent for feature #{feature_id} (PID {proc.pid})",
debug_log.log("STOP", f"Killed testing agent for feature #{feature_id} (PID {pid})",
status=result.status, children_found=result.children_found,
children_terminated=result.children_terminated, children_killed=result.children_killed)
# Clear dict so get_status() doesn't report stale agents while
# _on_agent_complete callbacks are still in flight.
with self._lock:
self.running_testing_agents.clear()
async def run_loop(self):
"""Main orchestration loop."""
self.is_running = True

View File

@@ -16,7 +16,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from sqlalchemy import Column, DateTime, String, create_engine
from sqlalchemy import Column, DateTime, Integer, String, create_engine, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
@@ -85,6 +85,7 @@ class Project(Base):
name = Column(String(50), primary_key=True, index=True)
path = Column(String, nullable=False) # POSIX format for cross-platform
created_at = Column(DateTime, nullable=False)
default_concurrency = Column(Integer, nullable=False, default=3)
class Settings(Base):
@@ -146,12 +147,26 @@ def _get_engine():
}
)
Base.metadata.create_all(bind=_engine)
_migrate_add_default_concurrency(_engine)
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
logger.debug("Initialized registry database at: %s", db_path)
return _engine, _SessionLocal
def _migrate_add_default_concurrency(engine) -> None:
"""Add default_concurrency column if missing (for existing databases)."""
with engine.connect() as conn:
result = conn.execute(text("PRAGMA table_info(projects)"))
columns = [row[1] for row in result.fetchall()]
if "default_concurrency" not in columns:
conn.execute(text(
"ALTER TABLE projects ADD COLUMN default_concurrency INTEGER DEFAULT 3"
))
conn.commit()
logger.info("Migrated projects table: added default_concurrency column")
@contextmanager
def _get_session():
"""
@@ -307,7 +322,8 @@ def list_registered_projects() -> dict[str, dict[str, Any]]:
return {
p.name: {
"path": p.path,
"created_at": p.created_at.isoformat() if p.created_at else None
"created_at": p.created_at.isoformat() if p.created_at else None,
"default_concurrency": getattr(p, 'default_concurrency', 3) or 3
}
for p in projects
}
@@ -333,7 +349,8 @@ def get_project_info(name: str) -> dict[str, Any] | None:
return None
return {
"path": project.path,
"created_at": project.created_at.isoformat() if project.created_at else None
"created_at": project.created_at.isoformat() if project.created_at else None,
"default_concurrency": getattr(project, 'default_concurrency', 3) or 3
}
finally:
session.close()
@@ -362,6 +379,55 @@ def update_project_path(name: str, new_path: Path) -> bool:
return True
def get_project_concurrency(name: str) -> int:
"""
Get project's default concurrency (1-5).
Args:
name: The project name.
Returns:
The default concurrency value (defaults to 3 if not set or project not found).
"""
_, SessionLocal = _get_engine()
session = SessionLocal()
try:
project = session.query(Project).filter(Project.name == name).first()
if project is None:
return 3
return getattr(project, 'default_concurrency', 3) or 3
finally:
session.close()
def set_project_concurrency(name: str, concurrency: int) -> bool:
"""
Set project's default concurrency (1-5).
Args:
name: The project name.
concurrency: The concurrency value (1-5).
Returns:
True if updated, False if project wasn't found.
Raises:
ValueError: If concurrency is not between 1 and 5.
"""
if concurrency < 1 or concurrency > 5:
raise ValueError("concurrency must be between 1 and 5")
with _get_session() as session:
project = session.query(Project).filter(Project.name == name).first()
if not project:
return False
project.default_concurrency = concurrency
logger.info("Set project '%s' default_concurrency to %d", name, concurrency)
return True
# =============================================================================
# Validation Functions
# =============================================================================

View File

@@ -6,13 +6,22 @@ Pre-tool-use hooks that validate bash commands for security.
Uses an allowlist approach - only explicitly permitted commands can run.
"""
import logging
import os
import re
import shlex
from pathlib import Path
from typing import Optional
import yaml
# Logger for security-related events (fallback parsing, validation failures, etc.)
logger = logging.getLogger(__name__)
# Regex pattern for valid pkill process names (no regex metacharacters allowed)
# Matches alphanumeric names with dots, underscores, and hyphens
VALID_PROCESS_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
# Allowed commands for development tasks
# Minimal set needed for the autonomous coding demo
ALLOWED_COMMANDS = {
@@ -135,6 +144,45 @@ def split_command_segments(command_string: str) -> list[str]:
return result
def _extract_primary_command(segment: str) -> str | None:
"""
Fallback command extraction when shlex fails.
Extracts the first word that looks like a command, handling cases
like complex docker exec commands with nested quotes.
Args:
segment: The command segment to parse
Returns:
The primary command name, or None if extraction fails
"""
# Remove leading whitespace
segment = segment.lstrip()
if not segment:
return None
# Skip env var assignments at start (VAR=value cmd)
words = segment.split()
while words and "=" in words[0] and not words[0].startswith("="):
words = words[1:]
if not words:
return None
# Extract first token (the command)
first_word = words[0]
# Match valid command characters (alphanumeric, dots, underscores, hyphens, slashes)
match = re.match(r"^([a-zA-Z0-9_./-]+)", first_word)
if match:
cmd = match.group(1)
return os.path.basename(cmd)
return None
def extract_commands(command_string: str) -> list[str]:
"""
Extract command names from a shell command string.
@@ -151,7 +199,6 @@ def extract_commands(command_string: str) -> list[str]:
commands = []
# shlex doesn't treat ; as a separator, so we need to pre-process
import re
# Split on semicolons that aren't inside quotes (simple heuristic)
# This handles common cases like "echo hello; ls"
@@ -166,8 +213,21 @@ def extract_commands(command_string: str) -> list[str]:
tokens = shlex.split(segment)
except ValueError:
# Malformed command (unclosed quotes, etc.)
# Return empty to trigger block (fail-safe)
return []
# Try fallback extraction instead of blocking entirely
fallback_cmd = _extract_primary_command(segment)
if fallback_cmd:
logger.debug(
"shlex fallback used: segment=%r -> command=%r",
segment,
fallback_cmd,
)
commands.append(fallback_cmd)
else:
logger.debug(
"shlex fallback failed: segment=%r (no command extracted)",
segment,
)
continue
if not tokens:
continue
@@ -219,23 +279,37 @@ def extract_commands(command_string: str) -> list[str]:
return commands
def validate_pkill_command(command_string: str) -> tuple[bool, str]:
# Default pkill process names (hardcoded baseline, always available)
DEFAULT_PKILL_PROCESSES = {
"node",
"npm",
"npx",
"vite",
"next",
}
def validate_pkill_command(
command_string: str,
extra_processes: Optional[set[str]] = None
) -> tuple[bool, str]:
"""
Validate pkill commands - only allow killing dev-related processes.
Uses shlex to parse the command, avoiding regex bypass vulnerabilities.
Args:
command_string: The pkill command to validate
extra_processes: Optional set of additional process names to allow
(from org/project config pkill_processes)
Returns:
Tuple of (is_allowed, reason_if_blocked)
"""
# Allowed process names for pkill
allowed_process_names = {
"node",
"npm",
"npx",
"vite",
"next",
}
# Merge default processes with any extra configured processes
allowed_process_names = DEFAULT_PKILL_PROCESSES.copy()
if extra_processes:
allowed_process_names |= extra_processes
try:
tokens = shlex.split(command_string)
@@ -254,17 +328,19 @@ def validate_pkill_command(command_string: str) -> tuple[bool, str]:
if not args:
return False, "pkill requires a process name"
# The target is typically the last non-flag argument
target = args[-1]
# Validate every non-flag argument (pkill accepts multiple patterns on BSD)
# This defensively ensures no disallowed process can be targeted
targets = []
for arg in args:
# For -f flag (full command line match), take the first word as process name
# e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
t = arg.split()[0] if " " in arg else arg
targets.append(t)
# For -f flag (full command line match), extract the first word as process name
# e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
if " " in target:
target = target.split()[0]
if target in allowed_process_names:
disallowed = [t for t in targets if t not in allowed_process_names]
if not disallowed:
return True, ""
return False, f"pkill only allowed for dev processes: {allowed_process_names}"
return False, f"pkill only allowed for processes: {sorted(allowed_process_names)}"
def validate_chmod_command(command_string: str) -> tuple[bool, str]:
@@ -423,41 +499,74 @@ def load_org_config() -> Optional[dict]:
config = yaml.safe_load(f)
if not config:
logger.warning(f"Org config at {config_path} is empty")
return None
# Validate structure
if not isinstance(config, dict):
logger.warning(f"Org config at {config_path} must be a YAML dictionary")
return None
if "version" not in config:
logger.warning(f"Org config at {config_path} missing required 'version' field")
return None
# Validate allowed_commands if present
if "allowed_commands" in config:
allowed = config["allowed_commands"]
if not isinstance(allowed, list):
logger.warning(f"Org config at {config_path}: 'allowed_commands' must be a list")
return None
for cmd in allowed:
for i, cmd in enumerate(allowed):
if not isinstance(cmd, dict):
logger.warning(f"Org config at {config_path}: allowed_commands[{i}] must be a dict")
return None
if "name" not in cmd:
logger.warning(f"Org config at {config_path}: allowed_commands[{i}] missing 'name'")
return None
# Validate that name is a non-empty string
if not isinstance(cmd["name"], str) or cmd["name"].strip() == "":
logger.warning(f"Org config at {config_path}: allowed_commands[{i}] has invalid 'name'")
return None
# Validate blocked_commands if present
if "blocked_commands" in config:
blocked = config["blocked_commands"]
if not isinstance(blocked, list):
logger.warning(f"Org config at {config_path}: 'blocked_commands' must be a list")
return None
for cmd in blocked:
for i, cmd in enumerate(blocked):
if not isinstance(cmd, str):
logger.warning(f"Org config at {config_path}: blocked_commands[{i}] must be a string")
return None
# Validate pkill_processes if present
if "pkill_processes" in config:
processes = config["pkill_processes"]
if not isinstance(processes, list):
logger.warning(f"Org config at {config_path}: 'pkill_processes' must be a list")
return None
# Normalize and validate each process name against safe pattern
normalized = []
for i, proc in enumerate(processes):
if not isinstance(proc, str):
logger.warning(f"Org config at {config_path}: pkill_processes[{i}] must be a string")
return None
proc = proc.strip()
# Block empty strings and regex metacharacters
if not proc or not VALID_PROCESS_NAME_PATTERN.fullmatch(proc):
logger.warning(f"Org config at {config_path}: pkill_processes[{i}] has invalid value '{proc}'")
return None
normalized.append(proc)
config["pkill_processes"] = normalized
return config
except (yaml.YAMLError, IOError, OSError):
except yaml.YAMLError as e:
logger.warning(f"Failed to parse org config at {config_path}: {e}")
return None
except (IOError, OSError) as e:
logger.warning(f"Failed to read org config at {config_path}: {e}")
return None
@@ -471,7 +580,7 @@ def load_project_commands(project_dir: Path) -> Optional[dict]:
Returns:
Dict with parsed YAML config, or None if file doesn't exist or is invalid
"""
config_path = project_dir / ".autocoder" / "allowed_commands.yaml"
config_path = project_dir.resolve() / ".autocoder" / "allowed_commands.yaml"
if not config_path.exists():
return None
@@ -481,36 +590,68 @@ def load_project_commands(project_dir: Path) -> Optional[dict]:
config = yaml.safe_load(f)
if not config:
logger.warning(f"Project config at {config_path} is empty")
return None
# Validate structure
if not isinstance(config, dict):
logger.warning(f"Project config at {config_path} must be a YAML dictionary")
return None
if "version" not in config:
logger.warning(f"Project config at {config_path} missing required 'version' field")
return None
commands = config.get("commands", [])
if not isinstance(commands, list):
logger.warning(f"Project config at {config_path}: 'commands' must be a list")
return None
# Enforce 100 command limit
if len(commands) > 100:
logger.warning(f"Project config at {config_path} exceeds 100 command limit ({len(commands)} commands)")
return None
# Validate each command entry
for cmd in commands:
for i, cmd in enumerate(commands):
if not isinstance(cmd, dict):
logger.warning(f"Project config at {config_path}: commands[{i}] must be a dict")
return None
if "name" not in cmd:
logger.warning(f"Project config at {config_path}: commands[{i}] missing 'name'")
return None
# Validate name is a string
if not isinstance(cmd["name"], str):
# Validate name is a non-empty string
if not isinstance(cmd["name"], str) or cmd["name"].strip() == "":
logger.warning(f"Project config at {config_path}: commands[{i}] has invalid 'name'")
return None
# Validate pkill_processes if present
if "pkill_processes" in config:
processes = config["pkill_processes"]
if not isinstance(processes, list):
logger.warning(f"Project config at {config_path}: 'pkill_processes' must be a list")
return None
# Normalize and validate each process name against safe pattern
normalized = []
for i, proc in enumerate(processes):
if not isinstance(proc, str):
logger.warning(f"Project config at {config_path}: pkill_processes[{i}] must be a string")
return None
proc = proc.strip()
# Block empty strings and regex metacharacters
if not proc or not VALID_PROCESS_NAME_PATTERN.fullmatch(proc):
logger.warning(f"Project config at {config_path}: pkill_processes[{i}] has invalid value '{proc}'")
return None
normalized.append(proc)
config["pkill_processes"] = normalized
return config
except (yaml.YAMLError, IOError, OSError):
except yaml.YAMLError as e:
logger.warning(f"Failed to parse project config at {config_path}: {e}")
return None
except (IOError, OSError) as e:
logger.warning(f"Failed to read project config at {config_path}: {e}")
return None
@@ -628,6 +769,42 @@ def get_project_allowed_commands(project_dir: Optional[Path]) -> set[str]:
return allowed
def get_effective_pkill_processes(project_dir: Optional[Path]) -> set[str]:
"""
Get effective pkill process names after hierarchy resolution.
Merges processes from:
1. DEFAULT_PKILL_PROCESSES (hardcoded baseline)
2. Org config pkill_processes
3. Project config pkill_processes
Args:
project_dir: Path to the project directory, or None
Returns:
Set of allowed process names for pkill
"""
# Start with default processes
processes = DEFAULT_PKILL_PROCESSES.copy()
# Add org-level pkill_processes
org_config = load_org_config()
if org_config:
org_processes = org_config.get("pkill_processes", [])
if isinstance(org_processes, list):
processes |= {p for p in org_processes if isinstance(p, str) and p.strip()}
# Add project-level pkill_processes
if project_dir:
project_config = load_project_commands(project_dir)
if project_config:
proj_processes = project_config.get("pkill_processes", [])
if isinstance(proj_processes, list):
processes |= {p for p in proj_processes if isinstance(p, str) and p.strip()}
return processes
def is_command_allowed(command: str, allowed_commands: set[str]) -> bool:
"""
Check if a command is allowed (supports patterns).
@@ -692,6 +869,9 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
# Get effective commands using hierarchy resolution
allowed_commands, blocked_commands = get_effective_commands(project_dir)
# Get effective pkill processes (includes org/project config)
pkill_processes = get_effective_pkill_processes(project_dir)
# Split into segments for per-command validation
segments = split_command_segments(command)
@@ -725,7 +905,9 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
cmd_segment = command # Fallback to full command
if cmd == "pkill":
allowed, reason = validate_pkill_command(cmd_segment)
# Pass configured extra processes (beyond defaults)
extra_procs = pkill_processes - DEFAULT_PKILL_PROCESSES
allowed, reason = validate_pkill_command(cmd_segment, extra_procs if extra_procs else None)
if not allowed:
return {"decision": "block", "reason": reason}
elif cmd == "chmod":

View File

@@ -88,35 +88,49 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS - allow only localhost origins for security
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173", # Vite dev server
"http://127.0.0.1:5173",
"http://localhost:8888", # Production
"http://127.0.0.1:8888",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Check if remote access is enabled via environment variable
# Set by start_ui.py when --host is not 127.0.0.1
ALLOW_REMOTE = os.environ.get("AUTOCODER_ALLOW_REMOTE", "").lower() in ("1", "true", "yes")
# CORS - allow all origins when remote access is enabled, otherwise localhost only
if ALLOW_REMOTE:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for remote access
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
else:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173", # Vite dev server
"http://127.0.0.1:5173",
"http://localhost:8888", # Production
"http://127.0.0.1:8888",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# Security Middleware
# ============================================================================
@app.middleware("http")
async def require_localhost(request: Request, call_next):
"""Only allow requests from localhost."""
client_host = request.client.host if request.client else None
if not ALLOW_REMOTE:
@app.middleware("http")
async def require_localhost(request: Request, call_next):
"""Only allow requests from localhost (disabled when AUTOCODER_ALLOW_REMOTE=1)."""
client_host = request.client.host if request.client else None
# Allow localhost connections
if client_host not in ("127.0.0.1", "::1", "localhost", None):
raise HTTPException(status_code=403, detail="Localhost access only")
# Allow localhost connections
if client_host not in ("127.0.0.1", "::1", "localhost", None):
raise HTTPException(status_code=403, detail="Localhost access only")
return await call_next(request)
return await call_next(request)
# ============================================================================

View File

@@ -93,7 +93,7 @@ async def get_agent_status(project_name: str):
return AgentStatus(
status=manager.status,
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,
model=manager.model,
parallel_mode=manager.parallel_mode,

View File

@@ -129,7 +129,7 @@ async def get_devserver_status(project_name: str) -> DevServerStatus:
pid=manager.pid,
url=manager.detected_url,
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:
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()
feature.priority = (max_priority.priority if max_priority else 0) + 1000
feature.priority = (max_priority.priority + 1) if max_priority else 1
session.commit()

View File

@@ -18,6 +18,7 @@ from ..schemas import (
ProjectDetail,
ProjectPrompts,
ProjectPromptsUpdate,
ProjectSettingsUpdate,
ProjectStats,
ProjectSummary,
)
@@ -63,13 +64,23 @@ def _get_registry_functions():
sys.path.insert(0, str(root))
from registry import (
get_project_concurrency,
get_project_path,
list_registered_projects,
register_project,
set_project_concurrency,
unregister_project,
validate_project_path,
)
return register_project, unregister_project, get_project_path, list_registered_projects, validate_project_path
return (
register_project,
unregister_project,
get_project_path,
list_registered_projects,
validate_project_path,
get_project_concurrency,
set_project_concurrency,
)
router = APIRouter(prefix="/api/projects", tags=["projects"])
@@ -102,7 +113,8 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
async def list_projects():
"""List all registered projects."""
_init_imports()
_, _, _, list_registered_projects, validate_project_path = _get_registry_functions()
(_, _, _, list_registered_projects, validate_project_path,
get_project_concurrency, _) = _get_registry_functions()
projects = list_registered_projects()
result = []
@@ -123,6 +135,7 @@ async def list_projects():
path=info["path"],
has_spec=has_spec,
stats=stats,
default_concurrency=info.get("default_concurrency", 3),
))
return result
@@ -132,7 +145,8 @@ async def list_projects():
async def create_project(project: ProjectCreate):
"""Create a new project at the specified path."""
_init_imports()
register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions()
(register_project, _, get_project_path, list_registered_projects,
_, _, _) = _get_registry_functions()
name = validate_project_name(project.name)
project_path = Path(project.path).resolve()
@@ -203,6 +217,7 @@ async def create_project(project: ProjectCreate):
path=project_path.as_posix(),
has_spec=False, # Just created, no spec yet
stats=ProjectStats(passing=0, total=0, percentage=0.0),
default_concurrency=3,
)
@@ -210,7 +225,7 @@ async def create_project(project: ProjectCreate):
async def get_project(name: str):
"""Get detailed information about a project."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
(_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -231,6 +246,7 @@ async def get_project(name: str):
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
default_concurrency=get_project_concurrency(name),
)
@@ -244,7 +260,7 @@ async def delete_project(name: str, delete_files: bool = False):
delete_files: If True, also delete the project directory and files
"""
_init_imports()
_, unregister_project, get_project_path, _, _ = _get_registry_functions()
(_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -280,7 +296,7 @@ async def delete_project(name: str, delete_files: bool = False):
async def get_project_prompts(name: str):
"""Get the content of project prompt files."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -313,7 +329,7 @@ async def get_project_prompts(name: str):
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
"""Update project prompt files."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -343,7 +359,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
async def get_project_stats_endpoint(name: str):
"""Get current progress statistics for a project."""
_init_imports()
_, _, get_project_path, _, _ = _get_registry_functions()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -355,3 +371,121 @@ async def get_project_stats_endpoint(name: str):
raise HTTPException(status_code=404, detail="Project directory not found")
return get_project_stats(project_dir)
@router.post("/{name}/reset")
async def reset_project(name: str, full_reset: bool = False):
"""
Reset a project to its initial state.
Args:
name: Project name to reset
full_reset: If True, also delete prompts/ directory (triggers setup wizard)
Returns:
Dictionary with list of deleted files and reset type
"""
_init_imports()
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
# Check if agent is running
lock_file = project_dir / ".agent.lock"
if lock_file.exists():
raise HTTPException(
status_code=409,
detail="Cannot reset project while agent is running. Stop the agent first."
)
# Dispose of database engines to release file locks (required on Windows)
# Import here to avoid circular imports
from api.database import dispose_engine as dispose_features_engine
from server.services.assistant_database import dispose_engine as dispose_assistant_engine
dispose_features_engine(project_dir)
dispose_assistant_engine(project_dir)
deleted_files: list[str] = []
# Files to delete in quick reset
quick_reset_files = [
"features.db",
"features.db-wal", # WAL mode journal file
"features.db-shm", # WAL mode shared memory file
"assistant.db",
"assistant.db-wal",
"assistant.db-shm",
".claude_settings.json",
".claude_assistant_settings.json",
]
for filename in quick_reset_files:
file_path = project_dir / filename
if file_path.exists():
try:
file_path.unlink()
deleted_files.append(filename)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {e}")
# Full reset: also delete prompts directory
if full_reset:
prompts_dir = project_dir / "prompts"
if prompts_dir.exists():
try:
shutil.rmtree(prompts_dir)
deleted_files.append("prompts/")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete prompts/: {e}")
return {
"success": True,
"reset_type": "full" if full_reset else "quick",
"deleted_files": deleted_files,
"message": f"Project '{name}' has been reset" + (" (full reset)" if full_reset else " (quick reset)")
}
@router.patch("/{name}/settings", response_model=ProjectDetail)
async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
"""Update project-level settings (concurrency, etc.)."""
_init_imports()
(_, _, get_project_path, _, _, get_project_concurrency,
set_project_concurrency) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
# Update concurrency if provided
if settings.default_concurrency is not None:
success = set_project_concurrency(name, settings.default_concurrency)
if not success:
raise HTTPException(status_code=500, detail="Failed to update concurrency")
# Return updated project details
has_spec = _check_spec_exists(project_dir)
stats = get_project_stats(project_dir)
prompts_dir = _get_project_prompts_dir(project_dir)
return ProjectDetail(
name=name,
path=project_dir.as_posix(),
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
default_concurrency=get_project_concurrency(name),
)

View File

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

View File

@@ -40,7 +40,15 @@ def _parse_yolo_mode(value: str | None) -> bool:
def _is_glm_mode() -> bool:
"""Check if GLM API is configured via environment variables."""
return bool(os.getenv("ANTHROPIC_BASE_URL"))
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
# GLM mode is when ANTHROPIC_BASE_URL is set but NOT pointing to Ollama
return bool(base_url) and not _is_ollama_mode()
def _is_ollama_mode() -> bool:
"""Check if Ollama API is configured via environment variables."""
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
@router.get("/models", response_model=ModelsResponse)
@@ -82,6 +90,7 @@ async def get_settings():
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
glm_mode=_is_glm_mode(),
ollama_mode=_is_ollama_mode(),
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
)
@@ -104,5 +113,6 @@ async def update_settings(update: SettingsUpdate):
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
model=all_settings.get("model", DEFAULT_MODEL),
glm_mode=_is_glm_mode(),
ollama_mode=_is_ollama_mode(),
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
)

View File

@@ -45,6 +45,7 @@ class ProjectSummary(BaseModel):
path: str
has_spec: bool
stats: ProjectStats
default_concurrency: int = 3
class ProjectDetail(BaseModel):
@@ -54,6 +55,7 @@ class ProjectDetail(BaseModel):
has_spec: bool
stats: ProjectStats
prompts_dir: str
default_concurrency: int = 3
class ProjectPrompts(BaseModel):
@@ -70,6 +72,18 @@ class ProjectPromptsUpdate(BaseModel):
coding_prompt: str | None = None
class ProjectSettingsUpdate(BaseModel):
"""Request schema for updating project-level settings."""
default_concurrency: int | None = None
@field_validator('default_concurrency')
@classmethod
def validate_concurrency(cls, v: int | None) -> int | None:
if v is not None and (v < 1 or v > 5):
raise ValueError("default_concurrency must be between 1 and 5")
return v
# ============================================================================
# Feature Schemas
# ============================================================================
@@ -382,6 +396,7 @@ class SettingsResponse(BaseModel):
yolo_mode: bool = False
model: str = DEFAULT_MODEL
glm_mode: bool = False # True if GLM API is configured via .env
ollama_mode: bool = False # True if Ollama API is configured via .env
testing_agent_ratio: int = 1 # Regression testing agents (0-3)

View File

@@ -79,6 +79,26 @@ def get_engine(project_dir: Path):
return _engine_cache[cache_key]
def dispose_engine(project_dir: Path) -> bool:
"""Dispose of and remove the cached engine for a project.
This closes all database connections, releasing file locks on Windows.
Should be called before deleting the database file.
Returns:
True if an engine was disposed, False if no engine was cached.
"""
cache_key = project_dir.as_posix()
if cache_key in _engine_cache:
engine = _engine_cache.pop(cache_key)
engine.dispose()
logger.debug(f"Disposed database engine for {cache_key}")
return True
return False
def get_session(project_dir: Path):
"""Get a new database session for a project."""
engine = get_engine(project_dir)

View File

@@ -428,7 +428,9 @@ class DevServerProcessManager:
# Global registry of dev server managers per project with thread safety
_managers: dict[str, DevServerProcessManager] = {}
# Key is (project_name, resolved_project_dir) to prevent cross-project contamination
# when different projects share the same name but have different paths
_managers: dict[tuple[str, str], DevServerProcessManager] = {}
_managers_lock = threading.Lock()
@@ -444,18 +446,11 @@ def get_devserver_manager(project_name: str, project_dir: Path) -> DevServerProc
DevServerProcessManager instance for the project
"""
with _managers_lock:
if project_name in _managers:
manager = _managers[project_name]
# Update project_dir in case project was moved
if manager.project_dir.resolve() != project_dir.resolve():
logger.info(
f"Project {project_name} path updated: {manager.project_dir} -> {project_dir}"
)
manager.project_dir = project_dir
manager.lock_file = project_dir / ".devserver.lock"
return manager
_managers[project_name] = DevServerProcessManager(project_name, project_dir)
return _managers[project_name]
# Use composite key to prevent cross-project UI contamination (#71)
key = (project_name, str(project_dir.resolve()))
if key not in _managers:
_managers[key] = DevServerProcessManager(project_name, project_dir)
return _managers[key]
async def cleanup_all_devservers() -> None:

View File

@@ -10,8 +10,8 @@ import asyncio
import json
import logging
import os
import re
import shutil
import sys
import threading
import uuid
from datetime import datetime
@@ -38,6 +38,13 @@ API_ENV_VARS = [
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
]
# Feature MCP tools needed for expand session
EXPAND_FEATURE_TOOLS = [
"mcp__features__feature_create",
"mcp__features__feature_create_bulk",
"mcp__features__feature_get_stats",
]
async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
"""
@@ -61,9 +68,8 @@ class ExpandChatSession:
Unlike SpecChatSession which writes spec files, this session:
1. Reads existing app_spec.txt for context
2. Parses feature definitions from Claude's output
3. Creates features via REST API
4. Tracks which features were created during the session
2. Chats with the user to define new features
3. Claude creates features via the feature_create_bulk MCP tool
"""
def __init__(self, project_name: str, project_dir: Path):
@@ -145,10 +151,14 @@ class ExpandChatSession:
return
# Create temporary security settings file (unique per session to avoid conflicts)
# Note: permission_mode="bypassPermissions" is safe here because:
# 1. Only Read/Glob file tools are allowed (no Write/Edit)
# 2. MCP tools are restricted to feature creation only
# 3. No Bash access - cannot execute arbitrary commands
security_settings = {
"sandbox": {"enabled": True},
"permissions": {
"defaultMode": "acceptEdits",
"defaultMode": "bypassPermissions",
"allow": [
"Read(./**)",
"Glob(./**)",
@@ -171,6 +181,18 @@ class ExpandChatSession:
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
# Build MCP servers config for feature creation
mcp_servers = {
"features": {
"command": sys.executable,
"args": ["-m", "mcp_server.feature_mcp"],
"env": {
"PROJECT_DIR": str(self.project_dir.resolve()),
"PYTHONPATH": str(ROOT_DIR.resolve()),
},
},
}
# Create Claude SDK client
try:
self.client = ClaudeSDKClient(
@@ -181,8 +203,10 @@ class ExpandChatSession:
allowed_tools=[
"Read",
"Glob",
*EXPAND_FEATURE_TOOLS,
],
permission_mode="acceptEdits",
mcp_servers=mcp_servers,
permission_mode="bypassPermissions",
max_turns=100,
cwd=str(self.project_dir.resolve()),
settings=str(settings_file.resolve()),
@@ -267,7 +291,8 @@ class ExpandChatSession:
"""
Internal method to query Claude and stream responses.
Handles text responses and detects feature creation blocks.
Feature creation is handled by Claude calling the feature_create_bulk
MCP tool directly -- no text parsing needed.
"""
if not self.client:
return
@@ -291,9 +316,6 @@ class ExpandChatSession:
else:
await self.client.query(message)
# Accumulate full response to detect feature blocks
full_response = ""
# Stream the response
async for msg in self.client.receive_response():
msg_type = type(msg).__name__
@@ -305,7 +327,6 @@ class ExpandChatSession:
if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text
if text:
full_response += text
yield {"type": "text", "content": text}
self.messages.append({
@@ -314,123 +335,6 @@ class ExpandChatSession:
"timestamp": datetime.now().isoformat()
})
# Check for feature creation blocks in full response (handle multiple blocks)
features_matches = re.findall(
r'<features_to_create>\s*(\[[\s\S]*?\])\s*</features_to_create>',
full_response
)
if features_matches:
# Collect all features from all blocks, deduplicating by name
all_features: list[dict] = []
seen_names: set[str] = set()
for features_json in features_matches:
try:
features_data = json.loads(features_json)
if features_data and isinstance(features_data, list):
for feature in features_data:
name = feature.get("name", "")
if name and name not in seen_names:
seen_names.add(name)
all_features.append(feature)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse features JSON block: {e}")
# Continue processing other blocks
if all_features:
try:
# Create all deduplicated features
created = await self._create_features_bulk(all_features)
if created:
self.features_created += len(created)
self.created_feature_ids.extend([f["id"] for f in created])
yield {
"type": "features_created",
"count": len(created),
"features": created
}
logger.info(f"Created {len(created)} features for {self.project_name}")
except Exception:
logger.exception("Failed to create features")
yield {
"type": "error",
"content": "Failed to create features"
}
async def _create_features_bulk(self, features: list[dict]) -> list[dict]:
"""
Create features directly in the database.
Args:
features: List of feature dictionaries with category, name, description, steps
Returns:
List of created feature dictionaries with IDs
Note:
Uses flush() to get IDs immediately without re-querying by priority range,
which could pick up rows from concurrent writers.
"""
# Import database classes
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from api.database import Feature, create_database
# Get database session
_, SessionLocal = create_database(self.project_dir)
session = SessionLocal()
try:
# Determine starting priority
max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first()
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
created_rows: list = []
for f in features:
db_feature = Feature(
priority=current_priority,
category=f.get("category", "functional"),
name=f.get("name", "Unnamed feature"),
description=f.get("description", ""),
steps=f.get("steps", []),
passes=False,
in_progress=False,
)
session.add(db_feature)
created_rows.append(db_feature)
current_priority += 1
# Flush to get IDs without relying on priority range query
session.flush()
# Build result from the flushed objects (IDs are now populated)
created_features = [
{
"id": db_feature.id,
"name": db_feature.name,
"category": db_feature.category,
}
for db_feature in created_rows
]
session.commit()
return created_features
except Exception:
session.rollback()
raise
finally:
session.close()
def get_features_created(self) -> int:
"""Get the total number of features created in this session."""
return self.features_created

View File

@@ -349,14 +349,20 @@ class AgentProcessManager:
try:
# Start subprocess with piped stdout/stderr
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
# IMPORTANT: Set PYTHONUNBUFFERED to ensure output isn't delayed
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.project_dir),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
# PYTHONUNBUFFERED ensures output isn't delayed
popen_kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"cwd": str(self.project_dir),
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
self.process = subprocess.Popen(cmd, **popen_kwargs)
# Atomic lock creation - if it fails, another process beat us
if not self._create_lock():
@@ -510,7 +516,9 @@ class AgentProcessManager:
# Global registry of process managers per project with thread safety
_managers: dict[str, AgentProcessManager] = {}
# Key is (project_name, resolved_project_dir) to prevent cross-project contamination
# when different projects share the same name but have different paths
_managers: dict[tuple[str, str], AgentProcessManager] = {}
_managers_lock = threading.Lock()
@@ -523,9 +531,11 @@ def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentPr
root_dir: Root directory of the autonomous-coding-ui project
"""
with _managers_lock:
if project_name not in _managers:
_managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir)
return _managers[project_name]
# Use composite key to prevent cross-project UI contamination (#71)
key = (project_name, str(project_dir.resolve()))
if key not in _managers:
_managers[key] = AgentProcessManager(project_name, project_dir, root_dir)
return _managers[key]
async def cleanup_all_managers() -> None:

View File

@@ -39,5 +39,3 @@ pip install -r requirements.txt --quiet
REM Run the Python launcher
python "%~dp0start_ui.py" %*
pause

View File

@@ -13,12 +13,16 @@ Automated launcher that handles all setup:
7. Opens browser to the UI
Usage:
python start_ui.py [--dev]
python start_ui.py [--dev] [--host HOST] [--port PORT]
Options:
--dev Run in development mode with Vite hot reload
--dev Run in development mode with Vite hot reload
--host HOST Host to bind to (default: 127.0.0.1)
Use 0.0.0.0 for remote access (security warning will be shown)
--port PORT Port to bind to (default: 8888)
"""
import argparse
import asyncio
import os
import shutil
@@ -133,10 +137,25 @@ def check_node() -> bool:
def install_npm_deps() -> bool:
"""Install npm dependencies if node_modules doesn't exist."""
"""Install npm dependencies if node_modules doesn't exist or is stale."""
node_modules = UI_DIR / "node_modules"
package_json = UI_DIR / "package.json"
package_lock = UI_DIR / "package-lock.json"
if node_modules.exists():
# Check if npm install is needed
needs_install = False
if not node_modules.exists():
needs_install = True
elif package_json.exists():
# If package.json or package-lock.json is newer than node_modules, reinstall
node_modules_mtime = node_modules.stat().st_mtime
if package_json.stat().st_mtime > node_modules_mtime:
needs_install = True
elif package_lock.exists() and package_lock.stat().st_mtime > node_modules_mtime:
needs_install = True
if not needs_install:
print(" npm dependencies already installed")
return True
@@ -235,26 +254,31 @@ def build_frontend() -> bool:
return run_command([npm_cmd, "run", "build"], cwd=UI_DIR)
def start_dev_server(port: int) -> tuple:
def start_dev_server(port: int, host: str = "127.0.0.1") -> tuple:
"""Start both Vite and FastAPI in development mode."""
venv_python = get_venv_python()
print("\n Starting development servers...")
print(f" - FastAPI backend: http://127.0.0.1:{port}")
print(f" - FastAPI backend: http://{host}:{port}")
print(" - Vite frontend: http://127.0.0.1:5173")
# Set environment for remote access if needed
env = os.environ.copy()
if host != "127.0.0.1":
env["AUTOCODER_ALLOW_REMOTE"] = "1"
# Start FastAPI
backend = subprocess.Popen([
str(venv_python), "-m", "uvicorn",
"server.main:app",
"--host", "127.0.0.1",
"--host", host,
"--port", str(port),
"--reload"
], cwd=str(ROOT))
], cwd=str(ROOT), env=env)
# Start Vite with API port env var for proxy configuration
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
vite_env = os.environ.copy()
vite_env = env.copy()
vite_env["VITE_API_PORT"] = str(port)
frontend = subprocess.Popen([
npm_cmd, "run", "dev"
@@ -263,15 +287,18 @@ def start_dev_server(port: int) -> tuple:
return backend, frontend
def start_production_server(port: int):
"""Start FastAPI server in production mode with hot reload."""
def start_production_server(port: int, host: str = "127.0.0.1"):
"""Start FastAPI server in production mode."""
venv_python = get_venv_python()
print(f"\n Starting server at http://127.0.0.1:{port} (with hot reload)")
print(f"\n Starting server at http://{host}:{port}")
# Set PYTHONASYNCIODEBUG to help with Windows subprocess issues
env = os.environ.copy()
# Enable remote access in server if not localhost
if host != "127.0.0.1":
env["AUTOCODER_ALLOW_REMOTE"] = "1"
# NOTE: --reload is NOT used because on Windows it breaks asyncio subprocess
# support (uvicorn's reload worker doesn't inherit the ProactorEventLoop policy).
# This affects Claude SDK which uses asyncio.create_subprocess_exec.
@@ -279,14 +306,34 @@ def start_production_server(port: int):
return subprocess.Popen([
str(venv_python), "-m", "uvicorn",
"server.main:app",
"--host", "127.0.0.1",
"--host", host,
"--port", str(port),
], cwd=str(ROOT), env=env)
def main() -> None:
"""Main entry point."""
dev_mode = "--dev" in sys.argv
parser = argparse.ArgumentParser(description="AutoCoder UI Launcher")
parser.add_argument("--dev", action="store_true", help="Run in development mode with Vite hot reload")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=None, help="Port to bind to (default: auto-detect from 8888)")
args = parser.parse_args()
dev_mode = args.dev
host = args.host
# Security warning for remote access
if host != "127.0.0.1":
print("\n" + "!" * 50)
print(" SECURITY WARNING")
print("!" * 50)
print(f" Remote access enabled on host: {host}")
print(" The AutoCoder UI will be accessible from other machines.")
print(" Ensure you understand the security implications:")
print(" - The agent has file system access to project directories")
print(" - The API can start/stop agents and modify files")
print(" - Consider using a firewall or VPN for protection")
print("!" * 50 + "\n")
print("=" * 50)
print(" AutoCoder UI Setup")
@@ -335,18 +382,20 @@ def main() -> None:
step = 5 if dev_mode else 6
print_step(step, total_steps, "Starting server")
port = find_available_port()
port = args.port if args.port else find_available_port()
try:
if dev_mode:
backend, frontend = start_dev_server(port)
backend, frontend = start_dev_server(port, host)
# Open browser to Vite dev server
# Open browser to Vite dev server (always localhost for Vite)
time.sleep(3)
webbrowser.open("http://127.0.0.1:5173")
print("\n" + "=" * 50)
print(" Development mode active")
if host != "127.0.0.1":
print(f" Backend accessible at: http://{host}:{port}")
print(" Press Ctrl+C to stop")
print("=" * 50)
@@ -362,14 +411,15 @@ def main() -> None:
backend.wait()
frontend.wait()
else:
server = start_production_server(port)
server = start_production_server(port, host)
# Open browser
# Open browser (only if localhost)
time.sleep(2)
webbrowser.open(f"http://127.0.0.1:{port}")
if host == "127.0.0.1":
webbrowser.open(f"http://127.0.0.1:{port}")
print("\n" + "=" * 50)
print(f" Server running at http://127.0.0.1:{port}")
print(f" Server running at http://{host}:{port}")
print(" Press Ctrl+C to stop")
print("=" * 50)

105
test_client.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Client Utility Tests
====================
Tests for the client module utility functions.
Run with: python test_client.py
"""
import os
import unittest
from client import convert_model_for_vertex
class TestConvertModelForVertex(unittest.TestCase):
"""Tests for convert_model_for_vertex function."""
def setUp(self):
"""Save original env state."""
self._orig_vertex = os.environ.get("CLAUDE_CODE_USE_VERTEX")
def tearDown(self):
"""Restore original env state."""
if self._orig_vertex is None:
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
else:
os.environ["CLAUDE_CODE_USE_VERTEX"] = self._orig_vertex
# --- Vertex AI disabled (default) ---
def test_returns_model_unchanged_when_vertex_disabled(self):
os.environ.pop("CLAUDE_CODE_USE_VERTEX", None)
self.assertEqual(
convert_model_for_vertex("claude-opus-4-5-20251101"),
"claude-opus-4-5-20251101",
)
def test_returns_model_unchanged_when_vertex_set_to_zero(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "0"
self.assertEqual(
convert_model_for_vertex("claude-opus-4-5-20251101"),
"claude-opus-4-5-20251101",
)
def test_returns_model_unchanged_when_vertex_set_to_empty(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = ""
self.assertEqual(
convert_model_for_vertex("claude-sonnet-4-5-20250929"),
"claude-sonnet-4-5-20250929",
)
# --- Vertex AI enabled: standard conversions ---
def test_converts_opus_model(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("claude-opus-4-5-20251101"),
"claude-opus-4-5@20251101",
)
def test_converts_sonnet_model(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("claude-sonnet-4-5-20250929"),
"claude-sonnet-4-5@20250929",
)
def test_converts_haiku_model(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("claude-3-5-haiku-20241022"),
"claude-3-5-haiku@20241022",
)
# --- Vertex AI enabled: already converted or non-matching ---
def test_already_vertex_format_unchanged(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("claude-opus-4-5@20251101"),
"claude-opus-4-5@20251101",
)
def test_non_claude_model_unchanged(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("gpt-4o"),
"gpt-4o",
)
def test_model_without_date_suffix_unchanged(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(
convert_model_for_vertex("claude-opus-4-5"),
"claude-opus-4-5",
)
def test_empty_string_unchanged(self):
os.environ["CLAUDE_CODE_USE_VERTEX"] = "1"
self.assertEqual(convert_model_for_vertex(""), "")
if __name__ == "__main__":
unittest.main()

426
test_dependency_resolver.py Normal file
View File

@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""
Dependency Resolver Tests
=========================
Tests for the dependency resolver functions including cycle detection.
Run with: python test_dependency_resolver.py
"""
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from api.dependency_resolver import (
are_dependencies_satisfied,
compute_scheduling_scores,
get_blocked_features,
get_blocking_dependencies,
get_ready_features,
resolve_dependencies,
would_create_circular_dependency,
)
def test_compute_scheduling_scores_simple_chain():
"""Test scheduling scores for a simple linear dependency chain."""
print("\nTesting compute_scheduling_scores with simple chain:")
features = [
{"id": 1, "priority": 1, "dependencies": []},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [2]},
]
scores = compute_scheduling_scores(features)
# All features should have scores
passed = True
for f in features:
if f["id"] not in scores:
print(f" FAIL: Feature {f['id']} missing from scores")
passed = False
if passed:
# Root feature (1) should have highest score (unblocks most)
if scores[1] > scores[2] > scores[3]:
print(" PASS: Root feature has highest score, leaf has lowest")
else:
print(f" FAIL: Expected scores[1] > scores[2] > scores[3], got {scores}")
passed = False
return passed
def test_compute_scheduling_scores_with_cycle():
"""Test that compute_scheduling_scores handles circular dependencies without hanging."""
print("\nTesting compute_scheduling_scores with circular dependencies:")
# Create a cycle: 1 -> 2 -> 3 -> 1
features = [
{"id": 1, "priority": 1, "dependencies": [3]},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [2]},
]
# Use timeout to detect infinite loop
def compute_with_timeout():
return compute_scheduling_scores(features)
start = time.time()
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(compute_with_timeout)
scores = future.result(timeout=5.0) # 5 second timeout
elapsed = time.time() - start
# Should complete quickly (< 1 second for 3 features)
if elapsed > 1.0:
print(f" FAIL: Took {elapsed:.2f}s (expected < 1s)")
return False
# All features should have scores (even cyclic ones)
if len(scores) == 3:
print(f" PASS: Completed in {elapsed:.3f}s with {len(scores)} scores")
return True
else:
print(f" FAIL: Expected 3 scores, got {len(scores)}")
return False
except FuturesTimeoutError:
print(" FAIL: Infinite loop detected (timed out after 5s)")
return False
def test_compute_scheduling_scores_self_reference():
"""Test scheduling scores with self-referencing dependency."""
print("\nTesting compute_scheduling_scores with self-reference:")
features = [
{"id": 1, "priority": 1, "dependencies": [1]}, # Self-reference
{"id": 2, "priority": 2, "dependencies": []},
]
start = time.time()
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(lambda: compute_scheduling_scores(features))
scores = future.result(timeout=5.0)
elapsed = time.time() - start
if elapsed > 1.0:
print(f" FAIL: Took {elapsed:.2f}s (expected < 1s)")
return False
if len(scores) == 2:
print(f" PASS: Completed in {elapsed:.3f}s with {len(scores)} scores")
return True
else:
print(f" FAIL: Expected 2 scores, got {len(scores)}")
return False
except FuturesTimeoutError:
print(" FAIL: Infinite loop detected (timed out after 5s)")
return False
def test_compute_scheduling_scores_complex_cycle():
"""Test scheduling scores with complex circular dependencies."""
print("\nTesting compute_scheduling_scores with complex cycle:")
# Features 1-3 form a cycle, feature 4 depends on 1
features = [
{"id": 1, "priority": 1, "dependencies": [3]},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [2]},
{"id": 4, "priority": 4, "dependencies": [1]}, # Outside cycle
]
start = time.time()
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(lambda: compute_scheduling_scores(features))
scores = future.result(timeout=5.0)
elapsed = time.time() - start
if elapsed > 1.0:
print(f" FAIL: Took {elapsed:.2f}s (expected < 1s)")
return False
if len(scores) == 4:
print(f" PASS: Completed in {elapsed:.3f}s with {len(scores)} scores")
return True
else:
print(f" FAIL: Expected 4 scores, got {len(scores)}")
return False
except FuturesTimeoutError:
print(" FAIL: Infinite loop detected (timed out after 5s)")
return False
def test_compute_scheduling_scores_diamond():
"""Test scheduling scores with diamond dependency pattern."""
print("\nTesting compute_scheduling_scores with diamond pattern:")
# 1
# / \
# 2 3
# \ /
# 4
features = [
{"id": 1, "priority": 1, "dependencies": []},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [1]},
{"id": 4, "priority": 4, "dependencies": [2, 3]},
]
scores = compute_scheduling_scores(features)
# Feature 1 should have highest score (unblocks 2, 3, and transitively 4)
if scores[1] > scores[2] and scores[1] > scores[3] and scores[1] > scores[4]:
# Feature 4 should have lowest score (leaf, unblocks nothing)
if scores[4] < scores[2] and scores[4] < scores[3]:
print(" PASS: Root has highest score, leaf has lowest")
return True
else:
print(f" FAIL: Leaf should have lowest score. Scores: {scores}")
return False
else:
print(f" FAIL: Root should have highest score. Scores: {scores}")
return False
def test_compute_scheduling_scores_empty():
"""Test scheduling scores with empty feature list."""
print("\nTesting compute_scheduling_scores with empty list:")
scores = compute_scheduling_scores([])
if scores == {}:
print(" PASS: Returns empty dict for empty input")
return True
else:
print(f" FAIL: Expected empty dict, got {scores}")
return False
def test_would_create_circular_dependency():
"""Test cycle detection for new dependencies."""
print("\nTesting would_create_circular_dependency:")
# Current dependencies: 2 depends on 1, 3 depends on 2
# Dependency chain: 3 -> 2 -> 1 (arrows mean "depends on")
features = [
{"id": 1, "priority": 1, "dependencies": []},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [2]},
]
passed = True
# source_id gains dependency on target_id
# Adding "1 depends on 3" would create cycle: 1 -> 3 -> 2 -> 1
if would_create_circular_dependency(features, 1, 3):
print(" PASS: Detected cycle when adding 1 depends on 3")
else:
print(" FAIL: Should detect cycle when adding 1 depends on 3")
passed = False
# Adding "3 depends on 1" would NOT create cycle (redundant but not circular)
if not would_create_circular_dependency(features, 3, 1):
print(" PASS: No false positive for 3 depends on 1")
else:
print(" FAIL: False positive for 3 depends on 1")
passed = False
# Self-reference should be detected
if would_create_circular_dependency(features, 1, 1):
print(" PASS: Detected self-reference")
else:
print(" FAIL: Should detect self-reference")
passed = False
return passed
def test_resolve_dependencies_with_cycle():
"""Test resolve_dependencies detects and reports cycles."""
print("\nTesting resolve_dependencies with cycle:")
# Create a cycle: 1 -> 2 -> 3 -> 1
features = [
{"id": 1, "priority": 1, "dependencies": [3]},
{"id": 2, "priority": 2, "dependencies": [1]},
{"id": 3, "priority": 3, "dependencies": [2]},
]
result = resolve_dependencies(features)
# Should report circular dependencies
if result["circular_dependencies"]:
print(f" PASS: Detected cycle: {result['circular_dependencies']}")
return True
else:
print(" FAIL: Should report circular dependencies")
return False
def test_are_dependencies_satisfied():
"""Test dependency satisfaction checking."""
print("\nTesting are_dependencies_satisfied:")
features = [
{"id": 1, "priority": 1, "dependencies": [], "passes": True},
{"id": 2, "priority": 2, "dependencies": [1], "passes": False},
{"id": 3, "priority": 3, "dependencies": [2], "passes": False},
]
passed = True
# Feature 1 has no deps, should be satisfied
if are_dependencies_satisfied(features[0], features):
print(" PASS: Feature 1 (no deps) is satisfied")
else:
print(" FAIL: Feature 1 should be satisfied")
passed = False
# Feature 2 depends on 1 which passes, should be satisfied
if are_dependencies_satisfied(features[1], features):
print(" PASS: Feature 2 (dep on passing) is satisfied")
else:
print(" FAIL: Feature 2 should be satisfied")
passed = False
# Feature 3 depends on 2 which doesn't pass, should NOT be satisfied
if not are_dependencies_satisfied(features[2], features):
print(" PASS: Feature 3 (dep on non-passing) is not satisfied")
else:
print(" FAIL: Feature 3 should not be satisfied")
passed = False
return passed
def test_get_blocking_dependencies():
"""Test getting blocking dependency IDs."""
print("\nTesting get_blocking_dependencies:")
features = [
{"id": 1, "priority": 1, "dependencies": [], "passes": True},
{"id": 2, "priority": 2, "dependencies": [], "passes": False},
{"id": 3, "priority": 3, "dependencies": [1, 2], "passes": False},
]
blocking = get_blocking_dependencies(features[2], features)
# Only feature 2 should be blocking (1 passes)
if blocking == [2]:
print(" PASS: Correctly identified blocking dependency")
return True
else:
print(f" FAIL: Expected [2], got {blocking}")
return False
def test_get_ready_features():
"""Test getting ready features."""
print("\nTesting get_ready_features:")
features = [
{"id": 1, "priority": 1, "dependencies": [], "passes": True},
{"id": 2, "priority": 2, "dependencies": [], "passes": False, "in_progress": False},
{"id": 3, "priority": 3, "dependencies": [1], "passes": False, "in_progress": False},
{"id": 4, "priority": 4, "dependencies": [2], "passes": False, "in_progress": False},
]
ready = get_ready_features(features)
# Features 2 and 3 should be ready
# Feature 1 passes, feature 4 blocked by 2
ready_ids = [f["id"] for f in ready]
if 2 in ready_ids and 3 in ready_ids:
if 1 not in ready_ids and 4 not in ready_ids:
print(f" PASS: Ready features: {ready_ids}")
return True
else:
print(f" FAIL: Should not include passing/blocked. Got: {ready_ids}")
return False
else:
print(f" FAIL: Should include 2 and 3. Got: {ready_ids}")
return False
def test_get_blocked_features():
"""Test getting blocked features."""
print("\nTesting get_blocked_features:")
features = [
{"id": 1, "priority": 1, "dependencies": [], "passes": False},
{"id": 2, "priority": 2, "dependencies": [1], "passes": False},
]
blocked = get_blocked_features(features)
# Feature 2 should be blocked by 1
if len(blocked) == 1 and blocked[0]["id"] == 2:
if blocked[0]["blocked_by"] == [1]:
print(" PASS: Correctly identified blocked feature")
return True
else:
print(f" FAIL: Wrong blocked_by: {blocked[0]['blocked_by']}")
return False
else:
print(f" FAIL: Expected feature 2 blocked, got: {blocked}")
return False
def run_all_tests():
"""Run all tests and report results."""
print("=" * 60)
print("Dependency Resolver Tests")
print("=" * 60)
tests = [
test_compute_scheduling_scores_simple_chain,
test_compute_scheduling_scores_with_cycle,
test_compute_scheduling_scores_self_reference,
test_compute_scheduling_scores_complex_cycle,
test_compute_scheduling_scores_diamond,
test_compute_scheduling_scores_empty,
test_would_create_circular_dependency,
test_resolve_dependencies_with_cycle,
test_are_dependencies_satisfied,
test_get_blocking_dependencies,
test_get_ready_features,
test_get_blocked_features,
]
passed = 0
failed = 0
for test in tests:
try:
if test():
passed += 1
else:
failed += 1
except Exception as e:
print(f" ERROR: {e}")
failed += 1
print("\n" + "=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View File

@@ -18,11 +18,13 @@ from security import (
bash_security_hook,
extract_commands,
get_effective_commands,
get_effective_pkill_processes,
load_org_config,
load_project_commands,
matches_pattern,
validate_chmod_command,
validate_init_script,
validate_pkill_command,
validate_project_command,
)
@@ -105,6 +107,8 @@ def test_extract_commands():
("/usr/bin/node script.js", ["node"]),
("VAR=value ls", ["ls"]),
("git status || git init", ["git", "git"]),
# Fallback parser test: complex nested quotes that break shlex
('docker exec container php -r "echo \\"test\\";"', ["docker"]),
]
for cmd, expected in test_cases:
@@ -451,6 +455,21 @@ commands:
print(" FAIL: Non-allowed command 'rustc' should be blocked")
failed += 1
# Test 4: Empty command name is rejected
config_path.write_text("""version: 1
commands:
- name: ""
description: Empty name should be rejected
""")
result = load_project_commands(project_dir)
if result is None:
print(" PASS: Empty command name rejected in project config")
passed += 1
else:
print(" FAIL: Empty command name should be rejected in project config")
print(f" Got: {result}")
failed += 1
return passed, failed
@@ -670,6 +689,240 @@ blocked_commands:
return passed, failed
def test_pkill_extensibility():
"""Test that pkill processes can be extended via config."""
print("\nTesting pkill process extensibility:\n")
passed = 0
failed = 0
# Test 1: Default processes work without config
allowed, reason = validate_pkill_command("pkill node")
if allowed:
print(" PASS: Default process 'node' allowed")
passed += 1
else:
print(f" FAIL: Default process 'node' should be allowed: {reason}")
failed += 1
# Test 2: Non-default process blocked without config
allowed, reason = validate_pkill_command("pkill python")
if not allowed:
print(" PASS: Non-default process 'python' blocked without config")
passed += 1
else:
print(" FAIL: Non-default process 'python' should be blocked without config")
failed += 1
# Test 3: Extra processes allowed when passed
allowed, reason = validate_pkill_command("pkill python", extra_processes={"python"})
if allowed:
print(" PASS: Extra process 'python' allowed when configured")
passed += 1
else:
print(f" FAIL: Extra process 'python' should be allowed when configured: {reason}")
failed += 1
# Test 4: Default processes still work with extra processes
allowed, reason = validate_pkill_command("pkill npm", extra_processes={"python"})
if allowed:
print(" PASS: Default process 'npm' still works with extra processes")
passed += 1
else:
print(f" FAIL: Default process should still work: {reason}")
failed += 1
# Test 5: Test get_effective_pkill_processes with org config
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
org_dir = Path(tmphome) / ".autocoder"
org_dir.mkdir()
org_config_path = org_dir / "config.yaml"
# Create org config with extra pkill processes
org_config_path.write_text("""version: 1
pkill_processes:
- python
- uvicorn
""")
project_dir = Path(tmpproject)
processes = get_effective_pkill_processes(project_dir)
# Should include defaults + org processes
if "node" in processes and "python" in processes and "uvicorn" in processes:
print(" PASS: Org pkill_processes merged with defaults")
passed += 1
else:
print(f" FAIL: Expected node, python, uvicorn in {processes}")
failed += 1
# Test 6: Test get_effective_pkill_processes with project config
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
project_dir = Path(tmpproject)
project_autocoder = project_dir / ".autocoder"
project_autocoder.mkdir()
project_config = project_autocoder / "allowed_commands.yaml"
# Create project config with extra pkill processes
project_config.write_text("""version: 1
commands: []
pkill_processes:
- gunicorn
- flask
""")
processes = get_effective_pkill_processes(project_dir)
# Should include defaults + project processes
if "node" in processes and "gunicorn" in processes and "flask" in processes:
print(" PASS: Project pkill_processes merged with defaults")
passed += 1
else:
print(f" FAIL: Expected node, gunicorn, flask in {processes}")
failed += 1
# Test 7: Integration test - pkill python blocked by default
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
project_dir = Path(tmpproject)
input_data = {"tool_name": "Bash", "tool_input": {"command": "pkill python"}}
context = {"project_dir": str(project_dir)}
result = asyncio.run(bash_security_hook(input_data, context=context))
if result.get("decision") == "block":
print(" PASS: pkill python blocked without config")
passed += 1
else:
print(" FAIL: pkill python should be blocked without config")
failed += 1
# Test 8: Integration test - pkill python allowed with org config
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
org_dir = Path(tmphome) / ".autocoder"
org_dir.mkdir()
org_config_path = org_dir / "config.yaml"
org_config_path.write_text("""version: 1
pkill_processes:
- python
""")
project_dir = Path(tmpproject)
input_data = {"tool_name": "Bash", "tool_input": {"command": "pkill python"}}
context = {"project_dir": str(project_dir)}
result = asyncio.run(bash_security_hook(input_data, context=context))
if result.get("decision") != "block":
print(" PASS: pkill python allowed with org config")
passed += 1
else:
print(f" FAIL: pkill python should be allowed with org config: {result}")
failed += 1
# Test 9: Regex metacharacters should be rejected in pkill_processes
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
org_dir = Path(tmphome) / ".autocoder"
org_dir.mkdir()
org_config_path = org_dir / "config.yaml"
# Try to register a regex pattern (should be rejected)
org_config_path.write_text("""version: 1
pkill_processes:
- ".*"
""")
config = load_org_config()
if config is None:
print(" PASS: Regex pattern '.*' rejected in pkill_processes")
passed += 1
else:
print(" FAIL: Regex pattern '.*' should be rejected")
failed += 1
# Test 10: Valid process names with dots/underscores/hyphens should be accepted
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
org_dir = Path(tmphome) / ".autocoder"
org_dir.mkdir()
org_config_path = org_dir / "config.yaml"
# Valid names with special chars
org_config_path.write_text("""version: 1
pkill_processes:
- my-app
- app_server
- node.js
""")
config = load_org_config()
if config is not None and config.get("pkill_processes") == ["my-app", "app_server", "node.js"]:
print(" PASS: Valid process names with dots/underscores/hyphens accepted")
passed += 1
else:
print(f" FAIL: Valid process names should be accepted: {config}")
failed += 1
# Test 11: Names with spaces should be rejected
with tempfile.TemporaryDirectory() as tmphome:
with tempfile.TemporaryDirectory() as tmpproject:
with temporary_home(tmphome):
org_dir = Path(tmphome) / ".autocoder"
org_dir.mkdir()
org_config_path = org_dir / "config.yaml"
org_config_path.write_text("""version: 1
pkill_processes:
- "my app"
""")
config = load_org_config()
if config is None:
print(" PASS: Process name with space rejected")
passed += 1
else:
print(" FAIL: Process name with space should be rejected")
failed += 1
# Test 12: Multiple patterns - all must be allowed (BSD behavior)
# On BSD, "pkill node sshd" would kill both, so we must validate all patterns
allowed, reason = validate_pkill_command("pkill node npm")
if allowed:
print(" PASS: Multiple allowed patterns accepted")
passed += 1
else:
print(f" FAIL: Multiple allowed patterns should be accepted: {reason}")
failed += 1
# Test 13: Multiple patterns - block if any is disallowed
allowed, reason = validate_pkill_command("pkill node sshd")
if not allowed:
print(" PASS: Multiple patterns blocked when one is disallowed")
passed += 1
else:
print(" FAIL: Should block when any pattern is disallowed")
failed += 1
# Test 14: Multiple patterns - only first allowed, second disallowed
allowed, reason = validate_pkill_command("pkill npm python")
if not allowed:
print(" PASS: Multiple patterns blocked (first allowed, second not)")
passed += 1
else:
print(" FAIL: Should block when second pattern is disallowed")
failed += 1
return passed, failed
def main():
print("=" * 70)
print(" SECURITY HOOK TESTS")
@@ -733,6 +986,11 @@ def main():
passed += org_block_passed
failed += org_block_failed
# Test pkill process extensibility
pkill_passed, pkill_failed = test_pkill_extensibility()
passed += pkill_passed
failed += pkill_failed
# Commands that SHOULD be blocked
print("\nCommands that should be BLOCKED:\n")
dangerous = [

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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<body>
<div id="root"></div>

1015
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"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.60.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@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-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@eslint/js": "^9.19.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/dagre": "^0.7.53",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"tailwindcss": "^4.0.0-beta.4",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
"@types/node": "^22.12.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.14.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.7.3",
"typescript-eslint": "^8.23.0",
"vite": "^7.3.0"
}
}

BIN
ui/public/ollama.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -4,6 +4,7 @@ import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/u
import { useProjectWebSocket } from './hooks/useWebSocket'
import { useFeatureSound } from './hooks/useFeatureSound'
import { useCelebration } from './hooks/useCelebration'
import { useTheme } from './hooks/useTheme'
import { ProjectSelector } from './components/ProjectSelector'
import { KanbanBoard } from './components/KanbanBoard'
import { AgentControl } from './components/AgentControl'
@@ -24,14 +25,22 @@ import { DevServerControl } from './components/DevServerControl'
import { ViewToggle, type ViewMode } from './components/ViewToggle'
import { DependencyGraph } from './components/DependencyGraph'
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
import { ThemeSelector } from './components/ThemeSelector'
import { ResetProjectModal } from './components/ResetProjectModal'
import { ProjectSetupRequired } from './components/ProjectSetupRequired'
import { getDependencyGraph } from './lib/api'
import { Loader2, Settings, Moon, Sun } from 'lucide-react'
import { Loader2, Settings, Moon, Sun, RotateCcw } from 'lucide-react'
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 DARK_MODE_KEY = 'autocoder-dark-mode'
const VIEW_MODE_KEY = 'autocoder-view-mode'
// Bottom padding for main content when debug panel is collapsed (40px header + 8px margin)
const COLLAPSED_DEBUG_PANEL_CLEARANCE = 48
function App() {
// Initialize selected project from localStorage
const [selectedProject, setSelectedProject] = useState<string | null>(() => {
@@ -52,14 +61,8 @@ function App() {
const [showSettings, setShowSettings] = useState(false)
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false)
const [showResetModal, setShowResetModal] = useState(false)
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>(() => {
try {
const stored = localStorage.getItem(VIEW_MODE_KEY)
@@ -75,6 +78,7 @@ function App() {
const { data: settings } = useSettings()
useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject)
const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme()
// Get has_spec from the selected project
const selectedProjectData = projects?.find(p => p.name === selectedProject)
@@ -88,20 +92,6 @@ function App() {
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
useEffect(() => {
try {
@@ -216,10 +206,18 @@ function App() {
setShowKeyboardHelp(true)
}
// R : Open reset modal (when project selected and agent not running)
if ((e.key === 'r' || e.key === 'R') && selectedProject && wsState.agentStatus !== 'running') {
e.preventDefault()
setShowResetModal(true)
}
// Escape : Close modals
if (e.key === 'Escape') {
if (showKeyboardHelp) {
setShowKeyboardHelp(false)
} else if (showResetModal) {
setShowResetModal(false)
} else if (showExpandProject) {
setShowExpandProject(false)
} else if (showSettings) {
@@ -238,7 +236,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode])
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus])
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -256,9 +254,9 @@ function App() {
}
return (
<div className="min-h-screen bg-neo-bg">
<div className="min-h-screen bg-background">
{/* Header */}
<header className="bg-neo-card text-neo-text border-b-4 border-neo-border">
<header className="sticky top-0 z-50 bg-card/80 backdrop-blur-md text-foreground border-b-2 border-border">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo and Title */}
@@ -281,6 +279,7 @@ function App() {
<AgentControl
projectName={selectedProject}
status={wsState.agentStatus}
defaultConcurrency={selectedProjectData?.default_concurrency}
/>
<DevServerControl
@@ -289,36 +288,67 @@ function App() {
url={wsState.devServerUrl}
/>
<button
<Button
onClick={() => setShowSettings(true)}
className="neo-btn text-sm py-2 px-3"
variant="outline"
size="sm"
title="Settings (,)"
aria-label="Open Settings"
>
<Settings size={18} />
</button>
</Button>
<Button
onClick={() => setShowResetModal(true)}
variant="outline"
size="sm"
title="Reset Project (R)"
aria-label="Reset Project"
disabled={wsState.agentStatus === 'running'}
>
<RotateCcw size={18} />
</Button>
{/* Ollama Mode Indicator */}
{settings?.ollama_mode && (
<div
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)"
>
<img src="/ollama.png" alt="Ollama" className="w-5 h-5" />
<span className="text-xs font-bold text-foreground">Ollama</span>
</div>
)}
{/* GLM Mode Badge */}
{settings?.glm_mode && (
<span
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"
<Badge
className="bg-purple-500 text-white hover:bg-purple-600"
title="Using GLM API (configured via .env)"
>
GLM
</span>
</Badge>
)}
</>
)}
{/* Theme selector */}
<ThemeSelector
themes={themes}
currentTheme={theme}
onThemeChange={setTheme}
/>
{/* Dark mode toggle - always visible */}
<button
onClick={() => setDarkMode(!darkMode)}
className="neo-btn text-sm py-2 px-3"
<Button
onClick={toggleDarkMode}
variant="outline"
size="sm"
title="Toggle dark mode"
aria-label="Toggle dark mode"
>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
</Button>
</div>
</div>
</div>
@@ -327,17 +357,27 @@ function App() {
{/* Main Content */}
<main
className="max-w-7xl mx-auto px-4 py-8"
style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : undefined }}
style={{ paddingBottom: debugOpen ? debugPanelHeight + 32 : COLLAPSED_DEBUG_PANEL_CLEARANCE }}
>
{!selectedProject ? (
<div className="neo-empty-state mt-12">
<div className="text-center mt-12">
<h2 className="font-display text-2xl font-bold mb-2">
Welcome to AutoCoder
</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.
</p>
</div>
) : !hasSpec ? (
<ProjectSetupRequired
projectName={selectedProject}
projectPath={selectedProjectData?.path}
onCreateWithClaude={() => setShowSpecChat(true)}
onEditManually={() => {
// Open debug panel for the user to see the project path
setDebugOpen(true)
}}
/>
) : (
<div className="space-y-8">
{/* Progress Dashboard */}
@@ -370,15 +410,17 @@ function App() {
features.in_progress.length === 0 &&
features.done.length === 0 &&
wsState.agentStatus === 'running' && (
<div className="neo-card p-8 text-center">
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-neo-progress" />
<h3 className="font-display font-bold text-xl mb-2">
Initializing Features...
</h3>
<p className="text-neo-text-secondary">
The agent is reading your spec and creating features. This may take a moment.
</p>
</div>
<Card className="p-8 text-center">
<CardContent className="p-0">
<Loader2 size={32} className="animate-spin mx-auto mb-4 text-primary" />
<h3 className="font-display font-bold text-xl mb-2">
Initializing Features...
</h3>
<p className="text-muted-foreground">
The agent is reading your spec and creating features. This may take a moment.
</p>
</CardContent>
</Card>
)}
{/* View Toggle - only show when there are features */}
@@ -400,7 +442,7 @@ function App() {
hasSpec={hasSpec}
/>
) : (
<div className="neo-card overflow-hidden" style={{ height: '600px' }}>
<Card className="overflow-hidden" style={{ height: '600px' }}>
{graphData ? (
<DependencyGraph
graphData={graphData}
@@ -409,10 +451,10 @@ function App() {
/>
) : (
<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>
</Card>
)}
</div>
)}
@@ -450,7 +492,7 @@ function App() {
{/* Spec Creation Chat - for creating spec from empty kanban */}
{showSpecChat && selectedProject && (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]">
<div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat
projectName={selectedProject}
onComplete={() => {
@@ -497,13 +539,24 @@ function App() {
)}
{/* Settings Modal */}
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
{/* Keyboard Shortcuts Help */}
{showKeyboardHelp && (
<KeyboardShortcutsHelp onClose={() => setShowKeyboardHelp(false)} />
<KeyboardShortcutsHelp isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} />
{/* Reset Project Modal */}
{showResetModal && selectedProject && (
<ResetProjectModal
isOpen={showResetModal}
projectName={selectedProject}
onClose={() => setShowResetModal(false)}
onResetComplete={(wasFullReset) => {
// If full reset, the spec was deleted - show spec creation chat
if (wasFullReset) {
setShowSpecChat(true)
}
}}
/>
)}
{/* Celebration Overlay - shows when a feature is completed by an agent */}

View File

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

View File

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

View File

@@ -606,7 +606,7 @@ export function AgentAvatar({ name, state, size = 'md', showName = false }: Agen
<SvgComponent colors={colors} size={svgSize} />
</div>
{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}
</span>
)}

View File

@@ -3,6 +3,9 @@ import { useState } from 'react'
import { createPortal } from 'react-dom'
import { AgentAvatar } from './AgentAvatar'
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 {
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 {
switch (state) {
case 'success':
return 'text-neo-done'
return 'text-primary'
case 'error':
return 'text-neo-pending' // Yellow - just pivoting, not a real error
return 'text-yellow-600'
case 'struggling':
return 'text-orange-500' // Orange - working hard, being persistent
return 'text-orange-500'
case 'working':
case 'testing':
return 'text-neo-progress'
return 'text-primary'
case 'thinking':
return 'text-neo-pending'
return 'text-yellow-600'
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') {
return {
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,
}
}
// Default to coding
return {
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,
}
}
@@ -74,75 +76,66 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
const TypeIcon = typeBadge.icon
return (
<div
className={`
neo-card p-3 min-w-[180px] max-w-[220px]
${isActive ? 'animate-pulse-neo' : ''}
transition-all duration-300
`}
>
{/* Agent type badge */}
<div className="flex justify-end mb-1">
<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>
<Card className={`min-w-[180px] max-w-[220px] py-3 ${isActive ? 'animate-pulse' : ''}`}>
<CardContent className="p-3 space-y-2">
{/* Agent type badge */}
<div className="flex justify-end">
<Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
<TypeIcon size={10} />
{typeBadge.label}
</Badge>
</div>
{/* Header with avatar and name */}
<div className="flex items-center gap-2 mb-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="font-display font-bold text-sm truncate">
{agent.agentName}
{/* Header with avatar and name */}
<div className="flex items-center gap-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{agent.agentName}
</div>
<div className={`text-xs ${getStateColor(agent.state)}`}>
{getStateText(agent.state)}
</div>
</div>
<div className={`text-xs ${getStateColor(agent.state)}`}>
{getStateText(agent.state)}
</div>
</div>
{/* Log button */}
{hasLogs && onShowLogs && (
<button
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}
{/* Log button */}
{hasLogs && onShowLogs && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => onShowLogs(agent.agentIndex)}
title={`View logs (${agent.logs?.length || 0} entries)`}
>
{agent.thought}
</p>
<ScrollText size={14} className="text-muted-foreground" />
</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>
{/* 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']) => {
switch (type) {
case 'error':
return 'text-neo-danger'
return 'text-destructive'
case 'state_change':
return 'text-neo-progress'
return 'text-primary'
default:
return 'text-neo-text'
return 'text-foreground'
}
}
// Use portal to render modal at document body level (avoids overflow:hidden issues)
return createPortal(
<div
className="fixed inset-0 flex items-center justify-center p-4 bg-black/50"
style={{ zIndex: 9999 }}
onClick={(e) => {
// Close when clicking backdrop
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 */}
<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">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div>
<div className="flex items-center gap-2">
<h2 className="font-display font-bold text-lg">
<h2 className="font-semibold text-lg">
{agent.agentName} Logs
</h2>
<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}
`}
>
<Badge variant="outline" className={`text-[10px] ${typeBadge.className}`}>
<TypeIcon size={10} />
{typeBadge.label}
</span>
</Badge>
</div>
<p className="text-sm text-neo-text-secondary">
<p className="text-sm text-muted-foreground">
Feature #{agent.featureId}: {agent.featureName}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="neo-button neo-button-sm flex items-center gap-1"
title="Copy all logs"
>
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={onClose}
className="p-2 hover:bg-neo-bg-secondary rounded transition-colors"
>
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<X size={20} />
</button>
</Button>
</div>
</div>
{/* Log content */}
<div className="flex-1 overflow-auto p-4 bg-neo-bg-secondary font-mono text-xs">
{logs.length === 0 ? (
<p className="text-neo-text-secondary italic">No logs available</p>
) : (
<div className="space-y-1">
{logs.map((log, idx) => (
<div className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/50">
<div className="font-mono text-xs space-y-1">
{logs.length === 0 ? (
<p className="text-muted-foreground italic">No logs available</p>
) : (
logs.map((log, idx) => (
<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()}]
</span>{' '}
{log.line}
</div>
))}
</div>
)}
))
)}
</div>
</div>
{/* 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
</div>
</div>
</Card>
</div>,
document.body
)

View File

@@ -1,26 +1,62 @@
import { useState } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react'
import {
useStartAgent,
useStopAgent,
useSettings,
useUpdateProjectSettings,
} from '../hooks/useProjects'
import { useNextScheduledRun } from '../hooks/useSchedules'
import { formatNextRun, formatEndTime } from '../lib/timeUtils'
import { ScheduleModal } from './ScheduleModal'
import type { AgentStatus } from '../lib/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface AgentControlProps {
projectName: string
status: AgentStatus
defaultConcurrency?: number
}
export function AgentControl({ projectName, status }: AgentControlProps) {
export function AgentControl({ projectName, status, defaultConcurrency = 3 }: AgentControlProps) {
const { data: settings } = useSettings()
const yoloMode = settings?.yolo_mode ?? false
// Concurrency: 1 = single agent, 2-5 = parallel
const [concurrency, setConcurrency] = useState(3)
const [concurrency, setConcurrency] = useState(defaultConcurrency)
// Sync concurrency when project changes or defaultConcurrency updates
useEffect(() => {
setConcurrency(defaultConcurrency)
}, [defaultConcurrency])
// Debounced save for concurrency changes
const updateProjectSettings = useUpdateProjectSettings(projectName)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleConcurrencyChange = useCallback((newConcurrency: number) => {
setConcurrency(newConcurrency)
// Clear previous timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Debounce save (500ms)
saveTimeoutRef.current = setTimeout(() => {
updateProjectSettings.mutate({ default_concurrency: newConcurrency })
}, 500)
}, [updateProjectSettings])
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
const startAgent = useStartAgent(projectName)
const stopAgent = useStopAgent(projectName)
@@ -30,18 +66,17 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
const isLoading = startAgent.isPending || stopAgent.isPending
const isRunning = status === 'running' || status === 'paused'
const isLoadingStatus = status === 'loading' // Status unknown, waiting for WebSocket
const isLoadingStatus = status === 'loading'
const isParallel = concurrency > 1
const handleStart = () => startAgent.mutate({
yoloMode,
parallelMode: isParallel,
maxConcurrency: concurrency, // Always pass concurrency (1-5)
maxConcurrency: concurrency,
testingAgentRatio: settings?.testing_agent_ratio,
})
const handleStop = () => stopAgent.mutate()
// Simplified: either show Start (when stopped/crashed), Stop (when running/paused), or loading spinner
const isStopped = status === 'stopped' || status === 'crashed'
return (
@@ -50,19 +85,19 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
{/* Concurrency slider - visible when stopped */}
{isStopped && (
<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
type="range"
min={1}
max={5}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))}
onChange={(e) => handleConcurrencyChange(Number(e.target.value))}
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' : ''}`}
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
</span>
</div>
@@ -70,80 +105,71 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
{/* Show concurrency indicator when running with multiple agents */}
{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} />
<span>{concurrency}x</span>
</div>
{concurrency}x
</Badge>
)}
{/* Schedule status display */}
{nextRun?.is_currently_running && nextRun.next_end && (
<div className="flex items-center gap-2 text-sm text-[var(--color-neo-done)] font-bold">
<Clock size={16} className="flex-shrink-0" />
<span>Running until {formatEndTime(nextRun.next_end)}</span>
</div>
<Badge variant="default" className="gap-1">
<Clock size={14} />
Running until {formatEndTime(nextRun.next_end)}
</Badge>
)}
{!nextRun?.is_currently_running && nextRun?.next_start && (
<div className="flex items-center gap-2 text-sm text-gray-900 dark:text-white font-bold">
<Clock size={16} className="flex-shrink-0" />
<span>Next: {formatNextRun(nextRun.next_start)}</span>
</div>
<Badge variant="secondary" className="gap-1">
<Clock size={14} />
Next: {formatNextRun(nextRun.next_start)}
</Badge>
)}
{/* Start/Stop button */}
{isLoadingStatus ? (
<button
disabled
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
title="Loading agent status..."
aria-label="Loading agent status"
>
<Button disabled variant="outline" size="sm">
<Loader2 size={18} className="animate-spin" />
</button>
</Button>
) : isStopped ? (
<button
<Button
onClick={handleStart}
disabled={isLoading}
className={`neo-btn text-sm py-2 px-3 ${
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
}`}
variant={yoloMode ? 'secondary' : 'default'}
size="sm"
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Play size={18} />
)}
</button>
</Button>
) : (
<button
<Button
onClick={handleStop}
disabled={isLoading}
className={`neo-btn text-sm py-2 px-3 ${
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
}`}
variant="destructive"
size="sm"
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Square size={18} />
)}
</button>
</Button>
)}
{/* Clock button to open schedule modal */}
<button
<Button
variant="outline"
size="sm"
onClick={() => setShowScheduleModal(true)}
className="neo-btn text-sm py-2 px-3"
title="Manage schedules"
aria-label="Manage agent schedules"
>
<Clock size={18} />
</button>
</Button>
</div>
{/* Schedule Modal */}

View File

@@ -4,6 +4,9 @@ import { AgentCard, AgentLogModal } from './AgentCard'
import { ActivityFeed } from './ActivityFeed'
import { OrchestratorStatusCard } from './OrchestratorStatusCard'
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'
@@ -35,7 +38,6 @@ export function AgentMissionControl({
return false
}
})
// State for log modal
const [selectedAgentForLogs, setSelectedAgentForLogs] = useState<ActiveAgent | null>(null)
const toggleActivityCollapsed = () => {
@@ -54,18 +56,18 @@ export function AgentMissionControl({
}
return (
<div className="neo-card mb-6 overflow-hidden">
<Card className="mb-6 overflow-hidden py-0">
{/* Header */}
<button
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">
<Rocket size={20} className="text-neo-text-on-bright" />
<span className="font-display font-bold text-neo-text-on-bright uppercase tracking-wide">
<Rocket size={20} className="text-primary-foreground" />
<span className="font-semibold text-primary-foreground uppercase tracking-wide">
Mission Control
</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} ${agents.length === 1 ? 'agent' : 'agents'} active`
: orchestratorStatus?.state === 'initializing'
@@ -74,12 +76,12 @@ export function AgentMissionControl({
? 'Complete'
: 'Orchestrating'
}
</span>
</Badge>
</div>
{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>
@@ -90,7 +92,7 @@ export function AgentMissionControl({
${isExpanded ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'}
`}
>
<div className="p-4">
<CardContent className="p-4">
{/* Orchestrator Status Card */}
{orchestratorStatus && (
<OrchestratorStatusCard status={orchestratorStatus} />
@@ -98,7 +100,7 @@ export function AgentMissionControl({
{/* Agent Cards Row */}
{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) => (
<AgentCard
key={`agent-${agent.agentIndex}`}
@@ -116,24 +118,26 @@ export function AgentMissionControl({
{/* Collapsible Activity Feed */}
{recentActivity.length > 0 && (
<div className="mt-4 pt-4 border-t-2 border-neo-border/30">
<button
<div className="mt-4 pt-4 border-t">
<Button
variant="ghost"
size="sm"
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" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
<Activity size={14} className="text-muted-foreground" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity
</span>
<span className="text-xs text-neo-muted">
<span className="text-xs text-muted-foreground">
({recentActivity.length})
</span>
{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
className={`
transition-all duration-200 ease-out overflow-hidden
@@ -144,7 +148,7 @@ export function AgentMissionControl({
</div>
</div>
)}
</div>
</CardContent>
</div>
{/* Log Modal */}
@@ -155,6 +159,6 @@ export function AgentMissionControl({
onClose={() => setSelectedAgentForLogs(null)}
/>
)}
</div>
</Card>
)
}

View File

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

View File

@@ -12,6 +12,9 @@ import { useAssistantChat } from '../hooks/useAssistantChat'
import { ChatMessage as ChatMessageComponent } from './ChatMessage'
import { ConversationHistory } from './ConversationHistory'
import type { ChatMessage } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
interface AssistantChatProps {
projectName: string
@@ -132,7 +135,7 @@ export function AssistantChat({
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isSubmitEnter(e)) {
e.preventDefault()
handleSend()
}
@@ -167,28 +170,28 @@ export function AssistantChat({
return (
<div className="flex flex-col h-full">
{/* 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 */}
<div className="flex items-center gap-1 relative">
<button
<Button
variant="ghost"
size="icon"
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"
disabled={isLoading}
>
<Plus size={16} />
</button>
<button
</Button>
<Button
variant={showHistory ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setShowHistory(!showHistory)}
className={`neo-btn neo-btn-ghost p-1.5 ${
showHistory
? 'text-[var(--color-neo-text)] bg-[var(--color-neo-pending)]'
: 'text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-text)]'
}`}
className="h-8 w-8"
title="Conversation history"
>
<History size={16} />
</button>
</Button>
{/* History dropdown */}
<ConversationHistory
@@ -204,34 +207,34 @@ export function AssistantChat({
<div className="flex items-center gap-2">
{connectionStatus === 'connected' ? (
<>
<Wifi size={14} className="text-[var(--color-neo-done)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connected</span>
<Wifi size={14} className="text-green-500" />
<span className="text-xs text-muted-foreground">Connected</span>
</>
) : connectionStatus === 'connecting' ? (
<>
<Loader2 size={14} className="text-[var(--color-neo-progress)] animate-spin" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Connecting...</span>
<Loader2 size={14} className="text-primary animate-spin" />
<span className="text-xs text-muted-foreground">Connecting...</span>
</>
) : (
<>
<WifiOff size={14} className="text-[var(--color-neo-danger)]" />
<span className="text-xs text-[var(--color-neo-text-secondary)]">Disconnected</span>
<WifiOff size={14} className="text-destructive" />
<span className="text-xs text-muted-foreground">Disconnected</span>
</>
)}
</div>
</div>
{/* Messages area */}
<div className="flex-1 overflow-y-auto bg-[var(--color-neo-bg)]">
<div className="flex-1 overflow-y-auto bg-background">
{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">
<Loader2 size={16} className="animate-spin" />
<span>Loading conversation...</span>
</div>
</div>
) : 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 ? (
<div className="flex items-center gap-2">
<Loader2 size={16} className="animate-spin" />
@@ -253,12 +256,12 @@ export function AssistantChat({
{/* Loading indicator */}
{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="flex items-center gap-2 text-[var(--color-neo-text-secondary)] text-sm">
<div className="px-4 py-2 border-t border-border bg-background">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<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-[var(--color-neo-progress)] 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: '0ms' }} />
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>Thinking...</span>
</div>
@@ -266,33 +269,21 @@ export function AssistantChat({
)}
{/* 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">
<textarea
<Textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
disabled={isLoading || isLoadingConversation || connectionStatus !== 'connected'}
className="
flex-1
neo-input
resize-none
min-h-[44px]
max-h-[120px]
py-2.5
"
className="flex-1 resize-none min-h-[44px] max-h-[120px]"
rows={1}
/>
<button
<Button
onClick={handleSend}
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"
>
{isLoading ? (
@@ -300,9 +291,9 @@ export function AssistantChat({
) : (
<Send size={18} />
)}
</button>
</Button>
</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
</p>
</div>

View File

@@ -3,6 +3,7 @@
*/
import { MessageCircle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface AssistantFABProps {
onClick: () => void
@@ -11,24 +12,14 @@ interface AssistantFABProps {
export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) {
return (
<button
<Button
onClick={onClick}
className={`
fixed bottom-6 right-6 z-50
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' : ''}
`}
size="icon"
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"
title={isOpen ? 'Close Assistant (Press A)' : 'Open Assistant (Press A)'}
aria-label={isOpen ? 'Close Assistant' : 'Open Assistant'}
>
{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 { useConversation } from '../hooks/useConversations'
import type { ChatMessage } from '../lib/types'
import { Button } from '@/components/ui/button'
interface AssistantPanelProps {
projectName: string
@@ -49,11 +50,23 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
)
// Fetch conversation details when we have an ID
const { data: conversationDetail, isLoading: isLoadingConversation } = useConversation(
const { data: conversationDetail, isLoading: isLoadingConversation, error: conversationError } = useConversation(
projectName,
conversationId
)
// Clear stored conversation ID if it no longer exists (404 error)
useEffect(() => {
if (conversationError && conversationId) {
const message = conversationError.message.toLowerCase()
// Only clear for 404 errors, not transient network issues
if (message.includes('not found') || message.includes('404')) {
console.warn(`Conversation ${conversationId} not found, clearing stored ID`)
setConversationId(null)
}
}
}, [conversationError, conversationId])
// Convert API messages to ChatMessage format for the chat component
const initialMessages: ChatMessage[] | undefined = conversationDetail?.messages.map((msg) => ({
id: `db-${msg.id}`,
@@ -103,45 +116,37 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
className={`
fixed right-0 top-0 bottom-0 z-50
w-[400px] max-w-[90vw]
bg-neo-card
border-l-4 border-[var(--color-neo-border)]
bg-card
border-l border-border
transform transition-transform duration-300 ease-out
flex flex-col
flex flex-col shadow-xl
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
style={{ boxShadow: 'var(--shadow-neo-left-lg)' }}
role="dialog"
aria-label="Project Assistant"
aria-hidden={!isOpen}
>
{/* 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="bg-neo-card border-2 border-neo-border p-1.5"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<div className="bg-card text-foreground border border-border p-1.5 rounded">
<Bot size={18} />
</div>
<div>
<h2 className="font-display font-bold text-neo-text-on-bright">Project Assistant</h2>
<p className="text-xs text-neo-text-on-bright opacity-80 font-mono">{projectName}</p>
<h2 className="font-semibold">Project Assistant</h2>
<p className="text-xs opacity-80 font-mono">{projectName}</p>
</div>
</div>
<button
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="
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)]
"
className="text-primary-foreground hover:bg-primary-foreground/20"
title="Close Assistant (Press A)"
aria-label="Close Assistant"
>
<X size={18} />
</button>
</Button>
</div>
{/* Chat area */}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Sparkles, PartyPopper } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
import { Card, CardContent } from '@/components/ui/card'
interface CelebrationOverlayProps {
agentName: AgentMascot | 'Unknown'
@@ -80,17 +81,18 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb
</div>
{/* Celebration card - click to dismiss */}
<button
type="button"
<Card
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 */}
<div className="flex items-center gap-2">
<Sparkles size={24} className="text-neo-pending animate-pulse" />
<PartyPopper size={28} className="text-neo-accent" />
<Sparkles size={24} className="text-neo-pending animate-pulse" />
<Sparkles size={24} className="text-yellow-300 animate-pulse" />
<PartyPopper size={28} className="text-white" />
<Sparkles size={24} className="text-yellow-300 animate-pulse" />
</div>
{/* Avatar celebrating */}
@@ -98,23 +100,23 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb
{/* Message */}
<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!
</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}
</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}!
</p>
</div>
{/* 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
</p>
</div>
</button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,12 +2,13 @@
* Chat Message Component
*
* 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 { Bot, User, Info } from 'lucide-react'
import type { ChatMessage as ChatMessageType } from '../lib/types'
import { Card } from '@/components/ui/card'
interface ChatMessageProps {
message: ChatMessageType
@@ -25,37 +26,34 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
minute: '2-digit',
})
// Role-specific styling using CSS variables for theme consistency
// Role-specific styling
const roleConfig = {
user: {
icon: User,
bgColor: 'bg-[var(--color-neo-pending)]',
textColor: 'text-[var(--color-neo-text-on-bright)]',
borderColor: 'border-[var(--color-neo-border)]',
bgColor: 'bg-primary',
textColor: 'text-primary-foreground',
align: 'justify-end',
bubbleAlign: 'items-end',
iconBg: 'bg-[var(--color-neo-pending)]',
shadow: 'var(--shadow-neo-md)',
iconBg: 'bg-primary',
iconColor: 'text-primary-foreground',
},
assistant: {
icon: Bot,
bgColor: 'bg-[var(--color-neo-card)]',
textColor: 'text-[var(--color-neo-text)]',
borderColor: 'border-[var(--color-neo-border)]',
bgColor: 'bg-muted',
textColor: 'text-foreground',
align: 'justify-start',
bubbleAlign: 'items-start',
iconBg: 'bg-[var(--color-neo-progress)]',
shadow: 'var(--shadow-neo-md)',
iconBg: 'bg-secondary',
iconColor: 'text-secondary-foreground',
},
system: {
icon: Info,
bgColor: 'bg-[var(--color-neo-done)]',
textColor: 'text-[var(--color-neo-text-on-bright)]',
borderColor: 'border-[var(--color-neo-border)]',
bgColor: 'bg-green-100 dark:bg-green-900/30',
textColor: 'text-green-900 dark:text-green-100',
align: 'justify-center',
bubbleAlign: 'items-center',
iconBg: 'bg-[var(--color-neo-done)]',
shadow: 'var(--shadow-neo-sm)',
iconBg: 'bg-green-500',
iconColor: 'text-white',
},
}
@@ -66,15 +64,7 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
if (role === 'system') {
return (
<div className={`flex ${config.align} px-4 py-2`}>
<div
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)' }}
>
<div className={`${config.bgColor} border border-border rounded-lg px-4 py-2 text-sm font-mono ${config.textColor}`}>
<span className="flex items-center gap-2">
<Icon size={14} />
{content}
@@ -90,28 +80,12 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{/* Message bubble */}
<div className="flex items-start gap-2">
{role === 'assistant' && (
<div
className={`
${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 className={`${config.iconBg} p-1.5 rounded flex-shrink-0`}>
<Icon size={16} className={config.iconColor} />
</div>
)}
<div
className={`
${config.bgColor}
border-3 ${config.borderColor}
px-4 py-3
${isStreaming ? 'animate-pulse-neo' : ''}
`}
style={{ boxShadow: config.shadow }}
>
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
{/* Parse content for basic markdown-like formatting */}
{content && (
<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 && (
<div className={`flex flex-wrap gap-2 ${content ? 'mt-3' : ''}`}>
{attachments.map((attachment) => (
<div
key={attachment.id}
className="border-2 border-[var(--color-neo-border)] p-1 bg-[var(--color-neo-card)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<div key={attachment.id} className="border border-border rounded p-1 bg-card">
<img
src={attachment.previewUrl}
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')}
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}
</span>
</div>
@@ -174,27 +144,19 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{/* Streaming indicator */}
{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' && (
<div
className={`
${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 className={`${config.iconBg} p-1.5 rounded flex-shrink-0`}>
<Icon size={16} className={config.iconColor} />
</div>
)}
</div>
{/* 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}
</span>
</div>

View File

@@ -1,12 +1,21 @@
/**
* 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.
*/
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 {
isOpen: boolean
@@ -31,74 +40,39 @@ export function ConfirmDialog({
onConfirm,
onCancel,
}: 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 (
<div className="neo-modal-backdrop" onClick={onCancel}>
<div
className="neo-modal w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div
className="p-2 border-2 border-[var(--color-neo-border)]"
style={{ boxShadow: 'var(--shadow-neo-sm)', backgroundColor: colors.icon }}
className={`p-2 rounded-lg ${
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>
<h2 className="font-display font-bold text-lg text-[var(--color-neo-text)]">
{title}
</h2>
<DialogTitle>{title}</DialogTitle>
</div>
<button
onClick={onCancel}
className="neo-btn neo-btn-ghost p-2"
</DialogHeader>
<DialogDescription asChild>
<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}
>
<X size={20} />
</button>
</div>
{/* Content */}
<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>
{isLoading ? 'Deleting...' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

@@ -12,6 +12,8 @@ import { Terminal } from './Terminal'
import { TerminalTabs } from './TerminalTabs'
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
import type { TerminalInfo } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const MIN_HEIGHT = 150
const MAX_HEIGHT = 600
@@ -273,18 +275,18 @@ export function DebugLogViewer({
return 'info'
}
// Get color class for log level using theme CSS variables
// Get color class for log level
const getLogColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'text-[var(--color-neo-log-error)]'
return 'text-red-500'
case 'warn':
return 'text-[var(--color-neo-log-warning)]'
return 'text-yellow-500'
case 'debug':
return 'text-[var(--color-neo-log-debug)]'
return 'text-blue-400'
case 'info':
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"
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">
<GripHorizontal size={12} className="text-[var(--color-neo-text-muted)] group-hover:text-[var(--color-neo-text-secondary)]" />
<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-muted-foreground group-hover:text-foreground" />
</div>
</div>
)}
{/* Header bar */}
<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">
{/* Collapse/expand toggle */}
<button
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)]" />
<span className="font-mono text-sm text-[var(--color-neo-bg)] font-bold">
<TerminalIcon size={16} className="text-green-500" />
<span className="font-mono text-sm text-foreground font-bold">
Debug
</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
</span>
</Badge>
</button>
{/* Tabs - only visible when open */}
{isOpen && (
<div className="flex items-center gap-1 ml-4">
<button
onClick={(e) => {
<Button
variant={activeTab === 'agent' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setActiveTab('agent')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
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)]'
}`}
className="h-7 text-xs font-mono gap-1.5"
>
<Cpu size={12} />
Agent
{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}
</span>
</Badge>
)}
</button>
<button
onClick={(e) => {
</Button>
<Button
variant={activeTab === 'devserver' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setActiveTab('devserver')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
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)]'
}`}
className="h-7 text-xs font-mono gap-1.5"
>
<Server size={12} />
Dev Server
{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}
</span>
</Badge>
)}
</button>
<button
onClick={(e) => {
</Button>
<Button
variant={activeTab === 'terminal' ? 'secondary' : 'ghost'}
size="sm"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setActiveTab('terminal')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
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)]'
}`}
className="h-7 text-xs font-mono gap-1.5"
>
<TerminalIcon size={12} />
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
</span>
</button>
</Badge>
</Button>
</div>
)}
@@ -406,14 +402,14 @@ export function DebugLogViewer({
{isOpen && activeTab !== 'terminal' && (
<>
{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()}
</span>
</Badge>
)}
{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
</span>
</Badge>
)}
</>
)}
@@ -422,22 +418,24 @@ export function DebugLogViewer({
<div className="flex items-center gap-2">
{/* Clear button - only for log tabs */}
{isOpen && activeTab !== 'terminal' && (
<button
onClick={(e) => {
<Button
variant="ghost"
size="icon"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleClear()
}}
className="p-1.5 hover:bg-[var(--color-neo-hover-subtle)] rounded transition-colors"
className="h-7 w-7"
title="Clear logs"
>
<Trash2 size={14} className="text-[var(--color-neo-text-muted)]" />
</button>
<Trash2 size={14} className="text-muted-foreground" />
</Button>
)}
<div className="p-1">
{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>
@@ -445,7 +443,7 @@ export function DebugLogViewer({
{/* Content area */}
{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 */}
{activeTab === 'agent' && (
<div
@@ -454,7 +452,7 @@ export function DebugLogViewer({
className="h-full overflow-y-auto p-2 font-mono text-sm"
>
{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.
</div>
) : (
@@ -467,9 +465,9 @@ export function DebugLogViewer({
return (
<div
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}
</span>
<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"
>
{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.
</div>
) : (
@@ -504,9 +502,9 @@ export function DebugLogViewer({
return (
<div
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}
</span>
<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 */}
<div className="flex-1 min-h-0 relative">
{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...
</div>
) : 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
</div>
) : (

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,11 @@ import { useExpandChat } from '../hooks/useExpandChat'
import { ChatMessage } from './ChatMessage'
import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard'
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
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
@@ -84,7 +89,7 @@ export function ExpandProjectChat({
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isSubmitEnter(e)) {
e.preventDefault()
handleSendMessage()
}
@@ -152,28 +157,28 @@ export function ExpandProjectChat({
switch (connectionStatus) {
case 'connected':
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} />
Connected
</span>
)
case 'connecting':
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" />
Connecting...
</span>
)
case 'error':
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} />
Error
</span>
)
default:
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} />
Disconnected
</span>
@@ -182,16 +187,16 @@ export function ExpandProjectChat({
}
return (
<div className="flex flex-col h-full bg-neo-bg">
<div className="flex flex-col h-full bg-background">
{/* 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">
<h2 className="font-display font-bold text-lg text-neo-text">
<h2 className="font-display font-bold text-lg text-foreground">
Expand Project: {projectName}
</h2>
<ConnectionIndicator />
{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} />
{featuresCreated} added
</span>
@@ -200,57 +205,63 @@ export function ExpandProjectChat({
<div className="flex items-center gap-2">
{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} />
Complete
</span>
)}
<button
<Button
onClick={onCancel}
className="neo-btn neo-btn-ghost p-2"
variant="ghost"
size="icon"
title="Close"
>
<X size={20} />
</button>
</Button>
</div>
</div>
{/* Error banner */}
{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} />
<span className="flex-1 text-sm">{error}</span>
<button
<AlertDescription className="flex-1">{error}</AlertDescription>
<Button
onClick={() => setError(null)}
className="p-1 hover:opacity-70 transition-opacity rounded"
variant="ghost"
size="icon"
className="h-6 w-6"
>
<X size={14} />
</button>
</div>
</Button>
</Alert>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="neo-card p-6 max-w-md">
<h3 className="font-display font-bold text-lg mb-2">
Starting Project Expansion
</h3>
<p className="text-sm text-neo-text-secondary">
Connecting to Claude to help you add new features to your project...
</p>
{connectionStatus === 'error' && (
<button
onClick={start}
className="neo-btn neo-btn-primary mt-4 text-sm"
>
<RotateCcw size={14} />
Retry Connection
</button>
)}
</div>
<Card className="p-6 max-w-md">
<CardContent className="p-0">
<h3 className="font-display font-bold text-lg mb-2">
Starting Project Expansion
</h3>
<p className="text-sm text-muted-foreground">
Connecting to Claude to help you add new features to your project...
</p>
{connectionStatus === 'error' && (
<Button
onClick={start}
className="mt-4"
size="sm"
>
<RotateCcw size={14} />
Retry Connection
</Button>
)}
</CardContent>
</Card>
</div>
)}
@@ -268,7 +279,7 @@ export function ExpandProjectChat({
{/* Input area */}
{!isComplete && (
<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}
onDragOver={handleDragOver}
>
@@ -278,22 +289,21 @@ export function ExpandProjectChat({
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className="relative group border-2 border-neo-border p-1 bg-neo-card"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover"
className="w-16 h-16 object-cover rounded"
/>
<button
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"
>
<X size={12} />
</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.substring(0, 7)}...`
: attachment.filename}
@@ -315,16 +325,17 @@ export function ExpandProjectChat({
/>
{/* Attach button */}
<button
<Button
onClick={() => fileInputRef.current?.click()}
disabled={connectionStatus !== 'connected'}
className="neo-btn neo-btn-ghost p-3"
variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)"
>
<Paperclip size={18} />
</button>
</Button>
<input
<Input
ref={inputRef}
type="text"
value={input}
@@ -335,24 +346,24 @@ export function ExpandProjectChat({
? 'Add a message with your image(s)...'
: 'Describe the features you want to add...'
}
className="neo-input flex-1"
className="flex-1"
disabled={isLoading || connectionStatus !== 'connected'}
/>
<button
<Button
onClick={handleSendMessage}
disabled={
(!input.trim() && pendingAttachments.length === 0) ||
isLoading ||
connectionStatus !== 'connected'
}
className="neo-btn neo-btn-primary px-6"
className="px-6"
>
<Send size={18} />
</button>
</Button>
</div>
{/* 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.
</p>
</div>
@@ -360,7 +371,7 @@ export function ExpandProjectChat({
{/* Completion footer */}
{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 gap-2">
<CheckCircle2 size={20} />
@@ -368,12 +379,12 @@ export function ExpandProjectChat({
Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}!
</span>
</div>
<button
<Button
onClick={() => onComplete(featuresCreated)}
className="neo-btn bg-neo-card"
variant="secondary"
>
Close
</button>
</Button>
</div>
</div>
)}

View File

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

View File

@@ -2,26 +2,27 @@ import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface FeatureCardProps {
feature: Feature
onClick: () => void
isInProgress?: boolean
allFeatures?: Feature[]
activeAgent?: ActiveAgent // Agent working on this feature
activeAgent?: ActiveAgent
}
// Generate consistent color for category using CSS variable references
// These map to the --color-neo-category-* variables defined in globals.css
// Generate consistent color for category
function getCategoryColor(category: string): string {
const colors = [
'var(--color-neo-category-pink)',
'var(--color-neo-category-cyan)',
'var(--color-neo-category-green)',
'var(--color-neo-category-yellow)',
'var(--color-neo-category-orange)',
'var(--color-neo-category-purple)',
'var(--color-neo-category-blue)',
'bg-pink-500',
'bg-cyan-500',
'bg-green-500',
'bg-yellow-500',
'bg-orange-500',
'bg-purple-500',
'bg-blue-500',
]
let hash = 0
@@ -38,86 +39,85 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
const hasActiveAgent = !!activeAgent
return (
<button
<Card
onClick={onClick}
className={`
w-full text-left neo-card p-4 cursor-pointer relative
${isInProgress ? 'animate-pulse-neo' : ''}
${feature.passes ? 'border-neo-done' : ''}
${isBlocked && !feature.passes ? 'border-neo-danger opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-neo-progress ring-offset-2' : ''}
cursor-pointer transition-all hover:border-primary py-3
${isInProgress ? 'animate-pulse' : ''}
${feature.passes ? 'border-primary/50' : ''}
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
`}
>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<span
className="neo-badge"
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category}
</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>
)}
<CardContent className="p-4 space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Badge className={`${categoryColor} text-white`}>
{feature.category}
</Badge>
<DependencyBadge feature={feature} allFeatures={allFeatures} compact />
</div>
<span className="font-mono text-sm text-muted-foreground">
#{feature.priority}
</span>
</div>
)}
{/* Status */}
<div className="flex items-center gap-2 text-sm">
{isInProgress ? (
<>
<Loader2 size={16} className="animate-spin text-neo-progress" />
<span className="text-neo-progress font-bold">Processing...</span>
</>
) : feature.passes ? (
<>
<CheckCircle2 size={16} className="text-neo-done" />
<span className="text-neo-done font-bold">Complete</span>
</>
) : isBlocked ? (
<>
<Circle size={16} className="text-neo-danger" />
<span className="text-neo-danger">Blocked</span>
</>
) : (
<>
<Circle size={16} className="text-neo-text-secondary" />
<span className="text-neo-text-secondary">Pending</span>
</>
{/* Name */}
<h3 className="font-semibold line-clamp-2">
{feature.name}
</h3>
{/* Description */}
<p className="text-sm text-muted-foreground line-clamp-2">
{feature.description}
</p>
{/* Agent working on this feature */}
{activeAgent && (
<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" />
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-primary">
{activeAgent.agentName} is working on this!
</div>
{activeAgent.thought && (
<div className="flex items-center gap-1 mt-0.5">
<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 { EditFeatureForm } from './EditFeatureForm'
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 {
const colors = [
'#ff006e', // pink (accent)
'#00b4d8', // cyan (progress)
'#70e000', // green (done)
'#ffd60a', // yellow (pending)
'#ff5400', // orange (danger)
'#8338ec', // purple
'#3a86ff', // blue
'bg-pink-500',
'bg-cyan-500',
'bg-green-500',
'bg-yellow-500',
'bg-orange-500',
'bg-purple-500',
'bg-blue-500',
]
let hash = 0
@@ -90,109 +101,91 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
}
return (
<div className="neo-modal-backdrop" onClick={onClose}>
<div
className="neo-modal w-full max-w-2xl p-0"
onClick={(e) => e.stopPropagation()}
>
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl p-0 gap-0">
{/* Header */}
<div className="flex items-start justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
<div>
<span
className="neo-badge mb-2"
style={{ backgroundColor: getCategoryColor(feature.category), color: 'var(--color-neo-text-on-bright)' }}
>
<DialogHeader className="p-6 pb-4">
<div className="flex items-start gap-3">
<Badge className={`${getCategoryColor(feature.category)} text-white`}>
{feature.category}
</span>
<h2 className="font-display text-2xl font-bold">
{feature.name}
</h2>
</Badge>
</div>
<button
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={24} />
</button>
</div>
<DialogTitle className="text-xl mt-2">{feature.name}</DialogTitle>
</DialogHeader>
<Separator />
{/* Content */}
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
{/* Error Message */}
{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)]">
<AlertCircle size={20} />
<span>{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto hover:opacity-70 transition-opacity"
>
<X size={16} />
</button>
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>{error}</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setError(null)}
>
<X size={14} />
</Button>
</AlertDescription>
</Alert>
)}
{/* 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 ? (
<>
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
<span className="font-display font-bold text-[var(--color-neo-done)]">
COMPLETE
</span>
<CheckCircle2 size={24} className="text-primary" />
<span className="font-semibold text-primary">COMPLETE</span>
</>
) : (
<>
<Circle size={24} className="text-[var(--color-neo-text-secondary)]" />
<span className="font-display font-bold text-[var(--color-neo-text-secondary)]">
PENDING
</span>
<Circle size={24} className="text-muted-foreground" />
<span className="font-semibold text-muted-foreground">PENDING</span>
</>
)}
<span className="ml-auto font-mono text-sm">
<span className="ml-auto font-mono text-sm text-muted-foreground">
Priority: #{feature.priority}
</span>
</div>
{/* Description */}
<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
</h3>
<p className="text-[var(--color-neo-text-secondary)]">
{feature.description}
</p>
<p className="text-foreground">{feature.description}</p>
</div>
{/* Blocked By Warning */}
{blockingDeps.length > 0 && (
<div className="p-4 bg-[var(--color-neo-warning-bg)] border-3 border-[var(--color-neo-warning-border)]">
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2 text-[var(--color-neo-warning-text)]">
<AlertTriangle size={16} />
Blocked By
</h3>
<p className="text-sm text-[var(--color-neo-warning-text)] mb-2">
This feature cannot start until the following dependencies are complete:
</p>
<ul className="space-y-1">
{blockingDeps.map(dep => (
<li
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<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>
<span className="text-[var(--color-neo-warning-text)]">{dep.name}</span>
</li>
))}
</ul>
</div>
<Alert variant="destructive" className="border-orange-500 bg-orange-50 dark:bg-orange-950/20">
<AlertTriangle className="h-4 w-4 text-orange-600" />
<AlertDescription>
<h4 className="font-semibold mb-1 text-orange-700 dark:text-orange-400">Blocked By</h4>
<p className="text-sm text-orange-600 dark:text-orange-300 mb-2">
This feature cannot start until the following dependencies are complete:
</p>
<ul className="space-y-1">
{blockingDeps.map(dep => (
<li key={dep.id} className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-300">
<Circle size={14} />
<span className="font-mono text-xs">#{dep.id}</span>
<span>{dep.name}</span>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* Dependencies */}
{dependencies.length > 0 && (
<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} />
Depends On
</h3>
@@ -200,15 +193,15 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{dependencies.map(dep => (
<li
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 ? (
<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={dep.passes ? 'text-[var(--color-neo-done)]' : ''}>{dep.name}</span>
<span className="font-mono text-xs text-muted-foreground">#{dep.id}</span>
<span className={dep.passes ? 'text-primary' : ''}>{dep.name}</span>
</li>
))}
</ul>
@@ -218,14 +211,14 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{/* Steps */}
{feature.steps.length > 0 && (
<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
</h3>
<ol className="list-decimal list-inside space-y-2">
{feature.steps.map((step, index) => (
<li
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}
</li>
@@ -237,69 +230,76 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{/* Actions */}
{!feature.passes && (
<div className="p-6 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
{showDeleteConfirm ? (
<div className="space-y-4">
<p className="font-bold text-center">
Are you sure you want to delete this feature?
</p>
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={deleteFeature.isPending}
className="neo-btn neo-btn-danger flex-1"
<>
<Separator />
<DialogFooter className="p-4 bg-muted/50">
{showDeleteConfirm ? (
<div className="w-full space-y-4">
<p className="font-medium text-center">
Are you sure you want to delete this feature?
</p>
<div className="flex gap-3">
<Button
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" />
) : (
'Yes, Delete'
<>
<SkipForward size={18} />
Skip
</>
)}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteFeature.isPending}
className="neo-btn neo-btn-ghost flex-1"
</Button>
<Button
variant="destructive"
size="icon"
onClick={() => setShowDeleteConfirm(true)}
disabled={skipFeature.isPending}
>
Cancel
</button>
<Trash2 size={18} />
</Button>
</div>
</div>
) : (
<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>
)}
</DialogFooter>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -18,7 +18,11 @@ import {
ArrowLeft,
} from 'lucide-react'
import * as api from '../lib/api'
import { isSubmitEnter } from '../lib/keyboard'
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 {
onSelect: (path: string) => void
@@ -139,37 +143,36 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
return (
<div className="flex flex-col h-full max-h-[70vh]">
{/* 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">
<Folder size={20} className="text-[var(--color-neo-progress)]" />
<span className="font-bold text-[var(--color-neo-text)]">Select Project Folder</span>
<Folder size={20} className="text-primary" />
<span className="font-semibold">Select Project Folder</span>
</div>
{/* Breadcrumb navigation */}
<div className="flex items-center gap-1 flex-wrap text-sm">
{directoryData?.parent_path && (
<button
<Button
variant="ghost"
size="icon-sm"
onClick={handleNavigateUp}
className="neo-btn neo-btn-ghost p-1"
title="Go up"
>
<ArrowLeft size={16} />
</button>
</Button>
)}
{breadcrumbs.map((crumb, index) => (
<div key={crumb.path} className="flex items-center">
{index > 0 && <ChevronRight size={14} className="text-[var(--color-neo-text-muted)] mx-1" />}
<button
{index > 0 && <ChevronRight size={14} className="text-muted-foreground mx-1" />}
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigate(crumb.path)}
className={`
px-2 py-1 rounded text-[var(--color-neo-text)]
hover:bg-[var(--color-neo-bg)]
${index === breadcrumbs.length - 1 ? 'font-bold' : ''}
`}
className={index === breadcrumbs.length - 1 ? 'font-semibold' : ''}
>
{crumb.name}
</button>
</Button>
</div>
))}
</div>
@@ -177,162 +180,161 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
{/* Drive selector (Windows only) */}
{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">
<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) => (
<button
<Button
key={drive.letter}
variant={currentPath?.startsWith(drive.letter) ? 'default' : 'outline'}
size="sm"
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} />
{drive.letter}: {drive.label && `(${drive.label})`}
</button>
</Button>
))}
</div>
</div>
)}
{/* Directory listing */}
<div className="flex-1 overflow-y-auto p-2 bg-[var(--color-neo-card)]">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
</div>
) : 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 className="flex-1 min-h-0 overflow-y-auto bg-card">
<div className="p-2">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
{createError && (
<p className="text-sm text-[var(--color-neo-danger)] mt-1">{createError}</p>
)}
</div>
)}
) : error ? (
<div className="p-4 text-center">
<AlertCircle size={32} className="mx-auto mb-2 text-destructive" />
<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 (isSubmitEnter(e, false)) 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>
{/* 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 */}
<div className="mb-3 p-2 bg-[var(--color-neo-bg)] rounded border-2 border-[var(--color-neo-border)]">
<div className="text-xs text-[var(--color-neo-text-secondary)] mb-1">Selected path:</div>
<div className="font-mono text-sm truncate text-[var(--color-neo-text)]">{selectedPath || 'No folder selected'}</div>
{selectedPath && (
<div className="text-xs text-[var(--color-neo-text-secondary)] mt-2 italic">
This folder will contain all project files
</div>
)}
</div>
<Card className="mb-3">
<CardContent className="p-2">
<div className="text-xs text-muted-foreground mb-1">Selected path:</div>
<div className="font-mono text-sm truncate">{selectedPath || 'No folder selected'}</div>
{selectedPath && (
<div className="text-xs text-muted-foreground mt-2 italic">
This folder will contain all project files
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<button
<Button
variant="outline"
onClick={() => setIsCreatingFolder(true)}
className="neo-btn neo-btn-ghost"
disabled={isCreatingFolder}
>
<FolderPlus size={16} />
New Folder
</button>
</Button>
<div className="flex items-center gap-2">
<button onClick={onCancel} className="neo-btn neo-btn-ghost">
<Button variant="outline" onClick={onCancel}>
Cancel
</button>
<button
onClick={handleSelect}
className="neo-btn neo-btn-primary"
disabled={!selectedPath}
>
</Button>
<Button onClick={handleSelect} disabled={!selectedPath}>
Select This Folder
</button>
</Button>
</div>
</div>
</div>

View File

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

View File

@@ -1,26 +1,29 @@
import { FeatureCard } from './FeatureCard'
import { Plus, Sparkles, Wand2 } from 'lucide-react'
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 {
title: string
count: number
features: Feature[]
allFeatures?: Feature[] // For dependency status calculation
activeAgents?: ActiveAgent[] // Active agents for showing which agent is working on a feature
allFeatures?: Feature[]
activeAgents?: ActiveAgent[]
color: 'pending' | 'progress' | 'done'
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
onExpandProject?: () => void
showExpandButton?: boolean
onCreateSpec?: () => void // Callback to start spec creation
showCreateSpec?: boolean // Show "Create Spec" button when project has no spec
onCreateSpec?: () => void
showCreateSpec?: boolean
}
const colorMap = {
pending: 'var(--color-neo-pending)',
progress: 'var(--color-neo-progress)',
done: 'var(--color-neo-done)',
pending: 'border-t-4 border-t-muted',
progress: 'border-t-4 border-t-primary',
done: 'border-t-4 border-t-primary',
}
export function KanbanColumn({
@@ -41,83 +44,78 @@ export function KanbanColumn({
const agentByFeatureId = new Map(
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 */}
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]">
{features.length === 0 ? (
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]">
{showCreateSpec && onCreateSpec ? (
<div className="space-y-4">
<p>No spec created yet</p>
<button
onClick={onCreateSpec}
className="neo-btn neo-btn-primary inline-flex items-center gap-2"
>
<Wand2 size={18} />
Create Spec with AI
</button>
</div>
) : (
'No features'
return (
<Card className={`overflow-hidden ${colorMap[color]} py-0`}>
{/* Header */}
<CardHeader className="px-4 py-3 border-b flex-row items-center justify-between space-y-0">
<CardTitle className="text-lg font-semibold flex items-center gap-2">
{title}
<Badge variant="secondary">{count}</Badge>
</CardTitle>
{(onAddFeature || onExpandProject) && (
<div className="flex items-center gap-2">
{onAddFeature && (
<Button
onClick={onAddFeature}
size="icon-sm"
title="Add new feature (N)"
>
<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>
) : (
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>
</CardHeader>
{/* 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 { 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 {
key: string
@@ -20,10 +27,11 @@ const shortcuts: Shortcut[] = [
]
interface KeyboardShortcutsHelpProps {
isOpen: boolean
onClose: () => void
}
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === '?') {
@@ -35,59 +43,49 @@ export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
if (isOpen) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleKeyDown])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="neo-card p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* 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>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard size={20} className="text-primary" />
Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
{/* Shortcuts list */}
<ul className="space-y-2">
<ul className="space-y-1">
{shortcuts.map((shortcut) => (
<li
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">
<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">
<div className="flex items-center gap-3">
<kbd className="px-2 py-1 text-xs font-mono bg-muted rounded border border-border min-w-[2rem] text-center">
{shortcut.key}
</kbd>
<span className="text-neo-text">{shortcut.description}</span>
<span className="text-sm">{shortcut.description}</span>
</div>
{shortcut.context && (
<span className="text-xs text-neo-muted">{shortcut.context}</span>
<Badge variant="secondary" className="text-xs">
{shortcut.context}
</Badge>
)}
</li>
))}
</ul>
{/* 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
</p>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -10,11 +10,25 @@
*/
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 { SpecCreationChat } from './SpecCreationChat'
import { FolderBrowser } from './FolderBrowser'
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'
@@ -75,7 +89,7 @@ export function NewProjectModal({
}
const handleFolderSelect = (path: string) => {
setProjectPath(path) // Use selected path directly - no subfolder creation
setProjectPath(path)
changeStep('method')
}
@@ -189,7 +203,7 @@ export function NewProjectModal({
// Full-screen chat view
if (step === 'chat') {
return (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]">
<div className="fixed inset-0 z-50 bg-background">
<SpecCreationChat
projectName={projectName.trim()}
onComplete={handleSpecComplete}
@@ -206,31 +220,20 @@ export function NewProjectModal({
// Folder step uses larger modal
if (step === 'folder') {
return (
<div className="neo-modal-backdrop" onClick={handleClose}>
<div
className="neo-modal w-full max-w-3xl max-h-[85vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col p-0">
{/* 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">
<Folder size={24} className="text-[var(--color-neo-progress)]" />
<Folder size={24} className="text-primary" />
<div>
<h2 className="font-display font-bold text-xl text-[var(--color-neo-text)]">
Select Project Location
</h2>
<p className="text-sm text-[var(--color-neo-text-secondary)]">
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>
<DialogTitle>Select Project Location</DialogTitle>
<DialogDescription>
Select the folder to use for project <span className="font-semibold font-mono">{projectName}</span>. Create a new folder or choose an existing one.
</DialogDescription>
</div>
</div>
<button
onClick={handleClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={20} />
</button>
</div>
</DialogHeader>
{/* Folder Browser */}
<div className="flex-1 overflow-hidden">
@@ -239,193 +242,151 @@ export function NewProjectModal({
onCancel={handleFolderCancel}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<div className="neo-modal-backdrop" onClick={handleClose}>
<div
className="neo-modal w-full max-w-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 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)]">
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{step === 'name' && 'Create New Project'}
{step === 'method' && 'Choose Setup Method'}
{step === 'complete' && 'Project Created!'}
</h2>
<button
onClick={handleClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={20} />
</button>
</div>
</DialogTitle>
</DialogHeader>
{/* Content */}
<div className="p-6">
{/* Step 1: Project Name */}
{step === 'name' && (
<form onSubmit={handleNameSubmit}>
<div className="mb-6">
<label className="block font-bold mb-2 text-[var(--color-neo-text)]">
Project Name
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-awesome-app"
className="neo-input"
pattern="^[a-zA-Z0-9_-]+$"
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?
{/* Step 1: Project Name */}
{step === 'name' && (
<form onSubmit={handleNameSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-awesome-app"
pattern="^[a-zA-Z0-9_-]+$"
autoFocus
/>
<p className="text-sm text-muted-foreground">
Use letters, numbers, hyphens, and underscores only.
</p>
</div>
<div className="space-y-4">
{/* Claude option */}
<button
onClick={() => handleMethodSelect('claude')}
disabled={createProject.isPending}
className="
w-full text-left p-4
hover:translate-x-[-2px] hover:translate-y-[-2px]
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
neo-card
"
>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="submit" disabled={!projectName.trim()}>
Next
<ArrowRight size={16} />
</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="p-2 bg-[var(--color-neo-progress)] border-2 border-[var(--color-neo-border)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<Bot size={24} className="text-[var(--color-neo-text-on-bright)]" />
<div className="p-2 bg-primary/10 rounded-lg">
<Bot size={24} className="text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-lg text-[var(--color-neo-text)]">Create with Claude</span>
<span className="neo-badge bg-[var(--color-neo-done)] text-[var(--color-neo-text-on-bright)] text-xs">
Recommended
</span>
<span className="font-semibold">Create with Claude</span>
<Badge>Recommended</Badge>
</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.
</p>
</div>
</div>
</button>
</CardContent>
</Card>
{/* Manual option */}
<button
onClick={() => handleMethodSelect('manual')}
disabled={createProject.isPending}
className="
w-full text-left p-4
hover:translate-x-[-2px] hover:translate-y-[-2px]
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
neo-card
"
>
{/* Manual option */}
<Card
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => !createProject.isPending && handleMethodSelect('manual')}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div
className="p-2 bg-[var(--color-neo-pending)] border-2 border-[var(--color-neo-border)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
>
<FileEdit size={24} className="text-[var(--color-neo-text-on-bright)]" />
<div className="p-2 bg-secondary rounded-lg">
<FileEdit size={24} className="text-secondary-foreground" />
</div>
<div className="flex-1">
<span className="font-bold text-lg text-[var(--color-neo-text)]">Edit Templates Manually</span>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
<span className="font-semibold">Edit Templates Manually</span>
<p className="text-sm text-muted-foreground mt-1">
Edit the template files directly. Best for developers who want full control.
</p>
</div>
</div>
</button>
</div>
{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>
</CardContent>
</Card>
</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-[var(--color-neo-done)] border-3 border-[var(--color-neo-border)] mb-4"
style={{ boxShadow: 'var(--shadow-neo-md)' }}
>
<CheckCircle2 size={32} className="text-[var(--color-neo-text-on-bright)]" />
</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">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{createProject.isPending && (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">Redirecting...</span>
<span>Creating project...</span>
</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>
</div>
</div>
<h3 className="font-semibold text-xl mb-2">{projectName}</h3>
<p className="text-muted-foreground">
Your project has been created successfully!
</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 { OrchestratorAvatar } from './OrchestratorAvatar'
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 {
status: OrchestratorStatus
@@ -31,16 +34,16 @@ function getStateText(state: OrchestratorState): string {
function getStateColor(state: OrchestratorState): string {
switch (state) {
case 'complete':
return 'text-neo-done'
return 'text-primary'
case 'spawning':
return 'text-[#7C3AED]' // Violet
return 'text-primary'
case 'scheduling':
case 'monitoring':
return 'text-neo-progress'
return 'text-primary'
case 'initializing':
return 'text-neo-pending'
return 'text-yellow-600 dark:text-yellow-400'
default:
return 'text-neo-text-secondary'
return 'text-muted-foreground'
}
}
@@ -62,91 +65,95 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps)
const [showEvents, setShowEvents] = useState(false)
return (
<div className="neo-card p-4 bg-gradient-to-r from-[#EDE9FE] to-[#F3E8FF] border-[#7C3AED]/30 mb-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<OrchestratorAvatar state={status.state} size="md" />
<Card className="mb-4 bg-primary/10 border-primary/30 py-4">
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<OrchestratorAvatar state={status.state} size="md" />
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Header row */}
<div className="flex items-center gap-2 mb-1">
<span className="font-display font-bold text-lg text-[#7C3AED]">
Maestro
</span>
<span className={`text-sm font-medium ${getStateColor(status.state)}`}>
{getStateText(status.state)}
</span>
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Header row */}
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-lg text-primary">
Maestro
</span>
<span className={`text-sm font-medium ${getStateColor(status.state)}`}>
{getStateText(status.state)}
</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>
{/* Current message */}
<p className="text-sm text-neo-text mb-3 line-clamp-2">
{status.message}
</p>
{/* Status badges row */}
<div className="flex flex-wrap items-center gap-2">
{/* 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">
<Code size={12} />
<span>Coding: {status.codingAgents}</span>
</div>
{/* 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>
{/* Recent events toggle */}
{status.recentEvents.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowEvents(!showEvents)}
className="text-primary hover:bg-primary/10"
>
<Sparkles size={12} />
Activity
{showEvents ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</Button>
)}
</div>
{/* Recent events toggle */}
{status.recentEvents.length > 0 && (
<button
onClick={() => setShowEvents(!showEvents)}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-[#7C3AED] hover:bg-[#7C3AED]/10 rounded transition-colors"
>
<Sparkles size={12} />
<span>Activity</span>
{showEvents ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{/* Collapsible recent events */}
{showEvents && status.recentEvents.length > 0 && (
<div className="mt-3 pt-3 border-t border-primary/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-primary shrink-0 font-mono">
{formatRelativeTime(event.timestamp)}
</span>
<span className="text-foreground">
{event.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* 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>
</CardContent>
</Card>
)
}

View File

@@ -1,4 +1,6 @@
import { Wifi, WifiOff } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface ProgressDashboardProps {
passing: number
@@ -14,66 +16,68 @@ export function ProgressDashboard({
isConnected,
}: ProgressDashboardProps) {
return (
<div className="neo-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-display text-xl font-bold uppercase">
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xl uppercase tracking-wide">
Progress
</h2>
<div className="flex items-center gap-2">
</CardTitle>
<Badge variant={isConnected ? 'default' : 'destructive'} className="gap-1">
{isConnected ? (
<>
<Wifi size={16} className="text-[var(--color-neo-done)]" />
<span className="text-sm text-[var(--color-neo-done)]">Live</span>
<Wifi size={14} />
Live
</>
) : (
<>
<WifiOff size={16} className="text-[var(--color-neo-danger)]" />
<span className="text-sm text-[var(--color-neo-danger)]">Offline</span>
<WifiOff size={14} />
Offline
</>
)}
</div>
</div>
</Badge>
</CardHeader>
{/* Large Percentage */}
<div className="text-center mb-6">
<span className="inline-flex items-baseline">
<span className="font-display text-6xl font-bold">
{percentage.toFixed(1)}
</span>
<span className="font-display text-3xl font-bold text-[var(--color-neo-text-secondary)]">
%
</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
<CardContent>
{/* Large Percentage */}
<div className="text-center mb-6">
<span className="inline-flex items-baseline">
<span className="text-6xl font-bold tabular-nums">
{percentage.toFixed(1)}
</span>
<span className="text-3xl font-semibold text-muted-foreground">
%
</span>
</span>
</div>
<div className="text-4xl text-[var(--color-neo-text-secondary)]">/</div>
<div>
<span className="font-mono text-3xl font-bold">
{total}
</span>
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
Total
</span>
{/* Progress Bar */}
<div className="h-3 bg-muted rounded-full overflow-hidden mb-6">
<div
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
style={{ width: `${percentage}%` }}
/>
</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 { ConfirmDialog } from './ConfirmDialog'
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 {
projects: ProjectSummary[]
@@ -32,8 +41,8 @@ export function ProjectSelector({
}
const handleDeleteClick = (e: React.MouseEvent, projectName: string) => {
// Prevent the click from selecting the project
e.stopPropagation()
e.preventDefault()
setProjectToDelete(projectName)
}
@@ -42,13 +51,11 @@ export function ProjectSelector({
try {
await deleteProject.mutateAsync(projectToDelete)
// If the deleted project was selected, clear the selection
if (selectedProject === projectToDelete) {
onSelectProject(null)
}
setProjectToDelete(null)
} catch (error) {
// Error is handled by the mutation, just close the dialog
console.error('Failed to delete project:', error)
setProjectToDelete(null)
}
@@ -62,106 +69,86 @@ export function ProjectSelector({
return (
<div className="relative">
{/* Dropdown Trigger */}
<button
onClick={() => setIsOpen(!isOpen)}
className="neo-btn bg-[var(--color-neo-card)] text-[var(--color-neo-text)] min-w-[200px] justify-between"
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : selectedProject ? (
<>
<span className="flex items-center gap-2">
<FolderOpen size={18} />
{selectedProject}
</span>
{selectedProjectData && selectedProjectData.stats.total > 0 && (
<span className="neo-badge bg-[var(--color-neo-done)] ml-2">
{selectedProjectData.stats.percentage}%
</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>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="min-w-[200px] justify-between"
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : selectedProject ? (
<>
<span className="flex items-center gap-2">
<FolderOpen size={18} />
{selectedProject}
</span>
{selectedProjectData && selectedProjectData.stats.total > 0 && (
<Badge className="ml-2">{selectedProjectData.stats.percentage}%</Badge>
)}
</>
) : (
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
No projects yet
</div>
<span className="text-muted-foreground">Select Project</span>
)}
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</Button>
</DropdownMenuTrigger>
{/* Divider */}
<div className="border-t-3 border-[var(--color-neo-border)]" />
<DropdownMenuContent align="start" className="w-[280px] p-0 flex flex-col">
{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: React.MouseEvent) => 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 */}
<button
onClick={() => {
<DropdownMenuSeparator className="my-0" />
<div className="p-1">
<DropdownMenuItem
onSelect={() => {
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} />
New Project
</button>
</DropdownMenuItem>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* New Project Modal */}
<NewProjectModal

View File

@@ -0,0 +1,90 @@
import { Sparkles, FileEdit, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface ProjectSetupRequiredProps {
projectName: string
projectPath?: string
onCreateWithClaude: () => void
onEditManually: () => void
}
export function ProjectSetupRequired({
projectName,
projectPath,
onCreateWithClaude,
onEditManually,
}: ProjectSetupRequiredProps) {
return (
<div className="max-w-2xl mx-auto mt-8">
<Card className="border-2">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-display">
Project Setup Required
</CardTitle>
<CardDescription className="text-base">
<span className="font-semibold">{projectName}</span> needs an app spec to get started
</CardDescription>
{projectPath && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground mt-2">
<FolderOpen size={14} />
<code className="bg-muted px-2 py-0.5 rounded text-xs">{projectPath}</code>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
Choose how you want to create your app specification:
</p>
<div className="grid gap-4 md:grid-cols-2">
{/* Create with Claude Option */}
<Card
className="cursor-pointer border-2 transition-all hover:border-primary hover:shadow-md"
onClick={onCreateWithClaude}
>
<CardContent className="pt-6 text-center space-y-3">
<div className="w-12 h-12 mx-auto bg-primary/10 rounded-full flex items-center justify-center">
<Sparkles className="text-primary" size={24} />
</div>
<h3 className="font-semibold text-lg">Create with Claude</h3>
<p className="text-sm text-muted-foreground">
Describe your app idea and Claude will help create a detailed specification
</p>
<Button className="w-full">
<Sparkles size={16} className="mr-2" />
Start Chat
</Button>
</CardContent>
</Card>
{/* Edit Manually Option */}
<Card
className="cursor-pointer border-2 transition-all hover:border-primary hover:shadow-md"
onClick={onEditManually}
>
<CardContent className="pt-6 text-center space-y-3">
<div className="w-12 h-12 mx-auto bg-muted rounded-full flex items-center justify-center">
<FileEdit className="text-muted-foreground" size={24} />
</div>
<h3 className="font-semibold text-lg">Edit Templates Manually</h3>
<p className="text-sm text-muted-foreground">
Create the prompts directory and edit template files yourself
</p>
<Button variant="outline" className="w-full">
<FileEdit size={16} className="mr-2" />
View Templates
</Button>
</CardContent>
</Card>
</div>
<p className="text-center text-xs text-muted-foreground pt-4">
The app spec tells the agent what to build. It includes the application name,
description, tech stack, and feature requirements.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,12 +2,16 @@
* Question Options Component
*
* Renders structured questions from AskUserQuestion tool.
* Shows clickable option buttons in neobrutalism style.
* Shows clickable option buttons.
*/
import { useState } from 'react'
import { Check } from 'lucide-react'
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 {
questions: SpecQuestion[]
@@ -91,165 +95,126 @@ export function QuestionOptions({
return (
<div className="space-y-6 p-4">
{questions.map((q, questionIdx) => (
<div
key={questionIdx}
className="neo-card p-4 bg-[var(--color-neo-card)]"
>
{/* Question header */}
<div className="flex items-center gap-3 mb-4">
<span className="neo-badge bg-[var(--color-neo-accent)] text-[var(--color-neo-text-on-bright)]">
{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)
<Card key={questionIdx}>
<CardContent className="p-4">
{/* Question header */}
<div className="flex items-center gap-3 mb-4">
<Badge>{q.header}</Badge>
<span className="font-bold text-foreground">
{q.question}
</span>
)}
</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
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}
/>
{q.multiSelect && (
<span className="text-xs text-muted-foreground font-mono">
(select multiple)
</span>
)}
</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 */}
<div className="flex justify-end">
<button
<Button
onClick={handleSubmit}
disabled={disabled || !allQuestionsAnswered}
className="neo-btn neo-btn-primary"
>
Continue
</button>
</Button>
</div>
</div>
)

View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Loader2, AlertTriangle, RotateCcw, Trash2, Check, X } from 'lucide-react'
import { useResetProject } from '../hooks/useProjects'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface ResetProjectModalProps {
isOpen: boolean
projectName: string
onClose: () => void
onResetComplete?: (wasFullReset: boolean) => void
}
export function ResetProjectModal({
isOpen,
projectName,
onClose,
onResetComplete,
}: ResetProjectModalProps) {
const [resetType, setResetType] = useState<'quick' | 'full'>('quick')
const resetProject = useResetProject(projectName)
const handleReset = async () => {
const isFullReset = resetType === 'full'
try {
await resetProject.mutateAsync(isFullReset)
onResetComplete?.(isFullReset)
onClose()
} catch {
// Error is handled by the mutation state
}
}
const handleClose = () => {
if (!resetProject.isPending) {
resetProject.reset()
setResetType('quick')
onClose()
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw size={20} />
Reset Project
</DialogTitle>
<DialogDescription>
Reset <span className="font-semibold">{projectName}</span> to start fresh
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Reset Type Toggle */}
<div className="flex rounded-lg border-2 border-border overflow-hidden">
<button
onClick={() => setResetType('quick')}
disabled={resetProject.isPending}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
resetType === 'quick'
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${resetProject.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<RotateCcw size={16} />
Quick Reset
</button>
<button
onClick={() => setResetType('full')}
disabled={resetProject.isPending}
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
resetType === 'full'
? 'bg-destructive text-destructive-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${resetProject.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Trash2 size={16} />
Full Reset
</button>
</div>
{/* Warning Box */}
<Alert variant={resetType === 'full' ? 'destructive' : 'default'} className="border-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">
{resetType === 'quick' ? 'What will be deleted:' : 'What will be deleted:'}
</div>
<ul className="list-none space-y-1 text-sm">
<li className="flex items-center gap-2">
<X size={14} className="text-destructive" />
All features and progress
</li>
<li className="flex items-center gap-2">
<X size={14} className="text-destructive" />
Assistant chat history
</li>
<li className="flex items-center gap-2">
<X size={14} className="text-destructive" />
Agent settings
</li>
{resetType === 'full' && (
<li className="flex items-center gap-2">
<X size={14} className="text-destructive" />
App spec and prompts
</li>
)}
</ul>
</AlertDescription>
</Alert>
{/* What will be preserved */}
<div className="bg-muted/50 rounded-lg border-2 border-border p-3">
<div className="font-semibold mb-2 text-sm">
{resetType === 'quick' ? 'What will be preserved:' : 'What will be preserved:'}
</div>
<ul className="list-none space-y-1 text-sm text-muted-foreground">
{resetType === 'quick' ? (
<>
<li className="flex items-center gap-2">
<Check size={14} className="text-green-600" />
App spec and prompts
</li>
<li className="flex items-center gap-2">
<Check size={14} className="text-green-600" />
Project code and files
</li>
</>
) : (
<>
<li className="flex items-center gap-2">
<Check size={14} className="text-green-600" />
Project code and files
</li>
<li className="flex items-center gap-2 text-muted-foreground/70">
<AlertTriangle size={14} />
Setup wizard will appear
</li>
</>
)}
</ul>
</div>
{/* Error Message */}
{resetProject.isError && (
<Alert variant="destructive">
<AlertDescription>
{resetProject.error instanceof Error
? resetProject.error.message
: 'Failed to reset project. Please try again.'}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={resetProject.isPending}
>
Cancel
</Button>
<Button
variant={resetType === 'full' ? 'destructive' : 'default'}
onClick={handleReset}
disabled={resetProject.isPending}
>
{resetProject.isPending ? (
<>
<Loader2 className="animate-spin mr-2" size={16} />
Resetting...
</>
) : (
<>
{resetType === 'quick' ? 'Quick Reset' : 'Full Reset'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,11 +2,10 @@
* Schedule Modal Component
*
* Modal for managing agent schedules (create, edit, delete).
* Follows neobrutalism design patterns from SettingsModal.
*/
import { useState, useEffect, useRef } from 'react'
import { Clock, GitBranch, Trash2, X } from 'lucide-react'
import { Clock, GitBranch, Trash2 } from 'lucide-react'
import {
useSchedules,
useCreateSchedule,
@@ -23,6 +22,20 @@ import {
toggleDay,
} from '../lib/timeUtils'
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 {
projectName: string
@@ -60,38 +73,6 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
}
}, [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 handleCreateSchedule = async () => {
@@ -114,8 +95,6 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
const { time: utcTime, dayShift } = localToUTCWithDayShift(newSchedule.start_time)
// 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 scheduleToCreate = {
@@ -169,287 +148,256 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
}
return (
<div
className="neo-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div ref={modalRef} className="neo-modal p-6" style={{ maxWidth: '650px', maxHeight: '80vh' }}>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent ref={modalRef} className="sm:max-w-[650px] max-h-[80vh] flex flex-col p-0">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Clock size={24} className="text-[var(--color-neo-progress)]" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Agent Schedules</h2>
</div>
<button
ref={firstFocusableRef}
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
<DialogHeader className="p-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Clock size={24} className="text-primary" />
Agent Schedules
</DialogTitle>
</DialogHeader>
{/* Error display */}
{error && (
<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}
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto px-6">
{/* Error display */}
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Loading state */}
{isLoading && (
<div className="text-center py-8 text-gray-600 dark:text-gray-300">
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"
/>
{/* Loading state */}
{isLoading && (
<div className="text-center py-8 text-muted-foreground">
Loading schedules...
</div>
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">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,
}))
}}
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>
)}
{/* Existing schedules */}
{!isLoading && schedules.length > 0 && (
<div className="space-y-3 mb-6">
{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)
const displayDays = adjustDaysForDayShift(schedule.days_of_week, dayShift)
{/* 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 (
<button
key={day.label}
onClick={() => handleToggleDay(day.bit)}
className={`neo-btn px-3 py-2 text-sm ${
isActive
? 'bg-[var(--color-neo-progress)] text-white border-[var(--color-neo-progress)]'
: 'neo-btn-ghost'
}`}
>
{day.label}
</button>
<Card key={schedule.id}>
<CardContent className="p-4">
<div className="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-semibold">{localTime}</span>
<span className="text-sm text-muted-foreground">
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 ${
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>
)}
{/* YOLO mode toggle */}
<div className="mb-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
{/* Empty state */}
{!isLoading && schedules.length === 0 && (
<div className="text-center py-6 text-muted-foreground mb-6">
<Clock size={48} className="mx-auto mb-2 opacity-50" />
<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}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, yolo_mode: e.target.checked }))
onCheckedChange={(checked: boolean | 'indeterminate') =>
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>
</div>
<Label htmlFor="yolo-mode" className="font-normal">
YOLO Mode (skip testing)
</Label>
</div>
{/* Concurrency slider */}
<div className="mb-4">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-200 mb-2">
Concurrent Agents (1-5)
</label>
<div className="flex items-center gap-3">
<GitBranch
size={16}
className={newSchedule.max_concurrency > 1 ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'}
/>
<input
type="range"
min={1}
max={5}
value={newSchedule.max_concurrency}
{/* Concurrency slider */}
<div className="mb-4 space-y-2">
<Label>Concurrent Agents (1-5)</Label>
<div className="flex items-center gap-3">
<GitBranch
size={16}
className={newSchedule.max_concurrency > 1 ? 'text-primary' : 'text-muted-foreground'}
/>
<input
type="range"
min={1}
max={5}
value={newSchedule.max_concurrency}
onChange={(e) =>
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) =>
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 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>
{/* 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 { X, Loader2, AlertCircle } from 'lucide-react'
import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react'
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 {
isOpen: boolean
onClose: () => void
}
export function SettingsModal({ onClose }: SettingsModalProps) {
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const { data: settings, isLoading, isError, refetch } = useSettings()
const { data: modelsData } = useAvailableModels()
const updateSettings = useUpdateSettings()
const modalRef = useRef<HTMLDivElement>(null)
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 { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
const handleYoloToggle = () => {
if (settings && !updateSettings.isPending) {
@@ -80,36 +45,14 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
const isSaving = updateSettings.isPending
return (
<div
className="neo-modal-backdrop"
onClick={onClose}
role="presentation"
>
<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">
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Settings
{isSaving && (
<Loader2 className="inline-block ml-2 animate-spin" size={16} />
)}
</h2>
<button
ref={closeButtonRef}
onClick={onClose}
className="neo-btn neo-btn-ghost p-2"
aria-label="Close settings"
>
<X size={20} />
</button>
</div>
{isSaving && <Loader2 className="animate-spin" size={16} />}
</DialogTitle>
</DialogHeader>
{/* Loading State */}
{isLoading && (
@@ -121,82 +64,126 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
{/* Error State */}
{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">
<div className="flex items-center gap-2">
<AlertCircle size={18} />
<span>Failed to load settings</span>
</div>
<button
onClick={() => refetch()}
className="mt-2 underline text-sm hover:opacity-70 transition-opacity"
>
Retry
</button>
</div>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load settings
<Button
variant="link"
onClick={() => refetch()}
className="ml-2 p-0 h-auto"
>
Retry
</Button>
</AlertDescription>
</Alert>
)}
{/* Settings Content */}
{settings && !isLoading && (
<div className="space-y-6">
{/* YOLO Mode Toggle */}
<div>
<div className="flex items-center justify-between">
<div>
<label
id="yolo-label"
className="font-display font-bold text-base"
>
YOLO Mode
</label>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
Skip testing for rapid prototyping
</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'
{/* Theme Selection */}
<div className="space-y-3">
<Label className="font-medium">Theme</Label>
<div className="grid gap-2">
{THEMES.map((themeOption) => (
<button
key={themeOption.id}
onClick={() => setTheme(themeOption.id)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${
theme === themeOption.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
/>
</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>
{/* Model Selection - Radio Group */}
<div>
<label
id="model-label"
className="font-display font-bold text-base block mb-2"
>
Model
</label>
<div
className="flex border-3 border-[var(--color-neo-border)]"
role="radiogroup"
aria-labelledby="model-label"
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="dark-mode" className="font-medium">
Dark Mode
</Label>
<p className="text-sm text-muted-foreground">
Switch between light and dark appearance
</p>
</div>
<Button
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) => (
<button
key={model.id}
onClick={() => handleModelChange(model.id)}
disabled={isSaving}
role="radio"
aria-checked={settings.model === model.id}
className={`flex-1 py-3 px-4 font-display font-bold text-sm transition-colors ${
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
settings.model === model.id
? 'bg-[var(--color-neo-accent)] text-[var(--color-neo-text-on-bright)]'
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{model.name}
@@ -206,32 +193,21 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
</div>
{/* Regression Agents */}
<div>
<label
id="testing-ratio-label"
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">
<div className="space-y-2">
<Label className="font-medium">Regression Agents</Label>
<p className="text-sm text-muted-foreground">
Number of regression testing agents (0 = disabled)
</p>
<div
className="flex border-3 border-[var(--color-neo-border)]"
role="radiogroup"
aria-labelledby="testing-ratio-label"
>
<div className="flex rounded-lg border overflow-hidden">
{[0, 1, 2, 3].map((ratio) => (
<button
key={ratio}
onClick={() => handleTestingRatioChange(ratio)}
disabled={isSaving}
role="radio"
aria-checked={settings.testing_agent_ratio === ratio}
className={`flex-1 py-2 px-3 font-display font-bold text-sm transition-colors ${
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
settings.testing_agent_ratio === ratio
? 'bg-[var(--color-neo-progress)] text-[var(--color-neo-text)]'
: 'bg-[var(--color-neo-card)] text-[var(--color-neo-text)] hover:bg-[var(--color-neo-hover-subtle)]'
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground hover:bg-muted'
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{ratio}
@@ -242,13 +218,15 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
{/* Update Error */}
{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">
Failed to save settings. Please try again.
</div>
<Alert variant="destructive">
<AlertDescription>
Failed to save settings. Please try again.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,9 @@
import { useEffect, useCallback } from 'react'
import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react'
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 {
onComplete: () => void
@@ -26,98 +29,100 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
}, [checkAndComplete])
return (
<div className="min-h-screen bg-[var(--color-neo-bg)] flex items-center justify-center p-4">
<div className="neo-card w-full max-w-lg p-8">
<h1 className="font-display text-3xl font-bold text-center mb-2">
Setup Wizard
</h1>
<p className="text-center text-[var(--color-neo-text-secondary)] mb-8">
Let's make sure everything is ready to go
</p>
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardContent className="p-8">
<h1 className="font-display text-3xl font-bold text-center mb-2">
Setup Wizard
</h1>
<p className="text-center text-muted-foreground mb-8">
Let's make sure everything is ready to go
</p>
<div className="space-y-4">
{/* API Health */}
<SetupItem
label="Backend Server"
description="FastAPI server is running"
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
/>
<div className="space-y-4">
{/* API Health */}
<SetupItem
label="Backend Server"
description="FastAPI server is running"
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
/>
{/* Claude CLI */}
<SetupItem
label="Claude CLI"
description="Claude Code CLI is installed"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.claude_cli
? 'success'
: 'error'
}
helpLink="https://docs.anthropic.com/claude/claude-code"
helpText="Install Claude Code"
/>
{/* Claude CLI */}
<SetupItem
label="Claude CLI"
description="Claude Code CLI is installed"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.claude_cli
? 'success'
: 'error'
}
helpLink="https://docs.anthropic.com/claude/claude-code"
helpText="Install Claude Code"
/>
{/* Credentials */}
<SetupItem
label="Anthropic Credentials"
description="API credentials are configured"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.credentials
? 'success'
: 'error'
}
helpLink="https://console.anthropic.com/account/keys"
helpText="Get API Key"
/>
{/* Credentials */}
<SetupItem
label="Anthropic Credentials"
description="API credentials are configured"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.credentials
? 'success'
: 'error'
}
helpLink="https://console.anthropic.com/account/keys"
helpText="Get API Key"
/>
{/* Node.js */}
<SetupItem
label="Node.js"
description="Node.js is installed (for UI dev)"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.node
? 'success'
: 'warning'
}
helpLink="https://nodejs.org"
helpText="Install Node.js"
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>
{/* Node.js */}
<SetupItem
label="Node.js"
description="Node.js is installed (for UI dev)"
status={
setupLoading
? 'loading'
: setupError
? 'error'
: setupStatus?.node
? 'success'
: 'warning'
}
helpLink="https://nodejs.org"
helpText="Install Node.js"
optional
/>
</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>
)
}
@@ -140,31 +145,31 @@ function SetupItem({
optional,
}: SetupItemProps) {
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 */}
<div className="flex-shrink-0 mt-1">
{status === 'success' ? (
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
<CheckCircle2 size={24} className="text-green-500" />
) : status === 'error' ? (
<XCircle size={24} className="text-[var(--color-neo-danger)]" />
<XCircle size={24} className="text-destructive" />
) : 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>
{/* Content */}
<div className="flex-1">
<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 && (
<span className="text-xs text-[var(--color-neo-text-secondary)]">
<span className="text-xs text-muted-foreground">
(optional)
</span>
)}
</div>
<p className="text-sm text-[var(--color-neo-text-secondary)]">
<p className="text-sm text-muted-foreground">
{description}
</p>
{(status === 'error' || status === 'warning') && helpLink && (
@@ -172,7 +177,7 @@ function SetupItem({
href={helpLink}
target="_blank"
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} />
</a>

View File

@@ -12,6 +12,11 @@ import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions'
import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types'
import { isSubmitEnter } from '../lib/keyboard'
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
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
@@ -123,7 +128,7 @@ export function SpecCreationChat({
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isSubmitEnter(e)) {
e.preventDefault()
handleSendMessage()
}
@@ -193,28 +198,28 @@ export function SpecCreationChat({
switch (connectionStatus) {
case 'connected':
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} />
Connected
</span>
)
case 'connecting':
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" />
Connecting...
</span>
)
case 'error':
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} />
Error
</span>
)
default:
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} />
Disconnected
</span>
@@ -223,11 +228,11 @@ export function SpecCreationChat({
}
return (
<div className="flex flex-col h-full bg-[var(--color-neo-bg)]">
<div className="flex flex-col h-full bg-background">
{/* 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">
<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}
</h2>
<ConnectionIndicator />
@@ -235,14 +240,14 @@ export function SpecCreationChat({
<div className="flex items-center gap-2">
{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} />
Complete
</span>
)}
{/* Load Sample Prompt */}
<button
<Button
onClick={() => {
setInput(SAMPLE_PROMPT)
// Also resize the textarea to fit content
@@ -251,68 +256,76 @@ export function SpecCreationChat({
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)"
>
<FileText size={16} />
Load Sample
</button>
</Button>
{/* Exit to Project - always visible escape hatch */}
<button
<Button
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)"
>
<ExternalLink size={16} />
Exit to Project
</button>
</Button>
<button
<Button
onClick={onCancel}
className="neo-btn neo-btn-ghost p-2"
variant="ghost"
size="icon"
title="Cancel"
>
<X size={20} />
</button>
</Button>
</div>
</div>
{/* Error banner */}
{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} />
<span className="flex-1 text-sm">{error}</span>
<button
<AlertDescription className="flex-1">{error}</AlertDescription>
<Button
onClick={() => setError(null)}
className="p-1 hover:opacity-70 transition-opacity rounded"
variant="ghost"
size="icon"
className="h-6 w-6"
>
<X size={14} />
</button>
</div>
</Button>
</Alert>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="neo-card p-6 max-w-md">
<h3 className="font-display font-bold text-lg mb-2">
Starting Spec Creation
</h3>
<p className="text-sm text-[var(--color-neo-text-secondary)]">
Connecting to Claude to help you create your app specification...
</p>
{connectionStatus === 'error' && (
<button
onClick={start}
className="neo-btn neo-btn-primary mt-4 text-sm"
>
<RotateCcw size={14} />
Retry Connection
</button>
)}
</div>
<Card className="p-6 max-w-md">
<CardContent className="p-0">
<h3 className="font-display font-bold text-lg mb-2">
Starting Spec Creation
</h3>
<p className="text-sm text-muted-foreground">
Connecting to Claude to help you create your app specification...
</p>
{connectionStatus === 'error' && (
<Button
onClick={start}
className="mt-4"
size="sm"
>
<RotateCcw size={14} />
Retry Connection
</Button>
)}
</CardContent>
</Card>
</div>
)}
@@ -339,7 +352,7 @@ export function SpecCreationChat({
{/* Input area */}
{!isComplete && (
<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}
onDragOver={handleDragOver}
>
@@ -349,22 +362,21 @@ export function SpecCreationChat({
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className="relative group border-2 border-[var(--color-neo-border)] p-1 bg-[var(--color-neo-card)]"
style={{ boxShadow: 'var(--shadow-neo-sm)' }}
className="relative group border-2 border-border p-1 bg-card rounded shadow-sm"
>
<img
src={attachment.previewUrl}
alt={attachment.filename}
className="w-16 h-16 object-cover"
className="w-16 h-16 object-cover rounded"
/>
<button
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"
>
<X size={12} />
</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.substring(0, 7)}...`
: attachment.filename}
@@ -386,16 +398,17 @@ export function SpecCreationChat({
/>
{/* Attach button */}
<button
<Button
onClick={() => fileInputRef.current?.click()}
disabled={connectionStatus !== 'connected'}
className="neo-btn neo-btn-ghost p-3"
variant="ghost"
size="icon"
title="Attach image (JPEG, PNG - max 5MB)"
>
<Paperclip size={18} />
</button>
</Button>
<textarea
<Textarea
ref={inputRef}
value={input}
onChange={(e) => {
@@ -412,25 +425,25 @@ export function SpecCreationChat({
? 'Add a message with your image(s)...'
: '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'}
rows={1}
/>
<button
<Button
onClick={handleSendMessage}
disabled={
(!input.trim() && pendingAttachments.length === 0) ||
(isLoading && !currentQuestions) ||
connectionStatus !== 'connected'
}
className="neo-btn neo-btn-primary px-6"
className="px-6"
>
<Send size={18} />
</button>
</Button>
</div>
{/* 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).
</p>
</div>
@@ -438,64 +451,63 @@ export function SpecCreationChat({
{/* Completion footer */}
{isComplete && (
<div className={`p-4 border-t-3 border-[var(--color-neo-border)] ${
initializerStatus === 'error' ? 'bg-[var(--color-neo-danger)]' : 'bg-[var(--color-neo-done)]'
<div className={`p-4 border-t-2 border-border ${
initializerStatus === 'error' ? 'bg-destructive' : 'bg-green-500'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{initializerStatus === 'starting' ? (
<>
<Loader2 size={20} className="animate-spin text-[var(--color-neo-text-on-bright)]" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]">
<Loader2 size={20} className="animate-spin text-white" />
<span className="font-bold text-white">
Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...
</span>
</>
) : initializerStatus === 'error' ? (
<>
<AlertCircle size={20} className="text-[var(--color-neo-text-on-bright)]" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]">
<AlertCircle size={20} className="text-white" />
<span className="font-bold text-white">
{initializerError || 'Failed to start agent'}
</span>
</>
) : (
<>
<CheckCircle2 size={20} className="text-[var(--color-neo-text-on-bright)]" />
<span className="font-bold text-[var(--color-neo-text-on-bright)]">Specification created successfully!</span>
<CheckCircle2 size={20} className="text-white" />
<span className="font-bold text-white">Specification created successfully!</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{initializerStatus === 'error' && onRetryInitializer && (
<button
<Button
onClick={onRetryInitializer}
className="neo-btn bg-[var(--color-neo-card)]"
variant="secondary"
>
<RotateCcw size={14} />
Retry
</button>
</Button>
)}
{initializerStatus === 'idle' && (
<>
{/* YOLO Mode Toggle */}
<button
<Button
onClick={() => setYoloEnabled(!yoloEnabled)}
className={`neo-btn text-sm py-2 px-3 ${
yoloEnabled ? 'neo-btn-warning' : 'bg-[var(--color-neo-card)]'
}`}
variant={yoloEnabled ? "default" : "secondary"}
size="sm"
className={yoloEnabled ? 'bg-yellow-500 hover:bg-yellow-600 text-yellow-900' : ''}
title="YOLO Mode: Skip testing for rapid prototyping"
>
<Zap size={16} className={yoloEnabled ? 'text-yellow-900' : ''} />
<span className={yoloEnabled ? 'text-yellow-900 font-bold' : ''}>
<Zap size={16} />
<span className={yoloEnabled ? 'font-bold' : ''}>
YOLO
</span>
</button>
<button
</Button>
<Button
onClick={() => onComplete('', yoloEnabled)}
className="neo-btn neo-btn-primary"
>
Continue to Project
<ArrowRight size={16} />
</button>
</Button>
</>
)}
</div>

View File

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

View File

@@ -8,6 +8,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Plus, X } from 'lucide-react'
import type { TerminalInfo } from '@/lib/types'
import { isSubmitEnter } from '@/lib/keyboard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface TerminalTabsProps {
terminals: TerminalInfo[]
@@ -94,7 +97,7 @@ export function TerminalTabs({
// Handle key events during editing
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (isSubmitEnter(e, false)) {
e.preventDefault()
submitEdit()
} else if (e.key === 'Escape') {
@@ -154,18 +157,18 @@ export function TerminalTabs({
)
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 */}
{terminals.map((terminal) => (
<div
key={terminal.id}
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
${
activeTerminalId === terminal.id
? 'bg-neo-progress text-black'
: 'bg-[#3a3a3a] text-white hover:bg-[var(--color-neo-hover-subtle)]'
? 'bg-primary text-primary-foreground'
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
}
`}
onClick={() => onSelect(terminal.id)}
@@ -173,14 +176,14 @@ export function TerminalTabs({
onContextMenu={(e) => handleContextMenu(e, terminal.id)}
>
{editingId === terminal.id ? (
<input
<Input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={submitEdit}
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()}
/>
) : (
@@ -210,31 +213,33 @@ export function TerminalTabs({
))}
{/* Add new terminal button */}
<button
<Button
variant="ghost"
size="icon"
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"
>
<Plus className="w-4 h-4" />
</button>
</Button>
{/* Context menu */}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-50 bg-neo-card border-2 border-[var(--color-neo-border)] py-1 min-w-[120px]"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-neo-md)' }}
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 }}
>
<button
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
</button>
{terminals.length > 1 && (
<button
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
</button>

View File

@@ -0,0 +1,167 @@
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<ReturnType<typeof setTimeout> | 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', 'theme-business')
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')
} else if (previewTheme === 'business') {
root.classList.add('theme-business')
}
}
// 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', 'theme-business')
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')
} else if (currentTheme === 'business') {
root.classList.add('theme-business')
}
}
}
}, [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
*
* Shows animated dots to indicate Claude is typing/thinking.
* Styled in neobrutalism aesthetic.
*/
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-1">
<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' }}
/>
<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' }}
/>
<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' }}
/>
</div>
<span className="text-sm font-mono animate-shimmer">
<span className="text-sm font-mono text-muted-foreground">
Claude is thinking...
</span>
</div>

View File

@@ -1,4 +1,5 @@
import { LayoutGrid, GitBranch } from 'lucide-react'
import { Button } from '@/components/ui/button'
export type ViewMode = 'kanban' | 'graph'
@@ -12,35 +13,25 @@ interface ViewToggleProps {
*/
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
return (
<div className="inline-flex rounded-lg border-2 border-neo-border p-1 bg-white shadow-neo-sm">
<button
<div className="inline-flex rounded-lg border p-1 bg-background">
<Button
variant={viewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
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"
>
<LayoutGrid size={16} />
<span>Kanban</span>
</button>
<button
Kanban
</Button>
<Button
variant={viewMode === 'graph' ? 'default' : 'ghost'}
size="sm"
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"
>
<GitBranch size={16} />
<span>Graph</span>
</button>
Graph
</Button>
</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 }

View File

@@ -26,6 +26,16 @@ export function useConversation(projectName: string | null, conversationId: numb
queryFn: () => api.getAssistantConversation(projectName!, conversationId!),
enabled: !!projectName && !!conversationId,
staleTime: 30_000, // Cache for 30 seconds
retry: (failureCount, error) => {
// Don't retry on "not found" errors (404) - conversation doesn't exist
if (error instanceof Error && (
error.message.toLowerCase().includes('not found') ||
error.message === 'HTTP 404'
)) {
return false
}
return failureCount < 3
},
})
}

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