Merge pull request #831 from coleam00/ui/agent-work-order

UI/agent work order
This commit is contained in:
sean-eskerium
2025-11-01 08:13:05 -04:00
committed by GitHub
166 changed files with 26576 additions and 209 deletions

View File

@@ -0,0 +1,55 @@
# Create Git Commit
Create an atomic git commit with a properly formatted commit message following best practices for the uncommited changes or these specific files if specified.
Specific files (skip if not specified):
- File 1: $1
- File 2: $2
- File 3: $3
- File 4: $4
- File 5: $5
## Instructions
**Commit Message Format:**
- Use conventional commits: `<type>: <description>`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Present tense (e.g., "add", "fix", "update", not "added", "fixed", "updated")
- 50 characters or less for the subject line
- Lowercase subject line
- No period at the end
- Be specific and descriptive
**Examples:**
- `feat: add web search tool with structured logging`
- `fix: resolve type errors in middleware`
- `test: add unit tests for config module`
- `docs: update CLAUDE.md with testing guidelines`
- `refactor: simplify logging configuration`
- `chore: update dependencies`
**Atomic Commits:**
- One logical change per commit
- If you've made multiple unrelated changes, consider splitting into separate commits
- Commit should be self-contained and not break the build
**IMPORTANT**
- NEVER mention claude code, anthropic, co authored by or anything similar in the commit messages
## Run
1. Review changes: `git diff HEAD`
2. Check status: `git status`
3. Stage changes: `git add -A`
4. Create commit: `git commit -m "<type>: <description>"`
## Report
- Output the commit message used
- Confirm commit was successful with commit hash
- List files that were committed

View File

@@ -0,0 +1,27 @@
# Execute PRP Plan
Implement a feature plan from the PRPs directory by following its Step by Step Tasks section.
## Variables
Plan file: $ARGUMENTS
## Instructions
- Read the entire plan file carefully
- Execute **every step** in the "Step by Step Tasks" section in order, top to bottom
- Follow the "Testing Strategy" to create proper unit and integration tests
- Complete all "Validation Commands" at the end
- Ensure all linters pass and all tests pass before finishing
- Follow CLAUDE.md guidelines for type safety, logging, and docstrings
## When done
- Move the PRP file to the completed directory in PRPs/features/completed
## Report
- Summarize completed work in a concise bullet point list
- Show files and lines changed: `git diff --stat`
- Confirm all validation commands passed
- Note any deviations from the plan (if any)

View File

@@ -0,0 +1,176 @@
# NOQA Analysis and Resolution
Find all noqa/type:ignore comments in the codebase, investigate why they exist, and provide recommendations for resolution or justification.
## Instructions
**Step 1: Find all NOQA comments**
- Use Grep tool to find all noqa comments: pattern `noqa|type:\s*ignore`
- Use output_mode "content" with line numbers (-n flag)
- Search across all Python files (type: "py")
- Document total count of noqa comments found
**Step 2: For EACH noqa comment (repeat this process):**
- Read the file containing the noqa comment with sufficient context (at least 10 lines before and after)
- Identify the specific linting rule or type error being suppressed
- Understand the code's purpose and why the suppression was added
- Investigate if the suppression is still necessary or can be resolved
**Step 3: Investigation checklist for each noqa:**
- What specific error/warning is being suppressed? (e.g., `type: ignore[arg-type]`, `noqa: F401`)
- Why was the suppression necessary? (legacy code, false positive, legitimate limitation, technical debt)
- Can the underlying issue be fixed? (refactor code, update types, improve imports)
- What would it take to remove the suppression? (effort estimate, breaking changes, architectural changes)
- Is the suppression justified long-term? (external library limitation, Python limitation, intentional design)
**Step 4: Research solutions:**
- Check if newer versions of tools (mypy, ruff) handle the case better
- Look for alternative code patterns that avoid the suppression
- Consider if type stubs or Protocol definitions could help
- Evaluate if refactoring would be worthwhile
## Report Format
Create a markdown report file (create the reports directory if not created yet): `PRPs/reports/noqa-analysis-{YYYY-MM-DD}.md`
Use this structure for the report:
````markdown
# NOQA Analysis Report
**Generated:** {date}
**Total NOQA comments found:** {count}
---
## Summary
- Total suppressions: {count}
- Can be removed: {count}
- Should remain: {count}
- Requires investigation: {count}
---
## Detailed Analysis
### 1. {File path}:{line number}
**Location:** `{file_path}:{line_number}`
**Suppression:** `{noqa comment or type: ignore}`
**Code context:**
```python
{relevant code snippet}
```
````
**Why it exists:**
{explanation of why the suppression was added}
**Options to resolve:**
1. {Option 1: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
2. {Option 2: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
**Tradeoffs:**
- {Tradeoff 1}
- {Tradeoff 2}
**Recommendation:** {Remove | Keep | Refactor}
{Justification for recommendation}
---
{Repeat for each noqa comment}
````
## Example Analysis Entry
```markdown
### 1. src/shared/config.py:45
**Location:** `src/shared/config.py:45`
**Suppression:** `# type: ignore[assignment]`
**Code context:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
return key # type: ignore[assignment]
````
**Why it exists:**
MyPy cannot infer that the ValueError prevents None from being returned, so it thinks the return type could be `str | None`.
**Options to resolve:**
1. Use assert to help mypy narrow the type
- Effort: Low
- Breaking: No
- Impact: Cleaner code, removes suppression
2. Add explicit cast with typing.cast()
- Effort: Low
- Breaking: No
- Impact: More verbose but type-safe
3. Refactor to use separate validation method
- Effort: Medium
- Breaking: No
- Impact: Better separation of concerns
**Tradeoffs:**
- Option 1 (assert) is cleanest but asserts can be disabled with -O flag
- Option 2 (cast) is most explicit but adds import and verbosity
- Option 3 is most robust but requires more refactoring
**Recommendation:** Remove (use Option 1)
Replace the type:ignore with an assert statement after the if check. This helps mypy understand the control flow while maintaining runtime safety. The assert will never fail in practice since the ValueError is raised first.
**Implementation:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
assert key is not None # Help mypy understand control flow
return key
```
```
## Report
After completing the analysis:
- Output the path to the generated report file
- Summarize findings:
- Total suppressions found
- How many can be removed immediately (low effort)
- How many should remain (justified)
- How many need deeper investigation or refactoring
- Highlight any quick wins (suppressions that can be removed with minimal effort)
```

View File

@@ -0,0 +1,176 @@
# Feature Planning
Create a new plan to implement the `PRP` using the exact specified markdown `PRP Format`. Follow the `Instructions` to create the plan use the `Relevant Files` to focus on the right files.
## Variables
FEATURE $1 $2
## Instructions
- IMPORTANT: You're writing a plan to implement a net new feature based on the `Feature` that will add value to the application.
- IMPORTANT: The `Feature` describes the feature that will be implemented but remember we're not implementing a new feature, we're creating the plan that will be used to implement the feature based on the `PRP Format` below.
- Create the plan in the `PRPs/features/` directory with filename: `{descriptive-name}.md`
- Replace `{descriptive-name}` with a short, descriptive name based on the feature (e.g., "add-auth-system", "implement-search", "create-dashboard")
- Use the `PRP Format` below to create the plan.
- Deeply research the codebase to understand existing patterns, architecture, and conventions before planning the feature.
- If no patterns are established or are unclear ask the user for clarifications while providing best recommendations and options
- IMPORTANT: Replace every <placeholder> in the `PRP Format` with the requested value. Add as much detail as needed to implement the feature successfully.
- Use your reasoning model: THINK HARD about the feature requirements, design, and implementation approach.
- Follow existing patterns and conventions in the codebase. Don't reinvent the wheel.
- Design for extensibility and maintainability.
- Deeply do web research to understand the latest trends and technologies in the field.
- Figure out latest best practices and library documentation.
- Include links to relevant resources and documentation with anchor tags for easy navigation.
- If you need a new library, use `uv add <package>` and report it in the `Notes` section.
- Read `CLAUDE.md` for project principles, logging rules, testing requirements, and docstring style.
- All code MUST have type annotations (strict mypy enforcement).
- Use Google-style docstrings for all functions, classes, and modules.
- Every new file in `src/` MUST have a corresponding test file in `tests/`.
- Respect requested files in the `Relevant Files` section.
## Relevant Files
Focus on the following files and vertical slice structure:
**Core Files:**
- `CLAUDE.md` - Project instructions, logging rules, testing requirements, docstring style
app/backend core files
app/frontend core files
## PRP Format
```md
# Feature: <feature name>
## Feature Description
<describe the feature in detail, including its purpose and value to users>
## User Story
As a <type of user>
I want to <action/goal>
So that <benefit/value>
## Problem Statement
<clearly define the specific problem or opportunity this feature addresses>
## Solution Statement
<describe the proposed solution approach and how it solves the problem>
## Relevant Files
Use these files to implement the feature:
<find and list the files that are relevant to the feature describe why they are relevant in bullet points. If there are new files that need to be created to implement the feature, list them in an h3 'New Files' section. inlcude line numbers for the relevant sections>
## Relevant research docstring
Use these documentation files and links to help with understanding the technology to use:
- [Documentation Link 1](https://example.com/doc1)
- [Anchor tag]
- [Short summary]
- [Documentation Link 2](https://example.com/doc2)
- [Anchor tag]
- [Short summary]
## Implementation Plan
### Phase 1: Foundation
<describe the foundational work needed before implementing the main feature>
### Phase 2: Core Implementation
<describe the main implementation work for the feature>
### Phase 3: Integration
<describe how the feature will integrate with existing functionality>
## Step by Step Tasks
IMPORTANT: Execute every step in order, top to bottom.
<list step by step tasks as h3 headers plus bullet points. use as many h3 headers as needed to implement the feature. Order matters:
1. Start with foundational shared changes (schemas, types)
2. Implement core functionality with proper logging
3. Create corresponding test files (unit tests mirror src/ structure)
4. Add integration tests if feature interacts with multiple components
5. Verify linters pass: `uv run ruff check src/ && uv run mypy src/`
6. Ensure all tests pass: `uv run pytest tests/`
7. Your last step should be running the `Validation Commands`>
<For tool implementations:
- Define Pydantic schemas in `schemas.py`
- Implement tool with structured logging and type hints
- Register tool with Pydantic AI agent
- Create unit tests in `tests/tools/<name>/test_<module>.py`
- Add integration test in `tests/integration/` if needed>
## Testing Strategy
See `CLAUDE.md` for complete testing requirements. Every file in `src/` must have a corresponding test file in `tests/`.
### Unit Tests
<describe unit tests needed for the feature. Mark with @pytest.mark.unit. Test individual components in isolation.>
### Integration Tests
<if the feature interacts with multiple components, describe integration tests needed. Mark with @pytest.mark.integration. Place in tests/integration/ when testing full application stack.>
### Edge Cases
<list edge cases that need to be tested>
## Acceptance Criteria
<list specific, measurable criteria that must be met for the feature to be considered complete>
## Validation Commands
Execute every command to validate the feature works correctly with zero regressions.
<list commands you'll use to validate with 100% confidence the feature is implemented correctly with zero regressions. Include (example for BE Biome and TS checks are used for FE):
- Linting: `uv run ruff check src/`
- Type checking: `uv run mypy src/`
- Unit tests: `uv run pytest tests/ -m unit -v`
- Integration tests: `uv run pytest tests/ -m integration -v` (if applicable)
- Full test suite: `uv run pytest tests/ -v`
- Manual API testing if needed (curl commands, test requests)>
**Required validation commands:**
- `uv run ruff check src/` - Lint check must pass
- `uv run mypy src/` - Type check must pass
- `uv run pytest tests/ -v` - All tests must pass with zero regressions
**Run server and test core endpoints:**
- Start server: @.claude/start-server
- Test endpoints with curl (at minimum: health check, main functionality)
- Verify structured logs show proper correlation IDs and context
- Stop server after validation
## Notes
<optionally list any additional notes, future considerations, or context that are relevant to the feature that will be helpful to the developer>
```
## Feature
Extract the feature details from the `issue_json` variable (parse the JSON and use the title and body fields).
## Report
- Summarize the work you've just done in a concise bullet point list.
- Include the full path to the plan file you created (e.g., `PRPs/features/add-auth-system.md`)

View File

@@ -0,0 +1,28 @@
# Prime
Execute the following sections to understand the codebase before starting new work, then summarize your understanding.
## Run
- List all tracked files: `git ls-files`
- Show project structure: `tree -I '.venv|__pycache__|*.pyc|.pytest_cache|.mypy_cache|.ruff_cache' -L 3`
## Read
- `CLAUDE.md` - Core project instructions, principles, logging rules, testing requirements
- `python/src/agent_work_orders` - Project overview and setup (if exists)
- Identify core files in the agent work orders directory to understand what we are woerking on and its intent
## Report
Provide a concise summary of:
1. **Project Purpose**: What this application does
2. **Architecture**: Key patterns (vertical slice, FastAPI + Pydantic AI)
3. **Core Principles**: TYPE SAFETY, KISS, YAGNI
4. **Tech Stack**: Main dependencies and tools
5. **Key Requirements**: Logging, testing, type annotations
6. **Current State**: What's implemented
Keep the summary brief (5-10 bullet points) and focused on what you need to know to contribute effectively.

View File

@@ -0,0 +1,89 @@
# Code Review
Review implemented work against a PRP specification to ensure code quality, correctness, and adherence to project standards.
## Variables
Plan file: $ARGUMENTS (e.g., `PRPs/features/add-web-search.md`)
## Instructions
**Understand the Changes:**
- Check current branch: `git branch`
- Review changes: `git diff origin/main` (or `git diff HEAD` if not on a branch)
- Read the PRP plan file to understand requirements
**Code Quality Review:**
- **Type Safety**: Verify all functions have type annotations, mypy passes
- **Logging**: Check structured logging is used correctly (event names, context, exception handling)
- **Docstrings**: Ensure Google-style docstrings on all functions/classes
- **Testing**: Verify unit tests exist for all new files, integration tests if needed
- **Architecture**: Confirm vertical slice structure is followed
- **CLAUDE.md Compliance**: Check adherence to core principles (KISS, YAGNI, TYPE SAFETY)
**Validation Ruff for BE and Biome for FE:**
- Run linters: `uv run ruff check src/ && uv run mypy src/`
- Run tests: `uv run pytest tests/ -v`
- Start server and test endpoints with curl (if applicable)
- Verify structured logs show proper correlation IDs and context
**Issue Severity:**
- `blocker` - Must fix before merge (breaks build, missing tests, type errors, security issues)
- `major` - Should fix (missing logging, incomplete docstrings, poor patterns)
- `minor` - Nice to have (style improvements, optimization opportunities)
## Report
Return ONLY valid JSON (no markdown, no explanations) save to [report-#.json] in prps/reports directory create the directory if it doesn't exist. Output will be parsed with JSON.parse().
### Output Structure
```json
{
"success": "boolean - true if NO BLOCKER issues, false if BLOCKER issues exist",
"review_summary": "string - 2-4 sentences: what was built, does it match spec, quality assessment",
"review_issues": [
{
"issue_number": "number - issue index",
"file_path": "string - file with the issue (if applicable)",
"issue_description": "string - what's wrong",
"issue_resolution": "string - how to fix it",
"severity": "string - blocker|major|minor"
}
],
"validation_results": {
"linting_passed": "boolean",
"type_checking_passed": "boolean",
"tests_passed": "boolean",
"api_endpoints_tested": "boolean - true if endpoints were tested with curl"
}
}
```
## Example Success Review
```json
{
"success": true,
"review_summary": "The web search tool has been implemented with proper type annotations, structured logging, and comprehensive tests. The implementation follows the vertical slice architecture and matches all spec requirements. Code quality is high with proper error handling and documentation.",
"review_issues": [
{
"issue_number": 1,
"file_path": "src/tools/web_search/tool.py",
"issue_description": "Missing debug log for API response",
"issue_resolution": "Add logger.debug with response metadata",
"severity": "minor"
}
],
"validation_results": {
"linting_passed": true,
"type_checking_passed": true,
"tests_passed": true,
"api_endpoints_tested": true
}
}
```

View File

@@ -0,0 +1,33 @@
# Start Servers
Start both the FastAPI backend and React frontend development servers with hot reload.
## Run
### Run in the background with bash tool
- Ensure you are in the right PWD
- Use the Bash tool to run the servers in the background so you can read the shell outputs
- IMPORTANT: run `git ls-files` first so you know where directories are located before you start
### Backend Server (FastAPI)
- Navigate to backend: `cd app/backend`
- Start server in background: `uv sync && uv run python run_api.py`
- Wait 2-3 seconds for startup
- Test health endpoint: `curl http://localhost:8000/health`
- Test products endpoint: `curl http://localhost:8000/api/products`
### Frontend Server (Bun + React)
- Navigate to frontend: `cd ../app/frontend`
- Start server in background: `bun install && bun dev`
- Wait 2-3 seconds for startup
- Frontend should be accessible at `http://localhost:3000`
## Report
- Confirm backend is running on `http://localhost:8000`
- Confirm frontend is running on `http://localhost:3000`
- Show the health check response from backend
- Mention: "Backend logs will show structured JSON logging for all requests"

View File

@@ -27,15 +27,59 @@ SUPABASE_SERVICE_KEY=
LOGFIRE_TOKEN=
LOG_LEVEL=INFO
# Claude API Key (Required for Agent Work Orders)
# Get your API key from: https://console.anthropic.com/
# Required for the agent work orders service to execute Claude CLI commands
ANTHROPIC_API_KEY=
# Generate an OAUTH token in terminal and it will use your Claude OAUTH token from your subscription.
CLAUDE_CODE_OAUTH_TOKEN=
# GitHub Personal Access Token (Required for Agent Work Orders PR creation)
# Get your token from: https://github.com/settings/tokens
# Required scopes: repo, workflow
# The agent work orders service uses this for gh CLI authentication to create PRs
GITHUB_PAT_TOKEN=
# Service Ports Configuration
# These ports are used for external access to the services
HOST=localhost
ARCHON_SERVER_PORT=8181
ARCHON_MCP_PORT=8051
ARCHON_AGENTS_PORT=8052
# Agent Work Orders Port (Optional - only needed if feature is enabled)
# Leave unset or comment out if you don't plan to use agent work orders
AGENT_WORK_ORDERS_PORT=8053
ARCHON_UI_PORT=3737
ARCHON_DOCS_PORT=3838
# Agent Work Orders Feature (Optional)
# Enable the agent work orders microservice for automated task execution
# Default: false (feature disabled)
# Set to "true" to enable: ENABLE_AGENT_WORK_ORDERS=true
# When enabled, requires Claude API key and GitHub PAT (see above)
ENABLE_AGENT_WORK_ORDERS=true
# Agent Work Orders Service Configuration (Optional)
# Only needed if ENABLE_AGENT_WORK_ORDERS=true
# Set these if running agent work orders service independently
# SERVICE_DISCOVERY_MODE: Controls how services find each other
# - "local": Services run on localhost with different ports
# - "docker_compose": Services use Docker container names
SERVICE_DISCOVERY_MODE=local
# Service URLs (for agent work orders service to call other services)
# These are automatically configured based on SERVICE_DISCOVERY_MODE
# Only override if you need custom service URLs
# ARCHON_SERVER_URL=http://localhost:8181
# ARCHON_MCP_URL=http://localhost:8051
# Agent Work Orders Persistence
# STATE_STORAGE_TYPE: "memory" (default, ephemeral) or "file" (persistent)
# FILE_STATE_DIRECTORY: Directory for file-based state storage
STATE_STORAGE_TYPE=file
FILE_STATE_DIRECTORY=agent-work-orders-state
# Frontend Configuration
# VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server
# Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com

12
.gitignore vendored
View File

@@ -5,6 +5,9 @@ __pycache__
PRPs/local
PRPs/completed/
PRPs/stories/
PRPs/examples
PRPs/features
PRPs/specs
PRPs/reviews/
/logs/
.zed
@@ -12,6 +15,15 @@ tmp/
temp/
UAT/
# Temporary validation/report markdown files
/*_RESULTS.md
/*_SUMMARY.md
/*_REPORT.md
/*_SUCCESS.md
/*_COMPLETION*.md
/ACTUAL_*.md
/VALIDATION_*.md
.DS_Store
# Local release notes testing

View File

@@ -104,12 +104,19 @@ uv run ruff check # Run linter
uv run ruff check --fix # Auto-fix linting issues
uv run mypy src/ # Type check
# Agent Work Orders Service (independent microservice)
make agent-work-orders # Run agent work orders service locally on 8053
# Or manually:
uv run python -m uvicorn src.agent_work_orders.server:app --port 8053 --reload
# Docker operations
docker compose up --build -d # Start all services
docker compose --profile backend up -d # Backend only (for hybrid dev)
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose restart archon-server # Restart after code changes
docker compose --profile work-orders up -d # Include agent work orders service
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose logs -f archon-agent-work-orders # View agent work orders service logs
docker compose restart archon-server # Restart after code changes
docker compose down # Stop all services
docker compose down -v # Stop and remove volumes
```
@@ -120,8 +127,19 @@ docker compose down -v # Stop and remove volumes
# Hybrid development (recommended) - backend in Docker, frontend local
make dev # Or manually: docker compose --profile backend up -d && cd archon-ui-main && npm run dev
# Hybrid with Agent Work Orders Service - backend in Docker, agent work orders local
make dev-work-orders # Starts backend in Docker, prompts to run agent service in separate terminal
# Then in separate terminal:
make agent-work-orders # Start agent work orders service locally
# Full Docker mode
make dev-docker # Or: docker compose up --build -d
docker compose --profile work-orders up -d # Include agent work orders service
# All Local (3 terminals) - for agent work orders service development
# Terminal 1: uv run python -m uvicorn src.server.main:app --port 8181 --reload
# Terminal 2: make agent-work-orders
# Terminal 3: cd archon-ui-main && npm run dev
# Run linters before committing
make lint # Runs both frontend and backend linters

View File

@@ -5,23 +5,27 @@ SHELL := /bin/bash
# Docker compose command - prefer newer 'docker compose' plugin over standalone 'docker-compose'
COMPOSE ?= $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
.PHONY: help dev dev-docker stop test test-fe test-be lint lint-fe lint-be clean install check
.PHONY: help dev dev-docker dev-docker-full dev-work-orders dev-hybrid-work-orders stop test test-fe test-be lint lint-fe lint-be clean install check agent-work-orders
help:
@echo "Archon Development Commands"
@echo "==========================="
@echo " make dev - Backend in Docker, frontend local (recommended)"
@echo " make dev-docker - Everything in Docker"
@echo " make stop - Stop all services"
@echo " make test - Run all tests"
@echo " make test-fe - Run frontend tests only"
@echo " make test-be - Run backend tests only"
@echo " make lint - Run all linters"
@echo " make lint-fe - Run frontend linter only"
@echo " make lint-be - Run backend linter only"
@echo " make clean - Remove containers and volumes"
@echo " make install - Install dependencies"
@echo " make check - Check environment setup"
@echo " make dev - Backend in Docker, frontend local (recommended)"
@echo " make dev-docker - Backend + frontend in Docker"
@echo " make dev-docker-full - Everything in Docker (server + mcp + ui + work orders)"
@echo " make dev-hybrid-work-orders - Server + MCP in Docker, UI + work orders local (2 terminals)"
@echo " make dev-work-orders - Backend in Docker, agent work orders local, frontend local"
@echo " make agent-work-orders - Run agent work orders service locally"
@echo " make stop - Stop all services"
@echo " make test - Run all tests"
@echo " make test-fe - Run frontend tests only"
@echo " make test-be - Run backend tests only"
@echo " make lint - Run all linters"
@echo " make lint-fe - Run frontend linter only"
@echo " make lint-be - Run backend linter only"
@echo " make clean - Remove containers and volumes"
@echo " make install - Install dependencies"
@echo " make check - Check environment setup"
# Install dependencies
install:
@@ -54,18 +58,73 @@ dev: check
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
npm run dev
# Full Docker development
# Full Docker development (backend + frontend, no work orders)
dev-docker: check
@echo "Starting full Docker environment..."
@echo "Starting Docker environment (backend + frontend)..."
@$(COMPOSE) --profile full up -d --build
@echo "✓ All services running"
@echo "✓ Services running"
@echo "Frontend: http://localhost:3737"
@echo "API: http://localhost:8181"
# Full Docker with all services (server + mcp + ui + agent work orders)
dev-docker-full: check
@echo "Starting full Docker environment with agent work orders..."
@$(COMPOSE) up archon-server archon-mcp archon-frontend archon-agent-work-orders -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo "✓ All services running"; \
echo "Frontend: http://localhost:3737"; \
echo "API: http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo "MCP: http://$${HOST:-localhost}:$${ARCHON_MCP_PORT:-8051}"; \
echo "Agent Work Orders: http://$${HOST:-localhost}:$${AGENT_WORK_ORDERS_PORT:-8053}"
# Agent work orders service locally (standalone)
agent-work-orders:
@echo "Starting Agent Work Orders service locally..."
@set -a; [ -f .env ] && . ./.env; set +a; \
export SERVICE_DISCOVERY_MODE=local; \
export ARCHON_SERVER_URL=http://localhost:$${ARCHON_SERVER_PORT:-8181}; \
export ARCHON_MCP_URL=http://localhost:$${ARCHON_MCP_PORT:-8051}; \
export AGENT_WORK_ORDERS_PORT=$${AGENT_WORK_ORDERS_PORT:-8053}; \
cd python && uv run python -m uvicorn src.agent_work_orders.server:app --host 0.0.0.0 --port $${AGENT_WORK_ORDERS_PORT:-8053} --reload
# Hybrid development with agent work orders (backend in Docker, agent work orders local, frontend local)
dev-work-orders: check
@echo "Starting hybrid development with agent work orders..."
@echo "Backend: Docker | Agent Work Orders: Local | Frontend: Local"
@$(COMPOSE) up archon-server archon-mcp -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo "Backend running at http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo "Starting agent work orders service..."; \
echo "Run in separate terminal: make agent-work-orders"; \
echo "Starting frontend..."; \
cd archon-ui-main && \
VITE_ARCHON_SERVER_PORT=$${ARCHON_SERVER_PORT:-8181} \
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
npm run dev
# Hybrid development: Server + MCP in Docker, UI + Work Orders local (requires 2 terminals)
dev-hybrid-work-orders: check
@echo "Starting hybrid development: Server + MCP in Docker, UI + Work Orders local"
@echo "================================================================"
@$(COMPOSE) up archon-server archon-mcp -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo ""; \
echo "✓ Server + MCP running in Docker"; \
echo " Server: http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo " MCP: http://$${HOST:-localhost}:$${ARCHON_MCP_PORT:-8051}"; \
echo ""; \
echo "Next steps:"; \
echo " 1. Terminal 1 (this one): Press Ctrl+C when done"; \
echo " 2. Terminal 2: make agent-work-orders"; \
echo " 3. Terminal 3: cd archon-ui-main && npm run dev"; \
echo ""; \
echo "Or use 'make dev-docker-full' to run everything in Docker."; \
@read -p "Press Enter to continue or Ctrl+C to stop..." _
# Stop all services
stop:
@echo "Stopping all services..."
@$(COMPOSE) --profile backend --profile frontend --profile full down
@$(COMPOSE) --profile backend --profile frontend --profile full --profile work-orders down
@echo "✓ Services stopped"
# Run all tests

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,11 @@ components/ # Legacy components (migrating)
**Purpose**: Document processing, code analysis, project generation
**Port**: 8052
### Agent Work Orders (Optional)
**Location**: `python/src/agent_work_orders/`
**Purpose**: Workflow execution engine using Claude Code CLI
**Port**: 8053
## API Structure
### RESTful Endpoints

View File

@@ -0,0 +1,269 @@
Zustand v4 AI Coding Assistant Standards
Purpose
These guidelines define how an AI coding assistant should generate, refactor, and reason about Zustand (v4) state management code. They serve as enforceable standards to ensure clarity, consistency, maintainability, and performance across all code suggestions.
1. General Rules
• Use TypeScript for all Zustand stores.
• All stores must be defined with the create() function from Zustand v4.
• State must be immutable; never mutate arrays or objects directly.
• Use functional updates with set((state) => ...) whenever referencing existing state.
• Never use useStore.getState() inside React render logic.
2. Store Creation Rules
Do:
import { create } from 'zustand';
type CounterStore = {
count: number;
increment: () => void;
reset: () => void;
};
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 })
}));
Dont:
• Define stores inline within components.
• Create multiple stores for related state when a single one suffices.
• Nest stores inside hooks or conditional logic.
Naming conventions:
• Hook: use<Entity>Store (e.g., useUserStore, useThemeStore).
• File: same as hook (e.g., useUserStore.ts).
3. Store Organization Rules
• Each feature (e.g., agent-work-orders, knowledge, settings, etc..) should have its own store file.
• Combine complex stores using slices, not nested state.
• Use middleware (persist, devtools, immer) only when necessary.
Example structure:
src/features/knowledge/state/
├── knowledgeStore.ts
└── slices/ #If necessary
├── nameSlice.ts #a name that represents the slice if needed
4. Selector and Subscription Rules
Core Principle: Components should subscribe only to the exact slice of state they need.
Do:
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
Dont:
const { count, increment } = useCounterStore(); // ❌ Causes unnecessary re-renders
Additional rules:
• Use shallow comparison (shallow) if selecting multiple fields.
• Avoid subscribing to derived values that can be computed locally.
5. Middleware and Side Effects
Allowed middleware: persist, devtools, immer, subscribeWithSelector.
Rules:
• Never persist volatile or sensitive data (e.g., tokens, temp state).
• Configure partialize to persist only essential state.
• Guard devtools with environment checks.
Example:
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
export const useSettingsStore = create(
devtools(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' }))
}),
{
name: 'settings-store',
partialize: (state) => ({ theme: state.theme })
}
)
)
);
6. Async Logic Rules
• Async actions should be defined inside the store.
• Avoid direct useEffect calls that depend on store state.
Do:
fetchData: async () => {
const data = await api.getData();
set({ data });
}
Dont:
useEffect(() => {
useStore.getState().fetchData(); // ❌ Side effect in React hook
}, []);
7. Anti-Patterns
❌ Anti-Pattern 🚫 Reason
Subscribing to full store Causes unnecessary re-renders
Inline store creation in component Breaks referential integrity
Mutating state directly Zustand expects immutability
Business logic inside components Should live in store actions
Using store for local-only UI state Clutters global state
Multiple independent stores for one domain Increases complexity
8. Testing Rules
• Each store must be testable as a pure function.
• Tests should verify: initial state, action side effects, and immutability.
Example Jest test:
import { useCounterStore } from '../state/useCounterStore';
test('increment increases count', () => {
const { increment, count } = useCounterStore.getState();
increment();
expect(useCounterStore.getState().count).toBe(count + 1);
});
9. Documentation Rules
• Every store file must include:
• Top-level JSDoc summarizing store purpose.
• Type definitions for state and actions.
• Examples for consumption patterns.
• Maintain a STATE_GUIDELINES.md index in the repo root linking all store docs.
10. Enforcement Summary (AI Assistant Logic)
When generating Zustand code:
• ALWAYS define stores with create() at module scope.
• NEVER create stores inside React components.
• ALWAYS use selectors in components.
• AVOID getState() in render logic.
• PREFER shallow comparison for multiple subscriptions.
• LIMIT middleware to proven cases (persist, devtools, immer).
• TEST every action in isolation.
• DOCUMENT store purpose, shape, and actions.
# Zustand v3 → v4 Summary (for AI Coding Assistants)
## Overview
Zustand v4 introduced a few key syntax and type changes focused on improving TypeScript inference, middleware chaining, and internal consistency.
All existing concepts (store creation, selectors, middleware, subscriptions) remain — only the *patterns* and *type structure* changed.
---
## Core Concept Changes
- **Curried Store Creation:**
`create()` now expects a *curried call* form when using generics or middleware.
The previous single-call pattern is deprecated.
- **TypeScript Inference Improvements:**
v4s curried syntax provides stronger type inference for complex stores and middleware combinations.
- **Stricter Generic Typing:**
Functions like `set`, `get`, and the store API have tighter TypeScript types.
Any implicit `any` usage or loosely typed middleware will now error until corrected.
---
## Middleware Updates
- Middleware is still supported but must be imported from subpaths (e.g., `zustand/middleware/immer`).
- The structure of most built-in middlewares (persist, devtools, immer, subscribeWithSelector) remains identical.
- Chaining multiple middlewares now depends on the curried `create` syntax for correct type inference.
---
## Persistence and Migration
- `persist` behavior is unchanged functionally, but TypeScript typing for the `migrate` function now defines the input state as `unknown`.
You must assert or narrow this type when using TypeScript.
- The `name`, `version`, and other options are unchanged.
---
## Type Adjustments
- The `set` function now includes a `replace` parameter for full state replacement.
- `get` and `api` generics are explicitly typed and must align with the store definition.
- Custom middleware and typed stores may need to specify generic parameters to avoid inference gaps.
---
## Behavior and API Consistency
- Core APIs like `getState()`, `setState()`, and `subscribe()` are still valid.
- Hook usage (`useStore(state => state.value)`) is identical.
- Differences are primarily at compile time (typing), not runtime.
---
## Migration/Usage Implications
For AI agents generating Zustand code:
- Always use the **curried `create<Type>()(…)`** pattern when defining stores.
- Always import middleware from `zustand/middleware/...`.
- Expect `set`, `get`, and `api` to have stricter typings.
- Assume `migrate` in persistence returns `unknown` and must be asserted.
- Avoid any v3-style `create<Type>(fn)` calls.
- Middleware chaining depends on the curried syntax — never use nested functions without it.
---
## Reference Behavior
- Functional concepts are unchanged: stores, actions, and reactivity all behave the same.
- Only the declaration pattern and TypeScript inference system differ.
---
## Summary
| Area | Zustand v3 | Zustand v4 |
|------|-------------|------------|
| Store creation | Single function call | Curried two-step syntax |
| TypeScript inference | Looser | Stronger, middleware-aware |
| Middleware imports | Flat path | Sub-path imports |
| Migrate typing | `any` | `unknown` |
| API methods | Same | Same, stricter typing |
| Runtime behavior | Same | Same |
---
## Key Principle for Code Generation
> “If defining a store, always use the curried `create()` syntax, import middleware from subpaths, and respect stricter generics. All functional behavior remains identical to v3.”
---
**Recommended Source:** [Zustand v4 Migration Guide Official Docs](https://zustand.docs.pmnd.rs/migrations/migrating-to-v4)

View File

@@ -0,0 +1,89 @@
# CLI reference
> Complete reference for Claude Code command-line interface, including commands and flags.
## CLI commands
| Command | Description | Example |
| :--------------------------------- | :--------------------------------------------- | :----------------------------------------------------------------- |
| `claude` | Start interactive REPL | `claude` |
| `claude "query"` | Start REPL with initial prompt | `claude "explain this project"` |
| `claude -p "query"` | Query via SDK, then exit | `claude -p "explain this function"` |
| `cat file \| claude -p "query"` | Process piped content | `cat logs.txt \| claude -p "explain"` |
| `claude -c` | Continue most recent conversation | `claude -c` |
| `claude -c -p "query"` | Continue via SDK | `claude -c -p "Check for type errors"` |
| `claude -r "<session-id>" "query"` | Resume session by ID | `claude -r "abc123" "Finish this PR"` |
| `claude update` | Update to latest version | `claude update` |
| `claude mcp` | Configure Model Context Protocol (MCP) servers | See the [Claude Code MCP documentation](/en/docs/claude-code/mcp). |
## CLI flags
Customize Claude Code's behavior with these command-line flags:
| Flag | Description | Example |
| :------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------- |
| `--add-dir` | Add additional working directories for Claude to access (validates each path exists as a directory) | `claude --add-dir ../apps ../lib` |
| `--agents` | Define custom [subagents](/en/docs/claude-code/sub-agents) dynamically via JSON (see below for format) | `claude --agents '{"reviewer":{"description":"Reviews code","prompt":"You are a code reviewer"}}'` |
| `--allowedTools` | A list of tools that should be allowed without prompting the user for permission, in addition to [settings.json files](/en/docs/claude-code/settings) | `"Bash(git log:*)" "Bash(git diff:*)" "Read"` |
| `--disallowedTools` | A list of tools that should be disallowed without prompting the user for permission, in addition to [settings.json files](/en/docs/claude-code/settings) | `"Bash(git log:*)" "Bash(git diff:*)" "Edit"` |
| `--print`, `-p` | Print response without interactive mode (see [SDK documentation](/en/docs/claude-code/sdk) for programmatic usage details) | `claude -p "query"` |
| `--append-system-prompt` | Append to system prompt (only with `--print`) | `claude --append-system-prompt "Custom instruction"` |
| `--output-format` | Specify output format for print mode (options: `text`, `json`, `stream-json`) | `claude -p "query" --output-format json` |
| `--input-format` | Specify input format for print mode (options: `text`, `stream-json`) | `claude -p --output-format json --input-format stream-json` |
| `--include-partial-messages` | Include partial streaming events in output (requires `--print` and `--output-format=stream-json`) | `claude -p --output-format stream-json --include-partial-messages "query"` |
| `--verbose` | Enable verbose logging, shows full turn-by-turn output (helpful for debugging in both print and interactive modes) | `claude --verbose` |
| `--max-turns` | Limit the number of agentic turns in non-interactive mode | `claude -p --max-turns 3 "query"` |
| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet` or `opus`) or a model's full name | `claude --model claude-sonnet-4-5-20250929` |
| `--permission-mode` | Begin in a specified [permission mode](iam#permission-modes) | `claude --permission-mode plan` |
| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode | `claude -p --permission-prompt-tool mcp_auth_tool "query"` |
| `--resume` | Resume a specific session by ID, or by choosing in interactive mode | `claude --resume abc123 "query"` |
| `--continue` | Load the most recent conversation in the current directory | `claude --continue` |
| `--dangerously-skip-permissions` | Skip permission prompts (use with caution) | `claude --dangerously-skip-permissions` |
<Tip>
The `--output-format json` flag is particularly useful for scripting and
automation, allowing you to parse Claude's responses programmatically.
</Tip>
### Agents flag format
The `--agents` flag accepts a JSON object that defines one or more custom subagents. Each subagent requires a unique name (as the key) and a definition object with the following fields:
| Field | Required | Description |
| :------------ | :------- | :-------------------------------------------------------------------------------------------------------------- |
| `description` | Yes | Natural language description of when the subagent should be invoked |
| `prompt` | Yes | The system prompt that guides the subagent's behavior |
| `tools` | No | Array of specific tools the subagent can use (e.g., `["Read", "Edit", "Bash"]`). If omitted, inherits all tools |
| `model` | No | Model alias to use: `sonnet`, `opus`, or `haiku`. If omitted, uses the default subagent model |
Example:
```bash theme={null}
claude --agents '{
"code-reviewer": {
"description": "Expert code reviewer. Use proactively after code changes.",
"prompt": "You are a senior code reviewer. Focus on code quality, security, and best practices.",
"tools": ["Read", "Grep", "Glob", "Bash"],
"model": "sonnet"
},
"debugger": {
"description": "Debugging specialist for errors and test failures.",
"prompt": "You are an expert debugger. Analyze errors, identify root causes, and provide fixes."
}
}'
```
For more details on creating and using subagents, see the [subagents documentation](/en/docs/claude-code/sub-agents).
For detailed information about print mode (`-p`) including output formats,
streaming, verbose logging, and programmatic usage, see the
[SDK documentation](/en/docs/claude-code/sdk).
## See also
- [Interactive mode](/en/docs/claude-code/interactive-mode) - Shortcuts, input modes, and interactive features
- [Slash commands](/en/docs/claude-code/slash-commands) - Interactive session commands
- [Quickstart guide](/en/docs/claude-code/quickstart) - Getting started with Claude Code
- [Common workflows](/en/docs/claude-code/common-workflows) - Advanced workflows and patterns
- [Settings](/en/docs/claude-code/settings) - Configuration options
- [SDK documentation](/en/docs/claude-code/sdk) - Programmatic usage and integrations

View File

@@ -204,12 +204,13 @@ The reset script safely removes all tables, functions, triggers, and policies wi
### Core Services
| Service | Container Name | Default URL | Purpose |
| ------------------ | -------------- | --------------------- | --------------------------------- |
| **Web Interface** | archon-ui | http://localhost:3737 | Main dashboard and controls |
| **API Service** | archon-server | http://localhost:8181 | Web crawling, document processing |
| **MCP Server** | archon-mcp | http://localhost:8051 | Model Context Protocol interface |
| **Agents Service** | archon-agents | http://localhost:8052 | AI/ML operations, reranking |
| Service | Container Name | Default URL | Purpose |
| -------------------------- | -------------------------- | --------------------- | ------------------------------------------ |
| **Web Interface** | archon-ui | http://localhost:3737 | Main dashboard and controls |
| **API Service** | archon-server | http://localhost:8181 | Web crawling, document processing |
| **MCP Server** | archon-mcp | http://localhost:8051 | Model Context Protocol interface |
| **Agents Service** | archon-agents | http://localhost:8052 | AI/ML operations, reranking |
| **Agent Work Orders** *(optional)* | archon-agent-work-orders | http://localhost:8053 | Workflow execution with Claude Code CLI |
## Upgrading
@@ -293,12 +294,13 @@ Archon uses true microservices architecture with clear separation of concerns:
### Service Responsibilities
| Service | Location | Purpose | Key Features |
| -------------- | -------------------- | ---------------------------- | ------------------------------------------------------------------ |
| **Frontend** | `archon-ui-main/` | Web interface and dashboard | React, TypeScript, TailwindCSS, Socket.IO client |
| **Server** | `python/src/server/` | Core business logic and APIs | FastAPI, service layer, Socket.IO broadcasts, all ML/AI operations |
| **MCP Server** | `python/src/mcp/` | MCP protocol interface | Lightweight HTTP wrapper, MCP tools, session management |
| **Agents** | `python/src/agents/` | PydanticAI agent hosting | Document and RAG agents, streaming responses |
| Service | Location | Purpose | Key Features |
| ------------------------ | ------------------------------ | -------------------------------- | ------------------------------------------------------------------ |
| **Frontend** | `archon-ui-main/` | Web interface and dashboard | React, TypeScript, TailwindCSS, Socket.IO client |
| **Server** | `python/src/server/` | Core business logic and APIs | FastAPI, service layer, Socket.IO broadcasts, all ML/AI operations |
| **MCP Server** | `python/src/mcp/` | MCP protocol interface | Lightweight HTTP wrapper, MCP tools, session management |
| **Agents** | `python/src/agents/` | PydanticAI agent hosting | Document and RAG agents, streaming responses |
| **Agent Work Orders** *(optional)* | `python/src/agent_work_orders/` | Workflow execution engine | Claude Code CLI automation, repository management, SSE updates |
### Communication Patterns
@@ -321,7 +323,8 @@ By default, Archon services run on the following ports:
- **archon-ui**: 3737
- **archon-server**: 8181
- **archon-mcp**: 8051
- **archon-agents**: 8052
- **archon-agents**: 8052 (optional)
- **archon-agent-work-orders**: 8053 (optional)
- **archon-docs**: 3838 (optional)
### Changing Ports
@@ -334,6 +337,7 @@ ARCHON_UI_PORT=3737
ARCHON_SERVER_PORT=8181
ARCHON_MCP_PORT=8051
ARCHON_AGENTS_PORT=8052
AGENT_WORK_ORDERS_PORT=8053
ARCHON_DOCS_PORT=3838
```

View File

@@ -0,0 +1,13 @@
# Frontend Environment Configuration
# Agent Work Orders Service (Optional)
# Only set if agent work orders service runs on different host/port than main server
# Default: Uses proxy through main server at /api/agent-work-orders
# Set to the base URL (without /api/agent-work-orders path)
# VITE_AGENT_WORK_ORDERS_URL=http://localhost:8053
# Development Tools
# Show TanStack Query DevTools (for developers only)
# Set to "true" to enable the DevTools panel in bottom right corner
# Defaults to "false" for end users
VITE_SHOW_DEVTOOLS=false

View File

@@ -38,7 +38,8 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"tailwind-merge": "latest",
"zod": "^3.25.46"
"zod": "^3.25.46",
"zustand": "^5.0.8"
},
"devDependencies": {
"@biomejs/biome": "2.2.2",
@@ -11844,6 +11845,35 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -58,7 +58,8 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"tailwind-merge": "latest",
"zod": "^3.25.46"
"zod": "^3.25.46",
"zustand": "^5.0.8"
},
"devDependencies": {
"@biomejs/biome": "2.2.2",

View File

@@ -14,6 +14,8 @@ import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import { TooltipProvider } from './features/ui/primitives/tooltip';
import { ProjectPage } from './pages/ProjectPage';
import StyleGuidePage from './pages/StyleGuidePage';
import { AgentWorkOrdersPage } from './pages/AgentWorkOrdersPage';
import { AgentWorkOrderDetailPage } from './pages/AgentWorkOrderDetailPage';
import { DisconnectScreenOverlay } from './components/DisconnectScreenOverlay';
import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundaryWithBugReport';
import { MigrationBanner } from './components/ui/MigrationBanner';
@@ -22,7 +24,7 @@ import { useMigrationStatus } from './hooks/useMigrationStatus';
const AppRoutes = () => {
const { projectsEnabled, styleGuideEnabled } = useSettings();
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
return (
<Routes>
@@ -43,6 +45,14 @@ const AppRoutes = () => {
) : (
<Route path="/projects" element={<Navigate to="/" replace />} />
)}
{agentWorkOrdersEnabled ? (
<>
<Route path="/agent-work-orders" element={<AgentWorkOrdersPage />} />
<Route path="/agent-work-orders/:id" element={<AgentWorkOrderDetailPage />} />
</>
) : (
<Route path="/agent-work-orders" element={<Navigate to="/" replace />} />
)}
</Routes>
);
};

View File

@@ -1,4 +1,4 @@
import { BookOpen, Palette, Settings } from "lucide-react";
import { BookOpen, Bot, Palette, Settings } from "lucide-react";
import type React from "react";
import { Link, useLocation } from "react-router-dom";
// TEMPORARY: Use old SettingsContext until settings are migrated
@@ -24,7 +24,7 @@ interface NavigationProps {
*/
export function Navigation({ className }: NavigationProps) {
const location = useLocation();
const { projectsEnabled, styleGuideEnabled } = useSettings();
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
// Navigation items configuration
const navigationItems: NavigationItem[] = [
@@ -34,6 +34,12 @@ export function Navigation({ className }: NavigationProps) {
label: "Knowledge Base",
enabled: true,
},
{
path: "/agent-work-orders",
icon: <Bot className="h-5 w-5" />,
label: "Agent Work Orders",
enabled: agentWorkOrdersEnabled,
},
{
path: "/mcp",
icon: (

View File

@@ -14,10 +14,16 @@ export const FeaturesSection = () => {
setTheme
} = useTheme();
const { showToast } = useToast();
const { styleGuideEnabled, setStyleGuideEnabled: setStyleGuideContext } = useSettings();
const {
styleGuideEnabled,
setStyleGuideEnabled: setStyleGuideContext,
agentWorkOrdersEnabled,
setAgentWorkOrdersEnabled: setAgentWorkOrdersContext
} = useSettings();
const isDarkMode = theme === 'dark';
const [projectsEnabled, setProjectsEnabled] = useState(true);
const [styleGuideEnabledLocal, setStyleGuideEnabledLocal] = useState(styleGuideEnabled);
const [agentWorkOrdersEnabledLocal, setAgentWorkOrdersEnabledLocal] = useState(agentWorkOrdersEnabled);
// Commented out for future release
const [agUILibraryEnabled, setAgUILibraryEnabled] = useState(false);
@@ -38,6 +44,10 @@ export const FeaturesSection = () => {
setStyleGuideEnabledLocal(styleGuideEnabled);
}, [styleGuideEnabled]);
useEffect(() => {
setAgentWorkOrdersEnabledLocal(agentWorkOrdersEnabled);
}, [agentWorkOrdersEnabled]);
const loadSettings = async () => {
try {
setLoading(true);
@@ -224,6 +234,29 @@ export const FeaturesSection = () => {
}
};
const handleAgentWorkOrdersToggle = async (checked: boolean) => {
if (loading) return;
try {
setLoading(true);
setAgentWorkOrdersEnabledLocal(checked);
// Update context which will save to backend
await setAgentWorkOrdersContext(checked);
showToast(
checked ? 'Agent Work Orders Enabled' : 'Agent Work Orders Disabled',
checked ? 'success' : 'warning'
);
} catch (error) {
console.error('Failed to update agent work orders setting:', error);
setAgentWorkOrdersEnabledLocal(!checked);
showToast('Failed to update agent work orders setting', 'error');
} finally {
setLoading(false);
}
};
return (
<>
<div className="grid grid-cols-2 gap-4">
@@ -298,6 +331,28 @@ export const FeaturesSection = () => {
</div>
</div>
{/* Agent Work Orders Toggle */}
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-600/5 backdrop-blur-sm border border-green-500/20 shadow-lg">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 dark:text-white">
Agent Work Orders
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enable automated development workflows with Claude Code CLI
</p>
</div>
<div className="flex-shrink-0">
<Switch
size="lg"
checked={agentWorkOrdersEnabledLocal}
onCheckedChange={handleAgentWorkOrdersToggle}
color="green"
icon={<Bot className="w-5 h-5" />}
disabled={loading}
/>
</div>
</div>
{/* COMMENTED OUT FOR FUTURE RELEASE - AG-UI Library Toggle */}
{/*
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-pink-500/10 to-pink-600/5 backdrop-blur-sm border border-pink-500/20 shadow-lg">

View File

@@ -6,6 +6,8 @@ interface SettingsContextType {
setProjectsEnabled: (enabled: boolean) => Promise<void>;
styleGuideEnabled: boolean;
setStyleGuideEnabled: (enabled: boolean) => Promise<void>;
agentWorkOrdersEnabled: boolean;
setAgentWorkOrdersEnabled: (enabled: boolean) => Promise<void>;
loading: boolean;
refreshSettings: () => Promise<void>;
}
@@ -27,16 +29,18 @@ interface SettingsProviderProps {
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const [projectsEnabled, setProjectsEnabledState] = useState(true);
const [styleGuideEnabled, setStyleGuideEnabledState] = useState(false);
const [agentWorkOrdersEnabled, setAgentWorkOrdersEnabledState] = useState(false);
const [loading, setLoading] = useState(true);
const loadSettings = async () => {
try {
setLoading(true);
// Load Projects and Style Guide settings
const [projectsResponse, styleGuideResponse] = await Promise.all([
// Load Projects, Style Guide, and Agent Work Orders settings
const [projectsResponse, styleGuideResponse, agentWorkOrdersResponse] = await Promise.all([
credentialsService.getCredential('PROJECTS_ENABLED').catch(() => ({ value: undefined })),
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined }))
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined })),
credentialsService.getCredential('AGENT_WORK_ORDERS_ENABLED').catch(() => ({ value: undefined }))
]);
if (projectsResponse.value !== undefined) {
@@ -51,10 +55,17 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
setStyleGuideEnabledState(false); // Default to false
}
if (agentWorkOrdersResponse.value !== undefined) {
setAgentWorkOrdersEnabledState(agentWorkOrdersResponse.value === 'true');
} else {
setAgentWorkOrdersEnabledState(false); // Default to false
}
} catch (error) {
console.error('Failed to load settings:', error);
setProjectsEnabledState(true);
setStyleGuideEnabledState(false);
setAgentWorkOrdersEnabledState(false);
} finally {
setLoading(false);
}
@@ -106,6 +117,27 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
}
};
const setAgentWorkOrdersEnabled = async (enabled: boolean) => {
try {
// Update local state immediately
setAgentWorkOrdersEnabledState(enabled);
// Save to backend
await credentialsService.createCredential({
key: 'AGENT_WORK_ORDERS_ENABLED',
value: enabled.toString(),
is_encrypted: false,
category: 'features',
description: 'Enable Agent Work Orders feature for automated development workflows'
});
} catch (error) {
console.error('Failed to update agent work orders setting:', error);
// Revert on error
setAgentWorkOrdersEnabledState(!enabled);
throw error;
}
};
const refreshSettings = async () => {
await loadSettings();
};
@@ -115,6 +147,8 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
setProjectsEnabled,
styleGuideEnabled,
setStyleGuideEnabled,
agentWorkOrdersEnabled,
setAgentWorkOrdersEnabled,
loading,
refreshSettings
};

View File

@@ -0,0 +1,228 @@
/**
* Add Repository Modal Component
*
* Modal for adding new configured repositories with GitHub verification.
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
*/
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Checkbox } from "@/features/ui/primitives/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
import { Input } from "@/features/ui/primitives/input";
import { Label } from "@/features/ui/primitives/label";
import { useCreateRepository } from "../hooks/useRepositoryQueries";
import type { WorkflowStep } from "../types";
export interface AddRepositoryModalProps {
/** Whether modal is open */
open: boolean;
/** Callback to change open state */
onOpenChange: (open: boolean) => void;
}
/**
* All available workflow steps
*/
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
{ value: "prp-review", label: "Review/Fix", description: "Review implementation and fix issues", dependsOn: ["execute"] },
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["commit"] },
];
/**
* Default selected steps for new repositories
*/
const DEFAULT_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute"];
export function AddRepositoryModal({ open, onOpenChange }: AddRepositoryModalProps) {
const [repositoryUrl, setRepositoryUrl] = useState("");
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>(DEFAULT_STEPS);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const createRepository = useCreateRepository();
/**
* Reset form state
*/
const resetForm = () => {
setRepositoryUrl("");
setSelectedSteps(DEFAULT_STEPS);
setError("");
};
/**
* Toggle workflow step selection
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
*/
const toggleStep = (step: WorkflowStep) => {
setSelectedSteps((prev) => {
if (prev.includes(step)) {
// Removing a step - also remove steps that depend on it
const stepsToRemove = new Set([step]);
// Find all steps that transitively depend on the one being removed (cascade)
let changed = true;
while (changed) {
changed = false;
WORKFLOW_STEPS.forEach((s) => {
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
stepsToRemove.add(s.value);
changed = true;
}
});
}
return prev.filter((s) => !stepsToRemove.has(s));
}
return [...prev, step];
});
};
/**
* Check if a step is disabled based on dependencies
*/
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
if (!step.dependsOn) return false;
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
};
/**
* Handle form submission
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validation
if (!repositoryUrl.trim()) {
setError("Repository URL is required");
return;
}
if (!repositoryUrl.includes("github.com")) {
setError("Must be a GitHub repository URL");
return;
}
if (selectedSteps.length === 0) {
setError("At least one workflow step must be selected");
return;
}
try {
setIsSubmitting(true);
await createRepository.mutateAsync({
repository_url: repositoryUrl,
verify: true,
});
// Success - close modal and reset form
resetForm();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create repository");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Add Repository</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column (2/3 width) - Form Fields */}
<div className="col-span-2 space-y-4">
{/* Repository URL */}
<div className="space-y-2">
<Label htmlFor="repository-url">Repository URL *</Label>
<Input
id="repository-url"
type="url"
placeholder="https://github.com/owner/repository"
value={repositoryUrl}
onChange={(e) => setRepositoryUrl(e.target.value)}
aria-invalid={!!error}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
GitHub repository URL. We'll verify access and extract metadata automatically.
</p>
</div>
{/* Info about auto-filled fields */}
<div className="p-3 bg-blue-500/10 dark:bg-blue-400/10 border border-blue-500/20 dark:border-blue-400/20 rounded-lg">
<p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Auto-filled from GitHub:</strong>
</p>
<ul className="text-xs text-gray-600 dark:text-gray-400 mt-1 space-y-0.5 ml-4 list-disc">
<li>Display Name (can be customized later via Edit)</li>
<li>Owner/Organization</li>
<li>Default Branch</li>
</ul>
</div>
</div>
{/* Right Column (1/3 width) - Workflow Steps */}
<div className="space-y-4">
<Label>Default Workflow Steps</Label>
<div className="space-y-2">
{WORKFLOW_STEPS.map((step) => {
const isSelected = selectedSteps.includes(step.value);
const isDisabled = isStepDisabled(step);
return (
<div key={step.value} className="flex items-center gap-2">
<Checkbox
id={`step-${step.value}`}
checked={isSelected}
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
disabled={isDisabled}
aria-label={step.label}
/>
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
{step.label}
</Label>
</div>
);
})}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} variant="cyan">
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
Adding...
</>
) : (
"Add Repository"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,322 @@
/**
* Create Work Order Modal Component
*
* Two-column modal for creating work orders with improved layout.
* Left column (2/3): Form fields for repository, request, issue
* Right column (1/3): Workflow steps selection
*/
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Checkbox } from "@/features/ui/primitives/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
import { Input, TextArea } from "@/features/ui/primitives/input";
import { Label } from "@/features/ui/primitives/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
import { useRepositories } from "../hooks/useRepositoryQueries";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { SandboxType, WorkflowStep } from "../types";
export interface CreateWorkOrderModalProps {
/** Whether modal is open */
open: boolean;
/** Callback to change open state */
onOpenChange: (open: boolean) => void;
}
/**
* All available workflow steps with dependency info
*/
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; dependsOn?: WorkflowStep[] }[] = [
{ value: "create-branch", label: "Create Branch" },
{ value: "planning", label: "Planning" },
{ value: "execute", label: "Execute" },
{ value: "prp-review", label: "Review/Fix", dependsOn: ["execute"] },
{ value: "commit", label: "Commit Changes", dependsOn: ["execute"] },
{ value: "create-pr", label: "Create Pull Request", dependsOn: ["commit"] },
];
export function CreateWorkOrderModal({ open, onOpenChange }: CreateWorkOrderModalProps) {
// Read preselected repository from Zustand store
const preselectedRepositoryId = useAgentWorkOrdersStore((s) => s.preselectedRepositoryId);
const { data: repositories = [] } = useRepositories();
const createWorkOrder = useCreateWorkOrder();
const [repositoryId, setRepositoryId] = useState(preselectedRepositoryId || "");
const [repositoryUrl, setRepositoryUrl] = useState("");
const [sandboxType, setSandboxType] = useState<SandboxType>("git_worktree");
const [userRequest, setUserRequest] = useState("");
const [githubIssueNumber, setGithubIssueNumber] = useState("");
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(["create-branch", "planning", "execute", "prp-review", "commit", "create-pr"]);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
/**
* Pre-populate form when repository is selected
*/
useEffect(() => {
if (preselectedRepositoryId) {
setRepositoryId(preselectedRepositoryId);
const repo = repositories.find((r) => r.id === preselectedRepositoryId);
if (repo) {
setRepositoryUrl(repo.repository_url);
setSandboxType(repo.default_sandbox_type);
setSelectedCommands(repo.default_commands as WorkflowStep[]);
}
}
}, [preselectedRepositoryId, repositories]);
/**
* Handle repository selection change
*/
const handleRepositoryChange = (newRepositoryId: string) => {
setRepositoryId(newRepositoryId);
const repo = repositories.find((r) => r.id === newRepositoryId);
if (repo) {
setRepositoryUrl(repo.repository_url);
setSandboxType(repo.default_sandbox_type);
setSelectedCommands(repo.default_commands as WorkflowStep[]);
}
};
/**
* Toggle workflow step selection
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
*/
const toggleStep = (step: WorkflowStep) => {
setSelectedCommands((prev) => {
if (prev.includes(step)) {
// Removing a step - also remove steps that depend on it
const stepsToRemove = new Set([step]);
// Find all steps that transitively depend on the one being removed (cascade)
let changed = true;
while (changed) {
changed = false;
WORKFLOW_STEPS.forEach((s) => {
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
stepsToRemove.add(s.value);
changed = true;
}
});
}
return prev.filter((s) => !stepsToRemove.has(s));
}
return [...prev, step];
});
};
/**
* Check if a step is disabled based on dependencies
*/
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
if (!step.dependsOn) return false;
return step.dependsOn.some((dep) => !selectedCommands.includes(dep));
};
/**
* Reset form state
*/
const resetForm = () => {
setRepositoryId(preselectedRepositoryId || "");
setRepositoryUrl("");
setSandboxType("git_worktree");
setUserRequest("");
setGithubIssueNumber("");
setSelectedCommands(["create-branch", "planning", "execute"]);
setError("");
};
/**
* Handle form submission
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validation
if (!repositoryUrl.trim()) {
setError("Repository URL is required");
return;
}
if (userRequest.trim().length < 10) {
setError("Request must be at least 10 characters");
return;
}
if (selectedCommands.length === 0) {
setError("At least one workflow step must be selected");
return;
}
try {
setIsSubmitting(true);
// Sort selected commands by WORKFLOW_STEPS order before sending to backend
// This ensures correct execution order regardless of checkbox click order
const sortedCommands = WORKFLOW_STEPS
.filter(step => selectedCommands.includes(step.value))
.map(step => step.value);
await createWorkOrder.mutateAsync({
repository_url: repositoryUrl,
sandbox_type: sandboxType,
user_request: userRequest,
github_issue_number: githubIssueNumber || undefined,
selected_commands: sortedCommands,
});
// Success - close modal and reset
resetForm();
onOpenChange(false);
} catch (err) {
// Preserve error details by truncating long messages instead of hiding them
// Show up to 500 characters to capture important debugging information
// while keeping the UI readable
const maxLength = 500;
let userMessage = "Failed to create work order. Please try again.";
if (err instanceof Error && err.message) {
if (err.message.length <= maxLength) {
userMessage = err.message;
} else {
// Truncate but preserve the start which often contains the most important details
userMessage = `${err.message.slice(0, maxLength)}... (truncated, ${err.message.length - maxLength} more characters)`;
}
}
setError(userMessage);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Create Work Order</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column (2/3 width) - Form Fields */}
<div className="col-span-2 space-y-4">
{/* Repository Selector */}
<div className="space-y-2">
<Label htmlFor="repository">Repository</Label>
<Select value={repositoryId} onValueChange={handleRepositoryChange}>
<SelectTrigger id="repository" aria-label="Select repository">
<SelectValue placeholder="Select a repository..." />
</SelectTrigger>
<SelectContent>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.display_name || repo.repository_url}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* User Request */}
<div className="space-y-2">
<Label htmlFor="user-request">Work Request</Label>
<TextArea
id="user-request"
placeholder="Describe the work you want the agent to perform..."
rows={4}
value={userRequest}
onChange={(e) => setUserRequest(e.target.value)}
aria-invalid={!!error && userRequest.length < 10}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">Minimum 10 characters</p>
</div>
{/* GitHub Issue Number (optional) */}
<div className="space-y-2">
<Label htmlFor="github-issue">GitHub Issue Number (Optional)</Label>
<Input
id="github-issue"
type="text"
placeholder="e.g., 42"
value={githubIssueNumber}
onChange={(e) => setGithubIssueNumber(e.target.value)}
/>
</div>
{/* Sandbox Type */}
<div className="space-y-2">
<Label htmlFor="sandbox-type">Sandbox Type</Label>
<Select value={sandboxType} onValueChange={(value) => setSandboxType(value as SandboxType)}>
<SelectTrigger id="sandbox-type" aria-label="Select sandbox type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="git_worktree">Git Worktree (Recommended)</SelectItem>
<SelectItem value="git_branch">Git Branch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Right Column (1/3 width) - Workflow Steps */}
<div className="space-y-4">
<Label>Workflow Steps</Label>
<div className="space-y-2">
{WORKFLOW_STEPS.map((step) => {
const isSelected = selectedCommands.includes(step.value);
const isDisabled = isStepDisabled(step);
return (
<div key={step.value} className="flex items-center gap-2">
<Checkbox
id={`step-${step.value}`}
checked={isSelected}
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
disabled={isDisabled}
aria-label={step.label}
/>
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
{step.label}
</Label>
</div>
);
})}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} variant="cyan">
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
Creating...
</>
) : (
"Create Work Order"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,269 @@
/**
* Edit Repository Modal Component
*
* Modal for editing configured repository settings.
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
*/
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Checkbox } from "@/features/ui/primitives/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
import { Label } from "@/features/ui/primitives/label";
import { SimpleTooltip, TooltipProvider } from "@/features/ui/primitives/tooltip";
import { useUpdateRepository } from "../hooks/useRepositoryQueries";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { WorkflowStep } from "../types";
export interface EditRepositoryModalProps {
/** Whether modal is open */
open: boolean;
/** Callback to change open state */
onOpenChange: (open: boolean) => void;
}
/**
* All available workflow steps
*/
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
{ value: "prp-review", label: "Review/Fix", description: "Review implementation and fix issues", dependsOn: ["execute"] },
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["commit"] },
];
export function EditRepositoryModal({ open, onOpenChange }: EditRepositoryModalProps) {
// Read editing repository from Zustand store
const repository = useAgentWorkOrdersStore((s) => s.editingRepository);
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>([]);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const updateRepository = useUpdateRepository();
/**
* Pre-populate form when repository changes
*/
useEffect(() => {
if (repository) {
setSelectedSteps(repository.default_commands);
setError("");
}
}, [repository]);
/**
* Toggle workflow step selection
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
*/
const toggleStep = (step: WorkflowStep) => {
setSelectedSteps((prev) => {
if (prev.includes(step)) {
// Removing a step - also remove steps that depend on it
const stepsToRemove = new Set([step]);
// Find all steps that transitively depend on the one being removed (cascade)
let changed = true;
while (changed) {
changed = false;
WORKFLOW_STEPS.forEach((s) => {
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
stepsToRemove.add(s.value);
changed = true;
}
});
}
return prev.filter((s) => !stepsToRemove.has(s));
}
return [...prev, step];
});
};
/**
* Check if a step is disabled based on dependencies
*/
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
if (!step.dependsOn) return false;
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
};
/**
* Handle form submission
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!repository) return;
setError("");
// Validation
if (selectedSteps.length === 0) {
setError("At least one workflow step must be selected");
return;
}
try {
setIsSubmitting(true);
// Sort selected steps by WORKFLOW_STEPS order before sending to backend
const sortedSteps = WORKFLOW_STEPS
.filter(step => selectedSteps.includes(step.value))
.map(step => step.value);
await updateRepository.mutateAsync({
id: repository.id,
request: {
default_sandbox_type: repository.default_sandbox_type,
default_commands: sortedSteps,
},
});
// Success - close modal
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update repository");
} finally {
setIsSubmitting(false);
}
};
if (!repository) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Repository</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column (2/3 width) - Repository Info */}
<div className="col-span-2 space-y-4">
{/* Repository Info Card */}
<div className="p-4 bg-gray-500/10 dark:bg-gray-400/10 border border-gray-500/20 dark:border-gray-400/20 rounded-lg space-y-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Repository Information</h4>
<div className="space-y-2 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">URL: </span>
<span className="text-gray-900 dark:text-white font-mono text-xs">{repository.repository_url}</span>
</div>
{repository.display_name && (
<div>
<span className="text-gray-500 dark:text-gray-400">Name: </span>
<span className="text-gray-900 dark:text-white">{repository.display_name}</span>
</div>
)}
{repository.owner && (
<div>
<span className="text-gray-500 dark:text-gray-400">Owner: </span>
<span className="text-gray-900 dark:text-white">{repository.owner}</span>
</div>
)}
{repository.default_branch && (
<div>
<span className="text-gray-500 dark:text-gray-400">Branch: </span>
<span className="text-gray-900 dark:text-white font-mono text-xs">
{repository.default_branch}
</span>
</div>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Repository metadata is auto-filled from GitHub and cannot be edited directly.
</p>
</div>
</div>
{/* Right Column (1/3 width) - Workflow Steps */}
<div className="space-y-4">
<Label>Default Workflow Steps</Label>
<TooltipProvider>
<div className="space-y-2">
{WORKFLOW_STEPS.map((step) => {
const isSelected = selectedSteps.includes(step.value);
const isDisabledForEnable = isStepDisabled(step);
const tooltipMessage = isDisabledForEnable && step.dependsOn
? `Requires: ${step.dependsOn.map((dep) => WORKFLOW_STEPS.find((s) => s.value === dep)?.label ?? dep).join(", ")}`
: undefined;
const checkbox = (
<Checkbox
id={`edit-step-${step.value}`}
checked={isSelected}
onCheckedChange={() => {
if (!isDisabledForEnable) {
toggleStep(step.value);
}
}}
disabled={isDisabledForEnable}
aria-label={step.label}
/>
);
return (
<div key={step.value} className="flex items-center gap-2">
{tooltipMessage ? (
<SimpleTooltip content={tooltipMessage} side="right">
{checkbox}
</SimpleTooltip>
) : (
checkbox
)}
<Label
htmlFor={`edit-step-${step.value}`}
className={
isDisabledForEnable
? "text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "cursor-pointer"
}
>
{step.label}
</Label>
</div>
);
})}
</div>
</TooltipProvider>
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
</div>
</div>
{/* Error Message */}
{error && (
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} variant="cyan">
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
Updating...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,209 @@
import { Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
import { cn } from "@/features/ui/primitives/styles";
import { Switch } from "@/features/ui/primitives/switch";
import type { LogEntry } from "../types";
interface ExecutionLogsProps {
/** Log entries to display (from SSE stream or historical data) */
logs: LogEntry[];
/** Whether logs are from live SSE stream (shows "Live" indicator) */
isLive?: boolean;
/** Callback to clear logs (optional, defaults to no-op) */
onClearLogs?: () => void;
}
/**
* Get color class for log level badge - STATIC lookup
*/
const logLevelColors: Record<string, string> = {
info: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-400/30",
warning: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-400/30",
error: "bg-red-500/20 text-red-600 dark:text-red-400 border-red-400/30",
debug: "bg-gray-500/20 text-gray-600 dark:text-gray-400 border-gray-400/30",
};
/**
* Format timestamp to relative time
*/
function formatRelativeTime(timestamp: string): string {
const now = Date.now();
const logTime = new Date(timestamp).getTime();
const diffSeconds = Math.floor((now - logTime) / 1000);
if (diffSeconds < 0) return "just now";
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
return `${Math.floor(diffSeconds / 3600)}h ago`;
}
/**
* Individual log entry component
*/
function LogEntryRow({ log }: { log: LogEntry }) {
const colorClass = logLevelColors[log.level] || logLevelColors.debug;
return (
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 dark:hover:bg-black/20 rounded font-mono text-sm">
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">
{formatRelativeTime(log.timestamp)}
</span>
<span className={cn("px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap", colorClass)}>
{log.level}
</span>
{log.step && <span className="text-cyan-600 dark:text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
<span className="text-gray-900 dark:text-gray-300 flex-1 min-w-0">{log.event}</span>
{log.progress && (
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">{log.progress}</span>
)}
</div>
);
}
export function ExecutionLogs({ logs, isLive = false, onClearLogs = () => {} }: ExecutionLogsProps) {
const [autoScroll, setAutoScroll] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [localLogs, setLocalLogs] = useState<LogEntry[]>(logs);
const [isCleared, setIsCleared] = useState(false);
const previousLogsLengthRef = useRef<number>(logs.length);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Update local logs when props change
useEffect(() => {
const currentLogsLength = logs.length;
const previousLogsLength = previousLogsLengthRef.current;
// If we cleared logs, only update if new logs arrive (length increases)
if (isCleared) {
if (currentLogsLength > previousLogsLength) {
// New logs arrived after clear - reset cleared state and show new logs
setLocalLogs(logs);
setIsCleared(false);
}
// Otherwise, keep local logs empty (user's cleared view)
} else {
// Normal case: update local logs with prop changes
setLocalLogs(logs);
}
previousLogsLengthRef.current = currentLogsLength;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logs]);
// Filter logs by level
const filteredLogs = levelFilter === "all" ? localLogs : localLogs.filter((log) => log.level === levelFilter);
/**
* Handle clear logs button click
*/
const handleClearLogs = () => {
setLocalLogs([]);
setIsCleared(true);
onClearLogs();
};
/**
* Auto-scroll to bottom when new logs arrive (if enabled)
*/
useEffect(() => {
if (autoScroll && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, [localLogs.length, autoScroll]); // Trigger on new logs, not filtered logs
return (
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg overflow-hidden bg-black/20 dark:bg-white/5 backdrop-blur">
{/* Header with controls */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-gray-700/30 bg-gray-900/50 dark:bg-gray-800/30">
<div className="flex items-center gap-3">
<span className="font-semibold text-gray-900 dark:text-gray-300">Execution Logs</span>
{/* Live/Historical indicator */}
{isLive ? (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full animate-pulse" />
<span className="text-xs text-green-600 dark:text-green-400">Live</span>
</div>
) : (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-gray-500 dark:bg-gray-400 rounded-full" />
<span className="text-xs text-gray-500 dark:text-gray-400">Historical</span>
</div>
)}
<span className="text-xs text-gray-500 dark:text-gray-400">({filteredLogs.length} entries)</span>
</div>
{/* Controls */}
<div className="flex items-center gap-3">
{/* Level filter using proper Select primitive */}
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-32 h-8 text-xs" aria-label="Filter log level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
</SelectContent>
</Select>
{/* Auto-scroll toggle using Switch primitive */}
<div className="flex items-center gap-2">
<label htmlFor="auto-scroll-toggle" className="text-xs text-gray-700 dark:text-gray-300">
Auto-scroll:
</label>
<Switch
id="auto-scroll-toggle"
checked={autoScroll}
onCheckedChange={setAutoScroll}
aria-label="Toggle auto-scroll"
/>
<span
className={cn(
"text-xs font-medium",
autoScroll ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
)}
>
{autoScroll ? "ON" : "OFF"}
</span>
</div>
{/* Clear logs button */}
<Button
variant="ghost"
size="sm"
onClick={handleClearLogs}
className="h-8 text-xs text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
aria-label="Clear logs"
disabled={localLogs.length === 0}
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
Clear logs
</Button>
</div>
</div>
{/* Log content - scrollable area */}
<div ref={scrollContainerRef} className="max-h-96 overflow-y-auto bg-black/40 dark:bg-black/20">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
<p>No logs match the current filter</p>
</div>
) : (
<div className="p-2">
{filteredLogs.map((log, index) => (
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { Activity, ChevronDown, ChevronUp, Clock, TrendingUp } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { useStepHistory, useWorkOrderLogs } from "../hooks/useAgentWorkOrderQueries";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { LiveProgress } from "../state/slices/sseSlice";
import type { LogEntry } from "../types";
import { ExecutionLogs } from "./ExecutionLogs";
interface RealTimeStatsProps {
/** Work order ID to stream logs for */
workOrderId: string | undefined;
}
/**
* Stable empty array reference to prevent infinite re-renders
* CRITICAL: Never use `|| []` in Zustand selectors - creates new reference each render
*/
const EMPTY_LOGS: never[] = [];
/**
* Type guard to narrow LogEntry to one with required step_number and total_steps
*/
type LogEntryWithSteps = LogEntry & {
step_number: number;
total_steps: number;
};
function hasStepInfo(log: LogEntry): log is LogEntryWithSteps {
return log.step_number !== undefined && log.total_steps !== undefined;
}
/**
* Calculate progress metrics from log entries
* Used as fallback when no SSE progress data exists (e.g., after refresh)
*/
function useCalculateProgressFromLogs(logs: LogEntry[]): LiveProgress | null {
return useMemo(() => {
if (logs.length === 0) return null;
// Find latest progress-related logs using type guard for proper narrowing
const stepLogs = logs.filter(hasStepInfo);
const latestStepLog = stepLogs[stepLogs.length - 1];
const workflowCompleted = logs.some((log) => log.event === "workflow_completed");
const workflowFailed = logs.some((log) => log.event === "workflow_failed" || log.level === "error");
const latestElapsed = logs.reduce((max, log) => {
return log.elapsed_seconds !== undefined && log.elapsed_seconds > max ? log.elapsed_seconds : max;
}, 0);
if (!latestStepLog && logs.length > 0) {
// Have logs but no step info - show minimal progress
return {
currentStep: "initializing",
progressPct: workflowCompleted ? 100 : workflowFailed ? 0 : 10,
elapsedSeconds: latestElapsed,
status: workflowCompleted ? "completed" : workflowFailed ? "failed" : "running",
};
}
if (latestStepLog) {
// Type guard ensures step_number and total_steps are defined, so safe to access
const stepNumber = latestStepLog.step_number;
const totalSteps = latestStepLog.total_steps;
const completedSteps = stepNumber - 1;
return {
currentStep: latestStepLog.step || "unknown",
stepNumber: stepNumber,
totalSteps: totalSteps,
progressPct: workflowCompleted ? 100 : Math.round((completedSteps / totalSteps) * 100),
elapsedSeconds: latestElapsed,
status: workflowCompleted ? "completed" : workflowFailed ? "failed" : "running",
};
}
return null;
}, [logs]);
}
/**
* Calculate progress from step history (persistent database data)
* Used when logs are not available (completed work orders, server restart)
*/
function useCalculateProgressFromSteps(stepHistory: any): LiveProgress | null {
return useMemo(() => {
if (!stepHistory?.steps || stepHistory.steps.length === 0) return null;
const steps = stepHistory.steps;
const totalSteps = steps.length;
const completedSteps = steps.filter((s: any) => s.success).length;
const lastStep = steps[steps.length - 1];
const hasFailure = steps.some((s: any) => !s.success);
// Calculate total duration
const totalDuration = steps.reduce((sum: number, step: any) => sum + (step.duration_seconds || 0), 0);
return {
currentStep: lastStep.step,
stepNumber: totalSteps,
totalSteps: totalSteps,
progressPct: Math.round((completedSteps / totalSteps) * 100),
elapsedSeconds: Math.round(totalDuration),
status: hasFailure ? "failed" : "completed",
};
}, [stepHistory]);
}
/**
* Convert step history to log entries for display
*/
function useConvertStepsToLogs(stepHistory: any): LogEntry[] {
return useMemo(() => {
if (!stepHistory?.steps) return [];
return stepHistory.steps.map((step: any, index: number) => ({
work_order_id: stepHistory.agent_work_order_id,
level: step.success ? ("info" as const) : ("error" as const),
event: step.success ? `Step completed: ${step.step}` : `Step failed: ${step.step}`,
timestamp: step.timestamp,
step: step.step,
step_number: index + 1,
total_steps: stepHistory.steps.length,
elapsed_seconds: Math.round(step.duration_seconds),
output: step.output || step.error_message,
})) as LogEntry[];
}, [stepHistory]);
}
/**
* Format elapsed seconds to human-readable duration
*/
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
export function RealTimeStats({ workOrderId }: RealTimeStatsProps) {
const [showLogs, setShowLogs] = useState(false);
// Zustand SSE slice - connection management and live data
const connectToLogs = useAgentWorkOrdersStore((s) => s.connectToLogs);
const disconnectFromLogs = useAgentWorkOrdersStore((s) => s.disconnectFromLogs);
const clearLogs = useAgentWorkOrdersStore((s) => s.clearLogs);
const sseProgress = useAgentWorkOrdersStore((s) => s.liveProgress[workOrderId ?? ""]);
const sseLogs = useAgentWorkOrdersStore((s) => s.liveLogs[workOrderId ?? ""]);
// Fetch historical logs from backend as fallback (for refresh/HMR)
const { data: historicalLogsData } = useWorkOrderLogs(workOrderId, { limit: 500 });
// Fetch step history for completed work orders (persistent data)
const { data: stepHistoryData } = useStepHistory(workOrderId);
// Calculate progress from step history (fallback for completed work orders)
const stepsProgress = useCalculateProgressFromSteps(stepHistoryData);
const stepsLogs = useConvertStepsToLogs(stepHistoryData);
// Data priority: SSE > Historical Logs API > Step History
const logs =
sseLogs && sseLogs.length > 0
? sseLogs
: historicalLogsData?.log_entries && historicalLogsData.log_entries.length > 0
? historicalLogsData.log_entries
: stepsLogs;
const progress = sseProgress || stepsProgress;
// Logs are "live" only if coming from SSE
const isLiveData = sseLogs && sseLogs.length > 0;
// Live elapsed time that updates every second
const [currentElapsedSeconds, setCurrentElapsedSeconds] = useState<number | null>(null);
/**
* Connect to SSE on mount for real-time updates
* Note: connectToLogs and disconnectFromLogs are stable Zustand actions
*/
useEffect(() => {
if (workOrderId) {
connectToLogs(workOrderId);
return () => disconnectFromLogs(workOrderId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workOrderId]);
/**
* Update elapsed time every second if work order is running
*/
useEffect(() => {
const isRunning = progress?.status !== "completed" && progress?.status !== "failed";
if (!progress || !isRunning) {
setCurrentElapsedSeconds(progress?.elapsedSeconds ?? null);
return;
}
// Start from last known elapsed time or 0
const startTime = Date.now();
const initialElapsed = progress.elapsedSeconds || 0;
const interval = setInterval(() => {
const additionalSeconds = Math.floor((Date.now() - startTime) / 1000);
setCurrentElapsedSeconds(initialElapsed + additionalSeconds);
}, 1000);
return () => clearInterval(interval);
}, [progress?.status, progress?.elapsedSeconds, progress]);
// Only hide if we have absolutely no data from any source
if (!progress && logs.length === 0) {
return null;
}
const currentStep = progress?.currentStep || "initializing";
const stepDisplay =
progress?.stepNumber !== undefined && progress?.totalSteps !== undefined
? `(${progress.stepNumber}/${progress.totalSteps})`
: "";
const progressPct = progress?.progressPct || 0;
const elapsedSeconds = currentElapsedSeconds !== null ? currentElapsedSeconds : progress?.elapsedSeconds || 0;
const latestLog = logs[logs.length - 1];
const currentActivity = latestLog?.event || "Initializing workflow...";
// Determine status for display
const status = progress?.status || "running";
const isRunning = status === "running";
const isCompleted = status === "completed";
const isFailed = status === "failed";
// Status display configuration
const statusConfig = {
running: { label: "Running", color: "text-blue-600 dark:text-blue-400", bgColor: "bg-blue-500 dark:bg-blue-400" },
completed: {
label: "Completed",
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500 dark:bg-green-400",
},
failed: { label: "Failed", color: "text-red-600 dark:text-red-400", bgColor: "bg-red-500 dark:bg-red-400" },
};
const currentStatus = statusConfig[status as keyof typeof statusConfig] || statusConfig.running;
return (
<div className="space-y-3">
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg p-4 bg-black/20 dark:bg-white/5 backdrop-blur">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-300 mb-3 flex items-center gap-2">
<Activity className="w-4 h-4" aria-hidden="true" />
{isRunning ? "Real-Time Execution" : "Execution Summary"}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Current Step */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Current Step</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
{currentStep}
{stepDisplay && <span className="text-gray-500 dark:text-gray-400 ml-2">{stepDisplay}</span>}
</div>
</div>
{/* Progress */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
<TrendingUp className="w-3 h-3" aria-hidden="true" />
Progress
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 h-2 bg-gray-700 dark:bg-gray-200/20 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 dark:from-cyan-400 dark:to-blue-400 transition-all duration-500 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-sm font-medium text-cyan-600 dark:text-cyan-400">{progressPct}%</span>
</div>
</div>
</div>
{/* Elapsed Time */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" />
Elapsed Time
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">{formatDuration(elapsedSeconds)}</div>
</div>
</div>
{/* Latest Activity with Status Indicator - at top */}
<div className="mt-4 pt-3 border-t border-white/10 dark:border-gray-700/30">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap">
Latest Activity:
</div>
<div className="text-sm text-gray-900 dark:text-gray-300 flex-1 min-w-0 truncate">{currentActivity}</div>
</div>
{/* Status Indicator - right side of Latest Activity */}
<div className={`flex items-center gap-1 text-xs ${currentStatus.color} flex-shrink-0`}>
<div className={`w-2 h-2 ${currentStatus.bgColor} rounded-full ${isRunning ? "animate-pulse" : ""}`} />
<span>{currentStatus.label}</span>
</div>
</div>
</div>
{/* Show Execution Logs button - at bottom */}
<div className="mt-3 pt-3 border-t border-white/10 dark:border-gray-700/30">
<Button
variant="ghost"
size="sm"
onClick={() => setShowLogs(!showLogs)}
className="w-full justify-center text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
aria-label={showLogs ? "Hide execution logs" : "Show execution logs"}
aria-expanded={showLogs}
>
{showLogs ? (
<>
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
Hide Execution Logs
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
Show Execution Logs
</>
)}
</Button>
</div>
</div>
{/* Collapsible Execution Logs */}
{showLogs && (
<ExecutionLogs
logs={logs}
isLive={isLiveData}
onClearLogs={() => {
if (workOrderId) {
clearLogs(workOrderId);
}
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,322 @@
/**
* Repository Card Component
*
* Displays a configured repository with custom stat pills matching the example layout.
* Uses SelectableCard primitive with glassmorphism styling.
*/
import { Activity, CheckCircle2, Clock, Copy, Edit, Trash2 } from "lucide-react";
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
import { cn } from "@/features/ui/primitives/styles";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { ConfiguredRepository } from "../types/repository";
export interface RepositoryCardProps {
/** Repository data to display */
repository: ConfiguredRepository;
/** Whether this repository is currently selected */
isSelected?: boolean;
/** Whether to show aurora glow effect (when selected) */
showAuroraGlow?: boolean;
/** Callback when repository is selected */
onSelect?: () => void;
/** Callback when delete button is clicked */
onDelete?: () => void;
/** Work order statistics for this repository */
stats?: {
total: number;
active: number;
done: number;
};
}
/**
* Get background class based on card state
*/
function getBackgroundClass(isSelected: boolean): string {
if (isSelected) {
return "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20";
}
return "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30";
}
/**
* Copy text to clipboard
*/
async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy:", err);
return false;
}
}
export function RepositoryCard({
repository,
isSelected = false,
showAuroraGlow = false,
onSelect,
onDelete,
stats = { total: 0, active: 0, done: 0 },
}: RepositoryCardProps) {
// Get modal action from Zustand store (no prop drilling)
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
const backgroundClass = getBackgroundClass(isSelected);
const handleCopyUrl = async (e: React.MouseEvent) => {
e.stopPropagation();
const success = await copyToClipboard(repository.repository_url);
if (success) {
// Could add toast notification here
console.log("Repository URL copied to clipboard");
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
openEditRepoModal(repository);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete) {
onDelete();
}
};
return (
<SelectableCard
isSelected={isSelected}
isPinned={false}
showAuroraGlow={showAuroraGlow}
onSelect={onSelect}
size="none"
blur="xl"
className={cn("w-72 min-h-[180px] flex flex-col shrink-0", backgroundClass)}
>
{/* Main content */}
<div className="flex-1 min-w-0 p-3 pb-2">
{/* Title */}
<div className="flex flex-col items-center justify-center mb-4 min-h-[48px]">
<h3
className={cn(
"font-medium text-center leading-tight line-clamp-2 transition-all duration-300",
isSelected
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
: "text-gray-500 dark:text-gray-400",
)}
>
{repository.display_name || repository.repository_url.replace("https://github.com/", "")}
</h3>
</div>
{/* Work order count pills - 3 custom pills with icons */}
<div className="flex items-stretch gap-2 w-full">
{/* Total pill */}
<div className="relative flex-1 min-w-0">
<div
className={cn(
"absolute inset-0 bg-pink-600 dark:bg-pink-400 rounded-full blur-md",
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
)}
/>
<div
className={cn(
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
isSelected
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
)}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<Clock
className={cn(
"w-4 h-4",
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
)}
aria-hidden="true"
/>
<span
className={cn(
"text-[8px] font-medium",
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
)}
>
Total
</span>
</div>
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-pink-300 dark:border-pink-500/30">
<span
className={cn(
"text-lg font-bold",
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
)}
>
{stats.total}
</span>
</div>
</div>
</div>
{/* In Progress pill */}
<div className="relative flex-1 min-w-0">
<div
className={cn(
"absolute inset-0 bg-blue-600 dark:bg-blue-400 rounded-full blur-md",
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
)}
/>
<div
className={cn(
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
isSelected
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
)}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<Activity
className={cn(
"w-4 h-4",
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
)}
aria-hidden="true"
/>
<span
className={cn(
"text-[8px] font-medium",
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
)}
>
Active
</span>
</div>
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-blue-300 dark:border-blue-500/30">
<span
className={cn(
"text-lg font-bold",
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
)}
>
{stats.active}
</span>
</div>
</div>
</div>
{/* Completed pill */}
<div className="relative flex-1 min-w-0">
<div
className={cn(
"absolute inset-0 bg-green-600 dark:bg-green-400 rounded-full blur-md",
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
)}
/>
<div
className={cn(
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
isSelected
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)]"
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
)}
>
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
<CheckCircle2
className={cn(
"w-4 h-4",
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
)}
aria-hidden="true"
/>
<span
className={cn(
"text-[8px] font-medium",
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
)}
>
Done
</span>
</div>
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-green-300 dark:border-green-500/30">
<span
className={cn(
"text-lg font-bold",
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
)}
>
{stats.done}
</span>
</div>
</div>
</div>
</div>
{/* Verification status */}
{repository.is_verified && (
<div className="flex justify-center mt-3">
<span className="text-xs text-green-600 dark:text-green-400"> Verified</span>
</div>
)}
</div>
{/* Bottom bar with action icons */}
<div className="flex items-center justify-end gap-2 px-3 py-2 mt-auto border-t border-gray-200/30 dark:border-gray-700/20">
<TooltipProvider>
{/* Edit button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleEdit}
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
aria-label="Edit repository"
>
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
{/* Copy URL button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopyUrl}
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
aria-label="Copy repository URL"
>
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
{/* Delete button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleDelete}
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
aria-label="Delete repository"
>
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</SelectableCard>
);
}

View File

@@ -0,0 +1,220 @@
/**
* Sidebar Repository Card Component
*
* Compact version of RepositoryCard for sidebar layout.
* Shows repository name, pin badge, and inline stat pills.
*/
import { Activity, CheckCircle2, Clock, Copy, Edit, Pin, Trash2 } from "lucide-react";
import { StatPill } from "@/features/ui/primitives/pill";
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
import { cn } from "@/features/ui/primitives/styles";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
import { copyToClipboard } from "@/features/shared/utils/clipboard";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { ConfiguredRepository } from "../types/repository";
export interface SidebarRepositoryCardProps {
/** Repository data to display */
repository: ConfiguredRepository;
/** Whether this repository is currently selected */
isSelected?: boolean;
/** Whether this repository is pinned */
isPinned?: boolean;
/** Whether to show aurora glow effect (when selected) */
showAuroraGlow?: boolean;
/** Callback when repository is selected */
onSelect?: () => void;
/** Callback when delete button is clicked */
onDelete?: () => void;
/** Work order statistics for this repository */
stats?: {
total: number;
active: number;
done: number;
};
}
/**
* Static lookup map for background gradient classes
*/
const BACKGROUND_CLASSES = {
pinned:
"bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10",
selected:
"bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20",
default: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
} as const;
/**
* Static lookup map for title text classes
*/
const TITLE_CLASSES = {
selected: "text-purple-700 dark:text-purple-300",
default: "text-gray-700 dark:text-gray-300",
} as const;
/**
* Get background class based on card state
*/
function getBackgroundClass(isPinned: boolean, isSelected: boolean): string {
if (isPinned) return BACKGROUND_CLASSES.pinned;
if (isSelected) return BACKGROUND_CLASSES.selected;
return BACKGROUND_CLASSES.default;
}
/**
* Get title class based on card state
*/
function getTitleClass(isSelected: boolean): string {
return isSelected ? TITLE_CLASSES.selected : TITLE_CLASSES.default;
}
export function SidebarRepositoryCard({
repository,
isSelected = false,
isPinned = false,
showAuroraGlow = false,
onSelect,
onDelete,
stats = { total: 0, active: 0, done: 0 },
}: SidebarRepositoryCardProps) {
// Get modal action from Zustand store (no prop drilling)
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
const backgroundClass = getBackgroundClass(isPinned, isSelected);
const titleClass = getTitleClass(isSelected);
const handleCopyUrl = async (e: React.MouseEvent) => {
e.stopPropagation();
const result = await copyToClipboard(repository.repository_url);
if (result.success) {
console.log("Repository URL copied to clipboard");
} else {
console.error("Failed to copy repository URL:", result.error);
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
openEditRepoModal(repository);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete) {
onDelete();
}
};
return (
<SelectableCard
isSelected={isSelected}
isPinned={isPinned}
showAuroraGlow={showAuroraGlow}
onSelect={onSelect}
size="none"
blur="md"
className={cn("p-2 w-56 flex flex-col", backgroundClass)}
>
{/* Main content */}
<div className="space-y-2">
{/* Title with pin badge - centered */}
<div className="flex items-center justify-center gap-2">
<h4 className={cn("font-medium text-sm line-clamp-1 text-center", titleClass)}>
{repository.display_name || repository.repository_url}
</h4>
{isPinned && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 bg-purple-500 dark:bg-purple-400 text-white text-[9px] font-bold rounded-full shrink-0"
aria-label="Pinned repository"
>
<Pin className="w-2.5 h-2.5" fill="currentColor" aria-hidden="true" />
</div>
)}
</div>
{/* Status Pills - all 3 in one row with icons - centered */}
<div className="flex items-center justify-center gap-1.5">
<StatPill
color="pink"
value={stats.total}
size="sm"
icon={<Clock className="w-3 h-3" aria-hidden="true" />}
aria-label={`${stats.total} total work orders`}
/>
<StatPill
color="blue"
value={stats.active}
size="sm"
icon={<Activity className="w-3 h-3" aria-hidden="true" />}
aria-label={`${stats.active} active work orders`}
/>
<StatPill
color="green"
value={stats.done}
size="sm"
icon={<CheckCircle2 className="w-3 h-3" aria-hidden="true" />}
aria-label={`${stats.done} completed work orders`}
/>
</div>
</div>
{/* Action buttons bar */}
<div className="flex items-center justify-center gap-2 px-2 py-2 mt-2 border-t border-gray-200/30 dark:border-gray-700/20">
<TooltipProvider>
{/* Edit button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleEdit}
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
aria-label="Edit repository"
>
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
{/* Copy URL button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopyUrl}
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
aria-label="Copy repository URL"
>
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
{/* Delete button */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleDelete}
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
aria-label="Delete repository"
>
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</SelectableCard>
);
}

View File

@@ -0,0 +1,267 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Edit3, Eye } from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/features/ui/primitives/button";
import { Card } from "@/features/ui/primitives/card";
import { cn } from "@/features/ui/primitives/styles";
interface StepHistoryCardProps {
step: {
id: string;
stepName: string;
timestamp: string;
output: string;
session: string;
collapsible: boolean;
isHumanInLoop?: boolean;
};
isExpanded: boolean;
onToggle: () => void;
document?: {
title: string;
content: {
markdown: string;
};
};
}
export const StepHistoryCard = ({ step, isExpanded, onToggle, document }: StepHistoryCardProps) => {
const [isEditingDocument, setIsEditingDocument] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [hasChanges, setHasChanges] = useState(false);
const handleToggleEdit = () => {
// Only initialize editedContent from document when entering edit mode and there's no existing draft
if (!isEditingDocument && document && !editedContent) {
setEditedContent(document.content.markdown);
}
setIsEditingDocument(!isEditingDocument);
// Don't clear hasChanges when toggling - preserve unsaved drafts
};
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(document ? value !== document.content.markdown : false);
};
const handleApproveAndContinue = () => {
console.log("Approved and continuing to next step");
setHasChanges(false);
setIsEditingDocument(false);
};
return (
<Card
blur="md"
transparency="light"
edgePosition="left"
edgeColor={step.isHumanInLoop ? "orange" : "blue"}
size="md"
className="overflow-visible"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900 dark:text-white">{step.stepName}</h4>
{step.isHumanInLoop && (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20">
<AlertCircle className="w-3 h-3" aria-hidden="true" />
Human-in-Loop
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{step.timestamp}</p>
</div>
{/* Collapse toggle - only show if collapsible */}
{step.collapsible && (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className={cn(
"px-2 transition-colors",
step.isHumanInLoop
? "text-orange-500 hover:text-orange-600 dark:hover:text-orange-400"
: "text-cyan-500 hover:text-cyan-600 dark:hover:text-cyan-400",
)}
aria-label={isExpanded ? "Collapse step" : "Expand step"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
)}
</div>
{/* Content - collapsible with animation */}
<AnimatePresence mode="wait">
{(isExpanded || !step.collapsible) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: 0.3,
ease: [0.04, 0.62, 0.23, 0.98],
},
opacity: {
duration: 0.2,
ease: "easeInOut",
},
}}
style={{ overflow: "hidden" }}
>
<motion.div
initial={{ y: -20 }}
animate={{ y: 0 }}
exit={{ y: -20 }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="space-y-3"
>
{/* Output content */}
<div
className={cn(
"p-4 rounded-lg border",
step.isHumanInLoop
? "bg-orange-50/50 dark:bg-orange-950/10 border-orange-200/50 dark:border-orange-800/30"
: "bg-cyan-50/30 dark:bg-cyan-950/10 border-cyan-200/50 dark:border-cyan-800/30",
)}
>
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{step.output}
</pre>
</div>
{/* Session info */}
<p
className={cn(
"text-xs font-mono",
step.isHumanInLoop ? "text-orange-600 dark:text-orange-400" : "text-cyan-600 dark:text-cyan-400",
)}
>
{step.session}
</p>
{/* Review and Approve Plan - only for human-in-loop steps with documents */}
{step.isHumanInLoop && document && (
<div className="mt-6 space-y-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Review and Approve Plan</h4>
{/* Document Card */}
<Card blur="md" transparency="light" size="md" className="overflow-visible">
{/* View/Edit toggle in top right */}
<div className="flex items-center justify-end mb-3">
<Button
variant="ghost"
size="sm"
onClick={handleToggleEdit}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
aria-label={isEditingDocument ? "Switch to preview mode" : "Switch to edit mode"}
>
{isEditingDocument ? (
<Eye className="w-4 h-4" aria-hidden="true" />
) : (
<Edit3 className="w-4 h-4" aria-hidden="true" />
)}
</Button>
</div>
{isEditingDocument ? (
<div className="space-y-4">
<textarea
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
className={cn(
"w-full min-h-[300px] p-4 rounded-lg",
"bg-white/50 dark:bg-black/30",
"border border-gray-300 dark:border-gray-700",
"text-gray-900 dark:text-white font-mono text-sm",
"focus:outline-none focus:border-orange-400 focus:ring-2 focus:ring-orange-400/20",
"resize-y",
)}
placeholder="Enter markdown content..."
/>
</div>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{
h1: ({ node, ...props }) => (
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-3 mt-4" {...props} />
),
h2: ({ node, ...props }) => (
<h2
className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-3"
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
className="text-base font-semibold text-gray-900 dark:text-white mb-2 mt-3"
{...props}
/>
),
p: ({ node, ...props }) => (
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2 leading-relaxed" {...props} />
),
ul: ({ node, ...props }) => (
<ul
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-2 space-y-1"
{...props}
/>
),
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
code: ({ node, ...props }) => (
<code
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-orange-600 dark:text-orange-400"
{...props}
/>
),
}}
>
{/* Prefer displaying live draft (editedContent) when non-empty/hasChanges over original document content */}
{editedContent && hasChanges ? editedContent : document.content.markdown}
</ReactMarkdown>
</div>
)}
{/* Approve button - always visible with glass styling */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/30">
<p className="text-xs text-gray-500 dark:text-gray-400">
{hasChanges ? "Unsaved changes" : "No changes"}
</p>
<Button
onClick={handleApproveAndContinue}
className={cn(
"backdrop-blur-md",
"bg-gradient-to-b from-green-100/80 to-white/60",
"dark:from-green-500/20 dark:to-green-500/10",
"text-green-700 dark:text-green-100",
"border border-green-300/50 dark:border-green-500/50",
"hover:from-green-200/90 hover:to-green-100/70",
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
"shadow-lg shadow-green-500/20",
)}
>
<CheckCircle2 className="w-4 h-4 mr-2" aria-hidden="true" />
Approve and Move to Next Step
</Button>
</div>
</Card>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Card>
);
};

View File

@@ -0,0 +1,222 @@
/**
* Work Order Row Component
*
* Individual table row for a work order with status indicator, start/details buttons,
* and expandable real-time stats section.
*/
import { ChevronDown, ChevronUp, Eye, Play } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { type PillColor, StatPill } from "@/features/ui/primitives/pill";
import { cn } from "@/features/ui/primitives/styles";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { AgentWorkOrder } from "../types";
import { RealTimeStats } from "./RealTimeStats";
export interface WorkOrderRowProps {
/** Work order data */
workOrder: AgentWorkOrder;
/** Repository display name (from configured repository) */
repositoryDisplayName?: string;
/** Row index for alternating backgrounds */
index: number;
/** Callback when start button is clicked */
onStart: (id: string) => void;
/** Whether this row was just started (auto-expand) */
wasJustStarted?: boolean;
}
/**
* Status color configuration
* Static lookup to avoid dynamic class construction
*/
interface StatusConfig {
color: PillColor;
edge: string;
glow: string;
label: string;
stepNumber: number;
}
const STATUS_COLORS: Record<string, StatusConfig> = {
pending: {
color: "pink",
edge: "bg-pink-500 dark:bg-pink-400",
glow: "rgba(236,72,153,0.5)",
label: "Pending",
stepNumber: 0,
},
running: {
color: "cyan",
edge: "bg-cyan-500 dark:bg-cyan-400",
glow: "rgba(34,211,238,0.5)",
label: "Running",
stepNumber: 1,
},
completed: {
color: "green",
edge: "bg-green-500 dark:bg-green-400",
glow: "rgba(34,197,94,0.5)",
label: "Completed",
stepNumber: 5,
},
failed: {
color: "orange",
edge: "bg-orange-500 dark:bg-orange-400",
glow: "rgba(249,115,22,0.5)",
label: "Failed",
stepNumber: 0,
},
} as const;
/**
* Get status configuration with fallback
*/
function getStatusConfig(status: string): StatusConfig {
return STATUS_COLORS[status] || STATUS_COLORS.pending;
}
export function WorkOrderRow({
workOrder: cachedWorkOrder,
repositoryDisplayName,
index,
onStart,
wasJustStarted = false,
}: WorkOrderRowProps) {
const [isExpanded, setIsExpanded] = useState(wasJustStarted);
const navigate = useNavigate();
// Subscribe to live progress from Zustand SSE slice
const liveProgress = useAgentWorkOrdersStore((s) => s.liveProgress[cachedWorkOrder.agent_work_order_id]);
// Merge: SSE data overrides cached data
const workOrder = {
...cachedWorkOrder,
...(liveProgress?.status && { status: liveProgress.status as AgentWorkOrder["status"] }),
};
const statusConfig = getStatusConfig(workOrder.status);
const handleStartClick = () => {
setIsExpanded(true); // Auto-expand when started
onStart(workOrder.agent_work_order_id);
};
const handleDetailsClick = () => {
navigate(`/agent-work-orders/${workOrder.agent_work_order_id}`);
};
const isPending = workOrder.status === "pending";
const canExpand = !isPending; // Only non-pending rows can be expanded
// Use display name if available, otherwise extract from URL
const displayRepo = repositoryDisplayName || workOrder.repository_url.split("/").slice(-2).join("/");
return (
<>
{/* Main row */}
<tr
className={cn(
"group transition-all duration-200",
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70 dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
"border-b border-gray-200 dark:border-gray-800",
)}
>
{/* Status indicator - glowing circle with optional collapse button */}
<td className="px-3 py-2 w-12">
<div className="flex items-center justify-center gap-1">
{canExpand && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronUp className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
) : (
<ChevronDown className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
)}
</button>
)}
<div
className={cn("w-3 h-3 rounded-full", statusConfig.edge)}
style={{ boxShadow: `0 0 8px ${statusConfig.glow}` }}
/>
</div>
</td>
{/* Work Order ID */}
<td className="px-4 py-2">
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{workOrder.agent_work_order_id}</span>
</td>
{/* Repository */}
<td className="px-4 py-2 w-40">
<span className="text-sm text-gray-900 dark:text-white">{displayRepo}</span>
</td>
{/* Branch */}
<td className="px-4 py-2">
<p className="text-sm text-gray-900 dark:text-white line-clamp-2">
{workOrder.git_branch_name || <span className="text-gray-400 dark:text-gray-500">-</span>}
</p>
</td>
{/* Status Badge - using StatPill */}
<td className="px-4 py-2 w-32">
<StatPill color={statusConfig.color} value={statusConfig.label} size="sm" />
</td>
{/* Actions */}
<td className="px-4 py-2 w-32">
{isPending ? (
<Button
onClick={handleStartClick}
size="xs"
variant="green"
className="w-full text-xs"
aria-label="Start work order"
>
<Play className="w-3 h-3 mr-1" aria-hidden="true" />
Start
</Button>
) : (
<Button
onClick={handleDetailsClick}
size="xs"
variant="blue"
className="w-full text-xs"
aria-label="View work order details"
>
<Eye className="w-3 h-3 mr-1" aria-hidden="true" />
Details
</Button>
)}
</td>
</tr>
{/* Expanded row with real-time stats - shows live or historical data */}
{isExpanded && canExpand && (
<tr
className={cn(
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
"border-b border-gray-200 dark:border-gray-800",
)}
>
<td colSpan={6} className="px-4 py-4">
<RealTimeStats workOrderId={workOrder.agent_work_order_id} />
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,137 @@
/**
* Work Order Table Component
*
* Displays work orders in a table with start buttons, status indicators,
* and expandable real-time stats.
*/
import { useEffect, useRef, useState } from "react";
import { useRepositories } from "../hooks/useRepositoryQueries";
import type { AgentWorkOrder } from "../types";
import { WorkOrderRow } from "./WorkOrderRow";
export interface WorkOrderTableProps {
/** Array of work orders to display */
workOrders: AgentWorkOrder[];
/** Optional repository ID to filter work orders */
selectedRepositoryId?: string;
/** Callback when start button is clicked */
onStartWorkOrder: (id: string) => void;
}
/**
* Enhanced work order with repository display name
*/
interface EnhancedWorkOrder extends AgentWorkOrder {
repositoryDisplayName?: string;
}
export function WorkOrderTable({ workOrders, selectedRepositoryId, onStartWorkOrder }: WorkOrderTableProps) {
const [justStartedId, setJustStartedId] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { data: repositories = [] } = useRepositories();
// Create a map of repository URL to display name for quick lookup
const repoUrlToDisplayName = repositories.reduce(
(acc, repo) => {
acc[repo.repository_url] = repo.display_name || repo.repository_url.split("/").slice(-2).join("/");
return acc;
},
{} as Record<string, string>,
);
// Filter work orders based on selected repository
// Find the repository URL from the selected repository ID, then filter work orders by that URL
const filteredWorkOrders = selectedRepositoryId
? (() => {
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
return selectedRepo ? workOrders.filter((wo) => wo.repository_url === selectedRepo.repository_url) : workOrders;
})()
: workOrders;
// Enhance work orders with display names
const enhancedWorkOrders: EnhancedWorkOrder[] = filteredWorkOrders.map((wo) => ({
...wo,
repositoryDisplayName: repoUrlToDisplayName[wo.repository_url],
}));
/**
* Handle start button click with auto-expand tracking
*/
const handleStart = (id: string) => {
setJustStartedId(id);
onStartWorkOrder(id);
// Clear any existing timeout before scheduling a new one
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Clear the tracking after animation
timeoutRef.current = setTimeout(() => {
setJustStartedId(null);
timeoutRef.current = null;
}, 1000);
};
// Cleanup timeout on unmount to prevent setState after unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
// Show empty state if no work orders
if (filteredWorkOrders.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-gray-500 dark:text-gray-400 mb-2">No work orders found</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{selectedRepositoryId
? "Create a work order for this repository to get started"
: "Create a work order to get started"}
</p>
</div>
</div>
);
}
return (
<div className="w-full overflow-x-auto scrollbar-hide">
<table className="w-full">
<thead>
<tr className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
<th className="w-12" aria-label="Status indicator" />
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">WO ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-40">
Repository
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Branch
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Status</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Actions</th>
</tr>
</thead>
<tbody>
{enhancedWorkOrders.map((workOrder, index) => (
<WorkOrderRow
key={workOrder.agent_work_order_id}
workOrder={workOrder}
repositoryDisplayName={workOrder.repositoryDisplayName}
index={index}
onStart={handleStart}
wasJustStarted={workOrder.agent_work_order_id === justStartedId}
/>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { motion } from "framer-motion";
import type React from "react";
import { cn } from "@/features/ui/primitives/styles";
interface WorkflowStepButtonProps {
isCompleted: boolean;
isActive: boolean;
stepName: string;
onClick?: () => void;
color?: "cyan" | "green" | "blue" | "purple";
size?: number;
}
// Helper function to get color hex values for animations
const getColorValue = (color: string) => {
const colorValues = {
purple: "rgb(168,85,247)",
green: "rgb(34,197,94)",
blue: "rgb(59,130,246)",
cyan: "rgb(34,211,238)",
};
return colorValues[color as keyof typeof colorValues] || colorValues.blue;
};
export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
isCompleted,
isActive,
stepName,
onClick,
color = "cyan",
size = 40,
}) => {
const colorMap = {
purple: {
border: "border-purple-400 dark:border-purple-300",
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
fill: "bg-purple-400 dark:bg-purple-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
},
green: {
border: "border-green-400 dark:border-green-300",
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
fill: "bg-green-400 dark:bg-green-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
},
blue: {
border: "border-blue-400 dark:border-blue-300",
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
fill: "bg-blue-400 dark:bg-blue-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
},
cyan: {
border: "border-cyan-400 dark:border-cyan-300",
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
fill: "bg-cyan-400 dark:bg-cyan-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
},
};
// Label colors matching the color prop
const labelColorMap = {
purple: "text-purple-400 dark:text-purple-300",
green: "text-green-400 dark:text-green-300",
blue: "text-blue-400 dark:text-blue-300",
cyan: "text-cyan-400 dark:text-cyan-300",
};
const styles = colorMap[color] || colorMap.cyan;
const labelColor = labelColorMap[color] || labelColorMap.cyan;
return (
<div className="flex flex-col items-center gap-2">
<motion.button
onClick={onClick}
className={cn(
"relative rounded-full border-2 transition-all duration-300",
styles.border,
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
styles.glowHover,
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
"hover:scale-110 active:scale-95",
)}
style={{ width: size, height: size }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
type="button"
aria-label={`${stepName} - ${isCompleted ? "completed" : isActive ? "in progress" : "pending"}`}
>
{/* Outer ring glow effect */}
<motion.div
className={cn(
"absolute inset-[-4px] rounded-full border-2 blur-sm",
isCompleted ? styles.border : "border-transparent",
)}
animate={{
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Inner glow effect */}
<motion.div
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
animate={{
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Checkmark icon container */}
<div className="relative w-full h-full flex items-center justify-center">
<motion.svg
width={size * 0.5}
height={size * 0.5}
viewBox="0 0 24 24"
fill="none"
className="relative z-10"
role="img"
aria-label={`${stepName} status indicator`}
animate={{
filter: isCompleted
? [
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
`drop-shadow(0 0 12px ${getColorValue(color)}) drop-shadow(0 0 16px ${getColorValue(color)})`,
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
]
: "none",
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
{/* Checkmark path */}
<path
d="M20 6L9 17l-5-5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className={isCompleted ? "text-white" : "text-gray-600"}
/>
</motion.svg>
</div>
</motion.button>
{/* Step name label */}
<span
className={cn(
"text-xs font-medium transition-colors",
isCompleted
? labelColor
: isActive
? labelColor
: "text-gray-500 dark:text-gray-400",
)}
>
{stepName}
</span>
</div>
);
};

View File

@@ -0,0 +1,123 @@
/**
* CreateWorkOrderModal Component Tests
*
* Tests for create work order modal form validation and submission.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CreateWorkOrderModal } from "../CreateWorkOrderModal";
// Mock the hooks
vi.mock("../../hooks/useAgentWorkOrderQueries", () => ({
useCreateWorkOrder: () => ({
mutateAsync: vi.fn().mockResolvedValue({
agent_work_order_id: "wo-new",
status: "pending",
}),
}),
}));
vi.mock("../../hooks/useRepositoryQueries", () => ({
useRepositories: () => ({
data: [
{
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
},
],
}),
}));
vi.mock("@/features/ui/hooks/useToast", () => ({
useToast: () => ({
showToast: vi.fn(),
}),
}));
describe("CreateWorkOrderModal", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should render when open", () => {
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
expect(screen.getByText("Create Work Order")).toBeInTheDocument();
});
it("should not render when closed", () => {
render(<CreateWorkOrderModal open={false} onOpenChange={vi.fn()} />, { wrapper });
expect(screen.queryByText("Create Work Order")).not.toBeInTheDocument();
});
it("should pre-populate fields from selected repository", async () => {
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} selectedRepositoryId="repo-1" />, {
wrapper,
});
// Wait for repository data to be populated
await waitFor(() => {
const urlInput = screen.getByLabelText("Repository URL") as HTMLInputElement;
expect(urlInput.value).toBe("https://github.com/test/repo");
});
});
it("should show validation error for empty request", async () => {
const user = userEvent.setup();
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
// Try to submit without filling required fields
const submitButton = screen.getByRole("button", { name: "Create Work Order" });
await user.click(submitButton);
// Should show validation error
await waitFor(() => {
expect(screen.getByText(/Request must be at least 10 characters/i)).toBeInTheDocument();
});
});
it("should disable commit and PR steps when execute is not selected", async () => {
const user = userEvent.setup();
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
// Uncheck execute step
const executeCheckbox = screen.getByLabelText("Execute");
await user.click(executeCheckbox);
// Commit and PR should be disabled
const commitCheckbox = screen.getByLabelText("Commit Changes") as HTMLInputElement;
const prCheckbox = screen.getByLabelText("Create Pull Request") as HTMLInputElement;
expect(commitCheckbox).toBeDisabled();
expect(prCheckbox).toBeDisabled();
});
it("should have accessible form labels", () => {
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
expect(screen.getByLabelText("Repository")).toBeInTheDocument();
expect(screen.getByLabelText("Repository URL")).toBeInTheDocument();
expect(screen.getByLabelText("Work Request")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,110 @@
/**
* RepositoryCard Component Tests
*
* Tests for repository card rendering and interactions.
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { ConfiguredRepository } from "../../types/repository";
import { RepositoryCard } from "../RepositoryCard";
const mockRepository: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repository",
display_name: "test/repository",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
describe("RepositoryCard", () => {
it("should render repository name and URL", () => {
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
expect(screen.getByText("test/repository")).toBeInTheDocument();
expect(screen.getByText(/test\/repository/)).toBeInTheDocument();
});
it("should display work order stats", () => {
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
expect(screen.getByLabelText("5 total work orders")).toBeInTheDocument();
expect(screen.getByLabelText("2 active work orders")).toBeInTheDocument();
expect(screen.getByLabelText("3 completed work orders")).toBeInTheDocument();
});
it("should show verified status when repository is verified", () => {
render(<RepositoryCard repository={mockRepository} stats={{ total: 0, active: 0, done: 0 }} />);
expect(screen.getByText("✓ Verified")).toBeInTheDocument();
});
it("should call onSelect when clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
const card = screen.getByRole("button", { name: /test\/repository/i });
await user.click(card);
expect(onSelect).toHaveBeenCalledOnce();
});
it("should show pin indicator when isPinned is true", () => {
render(<RepositoryCard repository={mockRepository} isPinned={true} stats={{ total: 0, active: 0, done: 0 }} />);
expect(screen.getByText("Pinned")).toBeInTheDocument();
});
it("should call onPin when pin button clicked", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
render(<RepositoryCard repository={mockRepository} onPin={onPin} stats={{ total: 0, active: 0, done: 0 }} />);
const pinButton = screen.getByLabelText("Pin repository");
await user.click(pinButton);
expect(onPin).toHaveBeenCalledOnce();
});
it("should call onDelete when delete button clicked", async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<RepositoryCard repository={mockRepository} onDelete={onDelete} stats={{ total: 0, active: 0, done: 0 }} />);
const deleteButton = screen.getByLabelText("Delete repository");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalledOnce();
});
it("should support keyboard navigation (Enter key)", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
const card = screen.getByRole("button", { name: /test\/repository/i });
card.focus();
await user.keyboard("{Enter}");
expect(onSelect).toHaveBeenCalledOnce();
});
it("should have proper ARIA attributes", () => {
render(<RepositoryCard repository={mockRepository} isSelected={true} stats={{ total: 0, active: 0, done: 0 }} />);
const card = screen.getByRole("button");
expect(card).toHaveAttribute("aria-selected", "true");
});
});

View File

@@ -0,0 +1,430 @@
/**
* Tests for Agent Work Order Query Hooks
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentWorkOrderKeys } from "../useAgentWorkOrderQueries";
vi.mock("../../services/agentWorkOrdersService", () => ({
agentWorkOrdersService: {
listWorkOrders: vi.fn(),
getWorkOrder: vi.fn(),
getStepHistory: vi.fn(),
createWorkOrder: vi.fn(),
startWorkOrder: vi.fn(),
},
}));
vi.mock("@/features/shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Number.POSITIVE_INFINITY,
},
}));
describe("agentWorkOrderKeys", () => {
it("should generate correct query keys", () => {
expect(agentWorkOrderKeys.all).toEqual(["agent-work-orders"]);
expect(agentWorkOrderKeys.lists()).toEqual(["agent-work-orders", "list"]);
expect(agentWorkOrderKeys.list("running")).toEqual(["agent-work-orders", "list", "running"]);
expect(agentWorkOrderKeys.list(undefined)).toEqual(["agent-work-orders", "list", undefined]);
expect(agentWorkOrderKeys.details()).toEqual(["agent-work-orders", "detail"]);
expect(agentWorkOrderKeys.detail("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123"]);
expect(agentWorkOrderKeys.stepHistory("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123", "steps"]);
});
});
describe("useWorkOrders", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch work orders without filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "running",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockWorkOrders);
});
it("should fetch work orders with status filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "completed",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders("completed"), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith("completed");
expect(result.current.data).toEqual(mockWorkOrders);
});
});
describe("useWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch single work order", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockWorkOrder = {
agent_work_order_id: "wo-123",
status: "running",
};
vi.mocked(agentWorkOrdersService.getWorkOrder).mockResolvedValue(mockWorkOrder as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getWorkOrder).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockWorkOrder);
});
it("should not fetch when id is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getWorkOrder).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useStepHistory", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch step history", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const mockHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
success: true,
},
],
};
vi.mocked(agentWorkOrdersService.getStepHistory).mockResolvedValue(mockHistory as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getStepHistory).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockHistory);
});
it("should not fetch when workOrderId is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getStepHistory).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useCreateWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
it("should create work order and invalidate queries", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useCreateWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch" as const,
user_request: "Test",
};
const mockCreated = {
agent_work_order_id: "wo-new",
...mockRequest,
status: "pending" as const,
};
vi.mocked(agentWorkOrdersService.createWorkOrder).mockResolvedValue(mockCreated as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useCreateWorkOrder(), { wrapper });
result.current.mutate(mockRequest);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.createWorkOrder).toHaveBeenCalledWith(mockRequest);
expect(result.current.data).toEqual(mockCreated);
});
});
describe("useStartWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
it("should start a pending work order with optimistic update", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockPendingWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-123",
git_branch_name: null,
agent_session_id: null,
sandbox_type: "git_worktree" as const,
github_issue_number: null,
status: "pending" as const,
current_phase: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
const mockRunningWorkOrder = {
...mockPendingWorkOrder,
status: "running" as const,
updated_at: "2024-01-01T00:01:00Z",
};
// Set initial data in cache
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
result.current.mutate("wo-123");
// Verify optimistic update happened immediately
await waitFor(() => {
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
expect((data as any)?.status).toBe("running");
});
// Wait for mutation to complete
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.startWorkOrder).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockRunningWorkOrder);
});
it("should rollback on error", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockPendingWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-123",
git_branch_name: null,
agent_session_id: null,
sandbox_type: "git_worktree" as const,
github_issue_number: null,
status: "pending" as const,
current_phase: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
// Set initial data in cache
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
const error = new Error("Failed to start work order");
vi.mocked(agentWorkOrdersService.startWorkOrder).mockRejectedValue(error);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
result.current.mutate("wo-123");
// Wait for mutation to fail
await waitFor(() => expect(result.current.isError).toBe(true));
// Verify data was rolled back to pending status
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
expect((data as any)?.status).toBe("pending");
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
expect(listData[0]?.status).toBe("pending");
});
it("should update both detail and list caches on success", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockPendingWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-123",
git_branch_name: null,
agent_session_id: null,
sandbox_type: "git_worktree" as const,
github_issue_number: null,
status: "pending" as const,
current_phase: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
const mockRunningWorkOrder = {
...mockPendingWorkOrder,
status: "running" as const,
updated_at: "2024-01-01T00:01:00Z",
};
// Set initial data in cache
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
result.current.mutate("wo-123");
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Verify both detail and list caches updated
const detailData = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
expect((detailData as any)?.status).toBe("running");
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
expect(listData[0]?.status).toBe("running");
});
});

View File

@@ -0,0 +1,382 @@
/**
* Repository Query Hooks Tests
*
* Unit tests for repository query hooks.
* Mocks repositoryService and query patterns.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
import {
repositoryKeys,
useCreateRepository,
useDeleteRepository,
useRepositories,
useUpdateRepository,
useVerifyRepository,
} from "../useRepositoryQueries";
// Mock the repository service
vi.mock("../../services/repositoryService", () => ({
repositoryService: {
listRepositories: vi.fn(),
createRepository: vi.fn(),
updateRepository: vi.fn(),
deleteRepository: vi.fn(),
verifyRepositoryAccess: vi.fn(),
},
}));
// Mock shared patterns
vi.mock("@/features/shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3000,
frequent: 5000,
normal: 30000,
rare: 300000,
static: Number.POSITIVE_INFINITY,
},
}));
// Mock toast hook
vi.mock("@/features/ui/hooks/useToast", () => ({
useToast: () => ({
showToast: vi.fn(),
}),
}));
// Import after mocking
import { repositoryService } from "../../services/repositoryService";
describe("useRepositoryQueries", () => {
let queryClient: QueryClient;
beforeEach(() => {
// Create fresh query client for each test
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
const createWrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe("repositoryKeys", () => {
it("should generate correct query keys", () => {
expect(repositoryKeys.all).toEqual(["repositories"]);
expect(repositoryKeys.lists()).toEqual(["repositories", "list"]);
expect(repositoryKeys.detail("repo-1")).toEqual(["repositories", "detail", "repo-1"]);
});
});
describe("useRepositories", () => {
it("should fetch repositories list", async () => {
const mockRepositories: ConfiguredRepository[] = [
{
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
vi.mocked(repositoryService.listRepositories).mockResolvedValue(mockRepositories);
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockRepositories);
expect(repositoryService.listRepositories).toHaveBeenCalledOnce();
});
it("should handle empty repository list", async () => {
vi.mocked(repositoryService.listRepositories).mockResolvedValue([]);
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it("should handle errors", async () => {
const error = new Error("Network error");
vi.mocked(repositoryService.listRepositories).mockRejectedValue(error);
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(error);
});
});
describe("useCreateRepository", () => {
it("should create repository with optimistic update", async () => {
const request: CreateRepositoryRequest = {
repository_url: "https://github.com/test/repo",
verify: true,
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
vi.mocked(repositoryService.createRepository).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
await act(async () => {
await result.current.mutateAsync(request);
});
expect(repositoryService.createRepository).toHaveBeenCalledWith(request);
});
it("should rollback on error", async () => {
const request: CreateRepositoryRequest = {
repository_url: "https://github.com/test/repo",
};
const error = new Error("Creation failed");
vi.mocked(repositoryService.createRepository).mockRejectedValue(error);
// Set initial data
queryClient.setQueryData(repositoryKeys.lists(), []);
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
await act(async () => {
try {
await result.current.mutateAsync(request);
} catch {
// Expected error
}
});
// Should rollback to empty array
const data = queryClient.getQueryData(repositoryKeys.lists());
expect(data).toEqual([]);
});
});
describe("useUpdateRepository", () => {
it("should update repository with optimistic update", async () => {
const id = "repo-1";
const request: UpdateRepositoryRequest = {
default_sandbox_type: "git_branch",
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_branch",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
};
vi.mocked(repositoryService.updateRepository).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
await act(async () => {
await result.current.mutateAsync({ id, request });
});
expect(repositoryService.updateRepository).toHaveBeenCalledWith(id, request);
});
it("should rollback on error", async () => {
const initialRepo: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
// Set initial data
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
const error = new Error("Update failed");
vi.mocked(repositoryService.updateRepository).mockRejectedValue(error);
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
await act(async () => {
try {
await result.current.mutateAsync({
id: "repo-1",
request: { default_sandbox_type: "git_branch" },
});
} catch {
// Expected error
}
});
// Should rollback to initial data
const data = queryClient.getQueryData(repositoryKeys.lists());
expect(data).toEqual([initialRepo]);
});
});
describe("useDeleteRepository", () => {
it("should delete repository with optimistic removal", async () => {
const initialRepo: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
// Set initial data
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
vi.mocked(repositoryService.deleteRepository).mockResolvedValue();
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
await act(async () => {
await result.current.mutateAsync("repo-1");
});
expect(repositoryService.deleteRepository).toHaveBeenCalledWith("repo-1");
});
it("should rollback on error", async () => {
const initialRepo: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
// Set initial data
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
const error = new Error("Delete failed");
vi.mocked(repositoryService.deleteRepository).mockRejectedValue(error);
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
await act(async () => {
try {
await result.current.mutateAsync("repo-1");
} catch {
// Expected error
}
});
// Should rollback to initial data
const data = queryClient.getQueryData(repositoryKeys.lists());
expect(data).toEqual([initialRepo]);
});
});
describe("useVerifyRepository", () => {
it("should verify repository and invalidate queries", async () => {
const mockResponse = {
is_accessible: true,
repository_id: "repo-1",
};
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
await act(async () => {
await result.current.mutateAsync("repo-1");
});
expect(repositoryService.verifyRepositoryAccess).toHaveBeenCalledWith("repo-1");
});
it("should handle inaccessible repository", async () => {
const mockResponse = {
is_accessible: false,
repository_id: "repo-1",
};
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
await act(async () => {
await result.current.mutateAsync("repo-1");
});
expect(result.current.data).toEqual(mockResponse);
});
it("should handle verification errors", async () => {
const error = new Error("GitHub API error");
vi.mocked(repositoryService.verifyRepositoryAccess).mockRejectedValue(error);
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
await act(async () => {
try {
await result.current.mutateAsync("repo-1");
} catch {
// Expected error
}
});
expect(result.current.isError).toBe(true);
});
});
});

View File

@@ -0,0 +1,187 @@
/**
* TanStack Query Hooks for Agent Work Orders
*
* This module provides React hooks for fetching and mutating agent work orders.
* Follows the pattern established in useProjectQueries.ts
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { agentWorkOrdersService } from "../services/agentWorkOrdersService";
import type {
AgentWorkOrder,
AgentWorkOrderStatus,
CreateAgentWorkOrderRequest,
StepHistory,
WorkOrderLogsResponse,
} from "../types";
/**
* Query key factory for agent work orders
* Provides consistent query keys for cache management
*/
export const agentWorkOrderKeys = {
all: ["agent-work-orders"] as const,
lists: () => [...agentWorkOrderKeys.all, "list"] as const,
list: (filter: AgentWorkOrderStatus | undefined) => [...agentWorkOrderKeys.lists(), filter] as const,
details: () => [...agentWorkOrderKeys.all, "detail"] as const,
detail: (id: string) => [...agentWorkOrderKeys.details(), id] as const,
stepHistory: (id: string) => [...agentWorkOrderKeys.detail(id), "steps"] as const,
logs: (id: string) => [...agentWorkOrderKeys.detail(id), "logs"] as const,
};
/**
* Hook to fetch list of agent work orders
* Real-time updates provided by SSE (no polling needed)
*
* @param statusFilter - Optional status to filter work orders
* @returns Query result with work orders array
*/
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus) {
return useQuery<AgentWorkOrder[], Error>({
queryKey: agentWorkOrderKeys.list(statusFilter),
queryFn: () => agentWorkOrdersService.listWorkOrders(statusFilter),
staleTime: STALE_TIMES.instant,
});
}
/**
* Hook to fetch a single agent work order
* Real-time updates provided by SSE (no polling needed)
*
* @param id - Work order ID (undefined disables query)
* @returns Query result with work order data
*/
export function useWorkOrder(id: string | undefined) {
return useQuery<AgentWorkOrder, Error>({
queryKey: id ? agentWorkOrderKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => (id ? agentWorkOrdersService.getWorkOrder(id) : Promise.reject(new Error("No ID provided"))),
enabled: !!id,
staleTime: STALE_TIMES.instant,
});
}
/**
* Hook to fetch step execution history for a work order
* Real-time updates provided by SSE (no polling needed)
*
* @param workOrderId - Work order ID (undefined disables query)
* @returns Query result with step history
*/
export function useStepHistory(workOrderId: string | undefined) {
return useQuery<StepHistory, Error>({
queryKey: workOrderId ? agentWorkOrderKeys.stepHistory(workOrderId) : DISABLED_QUERY_KEY,
queryFn: () =>
workOrderId ? agentWorkOrdersService.getStepHistory(workOrderId) : Promise.reject(new Error("No ID provided")),
enabled: !!workOrderId,
staleTime: STALE_TIMES.instant,
});
}
/**
* Hook to fetch historical logs for a work order
* Fetches buffered logs from backend (complementary to live SSE streaming)
*
* @param workOrderId - Work order ID (undefined disables query)
* @param options - Optional filters (limit, offset, level, step)
* @returns Query result with logs response
*/
export function useWorkOrderLogs(
workOrderId: string | undefined,
options?: {
limit?: number;
offset?: number;
level?: "info" | "warning" | "error" | "debug";
step?: string;
},
) {
return useQuery<WorkOrderLogsResponse, Error>({
queryKey: workOrderId ? [...agentWorkOrderKeys.logs(workOrderId), options] : DISABLED_QUERY_KEY,
queryFn: () =>
workOrderId
? agentWorkOrdersService.getWorkOrderLogs(workOrderId, options)
: Promise.reject(new Error("No ID provided")),
enabled: !!workOrderId,
staleTime: STALE_TIMES.normal, // 30 seconds cache for historical logs
});
}
/**
* Hook to create a new agent work order
* Automatically invalidates work order lists on success
*
* @returns Mutation object with mutate function
*/
export function useCreateWorkOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentWorkOrderRequest) => agentWorkOrdersService.createWorkOrder(request),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: agentWorkOrderKeys.lists() });
queryClient.setQueryData(agentWorkOrderKeys.detail(data.agent_work_order_id), data);
},
onError: (error) => {
console.error("Failed to create work order:", error);
},
});
}
/**
* Hook to start a pending work order (transition from pending to running)
* Implements optimistic update to immediately show running state in UI
* Triggers backend execution by updating status to "running"
*
* @returns Mutation object with mutate function
*/
export function useStartWorkOrder() {
const queryClient = useQueryClient();
return useMutation<
AgentWorkOrder,
Error,
string,
{ previousWorkOrder?: AgentWorkOrder; previousList?: AgentWorkOrder[] }
>({
mutationFn: (id: string) => agentWorkOrdersService.startWorkOrder(id),
onMutate: async (id) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: agentWorkOrderKeys.detail(id) });
// Snapshot the previous values
const previousWorkOrder = queryClient.getQueryData<AgentWorkOrder>(agentWorkOrderKeys.detail(id));
// Optimistically update the work order status to "running"
if (previousWorkOrder) {
const optimisticWorkOrder = {
...previousWorkOrder,
status: "running" as AgentWorkOrderStatus,
updated_at: new Date().toISOString(),
};
queryClient.setQueryData(agentWorkOrderKeys.detail(id), optimisticWorkOrder);
}
return { previousWorkOrder };
},
onError: (error, id, context) => {
console.error("Failed to start work order:", error);
// Rollback on error
if (context?.previousWorkOrder) {
queryClient.setQueryData(agentWorkOrderKeys.detail(id), context.previousWorkOrder);
}
},
onSuccess: (data, id) => {
// Replace optimistic update with server response
queryClient.setQueryData(agentWorkOrderKeys.detail(id), data);
// Invalidate all list queries to refetch with server data
queryClient.invalidateQueries({ queryKey: agentWorkOrderKeys.lists() });
},
});
}

View File

@@ -0,0 +1,277 @@
/**
* Repository Query Hooks
*
* TanStack Query hooks for repository management.
* Follows patterns from QUERY_PATTERNS.md with query key factories and optimistic updates.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { useToast } from "@/features/shared/hooks/useToast";
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/utils/optimistic";
import { repositoryService } from "../services/repositoryService";
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
/**
* Query key factory for repositories
* Follows the pattern: domain > scope > identifier
*/
export const repositoryKeys = {
all: ["repositories"] as const,
lists: () => [...repositoryKeys.all, "list"] as const,
detail: (id: string) => [...repositoryKeys.all, "detail", id] as const,
};
/**
* List all configured repositories
* @returns Query result with array of repositories
*/
export function useRepositories() {
return useQuery<ConfiguredRepository[]>({
queryKey: repositoryKeys.lists(),
queryFn: () => repositoryService.listRepositories(),
staleTime: STALE_TIMES.normal, // 30 seconds
refetchOnWindowFocus: true, // Refetch when tab gains focus (ETag makes this cheap)
});
}
/**
* Get single repository by ID
* @param id - Repository ID to fetch
* @returns Query result with repository detail
*/
export function useRepository(id: string | undefined) {
return useQuery<ConfiguredRepository>({
queryKey: id ? repositoryKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => {
if (!id) return Promise.reject("No repository ID provided");
// Note: Backend doesn't have a get-by-id endpoint yet, so we fetch from list
return repositoryService.listRepositories().then((repos) => {
const repo = repos.find((r) => r.id === id);
if (!repo) throw new Error("Repository not found");
return repo;
});
},
enabled: !!id,
staleTime: STALE_TIMES.normal,
});
}
/**
* Create a new configured repository with optimistic updates
* @returns Mutation result for creating repository
*/
export function useCreateRepository() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<
ConfiguredRepository,
Error,
CreateRepositoryRequest,
{ previousRepositories?: ConfiguredRepository[]; optimisticId: string }
>({
mutationFn: (request: CreateRepositoryRequest) => repositoryService.createRepository(request),
onMutate: async (newRepositoryData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
// Snapshot the previous value
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
// Create optimistic repository with stable ID
const optimisticRepository = createOptimisticEntity<ConfiguredRepository>({
repository_url: newRepositoryData.repository_url,
display_name: null,
owner: null,
default_branch: null,
is_verified: false,
last_verified_at: null,
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
// Optimistically add the new repository
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
if (!old) return [optimisticRepository];
// Add new repository at the beginning of the list
return [optimisticRepository, ...old];
});
return { previousRepositories, optimisticId: optimisticRepository._localId };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to create repository:", error, { variables });
// Rollback on error
if (context?.previousRepositories) {
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
}
showToast(`Failed to create repository: ${errorMessage}`, "error");
},
onSuccess: (response, _variables, context) => {
// Replace optimistic entity with real response
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
if (!old) return [response];
return replaceOptimisticEntity(old, context?.optimisticId, response);
});
showToast("Repository created successfully", "success");
},
});
}
/**
* Update an existing repository with optimistic updates
* @returns Mutation result for updating repository
*/
export function useUpdateRepository() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<
ConfiguredRepository,
Error,
{ id: string; request: UpdateRepositoryRequest },
{ previousRepositories?: ConfiguredRepository[] }
>({
mutationFn: ({ id, request }) => repositoryService.updateRepository(id, request),
onMutate: async ({ id, request }) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
// Snapshot the previous value
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
// Optimistically update the repository
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
if (!old) return old;
return old.map((repo) =>
repo.id === id
? {
...repo,
...request,
updated_at: new Date().toISOString(),
}
: repo,
);
});
return { previousRepositories };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to update repository:", error, { variables });
// Rollback on error
if (context?.previousRepositories) {
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
}
showToast(`Failed to update repository: ${errorMessage}`, "error");
},
onSuccess: (response) => {
// Replace with server response
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
if (!old) return [response];
return old.map((repo) => (repo.id === response.id ? response : repo));
});
showToast("Repository updated successfully", "success");
},
});
}
/**
* Delete a repository with optimistic removal
* @returns Mutation result for deleting repository
*/
export function useDeleteRepository() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<void, Error, string, { previousRepositories?: ConfiguredRepository[] }>({
mutationFn: (id: string) => repositoryService.deleteRepository(id),
onMutate: async (id) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
// Snapshot the previous value
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
// Optimistically remove the repository
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
if (!old) return old;
return old.filter((repo) => repo.id !== id);
});
return { previousRepositories };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to delete repository:", error, { variables });
// Rollback on error
if (context?.previousRepositories) {
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
}
showToast(`Failed to delete repository: ${errorMessage}`, "error");
},
onSuccess: () => {
showToast("Repository deleted successfully", "success");
},
});
}
/**
* Verify repository access and update metadata
* @returns Mutation result for verifying repository
*/
export function useVerifyRepository() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation<
{ is_accessible: boolean; repository_id: string },
Error,
string,
{ previousRepositories?: ConfiguredRepository[] }
>({
mutationFn: (id: string) => repositoryService.verifyRepositoryAccess(id),
onMutate: async (_id) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
// Snapshot the previous value
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
return { previousRepositories };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to verify repository:", error, { variables });
// Rollback on error
if (context?.previousRepositories) {
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
}
showToast(`Failed to verify repository: ${errorMessage}`, "error");
},
onSuccess: (response) => {
// Invalidate queries to refetch updated metadata from server
queryClient.invalidateQueries({ queryKey: repositoryKeys.lists() });
if (response.is_accessible) {
showToast("Repository verified successfully", "success");
} else {
showToast("Repository is not accessible", "warning");
}
},
});
}

View File

@@ -0,0 +1,158 @@
/**
* Tests for Agent Work Orders Service
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as apiClient from "@/features/shared/api/apiClient";
import type { AgentWorkOrder, CreateAgentWorkOrderRequest, StepHistory } from "../../types";
import { agentWorkOrdersService } from "../agentWorkOrdersService";
vi.mock("@/features/shared/api/apiClient", () => ({
callAPIWithETag: vi.fn(),
}));
describe("agentWorkOrdersService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockWorkOrder: AgentWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-abc",
git_branch_name: "feature/test",
agent_session_id: "session-xyz",
sandbox_type: "git_branch",
github_issue_number: null,
status: "running",
current_phase: "planning",
created_at: "2025-01-15T10:00:00Z",
updated_at: "2025-01-15T10:05:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
describe("createWorkOrder", () => {
it("should create a work order successfully", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.createWorkOrder(request);
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders", {
method: "POST",
body: JSON.stringify(request),
});
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on creation failure", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Creation failed"));
await expect(agentWorkOrdersService.createWorkOrder(request)).rejects.toThrow("Creation failed");
});
});
describe("listWorkOrders", () => {
it("should list all work orders without filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders();
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders");
expect(result).toEqual(mockList);
});
it("should list work orders with status filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders("running");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders?status=running");
expect(result).toEqual(mockList);
});
it("should throw error on list failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("List failed"));
await expect(agentWorkOrdersService.listWorkOrders()).rejects.toThrow("List failed");
});
});
describe("getWorkOrder", () => {
it("should get a work order by ID", async () => {
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.getWorkOrder("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123");
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on get failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Not found"));
await expect(agentWorkOrdersService.getWorkOrder("wo-123")).rejects.toThrow("Not found");
});
});
describe("getStepHistory", () => {
it("should get step history for a work order", async () => {
const mockHistory: StepHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
agent_name: "Branch Agent",
success: true,
output: "Branch created",
error_message: null,
duration_seconds: 5,
session_id: "session-1",
timestamp: "2025-01-15T10:00:00Z",
},
{
step: "planning",
agent_name: "Planning Agent",
success: true,
output: "Plan created",
error_message: null,
duration_seconds: 30,
session_id: "session-2",
timestamp: "2025-01-15T10:01:00Z",
},
],
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockHistory);
const result = await agentWorkOrdersService.getStepHistory("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123/steps");
expect(result).toEqual(mockHistory);
});
it("should throw error on step history failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("History failed"));
await expect(agentWorkOrdersService.getStepHistory("wo-123")).rejects.toThrow("History failed");
});
});
});

View File

@@ -0,0 +1,278 @@
/**
* Repository Service Tests
*
* Unit tests for repository service methods.
* Mocks callAPIWithETag to test request structure and response handling.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
import { repositoryService } from "../repositoryService";
// Mock the API client
vi.mock("@/features/shared/api/apiClient", () => ({
callAPIWithETag: vi.fn(),
}));
// Import after mocking
import { callAPIWithETag } from "@/features/shared/api/apiClient";
describe("repositoryService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("listRepositories", () => {
it("should call GET /api/agent-work-orders/repositories", async () => {
const mockRepositories: ConfiguredRepository[] = [
{
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
vi.mocked(callAPIWithETag).mockResolvedValue(mockRepositories);
const result = await repositoryService.listRepositories();
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
method: "GET",
});
expect(result).toEqual(mockRepositories);
});
it("should handle empty repository list", async () => {
vi.mocked(callAPIWithETag).mockResolvedValue([]);
const result = await repositoryService.listRepositories();
expect(result).toEqual([]);
});
it("should propagate API errors", async () => {
const error = new Error("Network error");
vi.mocked(callAPIWithETag).mockRejectedValue(error);
await expect(repositoryService.listRepositories()).rejects.toThrow("Network error");
});
});
describe("createRepository", () => {
it("should call POST /api/agent-work-orders/repositories with request body", async () => {
const request: CreateRepositoryRequest = {
repository_url: "https://github.com/test/repo",
verify: true,
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.createRepository(request);
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
expect(result).toEqual(mockResponse);
});
it("should handle creation without verification", async () => {
const request: CreateRepositoryRequest = {
repository_url: "https://github.com/test/repo",
verify: false,
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: null,
owner: null,
default_branch: null,
is_verified: false,
last_verified_at: null,
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.createRepository(request);
expect(result.is_verified).toBe(false);
expect(result.display_name).toBe(null);
});
it("should propagate validation errors", async () => {
const error = new Error("Invalid repository URL");
vi.mocked(callAPIWithETag).mockRejectedValue(error);
await expect(
repositoryService.createRepository({
repository_url: "invalid-url",
}),
).rejects.toThrow("Invalid repository URL");
});
});
describe("updateRepository", () => {
it("should call PATCH /api/agent-work-orders/repositories/:id with update request", async () => {
const id = "repo-1";
const request: UpdateRepositoryRequest = {
default_sandbox_type: "git_branch",
default_commands: ["create-branch", "planning", "execute"],
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_branch",
default_commands: ["create-branch", "planning", "execute"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.updateRepository(id, request);
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
expect(result).toEqual(mockResponse);
});
it("should handle partial updates", async () => {
const id = "repo-1";
const request: UpdateRepositoryRequest = {
default_sandbox_type: "git_worktree",
};
const mockResponse: ConfiguredRepository = {
id: "repo-1",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: "2024-01-01T00:00:00Z",
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.updateRepository(id, request);
expect(result.default_sandbox_type).toBe("git_worktree");
});
it("should handle not found errors", async () => {
const error = new Error("Repository not found");
vi.mocked(callAPIWithETag).mockRejectedValue(error);
await expect(
repositoryService.updateRepository("non-existent", {
default_sandbox_type: "git_branch",
}),
).rejects.toThrow("Repository not found");
});
});
describe("deleteRepository", () => {
it("should call DELETE /api/agent-work-orders/repositories/:id", async () => {
const id = "repo-1";
vi.mocked(callAPIWithETag).mockResolvedValue(undefined);
await repositoryService.deleteRepository(id);
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
method: "DELETE",
});
});
it("should handle not found errors", async () => {
const error = new Error("Repository not found");
vi.mocked(callAPIWithETag).mockRejectedValue(error);
await expect(repositoryService.deleteRepository("non-existent")).rejects.toThrow("Repository not found");
});
});
describe("verifyRepositoryAccess", () => {
it("should call POST /api/agent-work-orders/repositories/:id/verify", async () => {
const id = "repo-1";
const mockResponse = {
is_accessible: true,
repository_id: "repo-1",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.verifyRepositoryAccess(id);
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}/verify`, {
method: "POST",
});
expect(result).toEqual(mockResponse);
});
it("should handle inaccessible repositories", async () => {
const id = "repo-1";
const mockResponse = {
is_accessible: false,
repository_id: "repo-1",
};
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
const result = await repositoryService.verifyRepositoryAccess(id);
expect(result.is_accessible).toBe(false);
});
it("should handle verification errors", async () => {
const error = new Error("GitHub API error");
vi.mocked(callAPIWithETag).mockRejectedValue(error);
await expect(repositoryService.verifyRepositoryAccess("repo-1")).rejects.toThrow("GitHub API error");
});
});
});

View File

@@ -0,0 +1,134 @@
/**
* Agent Work Orders API Service
*
* This service handles all API communication for agent work orders.
* It follows the pattern established in projectService.ts
*/
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type {
AgentWorkOrder,
AgentWorkOrderStatus,
CreateAgentWorkOrderRequest,
StepHistory,
WorkflowStep,
WorkOrderLogsResponse,
} from "../types";
/**
* Get the base URL for agent work orders API
* Defaults to /api/agent-work-orders (proxy through main server)
* Can be overridden with VITE_AGENT_WORK_ORDERS_URL for direct connection
*/
const getBaseUrl = (): string => {
const directUrl = import.meta.env.VITE_AGENT_WORK_ORDERS_URL;
if (directUrl) {
// Direct URL should include the full path
return `${directUrl}/api/agent-work-orders`;
}
// Default: proxy through main server
return "/api/agent-work-orders";
};
export const agentWorkOrdersService = {
/**
* Create a new agent work order
*
* @param request - The work order creation request
* @returns Promise resolving to the created work order
* @throws Error if creation fails
*/
async createWorkOrder(request: CreateAgentWorkOrderRequest): Promise<AgentWorkOrder> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/`, {
method: "POST",
body: JSON.stringify(request),
});
},
/**
* List all agent work orders, optionally filtered by status
*
* @param statusFilter - Optional status to filter by
* @returns Promise resolving to array of work orders
* @throws Error if request fails
*/
async listWorkOrders(statusFilter?: AgentWorkOrderStatus): Promise<AgentWorkOrder[]> {
const baseUrl = getBaseUrl();
const params = statusFilter ? `?status=${statusFilter}` : "";
return await callAPIWithETag<AgentWorkOrder[]>(`${baseUrl}/${params}`);
},
/**
* Get a single agent work order by ID
*
* @param id - The work order ID
* @returns Promise resolving to the work order
* @throws Error if work order not found or request fails
*/
async getWorkOrder(id: string): Promise<AgentWorkOrder> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/${id}`);
},
/**
* Get the complete step execution history for a work order
*
* @param id - The work order ID
* @returns Promise resolving to the step history
* @throws Error if work order not found or request fails
*/
async getStepHistory(id: string): Promise<StepHistory> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<StepHistory>(`${baseUrl}/${id}/steps`);
},
/**
* Start a pending work order (transition from pending to running)
* This triggers backend execution by updating the status to "running"
*
* @param id - The work order ID to start
* @returns Promise resolving to the updated work order
* @throws Error if work order not found, already running, or request fails
*/
async startWorkOrder(id: string): Promise<AgentWorkOrder> {
const baseUrl = getBaseUrl();
// Note: Backend automatically starts execution when status transitions to "running"
// This is a conceptual API - actual implementation may vary based on backend
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/${id}/start`, {
method: "POST",
});
},
/**
* Get historical logs for a work order
* Fetches buffered logs from backend (not live streaming)
*
* @param id - The work order ID
* @param options - Optional filters (limit, offset, level, step)
* @returns Promise resolving to logs response
* @throws Error if work order not found or request fails
*/
async getWorkOrderLogs(
id: string,
options?: {
limit?: number;
offset?: number;
level?: "info" | "warning" | "error" | "debug";
step?: WorkflowStep;
},
): Promise<WorkOrderLogsResponse> {
const baseUrl = getBaseUrl();
const params = new URLSearchParams();
if (options?.limit) params.append("limit", options.limit.toString());
if (options?.offset) params.append("offset", options.offset.toString());
if (options?.level) params.append("level", options.level);
if (options?.step) params.append("step", options.step);
const queryString = params.toString();
const url = queryString ? `${baseUrl}/${id}/logs?${queryString}` : `${baseUrl}/${id}/logs`;
return await callAPIWithETag<WorkOrderLogsResponse>(url);
},
};

View File

@@ -0,0 +1,86 @@
/**
* Repository Service
*
* Service layer for repository CRUD operations.
* All methods use callAPIWithETag for automatic ETag caching.
*/
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
/**
* List all configured repositories
* @returns Array of configured repositories ordered by created_at DESC
*/
export async function listRepositories(): Promise<ConfiguredRepository[]> {
return callAPIWithETag<ConfiguredRepository[]>("/api/agent-work-orders/repositories", {
method: "GET",
});
}
/**
* Create a new configured repository
* @param request - Repository creation request with URL and optional verification
* @returns The created repository with metadata
*/
export async function createRepository(request: CreateRepositoryRequest): Promise<ConfiguredRepository> {
return callAPIWithETag<ConfiguredRepository>("/api/agent-work-orders/repositories", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
}
/**
* Update an existing configured repository
* @param id - Repository ID
* @param request - Partial update request with fields to modify
* @returns The updated repository
*/
export async function updateRepository(id: string, request: UpdateRepositoryRequest): Promise<ConfiguredRepository> {
return callAPIWithETag<ConfiguredRepository>(`/api/agent-work-orders/repositories/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
}
/**
* Delete a configured repository
* @param id - Repository ID to delete
*/
export async function deleteRepository(id: string): Promise<void> {
await callAPIWithETag<void>(`/api/agent-work-orders/repositories/${id}`, {
method: "DELETE",
});
}
/**
* Verify repository access and update metadata
* Re-verifies GitHub repository access and updates display_name, owner, default_branch
* @param id - Repository ID to verify
* @returns Verification result with is_accessible boolean
*/
export async function verifyRepositoryAccess(id: string): Promise<{ is_accessible: boolean; repository_id: string }> {
return callAPIWithETag<{ is_accessible: boolean; repository_id: string }>(
`/api/agent-work-orders/repositories/${id}/verify`,
{
method: "POST",
},
);
}
// Export all methods as named exports and default object
export const repositoryService = {
listRepositories,
createRepository,
updateRepository,
deleteRepository,
verifyRepositoryAccess,
};
export default repositoryService;

View File

@@ -0,0 +1,408 @@
/**
* Unit tests for Agent Work Orders Zustand Store
*
* Tests all slices: UI Preferences, Modals, Filters, and SSE
* Verifies state management (persist middleware handles localStorage automatically)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LogEntry } from "../../types";
import type { ConfiguredRepository } from "../../types/repository";
import { useAgentWorkOrdersStore } from "../agentWorkOrdersStore";
describe("AgentWorkOrdersStore", () => {
beforeEach(() => {
// Reset store to initial state
useAgentWorkOrdersStore.setState({
// UI Preferences
layoutMode: "sidebar",
sidebarExpanded: true,
// Modals
showAddRepoModal: false,
showEditRepoModal: false,
showCreateWorkOrderModal: false,
editingRepository: null,
preselectedRepositoryId: undefined,
// Filters
searchQuery: "",
selectedRepositoryId: undefined,
// SSE
logConnections: new Map(),
connectionStates: {},
liveLogs: {},
liveProgress: {},
});
// Clear localStorage
localStorage.clear();
});
afterEach(() => {
// Disconnect all SSE connections
const { disconnectAll } = useAgentWorkOrdersStore.getState();
disconnectAll();
});
describe("UI Preferences Slice", () => {
it("should set layout mode", () => {
const { setLayoutMode } = useAgentWorkOrdersStore.getState();
setLayoutMode("horizontal");
expect(useAgentWorkOrdersStore.getState().layoutMode).toBe("horizontal");
});
it("should toggle sidebar expansion", () => {
const { toggleSidebar } = useAgentWorkOrdersStore.getState();
toggleSidebar();
expect(useAgentWorkOrdersStore.getState().sidebarExpanded).toBe(false);
});
it("should set sidebar expanded directly", () => {
const { setSidebarExpanded } = useAgentWorkOrdersStore.getState();
setSidebarExpanded(false);
expect(useAgentWorkOrdersStore.getState().sidebarExpanded).toBe(false);
});
it("should reset UI preferences to defaults", () => {
const { setLayoutMode, setSidebarExpanded, resetUIPreferences } = useAgentWorkOrdersStore.getState();
// Change values
setLayoutMode("horizontal");
setSidebarExpanded(false);
// Reset
resetUIPreferences();
const state = useAgentWorkOrdersStore.getState();
expect(state.layoutMode).toBe("sidebar");
expect(state.sidebarExpanded).toBe(true);
});
});
describe("Modals Slice", () => {
it("should open and close add repository modal", () => {
const { openAddRepoModal, closeAddRepoModal } = useAgentWorkOrdersStore.getState();
openAddRepoModal();
expect(useAgentWorkOrdersStore.getState().showAddRepoModal).toBe(true);
closeAddRepoModal();
expect(useAgentWorkOrdersStore.getState().showAddRepoModal).toBe(false);
});
it("should open edit modal with repository context", () => {
const mockRepo: ConfiguredRepository = {
id: "repo-123",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: new Date().toISOString(),
default_sandbox_type: "git_worktree",
default_commands: ["create-branch", "planning"],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const { openEditRepoModal, closeEditRepoModal } = useAgentWorkOrdersStore.getState();
openEditRepoModal(mockRepo);
expect(useAgentWorkOrdersStore.getState().showEditRepoModal).toBe(true);
expect(useAgentWorkOrdersStore.getState().editingRepository).toBe(mockRepo);
closeEditRepoModal();
expect(useAgentWorkOrdersStore.getState().showEditRepoModal).toBe(false);
expect(useAgentWorkOrdersStore.getState().editingRepository).toBe(null);
});
it("should open create work order modal with preselected repository", () => {
const { openCreateWorkOrderModal, closeCreateWorkOrderModal } = useAgentWorkOrdersStore.getState();
openCreateWorkOrderModal("repo-456");
expect(useAgentWorkOrdersStore.getState().showCreateWorkOrderModal).toBe(true);
expect(useAgentWorkOrdersStore.getState().preselectedRepositoryId).toBe("repo-456");
closeCreateWorkOrderModal();
expect(useAgentWorkOrdersStore.getState().showCreateWorkOrderModal).toBe(false);
expect(useAgentWorkOrdersStore.getState().preselectedRepositoryId).toBeUndefined();
});
it("should close all modals and clear context", () => {
const mockRepo: ConfiguredRepository = {
id: "repo-123",
repository_url: "https://github.com/test/repo",
display_name: "test/repo",
owner: "test",
default_branch: "main",
is_verified: true,
last_verified_at: new Date().toISOString(),
default_sandbox_type: "git_worktree",
default_commands: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const { openAddRepoModal, openEditRepoModal, openCreateWorkOrderModal, closeAllModals } =
useAgentWorkOrdersStore.getState();
// Open all modals
openAddRepoModal();
openEditRepoModal(mockRepo);
openCreateWorkOrderModal("repo-789");
// Close all
closeAllModals();
const state = useAgentWorkOrdersStore.getState();
expect(state.showAddRepoModal).toBe(false);
expect(state.showEditRepoModal).toBe(false);
expect(state.showCreateWorkOrderModal).toBe(false);
expect(state.editingRepository).toBe(null);
expect(state.preselectedRepositoryId).toBeUndefined();
});
});
describe("Filters Slice", () => {
it("should set search query", () => {
const { setSearchQuery } = useAgentWorkOrdersStore.getState();
setSearchQuery("my-repo");
expect(useAgentWorkOrdersStore.getState().searchQuery).toBe("my-repo");
});
it("should select repository with URL sync callback", () => {
const mockSyncUrl = vi.fn();
const { selectRepository } = useAgentWorkOrdersStore.getState();
selectRepository("repo-123", mockSyncUrl);
expect(useAgentWorkOrdersStore.getState().selectedRepositoryId).toBe("repo-123");
expect(mockSyncUrl).toHaveBeenCalledWith("repo-123");
});
it("should clear all filters", () => {
const { setSearchQuery, selectRepository, clearFilters } = useAgentWorkOrdersStore.getState();
// Set some filters
setSearchQuery("test");
selectRepository("repo-456");
// Clear
clearFilters();
const state = useAgentWorkOrdersStore.getState();
expect(state.searchQuery).toBe("");
expect(state.selectedRepositoryId).toBeUndefined();
});
});
describe("SSE Slice", () => {
it("should parse step_started log and calculate correct progress", () => {
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-123";
const stepStartedLog: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "step_started",
timestamp: new Date().toISOString(),
step: "planning",
step_number: 2,
total_steps: 5,
elapsed_seconds: 15,
};
handleLogEvent(workOrderId, stepStartedLog);
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
expect(progress?.currentStep).toBe("planning");
expect(progress?.stepNumber).toBe(2);
expect(progress?.totalSteps).toBe(5);
// Progress based on completed steps: (2-1)/5 = 20%
expect(progress?.progressPct).toBe(20);
expect(progress?.elapsedSeconds).toBe(15);
});
it("should parse workflow_completed log and update status", () => {
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-456";
const completedLog: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "workflow_completed",
timestamp: new Date().toISOString(),
};
handleLogEvent(workOrderId, completedLog);
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
expect(progress?.status).toBe("completed");
});
it("should parse workflow_failed log and update status", () => {
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-789";
const failedLog: LogEntry = {
work_order_id: workOrderId,
level: "error",
event: "workflow_failed",
timestamp: new Date().toISOString(),
error: "Something went wrong",
};
handleLogEvent(workOrderId, failedLog);
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
expect(progress?.status).toBe("failed");
});
it("should maintain max 500 log entries", () => {
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-overflow";
// Add 600 logs
for (let i = 0; i < 600; i++) {
const log: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: `event_${i}`,
timestamp: new Date().toISOString(),
};
handleLogEvent(workOrderId, log);
}
const logs = useAgentWorkOrdersStore.getState().liveLogs[workOrderId];
expect(logs.length).toBe(500);
// Should keep most recent logs
expect(logs[logs.length - 1].event).toBe("event_599");
});
it("should clear logs for specific work order", () => {
const { handleLogEvent, clearLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-clear";
// Add some logs
const log: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "test_event",
timestamp: new Date().toISOString(),
};
handleLogEvent(workOrderId, log);
expect(useAgentWorkOrdersStore.getState().liveLogs[workOrderId]?.length).toBe(1);
// Clear
clearLogs(workOrderId);
expect(useAgentWorkOrdersStore.getState().liveLogs[workOrderId]?.length).toBe(0);
});
it("should accumulate progress metadata correctly", () => {
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-progress";
// First log with step info - step 1 starting
handleLogEvent(workOrderId, {
work_order_id: workOrderId,
level: "info",
event: "step_started",
timestamp: new Date().toISOString(),
step: "planning",
step_number: 1,
total_steps: 3,
});
let progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
expect(progress?.currentStep).toBe("planning");
expect(progress?.stepNumber).toBe(1);
expect(progress?.totalSteps).toBe(3);
// Step 1 of 3 starting: (1-1)/3 = 0%
expect(progress?.progressPct).toBe(0);
// Step completed
handleLogEvent(workOrderId, {
work_order_id: workOrderId,
level: "info",
event: "step_completed",
timestamp: new Date().toISOString(),
elapsed_seconds: 30,
});
progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
// Step 1 complete: 1/3 = 33%
expect(progress?.progressPct).toBe(33);
expect(progress?.elapsedSeconds).toBe(30);
});
});
describe("State Management", () => {
it("should manage all state types correctly", () => {
const { setLayoutMode, setSearchQuery, openAddRepoModal, handleLogEvent } = useAgentWorkOrdersStore.getState();
// Set UI preferences
setLayoutMode("horizontal");
// Set filters
setSearchQuery("test-query");
// Set modals
openAddRepoModal();
// Add SSE data
handleLogEvent("wo-test", {
work_order_id: "wo-test",
level: "info",
event: "test",
timestamp: new Date().toISOString(),
});
const state = useAgentWorkOrdersStore.getState();
// Verify all state is correct (persist middleware handles localStorage)
expect(state.layoutMode).toBe("horizontal");
expect(state.searchQuery).toBe("test-query");
expect(state.showAddRepoModal).toBe(true);
expect(state.liveLogs["wo-test"]?.length).toBe(1);
});
});
describe("Selective Subscriptions", () => {
it("should only trigger updates when subscribed field changes", () => {
const layoutModeCallback = vi.fn();
const searchQueryCallback = vi.fn();
// Subscribe to specific fields
const unsubLayoutMode = useAgentWorkOrdersStore.subscribe((state) => state.layoutMode, layoutModeCallback);
const unsubSearchQuery = useAgentWorkOrdersStore.subscribe((state) => state.searchQuery, searchQueryCallback);
// Change layoutMode - should trigger layoutMode callback only
const { setLayoutMode } = useAgentWorkOrdersStore.getState();
setLayoutMode("horizontal");
expect(layoutModeCallback).toHaveBeenCalledWith("horizontal", "sidebar");
expect(searchQueryCallback).not.toHaveBeenCalled();
// Clear mock calls
layoutModeCallback.mockClear();
searchQueryCallback.mockClear();
// Change searchQuery - should trigger searchQuery callback only
const { setSearchQuery } = useAgentWorkOrdersStore.getState();
setSearchQuery("new-query");
expect(searchQueryCallback).toHaveBeenCalledWith("new-query", "");
expect(layoutModeCallback).not.toHaveBeenCalled();
// Cleanup
unsubLayoutMode();
unsubSearchQuery();
});
});
});

View File

@@ -0,0 +1,345 @@
/**
* Integration tests for SSE Connection Lifecycle
*
* Tests EventSource connection management, event handling, and cleanup
* Mocks EventSource API to simulate connection states
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LogEntry } from "../../types";
import { useAgentWorkOrdersStore } from "../agentWorkOrdersStore";
// Mock EventSource
class MockEventSource {
url: string;
onopen: (() => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
readyState: number = 0;
private listeners: Map<string, ((event: Event) => void)[]> = new Map();
constructor(url: string) {
this.url = url;
this.readyState = 0; // CONNECTING
}
addEventListener(type: string, listener: (event: Event) => void): void {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type)?.push(listener);
}
removeEventListener(type: string, listener: (event: Event) => void): void {
const listeners = this.listeners.get(type);
if (listeners) {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
close(): void {
this.readyState = 2; // CLOSED
}
// Helper methods for testing
simulateOpen(): void {
this.readyState = 1; // OPEN
if (this.onopen) {
this.onopen();
}
}
simulateMessage(data: string): void {
if (this.onmessage) {
const event = new MessageEvent("message", { data });
this.onmessage(event);
}
}
simulateError(): void {
if (this.onerror) {
this.onerror();
}
}
}
describe("SSE Integration Tests", () => {
let mockEventSourceInstances: MockEventSource[] = [];
beforeEach(() => {
// Reset store
useAgentWorkOrdersStore.setState({
layoutMode: "sidebar",
sidebarExpanded: true,
showAddRepoModal: false,
showEditRepoModal: false,
showCreateWorkOrderModal: false,
editingRepository: null,
preselectedRepositoryId: undefined,
searchQuery: "",
selectedRepositoryId: undefined,
logConnections: new Map(),
connectionStates: {},
liveLogs: {},
liveProgress: {},
});
// Clear mock instances
mockEventSourceInstances = [];
// Mock EventSource globally
global.EventSource = vi.fn((url: string) => {
const instance = new MockEventSource(url);
mockEventSourceInstances.push(instance);
return instance as unknown as EventSource;
}) as unknown as typeof EventSource;
});
afterEach(() => {
// Disconnect all connections
const { disconnectAll } = useAgentWorkOrdersStore.getState();
disconnectAll();
vi.restoreAllMocks();
});
describe("connectToLogs", () => {
it("should create EventSource connection with correct URL", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-123";
connectToLogs(workOrderId);
expect(global.EventSource).toHaveBeenCalledWith(`/api/agent-work-orders/${workOrderId}/logs/stream`);
expect(mockEventSourceInstances.length).toBe(1);
expect(mockEventSourceInstances[0].url).toBe(`/api/agent-work-orders/${workOrderId}/logs/stream`);
});
it("should set connectionState to connecting initially", () => {
const { connectToLogs, connectionStates } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-456";
connectToLogs(workOrderId);
const state = useAgentWorkOrdersStore.getState();
expect(state.connectionStates[workOrderId]).toBe("connecting");
});
it("should prevent duplicate connections", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-duplicate";
connectToLogs(workOrderId);
connectToLogs(workOrderId); // Second call
// Should only create one connection
expect(mockEventSourceInstances.length).toBe(1);
});
it("should store connection in logConnections Map", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-789";
connectToLogs(workOrderId);
const state = useAgentWorkOrdersStore.getState();
expect(state.logConnections.has(workOrderId)).toBe(true);
});
});
describe("onopen event", () => {
it("should set connectionState to connected", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-open";
connectToLogs(workOrderId);
// Simulate open event
mockEventSourceInstances[0].simulateOpen();
const state = useAgentWorkOrdersStore.getState();
expect(state.connectionStates[workOrderId]).toBe("connected");
});
});
describe("onmessage event", () => {
it("should parse JSON and call handleLogEvent", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-message";
connectToLogs(workOrderId);
mockEventSourceInstances[0].simulateOpen();
const logEntry: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "step_started",
timestamp: new Date().toISOString(),
step: "planning",
step_number: 1,
total_steps: 5,
};
// Simulate message
mockEventSourceInstances[0].simulateMessage(JSON.stringify(logEntry));
const state = useAgentWorkOrdersStore.getState();
expect(state.liveLogs[workOrderId]?.length).toBe(1);
expect(state.liveLogs[workOrderId]?.[0].event).toBe("step_started");
expect(state.liveProgress[workOrderId]?.currentStep).toBe("planning");
});
it("should handle malformed JSON gracefully", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-malformed";
connectToLogs(workOrderId);
mockEventSourceInstances[0].simulateOpen();
// Simulate malformed JSON
mockEventSourceInstances[0].simulateMessage("invalid json {");
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse"), expect.anything());
consoleErrorSpy.mockRestore();
});
});
describe("onerror event", () => {
it("should set connectionState to error", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-error";
connectToLogs(workOrderId);
mockEventSourceInstances[0].simulateOpen();
// Simulate error
mockEventSourceInstances[0].simulateError();
const state = useAgentWorkOrdersStore.getState();
expect(state.connectionStates[workOrderId]).toBe("error");
});
it("should trigger auto-reconnect after error", async () => {
vi.useFakeTimers();
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-reconnect";
connectToLogs(workOrderId);
const firstConnection = mockEventSourceInstances[0];
firstConnection.simulateOpen();
// Simulate error
firstConnection.simulateError();
expect(firstConnection.close).toBeDefined();
// Fast-forward 5 seconds (auto-reconnect delay)
await vi.advanceTimersByTimeAsync(5000);
// Should create new connection
expect(mockEventSourceInstances.length).toBe(2);
vi.useRealTimers();
});
});
describe("disconnectFromLogs", () => {
it("should close connection and remove from Map", () => {
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-disconnect";
connectToLogs(workOrderId);
const connection = mockEventSourceInstances[0];
disconnectFromLogs(workOrderId);
expect(connection.readyState).toBe(2); // CLOSED
expect(useAgentWorkOrdersStore.getState().logConnections.has(workOrderId)).toBe(false);
});
it("should set connectionState to disconnected", () => {
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-disc-state";
connectToLogs(workOrderId);
disconnectFromLogs(workOrderId);
const state = useAgentWorkOrdersStore.getState();
expect(state.connectionStates[workOrderId]).toBe("disconnected");
});
it("should handle disconnect when no connection exists", () => {
const { disconnectFromLogs } = useAgentWorkOrdersStore.getState();
// Should not throw
expect(() => disconnectFromLogs("non-existent-id")).not.toThrow();
});
});
describe("disconnectAll", () => {
it("should close all connections and clear state", () => {
const { connectToLogs, disconnectAll } = useAgentWorkOrdersStore.getState();
// Create multiple connections
connectToLogs("wo-1");
connectToLogs("wo-2");
connectToLogs("wo-3");
expect(mockEventSourceInstances.length).toBe(3);
// Disconnect all
disconnectAll();
const state = useAgentWorkOrdersStore.getState();
expect(state.logConnections.size).toBe(0);
expect(Object.keys(state.connectionStates).length).toBe(0);
expect(Object.keys(state.liveLogs).length).toBe(0);
expect(Object.keys(state.liveProgress).length).toBe(0);
// All connections should be closed
mockEventSourceInstances.forEach((instance) => {
expect(instance.readyState).toBe(2); // CLOSED
});
});
});
describe("Multiple Subscribers Pattern", () => {
it("should share same connection across multiple subscribers", () => {
const { connectToLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-shared";
// First subscriber
connectToLogs(workOrderId);
// Second subscriber (same work order ID)
connectToLogs(workOrderId);
// Should only create one connection
expect(mockEventSourceInstances.length).toBe(1);
});
it("should keep connection open until all subscribers disconnect", () => {
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
const workOrderId = "wo-multi-sub";
// Simulate 2 components subscribing
connectToLogs(workOrderId);
const connection = mockEventSourceInstances[0];
// First component disconnects
disconnectFromLogs(workOrderId);
// Connection should be closed (our current implementation closes immediately)
// In a full reference counting implementation, connection would stay open
// This test documents current behavior
expect(connection.readyState).toBe(2); // CLOSED
});
});
});

View File

@@ -0,0 +1,78 @@
import { create } from "zustand";
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
import { createFiltersSlice, type FiltersSlice } from "./slices/filtersSlice";
import { createModalsSlice, type ModalsSlice } from "./slices/modalsSlice";
import { createSSESlice, type SSESlice } from "./slices/sseSlice";
import { createUIPreferencesSlice, type UIPreferencesSlice } from "./slices/uiPreferencesSlice";
/**
* Combined Agent Work Orders store type
* Combines all slices into a single store interface
*/
export type AgentWorkOrdersStore = UIPreferencesSlice & ModalsSlice & FiltersSlice & SSESlice;
/**
* Agent Work Orders global state store
*
* Manages:
* - UI preferences (layout mode, sidebar state) - PERSISTED
* - Modal state (which modal is open, editing context) - NOT persisted
* - Filter state (search query, selected repository) - PERSISTED
* - SSE connections (live updates, connection management) - NOT persisted
*
* Does NOT manage:
* - Server data (TanStack Query handles this)
* - Ephemeral UI state (local useState for row expansion, etc.)
*
* Zustand v5 Selector Patterns:
* ```typescript
* import { useShallow } from 'zustand/shallow';
*
* // ✅ Single primitive - stable reference
* const layoutMode = useAgentWorkOrdersStore((s) => s.layoutMode);
*
* // ✅ Single action - functions are stable
* const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
*
* // ✅ Multiple values - use useShallow to prevent infinite loops
* const { layoutMode, sidebarExpanded } = useAgentWorkOrdersStore(
* useShallow((s) => ({
* layoutMode: s.layoutMode,
* sidebarExpanded: s.sidebarExpanded
* }))
* );
* ```
*/
export const useAgentWorkOrdersStore = create<AgentWorkOrdersStore>()(
devtools(
subscribeWithSelector(
persist(
(...a) => ({
...createUIPreferencesSlice(...a),
...createModalsSlice(...a),
...createFiltersSlice(...a),
...createSSESlice(...a),
}),
{
name: "agent-work-orders-ui",
version: 2,
partialize: (state) => ({
// Persist UI preferences and search query
layoutMode: state.layoutMode,
sidebarExpanded: state.sidebarExpanded,
searchQuery: state.searchQuery,
// Persist SSE data to survive HMR
liveLogs: state.liveLogs,
liveProgress: state.liveProgress,
// Do NOT persist:
// - selectedRepositoryId (URL params are source of truth)
// - Modal state (ephemeral)
// - SSE connections (must be re-established, but data is preserved)
// - connectionStates (transient)
}),
},
),
),
{ name: "AgentWorkOrders" },
),
);

View File

@@ -0,0 +1,57 @@
import type { StateCreator } from "zustand";
export type FiltersSlice = {
// State
searchQuery: string;
selectedRepositoryId: string | undefined;
// Actions
setSearchQuery: (query: string) => void;
selectRepository: (id: string | undefined, syncUrl?: (id: string | undefined) => void) => void;
clearFilters: () => void;
};
/**
* Filters Slice
*
* Manages filter and selection state for repositories and work orders.
* Includes search query and selected repository ID.
*
* Persisted: YES (search/selection survives reload)
*
* URL Sync: selectedRepositoryId should also update URL query params.
* Use the syncUrl callback to keep URL in sync.
*
* @example
* ```typescript
* // Set search query
* const setSearchQuery = useAgentWorkOrdersStore((s) => s.setSearchQuery);
* setSearchQuery("my-repo");
*
* // Select repository with URL sync
* const selectRepository = useAgentWorkOrdersStore((s) => s.selectRepository);
* selectRepository("repo-id-123", (id) => {
* setSearchParams(id ? { repo: id } : {});
* });
* ```
*/
export const createFiltersSlice: StateCreator<FiltersSlice, [], [], FiltersSlice> = (set) => ({
// Initial state
searchQuery: "",
selectedRepositoryId: undefined,
// Actions
setSearchQuery: (query) => set({ searchQuery: query }),
selectRepository: (id, syncUrl) => {
set({ selectedRepositoryId: id });
// Callback to sync with URL search params
syncUrl?.(id);
},
clearFilters: () =>
set({
searchQuery: "",
selectedRepositoryId: undefined,
}),
});

View File

@@ -0,0 +1,92 @@
import type { StateCreator } from "zustand";
import type { ConfiguredRepository } from "../../types/repository";
export type ModalsSlice = {
// Modal visibility
showAddRepoModal: boolean;
showEditRepoModal: boolean;
showCreateWorkOrderModal: boolean;
// Modal context (which item is being edited)
editingRepository: ConfiguredRepository | null;
preselectedRepositoryId: string | undefined;
// Actions
openAddRepoModal: () => void;
closeAddRepoModal: () => void;
openEditRepoModal: (repository: ConfiguredRepository) => void;
closeEditRepoModal: () => void;
openCreateWorkOrderModal: (repositoryId?: string) => void;
closeCreateWorkOrderModal: () => void;
closeAllModals: () => void;
};
/**
* Modals Slice
*
* Manages modal visibility and context (which repository is being edited, etc.).
* Enables opening modals from anywhere without prop drilling.
*
* Persisted: NO (modals should not persist across page reloads)
*
* Note: Form state (repositoryUrl, selectedSteps, etc.) can be added to this slice
* if centralized validation/submission logic is desired. For simple forms that
* reset on close, local useState in the modal component is cleaner.
*
* @example
* ```typescript
* // Open modal from anywhere
* const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
* openEditRepoModal(repository);
*
* // Subscribe to modal state
* const showEditRepoModal = useAgentWorkOrdersStore((s) => s.showEditRepoModal);
* const editingRepository = useAgentWorkOrdersStore((s) => s.editingRepository);
* ```
*/
export const createModalsSlice: StateCreator<ModalsSlice, [], [], ModalsSlice> = (set) => ({
// Initial state
showAddRepoModal: false,
showEditRepoModal: false,
showCreateWorkOrderModal: false,
editingRepository: null,
preselectedRepositoryId: undefined,
// Actions
openAddRepoModal: () => set({ showAddRepoModal: true }),
closeAddRepoModal: () => set({ showAddRepoModal: false }),
openEditRepoModal: (repository) =>
set({
showEditRepoModal: true,
editingRepository: repository,
}),
closeEditRepoModal: () =>
set({
showEditRepoModal: false,
editingRepository: null,
}),
openCreateWorkOrderModal: (repositoryId) =>
set({
showCreateWorkOrderModal: true,
preselectedRepositoryId: repositoryId,
}),
closeCreateWorkOrderModal: () =>
set({
showCreateWorkOrderModal: false,
preselectedRepositoryId: undefined,
}),
closeAllModals: () =>
set({
showAddRepoModal: false,
showEditRepoModal: false,
showCreateWorkOrderModal: false,
editingRepository: null,
preselectedRepositoryId: undefined,
}),
});

View File

@@ -0,0 +1,268 @@
import type { StateCreator } from "zustand";
import type { LogEntry, SSEConnectionState } from "../../types";
export type LiveProgress = {
currentStep?: string;
stepNumber?: number;
totalSteps?: number;
progressPct?: number;
elapsedSeconds?: number;
status?: string;
};
export type SSESlice = {
// Active EventSource connections (keyed by work_order_id)
logConnections: Map<string, EventSource>;
// Connection states
connectionStates: Record<string, SSEConnectionState>;
// Live data from SSE (keyed by work_order_id)
// This OVERLAYS on top of TanStack Query cached data
liveLogs: Record<string, LogEntry[]>;
liveProgress: Record<string, LiveProgress>;
// Actions
connectToLogs: (workOrderId: string) => void;
disconnectFromLogs: (workOrderId: string) => void;
handleLogEvent: (workOrderId: string, log: LogEntry) => void;
clearLogs: (workOrderId: string) => void;
disconnectAll: () => void;
};
/**
* SSE Slice
*
* Manages Server-Sent Event connections and real-time data from log streams.
* Handles connection lifecycle, auto-reconnect, and live data aggregation.
*
* Persisted: NO (connections must be re-established on page load)
*
* Pattern:
* 1. Component calls connectToLogs(workOrderId) on mount
* 2. Zustand creates EventSource if not exists
* 3. Multiple components can subscribe to same connection
* 4. handleLogEvent parses logs and updates liveProgress
* 5. Component calls disconnectFromLogs on unmount
* 6. Zustand closes EventSource when no more subscribers
*
* @example
* ```typescript
* // Connect to SSE
* const connectToLogs = useAgentWorkOrdersStore((s) => s.connectToLogs);
* const disconnectFromLogs = useAgentWorkOrdersStore((s) => s.disconnectFromLogs);
*
* useEffect(() => {
* connectToLogs(workOrderId);
* return () => disconnectFromLogs(workOrderId);
* }, [workOrderId]);
*
* // Subscribe to live progress
* const progress = useAgentWorkOrdersStore((s) => s.liveProgress[workOrderId]);
* ```
*/
export const createSSESlice: StateCreator<SSESlice, [], [], SSESlice> = (set, get) => ({
// Initial state
logConnections: new Map(),
connectionStates: {},
liveLogs: {},
liveProgress: {},
// Actions
connectToLogs: (workOrderId) => {
const { logConnections } = get();
// Don't create duplicate connections
if (logConnections.has(workOrderId)) {
return;
}
// Set connecting state
set((state) => ({
connectionStates: {
...state.connectionStates,
[workOrderId]: "connecting" as SSEConnectionState,
},
}));
// Create EventSource for log stream
const url = `/api/agent-work-orders/${workOrderId}/logs/stream`;
const eventSource = new EventSource(url);
eventSource.onopen = () => {
set((state) => ({
connectionStates: {
...state.connectionStates,
[workOrderId]: "connected" as SSEConnectionState,
},
}));
};
eventSource.onmessage = (event) => {
try {
const logEntry: LogEntry = JSON.parse(event.data);
get().handleLogEvent(workOrderId, logEntry);
} catch (err) {
console.error("Failed to parse log entry:", err);
}
};
eventSource.onerror = (event) => {
// Check if this is a 404 (work order doesn't exist)
// EventSource doesn't give us status code, but we can check if it's a permanent failure
// by attempting to determine if the server is reachable
const target = event.target as EventSource;
// If the EventSource readyState is CLOSED (2), it won't reconnect
// This typically happens on 404 or permanent errors
if (target.readyState === EventSource.CLOSED) {
// Permanent failure (likely 404) - clean up and don't retry
eventSource.close();
set((state) => {
const newConnections = new Map(state.logConnections);
newConnections.delete(workOrderId);
// Remove from persisted state too
const newLiveLogs = { ...state.liveLogs };
const newLiveProgress = { ...state.liveProgress };
delete newLiveLogs[workOrderId];
delete newLiveProgress[workOrderId];
return {
logConnections: newConnections,
liveLogs: newLiveLogs,
liveProgress: newLiveProgress,
connectionStates: {
...state.connectionStates,
[workOrderId]: "disconnected" as SSEConnectionState,
},
};
});
return;
}
// Temporary error - retry after 5 seconds
set((state) => ({
connectionStates: {
...state.connectionStates,
[workOrderId]: "error" as SSEConnectionState,
},
}));
setTimeout(() => {
eventSource.close();
set((state) => {
const newConnections = new Map(state.logConnections);
newConnections.delete(workOrderId);
return { logConnections: newConnections };
});
get().connectToLogs(workOrderId); // Retry
}, 5000);
};
// Store connection
const newConnections = new Map(logConnections);
newConnections.set(workOrderId, eventSource);
set({ logConnections: newConnections });
},
disconnectFromLogs: (workOrderId) => {
const { logConnections } = get();
const connection = logConnections.get(workOrderId);
if (connection) {
connection.close();
const newConnections = new Map(logConnections);
newConnections.delete(workOrderId);
set({
logConnections: newConnections,
connectionStates: {
...get().connectionStates,
[workOrderId]: "disconnected" as SSEConnectionState,
},
});
}
},
handleLogEvent: (workOrderId, log) => {
// Add to logs array
set((state) => ({
liveLogs: {
...state.liveLogs,
[workOrderId]: [...(state.liveLogs[workOrderId] || []), log].slice(-500), // Keep last 500
},
}));
// Parse log to update progress
const progressUpdate: Partial<LiveProgress> = {};
if (log.event === "step_started") {
progressUpdate.currentStep = log.step;
progressUpdate.stepNumber = log.step_number;
progressUpdate.totalSteps = log.total_steps;
// Calculate progress based on COMPLETED steps (current - 1)
// If on step 3/3, progress is 66% (2 completed), not 100%
if (log.step_number !== undefined && log.total_steps !== undefined && log.total_steps > 0) {
const completedSteps = log.step_number - 1; // Steps completed before current
progressUpdate.progressPct = Math.round((completedSteps / log.total_steps) * 100);
}
}
// step_completed: Increment progress by 1 step
if (log.event === "step_completed") {
const currentProgress = get().liveProgress[workOrderId];
if (currentProgress?.stepNumber !== undefined && currentProgress?.totalSteps !== undefined) {
const completedSteps = currentProgress.stepNumber; // Current step now complete
progressUpdate.progressPct = Math.round((completedSteps / currentProgress.totalSteps) * 100);
}
}
if (log.elapsed_seconds !== undefined) {
progressUpdate.elapsedSeconds = log.elapsed_seconds;
}
if (log.event === "workflow_completed") {
progressUpdate.status = "completed";
progressUpdate.progressPct = 100; // Ensure 100% on completion
}
if (log.event === "workflow_failed" || log.level === "error") {
progressUpdate.status = "failed";
}
if (Object.keys(progressUpdate).length > 0) {
set((state) => ({
liveProgress: {
...state.liveProgress,
[workOrderId]: {
...state.liveProgress[workOrderId],
...progressUpdate,
},
},
}));
}
},
clearLogs: (workOrderId) => {
set((state) => ({
liveLogs: {
...state.liveLogs,
[workOrderId]: [],
},
}));
},
disconnectAll: () => {
const { logConnections } = get();
logConnections.forEach((conn) => conn.close());
set({
logConnections: new Map(),
connectionStates: {},
liveLogs: {},
liveProgress: {},
});
},
});

View File

@@ -0,0 +1,49 @@
import type { StateCreator } from "zustand";
export type LayoutMode = "horizontal" | "sidebar";
export type UIPreferencesSlice = {
// State
layoutMode: LayoutMode;
sidebarExpanded: boolean;
// Actions
setLayoutMode: (mode: LayoutMode) => void;
setSidebarExpanded: (expanded: boolean) => void;
toggleSidebar: () => void;
resetUIPreferences: () => void;
};
/**
* UI Preferences Slice
*
* Manages user interface preferences that should persist across sessions.
* Includes layout mode (horizontal/sidebar) and sidebar expansion state.
*
* Persisted: YES (via persist middleware in main store)
*
* @example
* ```typescript
* const layoutMode = useAgentWorkOrdersStore((s) => s.layoutMode);
* const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
* setLayoutMode("horizontal");
* ```
*/
export const createUIPreferencesSlice: StateCreator<UIPreferencesSlice, [], [], UIPreferencesSlice> = (set) => ({
// Initial state
layoutMode: "sidebar",
sidebarExpanded: true,
// Actions
setLayoutMode: (mode) => set({ layoutMode: mode }),
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
toggleSidebar: () => set((state) => ({ sidebarExpanded: !state.sidebarExpanded })),
resetUIPreferences: () =>
set({
layoutMode: "sidebar",
sidebarExpanded: true,
}),
});

View File

@@ -0,0 +1,219 @@
/**
* Agent Work Orders Type Definitions
*
* This module defines TypeScript interfaces and types for the Agent Work Orders feature.
* These types mirror the backend models from python/src/agent_work_orders/models.py
*/
/**
* Status of an agent work order
* - pending: Work order created but not started
* - running: Work order is currently executing
* - completed: Work order finished successfully
* - failed: Work order encountered an error
*/
export type AgentWorkOrderStatus = "pending" | "running" | "completed" | "failed";
/**
* Available workflow steps for agent work orders
* Each step represents a command that can be executed
*/
export type WorkflowStep = "create-branch" | "planning" | "execute" | "commit" | "create-pr" | "prp-review";
/**
* Type of git sandbox for work order execution
* - git_branch: Uses standard git branches
* - git_worktree: Uses git worktree for isolation
*/
export type SandboxType = "git_branch" | "git_worktree";
/**
* Agent Work Order entity
* Represents a complete AI-driven development workflow
*/
export interface AgentWorkOrder {
/** Unique identifier for the work order */
agent_work_order_id: string;
/** URL of the git repository to work on */
repository_url: string;
/** Unique identifier for the sandbox instance */
sandbox_identifier: string;
/** Name of the git branch created for this work order (null if not yet created) */
git_branch_name: string | null;
/** ID of the agent session executing this work order (null if not started) */
agent_session_id: string | null;
/** Type of sandbox being used */
sandbox_type: SandboxType;
/** GitHub issue number associated with this work order (optional) */
github_issue_number: string | null;
/** Current status of the work order */
status: AgentWorkOrderStatus;
/** Current workflow phase/step being executed (null if not started) */
current_phase: string | null;
/** Timestamp when work order was created */
created_at: string;
/** Timestamp when work order was last updated */
updated_at: string;
/** URL of the created pull request (null if not yet created) */
github_pull_request_url: string | null;
/** Number of commits made during execution */
git_commit_count: number;
/** Number of files changed during execution */
git_files_changed: number;
/** Error message if work order failed (null if successful or still running) */
error_message: string | null;
}
/**
* Request payload for creating a new agent work order
*/
export interface CreateAgentWorkOrderRequest {
/** URL of the git repository to work on */
repository_url: string;
/** Type of sandbox to use for execution */
sandbox_type: SandboxType;
/** User's natural language request describing the work to be done */
user_request: string;
/** Optional array of specific commands to execute (defaults to all if not provided) */
selected_commands?: WorkflowStep[];
/** Optional GitHub issue number to associate with this work order */
github_issue_number?: string | null;
/** Optional configured repository ID for linking work order to repository */
repository_id?: string;
}
/**
* Result of a single step execution within a workflow
*/
export interface StepExecutionResult {
/** The workflow step that was executed */
step: WorkflowStep;
/** Name of the agent that executed this step */
agent_name: string;
/** Whether the step completed successfully */
success: boolean;
/** Output/result from the step execution (null if no output) */
output: string | null;
/** Error message if step failed (null if successful) */
error_message: string | null;
/** How long the step took to execute (in seconds) */
duration_seconds: number;
/** Agent session ID for this step execution (null if not tracked) */
session_id: string | null;
/** Timestamp when step was executed */
timestamp: string;
}
/**
* Complete history of all steps executed for a work order
*/
export interface StepHistory {
/** The work order ID this history belongs to */
agent_work_order_id: string;
/** Array of all executed steps in chronological order */
steps: StepExecutionResult[];
}
/**
* Log entry from SSE stream
* Structured log event from work order execution
*/
export interface LogEntry {
/** Work order ID this log belongs to */
work_order_id: string;
/** Log level (info, warning, error, debug) */
level: "info" | "warning" | "error" | "debug";
/** Event name describing what happened */
event: string;
/** ISO timestamp when log was created */
timestamp: string;
/** Optional step name if log is associated with a step */
step?: WorkflowStep;
/** Optional step number (e.g., 2 for "2/5") */
step_number?: number;
/** Optional total steps (e.g., 5 for "2/5") */
total_steps?: number;
/** Optional progress string (e.g., "2/5") */
progress?: string;
/** Optional progress percentage (e.g., 40) */
progress_pct?: number;
/** Optional elapsed seconds */
elapsed_seconds?: number;
/** Optional error message */
error?: string;
/** Optional output/result */
output?: string;
/** Optional duration */
duration_seconds?: number;
/** Any additional structured fields from backend */
[key: string]: unknown;
}
/**
* Connection state for SSE stream
*/
export type SSEConnectionState = "connecting" | "connected" | "disconnected" | "error";
/**
* Response from GET /logs endpoint
* Contains historical log entries with pagination
*/
export interface WorkOrderLogsResponse {
/** Work order ID */
agent_work_order_id: string;
/** Array of log entries */
log_entries: LogEntry[];
/** Total number of logs available */
total: number;
/** Number of logs returned in this response */
limit: number;
/** Offset used for pagination */
offset: number;
}
// Export repository types
export type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "./repository";

View File

@@ -0,0 +1,82 @@
/**
* Repository Type Definitions
*
* This module defines TypeScript interfaces for configured repositories.
* These types mirror the backend models from python/src/agent_work_orders/models.py ConfiguredRepository
*/
import type { SandboxType, WorkflowStep } from "./index";
/**
* Configured repository with metadata and preferences
*
* Stores GitHub repository configuration for Agent Work Orders, including
* verification status, metadata extracted from GitHub API, and per-repository
* preferences for sandbox type and workflow commands.
*/
export interface ConfiguredRepository {
/** Unique UUID identifier for the configured repository */
id: string;
/** GitHub repository URL (https://github.com/owner/repo format) */
repository_url: string;
/** Human-readable repository name (e.g., 'owner/repo-name') */
display_name: string | null;
/** Repository owner/organization name */
owner: string | null;
/** Default branch name (e.g., 'main' or 'master') */
default_branch: string | null;
/** Boolean flag indicating if repository access has been verified */
is_verified: boolean;
/** Timestamp of last successful repository verification */
last_verified_at: string | null;
/** Default sandbox type for work orders */
default_sandbox_type: SandboxType;
/** Default workflow commands for work orders */
default_commands: WorkflowStep[];
/** Timestamp when repository configuration was created */
created_at: string;
/** Timestamp when repository configuration was last updated */
updated_at: string;
}
/**
* Request to create a new configured repository
*
* Creates a new repository configuration. If verify=True, the system will
* call the GitHub API to validate repository access and extract metadata
* (display_name, owner, default_branch) before storing.
*/
export interface CreateRepositoryRequest {
/** GitHub repository URL to configure */
repository_url: string;
/** Whether to verify repository access via GitHub API and extract metadata */
verify?: boolean;
}
/**
* Request to update an existing configured repository
*
* All fields are optional for partial updates. Only provided fields will be
* updated in the database.
*/
export interface UpdateRepositoryRequest {
/** Update the display name for this repository */
display_name?: string;
/** Update the default sandbox type for this repository */
default_sandbox_type?: SandboxType;
/** Update the default workflow commands for this repository */
default_commands?: WorkflowStep[];
}

View File

@@ -0,0 +1,339 @@
/**
* Agent Work Order Detail View
*
* Detailed view of a single agent work order showing progress, step history,
* logs, and full metadata.
*/
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { Card } from "@/features/ui/primitives/card";
import { RealTimeStats } from "../components/RealTimeStats";
import { StepHistoryCard } from "../components/StepHistoryCard";
import { WorkflowStepButton } from "../components/WorkflowStepButton";
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
import type { WorkflowStep } from "../types";
/**
* All available workflow steps in execution order
*/
const ALL_WORKFLOW_STEPS: WorkflowStep[] = [
"create-branch",
"planning",
"execute",
"prp-review",
"commit",
"create-pr",
];
export function AgentWorkOrderDetailView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [showDetails, setShowDetails] = useState(false);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
// Get live progress from SSE for total steps count
const liveProgress = useAgentWorkOrdersStore((s) => (id ? s.liveProgress[id] : undefined));
/**
* Toggle step expansion
*/
const toggleStepExpansion = (stepId: string) => {
setExpandedSteps((prev) => {
const newSet = new Set(prev);
if (newSet.has(stepId)) {
newSet.delete(stepId);
} else {
newSet.add(stepId);
}
return newSet;
});
};
if (isLoadingWorkOrder || isLoadingSteps) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 dark:bg-gray-800 rounded w-1/3" />
<div className="h-40 bg-gray-200 dark:bg-gray-800 rounded" />
<div className="h-60 bg-gray-200 dark:bg-gray-800 rounded" />
</div>
</div>
);
}
if (isErrorWorkOrder || isErrorSteps || !workOrder || !stepHistory) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<p className="text-red-400 mb-4">Failed to load work order</p>
<Button onClick={() => navigate("/agent-work-orders")}>Back to List</Button>
</div>
</div>
);
}
// Additional safety check for repository_url
const repoName = workOrder?.repository_url?.split("/").slice(-2).join("/") || "Unknown Repository";
return (
<div className="space-y-6">
{/* Breadcrumb navigation */}
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={() => navigate("/agent-work-orders")}
className="text-cyan-600 dark:text-cyan-400 hover:underline"
>
Work Orders
</button>
<span className="text-gray-400 dark:text-gray-600">/</span>
<button
type="button"
onClick={() => navigate("/agent-work-orders")}
className="text-cyan-600 dark:text-cyan-400 hover:underline"
>
{repoName}
</button>
<span className="text-gray-400 dark:text-gray-600">/</span>
<span className="text-gray-900 dark:text-white">{workOrder.agent_work_order_id}</span>
</div>
{/* Real-Time Execution Stats */}
<RealTimeStats workOrderId={id} />
{/* Workflow Progress Bar */}
<Card blur="md" transparency="light" edgePosition="top" edgeColor="cyan" size="lg" className="overflow-visible">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{repoName}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(!showDetails)}
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
aria-label={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? (
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
) : (
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
)}
Details
</Button>
</div>
{/* Workflow Steps - Show all steps, highlight completed */}
<div className="flex items-center justify-center gap-0">
{ALL_WORKFLOW_STEPS.map((stepName, index) => {
// Find if this step has been executed
const executedStep = stepHistory.steps.find((s) => s.step === stepName);
const isCompleted = executedStep?.success || false;
// Mark as active if it's the last executed step and not successful (still running)
const isActive =
executedStep &&
stepHistory.steps[stepHistory.steps.length - 1]?.step === stepName &&
!executedStep.success;
return (
<div key={stepName} className="flex items-center">
<WorkflowStepButton
isCompleted={isCompleted}
isActive={isActive}
stepName={stepName}
color="cyan"
size={50}
/>
{/* Connecting Line - only show between steps */}
{index < ALL_WORKFLOW_STEPS.length - 1 && (
<div className="relative flex-shrink-0" style={{ width: "80px", height: "50px" }}>
<div
className={
isCompleted
? "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
: "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-gray-600 dark:border-gray-700"
}
/>
</div>
)}
</div>
);
})}
</div>
{/* Collapsible Details Section */}
<AnimatePresence>
{showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: 0.3,
ease: [0.04, 0.62, 0.23, 0.98],
},
opacity: {
duration: 0.2,
ease: "easeInOut",
},
}}
style={{ overflow: "hidden" }}
className="mt-6"
>
<motion.div
initial={{ y: -20 }}
animate={{ y: 0 }}
exit={{ y: -20 }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-gray-200/50 dark:border-gray-700/30"
>
{/* Left Column - Details */}
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Details
</h4>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mt-0.5">
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Sandbox Type</p>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">
{workOrder.sandbox_type}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Repository</p>
<a
href={workOrder.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
>
{workOrder.repository_url}
<ExternalLink className="w-3 h-3" aria-hidden="true" />
</a>
</div>
{workOrder.git_branch_name && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Branch</p>
<p className="text-sm font-medium font-mono text-gray-900 dark:text-white mt-0.5">
{workOrder.git_branch_name}
</p>
</div>
)}
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Work Order ID</p>
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
{workOrder.agent_work_order_id}
</p>
</div>
{workOrder.agent_session_id && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Session ID</p>
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
{workOrder.agent_session_id}
</p>
</div>
)}
{workOrder.github_pull_request_url && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Pull Request</p>
<a
href={workOrder.github_pull_request_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
>
View PR
<ExternalLink className="w-3 h-3" aria-hidden="true" />
</a>
</div>
)}
{workOrder.github_issue_number && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">GitHub Issue</p>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">
#{workOrder.github_issue_number}
</p>
</div>
)}
</div>
</div>
</div>
{/* Right Column - Statistics */}
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Statistics
</h4>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Commits</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
{workOrder.git_commit_count}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Files Changed</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
{workOrder.git_files_changed}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Steps Completed</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
{stepHistory.steps.filter((s) => s.success).length} / {liveProgress?.totalSteps ?? stepHistory.steps.length}
</p>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Step History */}
<div className="space-y-4">
{stepHistory.steps.map((step, index) => {
const stepId = `${step.step}-${index}`;
const isExpanded = expandedSteps.has(stepId);
return (
<StepHistoryCard
key={stepId}
step={{
id: stepId,
stepName: step.step,
timestamp: new Date(step.timestamp).toLocaleString(),
output: step.output || "No output",
session: step.session_id || "Unknown session",
collapsible: true,
isHumanInLoop: false,
}}
isExpanded={isExpanded}
onToggle={() => toggleStepExpansion(stepId)}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,377 @@
/**
* Agent Work Orders View
*
* Main view for agent work orders with repository management and layout switching.
* Supports horizontal and sidebar layout modes.
*/
import { ChevronLeft, ChevronRight, GitBranch, LayoutGrid, List, Plus, Search } from "lucide-react";
import { useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useShallow } from "zustand/shallow";
import { Button } from "@/features/ui/primitives/button";
import { Input } from "@/features/ui/primitives/input";
import { PillNavigation, type PillNavigationItem } from "@/features/ui/primitives/pill-navigation";
import { cn } from "@/features/ui/primitives/styles";
import { AddRepositoryModal } from "../components/AddRepositoryModal";
import { CreateWorkOrderModal } from "../components/CreateWorkOrderModal";
import { EditRepositoryModal } from "../components/EditRepositoryModal";
import { RepositoryCard } from "../components/RepositoryCard";
import { SidebarRepositoryCard } from "../components/SidebarRepositoryCard";
import { WorkOrderTable } from "../components/WorkOrderTable";
import { useStartWorkOrder, useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
import { useDeleteRepository, useRepositories } from "../hooks/useRepositoryQueries";
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
export function AgentWorkOrdersView() {
const [searchParams, setSearchParams] = useSearchParams();
// Zustand UI Preferences - Group related state with useShallow
const { layoutMode, sidebarExpanded } = useAgentWorkOrdersStore(
useShallow((s) => ({
layoutMode: s.layoutMode,
sidebarExpanded: s.sidebarExpanded,
})),
);
// Zustand UI Preference Actions - Functions are stable, select individually
const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
const setSidebarExpanded = useAgentWorkOrdersStore((s) => s.setSidebarExpanded);
// Zustand Modals State - Group with useShallow
const { showAddRepoModal, showEditRepoModal, showCreateWorkOrderModal, editingRepository } = useAgentWorkOrdersStore(
useShallow((s) => ({
showAddRepoModal: s.showAddRepoModal,
showEditRepoModal: s.showEditRepoModal,
showCreateWorkOrderModal: s.showCreateWorkOrderModal,
editingRepository: s.editingRepository,
})),
);
// Zustand Modal Actions - Functions are stable, select individually
const openAddRepoModal = useAgentWorkOrdersStore((s) => s.openAddRepoModal);
const closeAddRepoModal = useAgentWorkOrdersStore((s) => s.closeAddRepoModal);
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
const closeEditRepoModal = useAgentWorkOrdersStore((s) => s.closeEditRepoModal);
const openCreateWorkOrderModal = useAgentWorkOrdersStore((s) => s.openCreateWorkOrderModal);
const closeCreateWorkOrderModal = useAgentWorkOrdersStore((s) => s.closeCreateWorkOrderModal);
// Zustand Filters - Select individually
const searchQuery = useAgentWorkOrdersStore((s) => s.searchQuery);
const setSearchQuery = useAgentWorkOrdersStore((s) => s.setSearchQuery);
// Use URL params as source of truth for selected repository (no Zustand state needed)
const selectedRepositoryId = searchParams.get("repo") || undefined;
// Fetch data
const { data: repositories = [], isLoading: isLoadingRepos } = useRepositories();
const { data: workOrders = [], isLoading: isLoadingWorkOrders } = useWorkOrders();
const startWorkOrder = useStartWorkOrder();
const deleteRepository = useDeleteRepository();
// Helper function to select repository (updates URL only)
const selectRepository = useCallback(
(id: string | undefined) => {
if (id) {
setSearchParams({ repo: id });
} else {
setSearchParams({});
}
},
[setSearchParams],
);
/**
* Handle repository deletion
*/
const handleDeleteRepository = useCallback(
async (id: string) => {
if (confirm("Are you sure you want to delete this repository configuration?")) {
await deleteRepository.mutateAsync(id);
// If this was the selected repository, clear selection
if (selectedRepositoryId === id) {
selectRepository(undefined);
}
}
},
[deleteRepository, selectedRepositoryId, selectRepository],
);
/**
* Calculate work order stats for a repository
*/
const getRepositoryStats = (repositoryId: string) => {
const repoWorkOrders = workOrders.filter((wo) => {
const repo = repositories.find((r) => r.id === repositoryId);
return repo && wo.repository_url === repo.repository_url;
});
return {
total: repoWorkOrders.length,
active: repoWorkOrders.filter((wo) => wo.status === "running" || wo.status === "pending").length,
done: repoWorkOrders.filter((wo) => wo.status === "completed").length,
};
};
/**
* Build tab items for PillNavigation
*/
const tabItems: PillNavigationItem[] = [
{ id: "all", label: "All Work Orders", icon: <GitBranch className="w-4 h-4" aria-hidden="true" /> },
];
if (selectedRepositoryId) {
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
if (selectedRepo) {
tabItems.push({
id: selectedRepositoryId,
label: selectedRepo.display_name || selectedRepo.repository_url,
icon: <GitBranch className="w-4 h-4" aria-hidden="true" />,
});
}
}
// Filter repositories by search query
const filteredRepositories = repositories.filter((repo) => {
const searchLower = searchQuery.toLowerCase();
return (
repo.display_name?.toLowerCase().includes(searchLower) ||
repo.repository_url.toLowerCase().includes(searchLower) ||
repo.owner?.toLowerCase().includes(searchLower)
);
});
return (
<div className="space-y-6">
{/* Header Section */}
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Title */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Agent Work Orders</h1>
{/* Search Bar */}
<div className="relative flex-1 min-w-0 max-w-md">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500"
aria-hidden="true"
/>
<Input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
aria-label="Search repositories"
/>
</div>
{/* Layout Toggle */}
<div className="flex gap-1 p-1 bg-black/30 dark:bg-white/10 rounded-lg border border-white/10 dark:border-gray-700">
<Button
variant="ghost"
size="sm"
onClick={() => setLayoutMode("sidebar")}
className={cn(
"px-3",
layoutMode === "sidebar" && "bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
)}
aria-label="Switch to sidebar layout"
aria-pressed={layoutMode === "sidebar"}
>
<List className="w-4 h-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setLayoutMode("horizontal")}
className={cn(
"px-3",
layoutMode === "horizontal" &&
"bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
)}
aria-label="Switch to horizontal layout"
aria-pressed={layoutMode === "horizontal"}
>
<LayoutGrid className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{/* New Repo Button */}
<Button onClick={openAddRepoModal} variant="cyan" aria-label="Add new repository">
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
New Repo
</Button>
</div>
{/* Modals */}
<AddRepositoryModal open={showAddRepoModal} onOpenChange={closeAddRepoModal} />
<EditRepositoryModal open={showEditRepoModal} onOpenChange={closeEditRepoModal} />
<CreateWorkOrderModal open={showCreateWorkOrderModal} onOpenChange={closeCreateWorkOrderModal} />
{/* Horizontal Layout */}
{layoutMode === "horizontal" && (
<>
{/* Repository cards in horizontal scroll */}
<div className="w-full max-w-full">
<div className="overflow-x-auto overflow-y-visible py-8 -mx-6 px-6 scrollbar-hide">
<div className="flex gap-4 min-w-max">
{filteredRepositories.length === 0 ? (
<div className="w-full text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
{searchQuery ? "No repositories match your search" : "No repositories configured"}
</p>
</div>
) : (
filteredRepositories.map((repository) => (
<RepositoryCard
key={repository.id}
repository={repository}
isSelected={selectedRepositoryId === repository.id}
showAuroraGlow={selectedRepositoryId === repository.id}
onSelect={() => selectRepository(repository.id)}
onDelete={() => handleDeleteRepository(repository.id)}
stats={getRepositoryStats(repository.id)}
/>
))
)}
</div>
</div>
</div>
{/* PillNavigation centered */}
<div className="flex items-center justify-center">
<PillNavigation
items={tabItems}
activeSection={selectedRepositoryId || "all"}
onSectionClick={(id) => {
if (id === "all") {
selectRepository(undefined);
} else {
selectRepository(id);
}
}}
/>
</div>
</>
)}
{/* Sidebar Layout */}
{layoutMode === "sidebar" && (
<div className="flex gap-4 min-w-0">
{/* Collapsible Sidebar */}
<div className={cn("shrink-0 transition-all duration-300 space-y-2", sidebarExpanded ? "w-56" : "w-12")}>
{/* Collapse/Expand button */}
<Button
variant="ghost"
size="sm"
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="w-full justify-center"
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
aria-expanded={sidebarExpanded}
>
{sidebarExpanded ? (
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronRight className="w-4 h-4" aria-hidden="true" />
)}
</Button>
{/* Sidebar content */}
{sidebarExpanded && (
<div className="space-y-2 px-1">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8 px-2">
<p className="text-xs text-gray-500 dark:text-gray-400">
{searchQuery ? "No repositories match" : "No repositories"}
</p>
</div>
) : (
filteredRepositories.map((repository) => (
<SidebarRepositoryCard
key={repository.id}
repository={repository}
isSelected={selectedRepositoryId === repository.id}
isPinned={false}
showAuroraGlow={selectedRepositoryId === repository.id}
onSelect={() => selectRepository(repository.id)}
onDelete={() => handleDeleteRepository(repository.id)}
stats={getRepositoryStats(repository.id)}
/>
))
)}
</div>
)}
</div>
{/* Main content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* PillNavigation centered */}
<div className="flex items-center justify-center">
<PillNavigation
items={tabItems}
activeSection={selectedRepositoryId || "all"}
onSectionClick={(id) => {
if (id === "all") {
selectRepository(undefined);
} else {
selectRepository(id);
}
}}
/>
</div>
{/* Work Orders Table */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
<Button
onClick={() => openCreateWorkOrderModal(selectedRepositoryId)}
variant="cyan"
aria-label="Create new work order"
>
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
New Work Order
</Button>
</div>
<WorkOrderTable
workOrders={workOrders}
selectedRepositoryId={selectedRepositoryId}
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
/>
</div>
</div>
</div>
)}
{/* Horizontal layout work orders table (below repository cards) */}
{layoutMode === "horizontal" && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
<Button
onClick={() => openCreateWorkOrderModal(selectedRepositoryId)}
variant="cyan"
aria-label="Create new work order"
>
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
New Work Order
</Button>
</div>
<WorkOrderTable
workOrders={workOrders}
selectedRepositoryId={selectedRepositoryId}
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
/>
</div>
)}
{/* Loading state */}
{(isLoadingRepos || isLoadingWorkOrders) && (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
</div>
)}
</div>
);
}

View File

@@ -150,7 +150,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
"focus:ring-1 focus:ring-cyan-400 px-2 py-1",
)}
/>
{description && description.trim() && (
{description?.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info
@@ -183,7 +183,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
{title}
</h3>
</SimpleTooltip>
{description && description.trim() && (
{description?.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info

View File

@@ -67,17 +67,17 @@ export const LevelSelector: React.FC<LevelSelectorProps> = ({ value, onValueChan
Crawl Depth
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
aria-label="Show crawl depth level details"
>
<Info className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltipContent}</TooltipContent>
</Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
aria-label="Show crawl depth level details"
>
<Info className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltipContent}</TooltipContent>
</Tooltip>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Higher levels crawl deeper into the website structure

View File

@@ -41,10 +41,7 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
try {
// Escape HTML entities FIRST per Prism documentation requirement
// Prism expects pre-escaped input to prevent XSS
const escaped = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escaped = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const lang = language?.toLowerCase() || "javascript";
const grammar = Prism.languages[lang] || Prism.languages.javascript;

View File

@@ -36,7 +36,7 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
useEffect(() => {
setViewMode(initialTab);
setSelectedItem(null); // Clear selected item when switching tabs
}, [item.source_id, initialTab]);
}, [initialTab, item.source_id]);
// Use pagination hook for current view mode
const paginationData = useInspectorPagination({

View File

@@ -155,7 +155,7 @@ export function usePaginatedInspectorData({
useEffect(() => {
resetDocs();
resetCode();
}, [sourceId, enabled, resetDocs, resetCode]);
}, [resetDocs, resetCode]);
return {
documents: {

View File

@@ -268,9 +268,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
{operation.discovered_file}
</a>
) : (
<span className="text-sm text-gray-400 truncate block">
{operation.discovered_file}
</span>
<span className="text-sm text-gray-400 truncate block">{operation.discovered_file}</span>
)}
</div>
)}
@@ -283,7 +281,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
{operation.linked_files.length > 1 ? "s" : ""}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{operation.linked_files.map((file: string, idx: number) => (
{operation.linked_files.map((file: string, idx: number) =>
isValidHttpUrl(file) ? (
<a
key={idx}
@@ -298,8 +296,8 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
<span key={idx} className="text-xs text-gray-400 truncate block">
{file}
</span>
)
))}
),
)}
</div>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveOperationsResponse, ProgressResponse } from "../../types";

View File

@@ -240,12 +240,12 @@ export function useMultipleOperations(
// Reset tracking sets when progress IDs change
// Use sorted JSON stringification for stable dependency that handles reordering
const progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
const _progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
useEffect(() => {
completedIds.current.clear();
errorIds.current.clear();
notFoundCounts.current.clear();
}, [progressIdsKey]); // Stable dependency across reorderings
}, [_progressIdsKey]); // Stable dependency across reorderings
const queries = useQueries({
queries: progressIds.map((progressId) => ({

View File

@@ -13,32 +13,32 @@ const SAFE_PROTOCOLS = ["http:", "https:"];
* @returns true if URL is safe (http/https), false otherwise
*/
export function isValidHttpUrl(url: string | undefined | null): boolean {
if (!url || typeof url !== "string") {
return false;
}
if (!url || typeof url !== "string") {
return false;
}
// Trim whitespace
const trimmed = url.trim();
if (!trimmed) {
return false;
}
// Trim whitespace
const trimmed = url.trim();
if (!trimmed) {
return false;
}
try {
const parsed = new URL(trimmed);
try {
const parsed = new URL(trimmed);
// Only allow http and https protocols
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
return false;
}
// Only allow http and https protocols
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
return false;
}
// Basic hostname validation (must have at least one dot or be localhost)
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
return false;
}
// Basic hostname validation (must have at least one dot or be localhost)
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
return false;
}
return true;
} catch {
// URL parsing failed - not a valid URL
return false;
}
return true;
} catch {
// URL parsing failed - not a valid URL
return false;
}
}

View File

@@ -51,7 +51,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
)}
>
{/* Main content area with padding */}
<div className="flex-1 p-4 pb-2">
{/* Title section */}

View File

@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { LayoutGrid, List, Plus, Search, X } from "lucide-react";
import type React from "react";
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { Button } from "../../ui/primitives/button";
import { Input } from "../../ui/primitives/input";
import { cn } from "../../ui/primitives/styles";

View File

@@ -55,7 +55,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
await createDocumentMutation.mutateAsync({
title,
document_type,
content: { markdown: "# " + title + "\n\nStart writing your document here..." },
content: { markdown: `# ${title}\n\nStart writing your document here...` },
// NOTE: Archon does not have user authentication - this is a single-user local app.
// "User" is a constant representing the sole user of this Archon instance.
author: "User",
@@ -94,7 +94,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
setShowAddModal(false);
setShowDeleteModal(false);
setDocumentToDelete(null);
}, [projectId]);
}, []);
// Auto-select first document when documents load
useEffect(() => {

View File

@@ -52,13 +52,7 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
setError(null);
onOpenChange(false);
} catch (err) {
setError(
typeof err === "string"
? err
: err instanceof Error
? err.message
: "Failed to create document"
);
setError(typeof err === "string" ? err : err instanceof Error ? err.message : "Failed to create document");
} finally {
setIsAdding(false);
}
@@ -81,7 +75,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
)}
<div>
<label htmlFor="document-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label
htmlFor="document-title"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Document Title
</label>
<Input
@@ -96,7 +93,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
</div>
<div>
<label htmlFor="document-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label
htmlFor="document-type"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Document Type
</label>
<Select value={type} onValueChange={setType} disabled={isAdding}>
@@ -104,11 +104,21 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
<SelectValue placeholder="Select a document type" />
</SelectTrigger>
<SelectContent color="cyan">
<SelectItem value="spec" color="cyan">Specification</SelectItem>
<SelectItem value="api" color="cyan">API Documentation</SelectItem>
<SelectItem value="guide" color="cyan">Guide</SelectItem>
<SelectItem value="note" color="cyan">Note</SelectItem>
<SelectItem value="design" color="cyan">Design</SelectItem>
<SelectItem value="spec" color="cyan">
Specification
</SelectItem>
<SelectItem value="api" color="cyan">
API Documentation
</SelectItem>
<SelectItem value="guide" color="cyan">
Guide
</SelectItem>
<SelectItem value="note" color="cyan">
Note
</SelectItem>
<SelectItem value="design" color="cyan">
Design
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -118,7 +118,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
aria-label={`${isActive ? "Selected: " : ""}${document.title}`}
className={cn("relative w-full cursor-pointer transition-all duration-300 group", isActive && "scale-[1.02]")}
>
<div>
<div>
{/* Document Type Badge */}
<div
className={cn(
@@ -177,7 +177,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
</Card>
);
});

View File

@@ -60,11 +60,8 @@ export const documentService = {
* Delete a document
*/
async deleteDocument(projectId: string, documentId: string): Promise<void> {
await callAPIWithETag<{ success: boolean; message: string }>(
`/api/projects/${projectId}/docs/${documentId}`,
{
method: "DELETE",
},
);
await callAPIWithETag<{ success: boolean; message: string }>(`/api/projects/${projectId}/docs/${documentId}`, {
method: "DELETE",
});
},
};

View File

@@ -3,7 +3,7 @@ import { useRef } from "react";
import { useDrop } from "react-dnd";
import { cn } from "../../../ui/primitives/styles";
import type { Task } from "../types";
import { getColumnColor, getColumnGlow, ItemTypes } from "../utils/task-styles";
import { getColumnGlow, ItemTypes } from "../utils/task-styles";
import { TaskCard } from "./TaskCard";
interface KanbanColumnProps {
@@ -90,7 +90,7 @@ export const KanbanColumn = ({
<div
className={cn(
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border backdrop-blur-md",
statusInfo.color
statusInfo.color,
)}
>
{statusInfo.icon}

View File

@@ -3,7 +3,7 @@ import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "../../types";
import { taskKeys, useCreateTask, useProjectTasks, useTaskCounts } from "../useTaskQueries";
import { taskKeys, useCreateTask, useProjectTasks } from "../useTaskQueries";
// Mock the services
vi.mock("../../services", () => ({

View File

@@ -1,13 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { Activity, CheckCircle2, FileText, LayoutGrid, List, ListTodo, Pin } from "lucide-react";
import { Activity, CheckCircle2, FileText, List, ListTodo, Pin } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
import { isOptimistic } from "../../shared/utils/optimistic";
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { Button, PillNavigation, SelectableCard } from "../../ui/primitives";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { StatPill } from "../../ui/primitives/pill";
import { cn } from "../../ui/primitives/styles";
import { NewProjectModal } from "../components/NewProjectModal";
@@ -71,7 +71,7 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
const sortedProjects = useMemo(() => {
// Filter by search query
const filtered = (projects as Project[]).filter((project) =>
project.title.toLowerCase().includes(searchQuery.toLowerCase())
project.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
// Sort: pinned first, then alphabetically

View File

@@ -42,11 +42,18 @@ function buildFullUrl(cleanEndpoint: string): string {
*/
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
try {
// Clean endpoint
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
// Handle absolute URLs (direct service connections)
const isAbsoluteUrl = endpoint.startsWith("http://") || endpoint.startsWith("https://");
// Construct the full URL
const fullUrl = buildFullUrl(cleanEndpoint);
let fullUrl: string;
if (isAbsoluteUrl) {
// Use absolute URL as-is (for direct service connections)
fullUrl = endpoint;
} else {
// Clean endpoint and build relative URL
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
fullUrl = buildFullUrl(cleanEndpoint);
}
// Build headers - only set Content-Type for requests with a body
// NOTE: We do NOT add If-None-Match headers; the browser handles ETag revalidation automatically
@@ -60,7 +67,7 @@ export async function callAPIWithETag<T = unknown>(endpoint: string, options: Re
// Only set Content-Type for requests that have a body (POST, PUT, PATCH, etc.)
// GET and DELETE requests should not have Content-Type header
const method = options.method?.toUpperCase() || "GET";
const _method = options.method?.toUpperCase() || "GET";
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";

View File

@@ -0,0 +1,336 @@
import { AnimatePresence, motion } from "framer-motion";
import { ChevronDown, ChevronUp, ExternalLink, Plus, User } from "lucide-react";
import { useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Card } from "@/features/ui/primitives/card";
import { cn } from "@/features/ui/primitives/styles";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
import { RealTimeStatsExample } from "./components/RealTimeStatsExample";
import { StepHistoryCard } from "./components/StepHistoryCard";
import { WorkflowStepButton } from "./components/WorkflowStepButton";
const MOCK_WORK_ORDER = {
id: "wo-1",
title: "Create comprehensive documentation",
status: "in_progress" as const,
workflow: {
currentStep: 2,
steps: [
{ id: "1", name: "Create Branch", status: "completed", duration: "33s" },
{ id: "2", name: "Planning", status: "in_progress", duration: "2m 11s" },
{ id: "3", name: "Execute", status: "pending", duration: null },
{ id: "4", name: "Commit", status: "pending", duration: null },
{ id: "5", name: "Create PR", status: "pending", duration: null },
],
},
stepHistory: [
{
id: "step-1",
stepName: "Create Branch",
timestamp: "7 minutes ago",
output: "docs/remove-archon-mentions",
session: "Session: a342d9ac-56c4-43ae-95b8-9ddf18143961",
collapsible: true,
},
{
id: "step-2",
stepName: "Planning",
timestamp: "5 minutes ago",
output: `## Report
**Work completed:**
- Conducted comprehensive codebase audit for "archon" and "Archon" mentions
- Verified main README.md is already breach (no archon mentions present)
- Identified 14 subdirectory README files that need verification
- Discovered historical git commits that added "hello from archon" but content has been removed
- Identified 3 remote branches with "archon" in their names (out of scope for this task)
- Created comprehensive PRP plan for documentation cleanup and verification`,
session: "Session: e3889823-b272-43c0-b11d-7a786d7e3c88",
collapsible: true,
isHumanInLoop: true,
},
],
document: {
id: "doc-1",
title: "Planning Document",
content: {
markdown: `# Documentation Cleanup Plan
## Overview
This document outlines the plan to remove all "archon" mentions from the codebase.
## Steps
1. Audit all README files
2. Check git history for sensitive content
3. Verify no configuration files reference "archon"
4. Update documentation
## Progress
- [x] Initial audit complete
- [ ] README updates pending
- [ ] Configuration review pending`,
},
},
};
export const AgentWorkOrderExample = () => {
const [hoveredStepIndex, setHoveredStepIndex] = useState<number | null>(null);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set(["step-2"]));
const [showDetails, setShowDetails] = useState(false);
const [humanInLoopCheckpoints, setHumanInLoopCheckpoints] = useState<Set<number>>(new Set());
const toggleStepExpansion = (stepId: string) => {
setExpandedSteps((prev) => {
const newSet = new Set(prev);
if (newSet.has(stepId)) {
newSet.delete(stepId);
} else {
newSet.add(stepId);
}
return newSet;
});
};
const addHumanInLoopCheckpoint = (index: number) => {
setHumanInLoopCheckpoints((prev) => {
const newSet = new Set(prev);
newSet.add(index);
return newSet;
});
setHoveredStepIndex(null);
};
const removeHumanInLoopCheckpoint = (index: number) => {
setHumanInLoopCheckpoints((prev) => {
const newSet = new Set(prev);
newSet.delete(index);
return newSet;
});
};
return (
<div className="space-y-6">
{/* Explanation Text */}
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Use this layout for:</strong> Agent work order workflows with step-by-step progress tracking,
collapsible history, and integrated document editing for human-in-the-loop approval.
</p>
{/* Real-Time Execution Stats */}
<RealTimeStatsExample status="plan" stepNumber={2} />
{/* Workflow Progress Bar */}
<Card blur="md" transparency="light" edgePosition="top" edgeColor="cyan" size="lg" className="overflow-visible">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{MOCK_WORK_ORDER.title}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(!showDetails)}
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
aria-label={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? (
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
) : (
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
)}
Details
</Button>
</div>
<div className="flex items-center justify-center gap-0">
{MOCK_WORK_ORDER.workflow.steps.map((step, index) => (
<div key={step.id} className="flex items-center">
{/* Step Button */}
<WorkflowStepButton
isCompleted={step.status === "completed"}
isActive={step.status === "in_progress"}
stepName={step.name}
color="cyan"
size={50}
/>
{/* Connecting Line - only show between steps */}
{index < MOCK_WORK_ORDER.workflow.steps.length - 1 && (
// biome-ignore lint/a11y/noStaticElementInteractions: Visual hover effect container for showing plus button
<div
className="relative flex-shrink-0"
style={{ width: "80px", height: "50px" }}
onMouseEnter={() => setHoveredStepIndex(index)}
onMouseLeave={() => setHoveredStepIndex(null)}
>
{/* Neon line */}
<div
className={cn(
"absolute top-1/2 left-0 right-0 h-[2px] transition-all duration-200",
step.status === "completed"
? "border-t-2 border-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
: "border-t-2 border-gray-600 dark:border-gray-700",
hoveredStepIndex === index &&
step.status !== "completed" &&
"border-cyan-400/50 shadow-[0_0_6px_rgba(34,211,238,0.3)]",
)}
/>
{/* Human-in-Loop Checkpoint Indicator */}
{humanInLoopCheckpoints.has(index) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => removeHumanInLoopCheckpoint(index)}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-orange-500 hover:bg-orange-600 rounded-full p-1.5 shadow-lg shadow-orange-500/50 border-2 border-orange-400 transition-colors cursor-pointer"
aria-label="Remove Human-in-Loop checkpoint"
>
<User className="w-3.5 h-3.5 text-white" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Click to remove</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Plus button on hover - only show if no checkpoint exists */}
{hoveredStepIndex === index && !humanInLoopCheckpoints.has(index) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => addHumanInLoopCheckpoint(index)}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-orange-500 hover:bg-orange-600 transition-colors shadow-lg shadow-orange-500/50 flex items-center justify-center text-white"
aria-label="Add Human-in-Loop step"
>
<Plus className="w-4 h-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Add Human-in-Loop</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
))}
</div>
{/* Collapsible Details Section */}
<AnimatePresence>
{showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: 0.3,
ease: [0.04, 0.62, 0.23, 0.98],
},
opacity: {
duration: 0.2,
ease: "easeInOut",
},
}}
style={{ overflow: "hidden" }}
className="mt-6"
>
<motion.div
initial={{ y: -20 }}
animate={{ y: 0 }}
exit={{ y: -20 }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-gray-200/50 dark:border-gray-700/30"
>
{/* Left Column */}
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Details
</h4>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mt-0.5">Running</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Sandbox Type</p>
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">git_branch</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Repository</p>
<a
href="https://github.com/Wirasm/dylan"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
>
https://github.com/Wirasm/dylan
<ExternalLink className="w-3 h-3" aria-hidden="true" />
</a>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Branch</p>
<p className="text-sm font-medium font-mono text-gray-900 dark:text-white mt-0.5">
docs/remove-archon-mentions
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Work Order ID</p>
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
wo-7fd39c8d
</p>
</div>
</div>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Statistics
</h4>
<div className="space-y-3">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Commits</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">0</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Files Changed</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">0</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Steps Completed</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">2 / 2</p>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Step History Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Step History</h3>
{MOCK_WORK_ORDER.stepHistory.map((step) => (
<StepHistoryCard
key={step.id}
step={step}
isExpanded={expandedSteps.has(step.id)}
onToggle={() => toggleStepExpansion(step.id)}
document={step.isHumanInLoop ? MOCK_WORK_ORDER.document : undefined}
/>
))}
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
import { cn } from "@/features/ui/primitives/styles";
import { Switch } from "@/features/ui/primitives/switch";
interface ExecutionLogsExampleProps {
/** Work order status to generate appropriate mock logs */
status: string;
}
interface MockLog {
timestamp: string;
level: "info" | "warning" | "error" | "debug";
event: string;
step?: string;
progress?: string;
}
/**
* Get color class for log level badge - STATIC lookup
*/
const logLevelColors: Record<string, string> = {
info: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-400/30",
warning: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-400/30",
error: "bg-red-500/20 text-red-600 dark:text-red-400 border-red-400/30",
debug: "bg-gray-500/20 text-gray-600 dark:text-gray-400 border-gray-400/30",
};
/**
* Format timestamp to relative time
*/
function formatRelativeTime(timestamp: string): string {
const now = Date.now();
const logTime = new Date(timestamp).getTime();
const diffSeconds = Math.floor((now - logTime) / 1000);
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
return `${Math.floor(diffSeconds / 3600)}h ago`;
}
/**
* Individual log entry component
*/
function LogEntryRow({ log }: { log: MockLog }) {
const colorClass = logLevelColors[log.level] || logLevelColors.debug;
return (
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 dark:hover:bg-black/20 rounded font-mono text-sm">
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">
{formatRelativeTime(log.timestamp)}
</span>
<span className={cn("px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap", colorClass)}>
{log.level}
</span>
{log.step && <span className="text-cyan-600 dark:text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
<span className="text-gray-900 dark:text-gray-300 flex-1">{log.event}</span>
{log.progress && (
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">{log.progress}</span>
)}
</div>
);
}
export function ExecutionLogsExample({ status }: ExecutionLogsExampleProps) {
const [autoScroll, setAutoScroll] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
// Generate mock logs based on status
const generateMockLogs = (): MockLog[] => {
const now = Date.now();
const baseTime = now - 300000; // 5 minutes ago
const logs: MockLog[] = [
{ timestamp: new Date(baseTime).toISOString(), level: "info", event: "workflow_started" },
{ timestamp: new Date(baseTime + 1000).toISOString(), level: "info", event: "sandbox_setup_started" },
{
timestamp: new Date(baseTime + 3000).toISOString(),
level: "info",
event: "repository_cloned",
step: "setup",
},
{ timestamp: new Date(baseTime + 5000).toISOString(), level: "info", event: "sandbox_setup_completed" },
];
if (status !== "pending") {
logs.push(
{
timestamp: new Date(baseTime + 10000).toISOString(),
level: "info",
event: "step_started",
step: "create-branch",
progress: "1/5",
},
{
timestamp: new Date(baseTime + 12000).toISOString(),
level: "info",
event: "agent_command_started",
step: "create-branch",
},
{
timestamp: new Date(baseTime + 45000).toISOString(),
level: "info",
event: "branch_created",
step: "create-branch",
},
);
}
if (status === "plan" || status === "execute" || status === "commit" || status === "create_pr") {
logs.push(
{
timestamp: new Date(baseTime + 60000).toISOString(),
level: "info",
event: "step_started",
step: "planning",
progress: "2/5",
},
{
timestamp: new Date(baseTime + 120000).toISOString(),
level: "debug",
event: "analyzing_codebase",
step: "planning",
},
);
}
return logs;
};
const mockLogs = generateMockLogs();
const filteredLogs = levelFilter === "all" ? mockLogs : mockLogs.filter((log) => log.level === levelFilter);
return (
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg overflow-hidden bg-black/20 dark:bg-white/5 backdrop-blur">
{/* Header with controls */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-gray-700/30 bg-gray-900/50 dark:bg-gray-800/30">
<div className="flex items-center gap-3">
<span className="font-semibold text-gray-900 dark:text-gray-300">Execution Logs</span>
{/* Live indicator */}
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs text-green-600 dark:text-green-400">Live</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">({filteredLogs.length} entries)</span>
</div>
{/* Controls */}
<div className="flex items-center gap-3">
{/* Level filter using proper Select primitive */}
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-32 h-8 text-xs" aria-label="Filter log level">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
</SelectContent>
</Select>
{/* Auto-scroll toggle using Switch primitive */}
<div className="flex items-center gap-2">
<label htmlFor="auto-scroll-toggle" className="text-xs text-gray-700 dark:text-gray-300">
Auto-scroll:
</label>
<Switch
id="auto-scroll-toggle"
checked={autoScroll}
onCheckedChange={setAutoScroll}
aria-label="Toggle auto-scroll"
/>
<span
className={cn(
"text-xs font-medium",
autoScroll ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
)}
>
{autoScroll ? "ON" : "OFF"}
</span>
</div>
{/* Clear logs button */}
<Button variant="ghost" size="xs" aria-label="Clear logs">
<Trash2 className="w-3 h-3" aria-hidden="true" />
</Button>
</div>
</div>
{/* Log content - scrollable area */}
<div className="max-h-96 overflow-y-auto bg-black/40 dark:bg-black/20">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
<p>No logs match the current filter</p>
</div>
) : (
<div className="p-2">
{filteredLogs.map((log, index) => (
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { Activity, ChevronDown, ChevronUp, Clock, TrendingUp } from "lucide-react";
import { useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { ExecutionLogsExample } from "./ExecutionLogsExample";
interface RealTimeStatsExampleProps {
/** Work order status for determining progress */
status: string;
/** Step number (1-5) */
stepNumber: number;
}
/**
* Format elapsed seconds to human-readable duration
*/
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
export function RealTimeStatsExample({ status, stepNumber }: RealTimeStatsExampleProps) {
const [showLogs, setShowLogs] = useState(false);
// Mock data based on status
const stepNames: Record<string, string> = {
create_branch: "create-branch",
plan: "planning",
execute: "execute",
commit: "commit",
create_pr: "create-pr",
};
const currentStep = stepNames[status] || "initializing";
const progressPct = (stepNumber / 5) * 100;
const mockElapsedSeconds = stepNumber * 120; // 2 minutes per step
const activities: Record<string, string> = {
create_branch: "Creating new branch for work order...",
plan: "Analyzing codebase and generating implementation plan...",
execute: "Writing code and applying changes...",
commit: "Committing changes to branch...",
create_pr: "Creating pull request on GitHub...",
};
const currentActivity = activities[status] || "Initializing workflow...";
return (
<div className="space-y-3">
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg p-4 bg-black/20 dark:bg-white/5 backdrop-blur">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-300 mb-3 flex items-center gap-2">
<Activity className="w-4 h-4" aria-hidden="true" />
Real-Time Execution
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Current Step */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Current Step</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
{currentStep}
<span className="text-gray-500 dark:text-gray-400 ml-2">({stepNumber}/5)</span>
</div>
</div>
{/* Progress */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
<TrendingUp className="w-3 h-3" aria-hidden="true" />
Progress
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-700 dark:bg-gray-200/20 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500 ease-out"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-sm font-medium text-cyan-600 dark:text-cyan-400">{progressPct}%</span>
</div>
</div>
</div>
{/* Elapsed Time */}
<div className="space-y-1">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" />
Elapsed Time
</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
{formatDuration(mockElapsedSeconds)}
</div>
</div>
</div>
{/* Latest Activity with Status Indicator - at top */}
<div className="mt-4 pt-3 border-t border-white/10 dark:border-gray-700/30">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap">
Latest Activity:
</div>
<div className="text-sm text-gray-900 dark:text-gray-300 flex-1 truncate">{currentActivity}</div>
</div>
{/* Status Indicator - right side of Latest Activity */}
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 flex-shrink-0">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>Running</span>
</div>
</div>
</div>
{/* Show Execution Logs button - at bottom */}
<div className="mt-3 pt-3 border-t border-white/10 dark:border-gray-700/30">
<Button
variant="ghost"
size="sm"
onClick={() => setShowLogs(!showLogs)}
className="w-full justify-center text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
aria-label={showLogs ? "Hide execution logs" : "Show execution logs"}
aria-expanded={showLogs}
>
{showLogs ? (
<>
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
Hide Execution Logs
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
Show Execution Logs
</>
)}
</Button>
</div>
</div>
{/* Collapsible Execution Logs */}
{showLogs && <ExecutionLogsExample status={status} />}
</div>
);
}

View File

@@ -0,0 +1,265 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Edit3, Eye } from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/features/ui/primitives/button";
import { Card } from "@/features/ui/primitives/card";
import { cn } from "@/features/ui/primitives/styles";
interface StepHistoryCardProps {
step: {
id: string;
stepName: string;
timestamp: string;
output: string;
session: string;
collapsible: boolean;
isHumanInLoop?: boolean;
};
isExpanded: boolean;
onToggle: () => void;
document?: {
title: string;
content: {
markdown: string;
};
};
}
export const StepHistoryCard = ({ step, isExpanded, onToggle, document }: StepHistoryCardProps) => {
const [isEditingDocument, setIsEditingDocument] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [hasChanges, setHasChanges] = useState(false);
const handleToggleEdit = () => {
if (!isEditingDocument && document) {
setEditedContent(document.content.markdown);
}
setIsEditingDocument(!isEditingDocument);
setHasChanges(false);
};
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(document ? value !== document.content.markdown : false);
};
const handleApproveAndContinue = () => {
console.log("Approved and continuing to next step");
setHasChanges(false);
setIsEditingDocument(false);
};
return (
<Card
blur="md"
transparency="light"
edgePosition="left"
edgeColor={step.isHumanInLoop ? "orange" : "blue"}
size="md"
className="overflow-visible"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900 dark:text-white">{step.stepName}</h4>
{step.isHumanInLoop && (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20">
<AlertCircle className="w-3 h-3" aria-hidden="true" />
Human-in-Loop
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{step.timestamp}</p>
</div>
{/* Collapse toggle - only show if collapsible */}
{step.collapsible && (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className={cn(
"px-2 transition-colors",
step.isHumanInLoop
? "text-orange-500 hover:text-orange-600 dark:hover:text-orange-400"
: "text-cyan-500 hover:text-cyan-600 dark:hover:text-cyan-400",
)}
aria-label={isExpanded ? "Collapse step" : "Expand step"}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
)}
</div>
{/* Content - collapsible with animation */}
<AnimatePresence mode="wait">
{(isExpanded || !step.collapsible) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: 0.3,
ease: [0.04, 0.62, 0.23, 0.98],
},
opacity: {
duration: 0.2,
ease: "easeInOut",
},
}}
style={{ overflow: "hidden" }}
>
<motion.div
initial={{ y: -20 }}
animate={{ y: 0 }}
exit={{ y: -20 }}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="space-y-3"
>
{/* Output content */}
<div
className={cn(
"p-4 rounded-lg border",
step.isHumanInLoop
? "bg-orange-50/50 dark:bg-orange-950/10 border-orange-200/50 dark:border-orange-800/30"
: "bg-cyan-50/30 dark:bg-cyan-950/10 border-cyan-200/50 dark:border-cyan-800/30",
)}
>
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
{step.output}
</pre>
</div>
{/* Session info */}
<p
className={cn(
"text-xs font-mono",
step.isHumanInLoop ? "text-orange-600 dark:text-orange-400" : "text-cyan-600 dark:text-cyan-400",
)}
>
{step.session}
</p>
{/* Review and Approve Plan - only for human-in-loop steps with documents */}
{step.isHumanInLoop && document && (
<div className="mt-6 space-y-3">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Review and Approve Plan</h4>
{/* Document Card */}
<Card blur="md" transparency="light" size="md" className="overflow-visible">
{/* View/Edit toggle in top right */}
<div className="flex items-center justify-end mb-3">
<Button
variant="ghost"
size="sm"
onClick={handleToggleEdit}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
aria-label={isEditingDocument ? "Switch to preview mode" : "Switch to edit mode"}
>
{isEditingDocument ? (
<Eye className="w-4 h-4" aria-hidden="true" />
) : (
<Edit3 className="w-4 h-4" aria-hidden="true" />
)}
</Button>
</div>
{isEditingDocument ? (
<div className="space-y-4">
<textarea
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
className={cn(
"w-full min-h-[300px] p-4 rounded-lg",
"bg-white/50 dark:bg-black/30",
"border border-gray-300 dark:border-gray-700",
"text-gray-900 dark:text-white font-mono text-sm",
"focus:outline-none focus:border-orange-400 focus:ring-2 focus:ring-orange-400/20",
"resize-y",
)}
placeholder="Enter markdown content..."
/>
</div>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{
h1: ({ node, ...props }) => (
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-3 mt-4" {...props} />
),
h2: ({ node, ...props }) => (
<h2
className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-3"
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
className="text-base font-semibold text-gray-900 dark:text-white mb-2 mt-3"
{...props}
/>
),
p: ({ node, ...props }) => (
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2 leading-relaxed" {...props} />
),
ul: ({ node, ...props }) => (
<ul
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-2 space-y-1"
{...props}
/>
),
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
code: ({ node, ...props }) => (
<code
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-orange-600 dark:text-orange-400"
{...props}
/>
),
}}
>
{document.content.markdown}
</ReactMarkdown>
</div>
)}
{/* Approve button - always visible with glass styling */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/30">
<p className="text-xs text-gray-500 dark:text-gray-400">
{hasChanges ? "Unsaved changes" : "No changes"}
</p>
<Button
onClick={handleApproveAndContinue}
className={cn(
"backdrop-blur-md",
"bg-gradient-to-b from-green-100/80 to-white/60",
"dark:from-green-500/20 dark:to-green-500/10",
"text-green-700 dark:text-green-100",
"border border-green-300/50 dark:border-green-500/50",
"hover:from-green-200/90 hover:to-green-100/70",
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
"shadow-lg shadow-green-500/20",
)}
>
<CheckCircle2 className="w-4 h-4 mr-2" aria-hidden="true" />
Approve and Move to Next Step
</Button>
</div>
</Card>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Card>
);
};

View File

@@ -0,0 +1,166 @@
import { motion } from "framer-motion";
import type React from "react";
import { cn } from "@/features/ui/primitives/styles";
interface WorkflowStepButtonProps {
isCompleted: boolean;
isActive: boolean;
stepName: string;
onClick?: () => void;
color?: "cyan" | "green" | "blue" | "purple";
size?: number;
}
// Helper function to get color hex values for animations
const getColorValue = (color: string) => {
const colorValues = {
purple: "rgb(168,85,247)",
green: "rgb(34,197,94)",
blue: "rgb(59,130,246)",
cyan: "rgb(34,211,238)",
};
return colorValues[color as keyof typeof colorValues] || colorValues.blue;
};
export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
isCompleted,
isActive,
stepName,
onClick,
color = "cyan",
size = 40,
}) => {
const colorMap = {
purple: {
border: "border-purple-400 dark:border-purple-300",
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
fill: "bg-purple-400 dark:bg-purple-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
},
green: {
border: "border-green-400 dark:border-green-300",
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
fill: "bg-green-400 dark:bg-green-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
},
blue: {
border: "border-blue-400 dark:border-blue-300",
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
fill: "bg-blue-400 dark:bg-blue-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
},
cyan: {
border: "border-cyan-400 dark:border-cyan-300",
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
fill: "bg-cyan-400 dark:bg-cyan-300",
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
},
};
const styles = colorMap[color];
return (
<div className="flex flex-col items-center gap-2">
<motion.button
onClick={onClick}
className={cn(
"relative rounded-full border-2 transition-all duration-300",
styles.border,
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
styles.glowHover,
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
"hover:scale-110 active:scale-95",
)}
style={{ width: size, height: size }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
type="button"
aria-label={`${stepName} - ${isCompleted ? "completed" : isActive ? "in progress" : "pending"}`}
>
{/* Outer ring glow effect */}
<motion.div
className={cn(
"absolute inset-[-4px] rounded-full border-2 blur-sm",
isCompleted ? styles.border : "border-transparent",
)}
animate={{
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Inner glow effect */}
<motion.div
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
animate={{
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Checkmark icon container */}
<div className="relative w-full h-full flex items-center justify-center">
<motion.svg
width={size * 0.5}
height={size * 0.5}
viewBox="0 0 24 24"
fill="none"
className="relative z-10"
role="img"
aria-label={`${stepName} status indicator`}
animate={{
filter: isCompleted
? [
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
`drop-shadow(0 0 12px ${getColorValue(color)}) drop-shadow(0 0 16px ${getColorValue(color)})`,
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
]
: "none",
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
}}
>
{/* Checkmark path */}
<path
d="M20 6L9 17l-5-5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className={isCompleted ? "text-white" : "text-gray-600"}
/>
</motion.svg>
</div>
</motion.button>
{/* Step name label */}
<span
className={cn(
"text-xs font-medium transition-colors",
isCompleted
? "text-cyan-400 dark:text-cyan-300"
: isActive
? "text-blue-500 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400",
)}
>
{stepName}
</span>
</div>
);
};

View File

@@ -1,4 +1,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { cn } from "@/features/ui/primitives/styles";
export interface SideNavigationSection {
@@ -14,9 +17,23 @@ interface SideNavigationProps {
}
export const SideNavigation = ({ sections, activeSection, onSectionClick }: SideNavigationProps) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="w-32 flex-shrink-0">
<div className={cn("flex-shrink-0 transition-all duration-300", isCollapsed ? "w-12" : "w-32")}>
<div className="sticky top-4 space-y-0.5">
{/* Collapse/Expand button */}
<div className="mb-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setIsCollapsed(!isCollapsed)}
className="px-2 py-1 h-auto text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
aria-label={isCollapsed ? "Expand navigation" : "Collapse navigation"}
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</Button>
</div>
{sections.map((section) => {
const isActive = activeSection === section.id;
return (
@@ -24,16 +41,18 @@ export const SideNavigation = ({ sections, activeSection, onSectionClick }: Side
key={section.id}
type="button"
onClick={() => onSectionClick(section.id)}
title={isCollapsed ? section.label : undefined}
className={cn(
"w-full text-left px-2 py-1.5 rounded-md transition-all duration-200",
"flex items-center gap-1.5",
isActive
? "bg-blue-500/10 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300 border-l-2 border-blue-500"
: "text-gray-600 dark:text-gray-400 hover:bg-white/5 dark:hover:bg-white/5 border-l-2 border-transparent",
isCollapsed && "justify-center",
)}
>
{section.icon && <span className="flex-shrink-0 w-3 h-3">{section.icon}</span>}
<span className="text-xs font-medium truncate">{section.label}</span>
{!isCollapsed && <span className="text-xs font-medium truncate">{section.label}</span>}
</button>
);
})}

View File

@@ -1,5 +1,6 @@
import { Database, FileText, FolderKanban, Navigation, Settings } from "lucide-react";
import { Briefcase, Database, FileText, FolderKanban, Navigation, Settings } from "lucide-react";
import { useState } from "react";
import { AgentWorkOrderLayoutExample } from "../layouts/AgentWorkOrderLayoutExample";
import { DocumentBrowserExample } from "../layouts/DocumentBrowserExample";
import { KnowledgeLayoutExample } from "../layouts/KnowledgeLayoutExample";
import { NavigationExplanation } from "../layouts/NavigationExplanation";
@@ -16,6 +17,7 @@ export const LayoutsTab = () => {
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
{ id: "knowledge", label: "Knowledge", icon: <Database className="w-4 h-4" /> },
{ id: "document-browser", label: "Document Browser", icon: <FileText className="w-4 h-4" /> },
{ id: "agent-work-orders", label: "Agent Work Orders", icon: <Briefcase className="w-4 h-4" /> },
];
// Render content based on active section
@@ -68,6 +70,16 @@ export const LayoutsTab = () => {
<DocumentBrowserExample />
</div>
);
case "agent-work-orders":
return (
<div>
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Agent Work Orders Layout</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Repository-based work order management with table view, status tracking, and integrated detail view.
</p>
<AgentWorkOrderLayoutExample />
</div>
);
default:
return (
<div>

View File

@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "./styles";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "ghost" | "link" | "cyan" | "knowledge"; // Tron-style purple button used on Knowledge Base
variant?: "default" | "destructive" | "outline" | "ghost" | "link" | "cyan" | "knowledge" | "green" | "blue"; // Tron-style glass buttons
size?: "default" | "sm" | "lg" | "icon" | "xs";
loading?: boolean;
children: React.ReactNode;
@@ -88,6 +88,30 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"dark:hover:shadow-[0_0_25px_rgba(168,85,247,0.7)]",
"focus-visible:ring-purple-500",
),
green: cn(
"backdrop-blur-md",
"bg-gradient-to-b from-green-100/80 to-white/60",
"dark:from-green-500/20 dark:to-green-500/10",
"text-green-700 dark:text-green-100",
"border border-green-300/50 dark:border-green-500/50",
"hover:from-green-200/90 hover:to-green-100/70",
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
"focus-visible:ring-green-500",
),
blue: cn(
"backdrop-blur-md",
"bg-gradient-to-b from-blue-100/80 to-white/60",
"dark:from-blue-500/20 dark:to-blue-500/10",
"text-blue-700 dark:text-blue-100",
"border border-blue-300/50 dark:border-blue-500/50",
"hover:from-blue-200/90 hover:to-blue-100/70",
"dark:hover:from-blue-400/30 dark:hover:to-blue-500/20",
"hover:shadow-[0_0_20px_rgba(59,130,246,0.5)]",
"dark:hover:shadow-[0_0_25px_rgba(59,130,246,0.7)]",
"focus-visible:ring-blue-500",
),
};
type ButtonSize = NonNullable<ButtonProps["size"]>;

View File

@@ -164,7 +164,7 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]');
highlightedElement?.scrollIntoView({ block: "nearest" });
}
}, [highlightedIndex, open]);
}, [open, highlightedIndex]);
return (
<Popover.Root open={open} onOpenChange={setOpen}>

View File

@@ -0,0 +1,12 @@
/**
* Agent Work Order 2 Detail Page
*
* Page wrapper for the redesigned agent work order detail view.
* Routes to this page from /agent-work-orders2/:id
*/
import { AgentWorkOrderDetailView } from "../features/agent-work-orders/views/AgentWorkOrderDetailView";
export function AgentWorkOrderDetailPage() {
return <AgentWorkOrderDetailView />;
}

View File

@@ -0,0 +1,12 @@
/**
* Agent Work Orders Page
*
* Page wrapper for the agent work orders interface.
* Routes to this page from /agent-work-orders
*/
import { AgentWorkOrdersView } from "../features/agent-work-orders/views/AgentWorkOrdersView";
export function AgentWorkOrdersPage() {
return <AgentWorkOrdersView />;
}

View File

@@ -294,35 +294,105 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
: [];
return [...new Set([...defaultHosts, ...hostFromEnv, ...customHosts])];
})(),
proxy: {
'/api': {
proxy: (() => {
const proxyConfig: Record<string, any> = {};
// Check if agent work orders service should be enabled
// This can be disabled via environment variable to prevent hard dependency
const agentWorkOrdersEnabled = env.AGENT_WORK_ORDERS_ENABLED !== 'false';
const agentWorkOrdersPort = env.AGENT_WORK_ORDERS_PORT || '8053';
// Agent Work Orders API proxy (must come before general /api if enabled)
if (agentWorkOrdersEnabled) {
proxyConfig['/api/agent-work-orders'] = {
target: isDocker ? `http://archon-agent-work-orders:${agentWorkOrdersPort}` : `http://localhost:${agentWorkOrdersPort}`,
changeOrigin: true,
secure: false,
timeout: 10000, // 10 second timeout
configure: (proxy: any, options: any) => {
const targetUrl = isDocker ? `http://archon-agent-work-orders:${agentWorkOrdersPort}` : `http://localhost:${agentWorkOrdersPort}`;
// Handle proxy errors (e.g., service is down)
proxy.on('error', (err: Error, req: any, res: any) => {
console.log('🚨 [VITE PROXY ERROR - Agent Work Orders]:', err.message);
console.log('🚨 [VITE PROXY ERROR] Target:', targetUrl);
console.log('🚨 [VITE PROXY ERROR] Request:', req.url);
// Send proper error response instead of hanging
if (!res.headersSent) {
res.writeHead(503, {
'Content-Type': 'application/json',
'X-Service-Unavailable': 'agent-work-orders'
});
res.end(JSON.stringify({
error: 'Service Unavailable',
message: 'Agent Work Orders service is not available',
service: 'agent-work-orders',
target: targetUrl
}));
}
});
// Handle connection timeout
proxy.on('proxyReq', (proxyReq: any, req: any, res: any) => {
console.log('🔄 [VITE PROXY - Agent Work Orders] Forwarding:', req.method, req.url, 'to', `${targetUrl}${req.url}`);
// Set timeout for the proxy request
proxyReq.setTimeout(10000, () => {
console.log('⏱️ [VITE PROXY - Agent Work Orders] Request timeout');
if (!res.headersSent) {
res.writeHead(504, {
'Content-Type': 'application/json',
'X-Service-Unavailable': 'agent-work-orders'
});
res.end(JSON.stringify({
error: 'Gateway Timeout',
message: 'Agent Work Orders service did not respond in time',
service: 'agent-work-orders',
target: targetUrl
}));
}
});
});
}
};
} else {
console.log('⚠️ [VITE PROXY] Agent Work Orders proxy disabled via AGENT_WORK_ORDERS_ENABLED=false');
}
// General /api proxy (always enabled, comes after specific routes if agent work orders is enabled)
proxyConfig['/api'] = {
target: `http://${proxyHost}:${port}`,
changeOrigin: true,
secure: false,
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
configure: (proxy: any, options: any) => {
proxy.on('error', (err: Error, req: any, res: any) => {
console.log('🚨 [VITE PROXY ERROR]:', err.message);
console.log('🚨 [VITE PROXY ERROR] Target:', `http://${proxyHost}:${port}`);
console.log('🚨 [VITE PROXY ERROR] Request:', req.url);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
proxy.on('proxyReq', (proxyReq: any, req: any, res: any) => {
console.log('🔄 [VITE PROXY] Forwarding:', req.method, req.url, 'to', `http://${proxyHost}:${port}${req.url}`);
});
}
},
};
// Health check endpoint proxy
'/health': {
proxyConfig['/health'] = {
target: `http://${host}:${port}`,
changeOrigin: true,
secure: false
},
};
// Socket.IO specific proxy configuration
'/socket.io': {
proxyConfig['/socket.io'] = {
target: `http://${host}:${port}`,
changeOrigin: true,
ws: true
}
},
};
return proxyConfig;
})(),
},
define: {
// CRITICAL: Don't inject Docker internal hostname into the build

View File

@@ -27,6 +27,7 @@ services:
- ARCHON_SERVER_PORT=${ARCHON_SERVER_PORT:-8181}
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
- AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT:-8053}
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
- ARCHON_HOST=${HOST:-localhost}
networks:
@@ -146,6 +147,58 @@ services:
retries: 3
start_period: 40s
# Agent Work Orders Service (Independent microservice for workflow execution)
archon-agent-work-orders:
profiles:
- work-orders # Only starts when explicitly using --profile work-orders
build:
context: ./python
dockerfile: Dockerfile.agent-work-orders
args:
BUILDKIT_INLINE_CACHE: 1
AGENT_WORK_ORDERS_PORT: ${AGENT_WORK_ORDERS_PORT:-8053}
container_name: archon-agent-work-orders
depends_on:
- archon-server
ports:
- "${AGENT_WORK_ORDERS_PORT:-8053}:${AGENT_WORK_ORDERS_PORT:-8053}"
environment:
- ENABLE_AGENT_WORK_ORDERS=true
- SERVICE_DISCOVERY_MODE=docker_compose
- STATE_STORAGE_TYPE=supabase
- ARCHON_SERVER_URL=http://archon-server:${ARCHON_SERVER_PORT:-8181}
- ARCHON_MCP_URL=http://archon-mcp:${ARCHON_MCP_PORT:-8051}
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT:-8053}
- CLAUDE_CLI_PATH=${CLAUDE_CLI_PATH:-claude}
- GH_CLI_PATH=${GH_CLI_PATH:-gh}
- GH_TOKEN=${GITHUB_PAT_TOKEN}
networks:
- app-network
volumes:
- ./python/src/agent_work_orders:/app/src/agent_work_orders # Hot reload for agent work orders
- /tmp/agent-work-orders:/tmp/agent-work-orders # Temp files
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test:
[
"CMD",
"python",
"-c",
'import urllib.request; urllib.request.urlopen("http://localhost:${AGENT_WORK_ORDERS_PORT:-8053}/health")',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend
archon-frontend:
build: ./archon-ui-main

View File

@@ -0,0 +1,135 @@
# Agent Work Orders Database Migrations
This document describes the database migrations for the Agent Work Orders feature.
## Overview
Agent Work Orders is an optional microservice that executes agent-based workflows using Claude Code CLI. These migrations set up the required database tables for the feature.
## Prerequisites
- Supabase project with the same credentials as main Archon server
- `SUPABASE_URL` and `SUPABASE_SERVICE_KEY` environment variables configured
## Migrations
### 1. `agent_work_orders_repositories.sql`
**Purpose**: Configure GitHub repositories for agent work orders
**Creates**:
- `archon_configured_repositories` table for storing repository configurations
- Indexes for fast repository lookups
- RLS policies for access control
- Validation constraints for repository URLs
**When to run**: Before using the repository configuration feature
**Usage**:
```bash
# Open Supabase dashboard → SQL Editor
# Copy and paste the entire migration file
# Execute
```
### 2. `agent_work_orders_state.sql`
**Purpose**: Persistent state management for agent work orders
**Creates**:
- `archon_agent_work_orders` - Main work order state and metadata table
- `archon_agent_work_order_steps` - Step execution history with foreign key constraints
- Indexes for fast queries (status, repository_url, created_at)
- Database triggers for automatic timestamp management
- RLS policies for service and authenticated access
**Features**:
- ACID guarantees for concurrent work order execution
- Foreign key CASCADE delete (steps deleted when work order deleted)
- Hybrid schema (frequently queried columns + JSONB for flexible metadata)
- Automatic `updated_at` timestamp management
**When to run**: To enable Supabase-backed persistent storage for agent work orders
**Usage**:
```bash
# Open Supabase dashboard → SQL Editor
# Copy and paste the entire migration file
# Execute
```
**Verification**:
```sql
-- Check tables exist
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'archon_agent_work_order%';
-- Verify indexes
SELECT tablename, indexname FROM pg_indexes
WHERE tablename LIKE 'archon_agent_work_order%'
ORDER BY tablename, indexname;
```
## Configuration
After applying migrations, configure the agent work orders service:
```bash
# Set environment variable
export STATE_STORAGE_TYPE=supabase
# Restart the service
docker compose restart archon-agent-work-orders
# OR
make agent-work-orders
```
## Health Check
Verify the configuration:
```bash
curl http://localhost:8053/health | jq '{storage_type, database}'
```
Expected response:
```json
{
"storage_type": "supabase",
"database": {
"status": "healthy",
"tables_exist": true
}
}
```
## Storage Options
Agent Work Orders supports three storage backends:
1. **Memory** (`STATE_STORAGE_TYPE=memory`) - Default, no persistence
2. **File** (`STATE_STORAGE_TYPE=file`) - Legacy file-based storage
3. **Supabase** (`STATE_STORAGE_TYPE=supabase`) - **Recommended for production**
## Rollback
To remove the agent work orders state tables:
```sql
-- Drop tables (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_agent_work_order_steps CASCADE;
DROP TABLE IF EXISTS archon_agent_work_orders CASCADE;
```
**Note**: The `update_updated_at_column()` function is shared with other Archon tables and should NOT be dropped.
## Documentation
For detailed setup instructions, see:
- `python/src/agent_work_orders/README.md` - Service configuration guide and migration instructions
## Migration History
- **agent_work_orders_repositories.sql** - Initial repository configuration support
- **agent_work_orders_state.sql** - Supabase persistence migration (replaces file-based storage)

View File

@@ -0,0 +1,233 @@
-- =====================================================
-- Agent Work Orders - Repository Configuration
-- =====================================================
-- This migration creates the archon_configured_repositories table
-- for storing configured GitHub repositories with metadata and preferences
--
-- Features:
-- - Repository URL validation and uniqueness
-- - GitHub metadata storage (display_name, owner, default_branch)
-- - Verification status tracking
-- - Per-repository preferences (sandbox type, workflow commands)
-- - Automatic timestamp management
-- - Row Level Security policies
--
-- Run this in your Supabase SQL Editor
-- =====================================================
-- =====================================================
-- SECTION 1: CREATE TABLE
-- =====================================================
-- Create archon_configured_repositories table
CREATE TABLE IF NOT EXISTS archon_configured_repositories (
-- Primary identification
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Repository identification
repository_url TEXT NOT NULL UNIQUE,
display_name TEXT, -- Extracted from GitHub (e.g., "owner/repo")
owner TEXT, -- Extracted from GitHub
default_branch TEXT, -- Extracted from GitHub (e.g., "main")
-- Verification status
is_verified BOOLEAN DEFAULT false,
last_verified_at TIMESTAMP WITH TIME ZONE,
-- Per-repository preferences
-- Note: default_sandbox_type is intentionally restricted to production-ready types only.
-- Experimental types (git_branch, e2b, dagger) are blocked for safety and stability.
default_sandbox_type TEXT DEFAULT 'git_worktree'
CHECK (default_sandbox_type IN ('git_worktree', 'full_clone', 'tmp_dir')),
default_commands JSONB DEFAULT '["create-branch", "planning", "execute", "commit", "create-pr"]'::jsonb,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- URL validation constraint
CONSTRAINT valid_repository_url CHECK (
repository_url ~ '^https://github\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+/?$'
)
);
-- =====================================================
-- SECTION 2: CREATE INDEXES
-- =====================================================
-- Unique index on repository_url (enforces constraint)
CREATE UNIQUE INDEX IF NOT EXISTS idx_configured_repositories_url
ON archon_configured_repositories(repository_url);
-- Index on is_verified for filtering verified repositories
CREATE INDEX IF NOT EXISTS idx_configured_repositories_verified
ON archon_configured_repositories(is_verified);
-- Index on created_at for ordering by most recent
CREATE INDEX IF NOT EXISTS idx_configured_repositories_created_at
ON archon_configured_repositories(created_at DESC);
-- GIN index on default_commands JSONB for querying by commands
CREATE INDEX IF NOT EXISTS idx_configured_repositories_commands
ON archon_configured_repositories USING GIN(default_commands);
-- =====================================================
-- SECTION 3: CREATE TRIGGER
-- =====================================================
-- Apply auto-update trigger for updated_at timestamp
-- Reuses existing update_updated_at_column() function from complete_setup.sql
CREATE TRIGGER update_configured_repositories_updated_at
BEFORE UPDATE ON archon_configured_repositories
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =====================================================
-- SECTION 4: ROW LEVEL SECURITY
-- =====================================================
-- Enable Row Level Security on the table
ALTER TABLE archon_configured_repositories ENABLE ROW LEVEL SECURITY;
-- Policy 1: Service role has full access (for API operations)
CREATE POLICY "Allow service role full access to archon_configured_repositories"
ON archon_configured_repositories
FOR ALL
USING (auth.role() = 'service_role');
-- Policy 2: Authenticated users can read and update (for frontend operations)
CREATE POLICY "Allow authenticated users to read and update archon_configured_repositories"
ON archon_configured_repositories
FOR ALL
TO authenticated
USING (true);
-- =====================================================
-- SECTION 5: TABLE COMMENTS
-- =====================================================
-- Add comments to document table structure
COMMENT ON TABLE archon_configured_repositories IS
'Stores configured GitHub repositories for Agent Work Orders with metadata, verification status, and per-repository preferences';
COMMENT ON COLUMN archon_configured_repositories.id IS
'Unique UUID identifier for the configured repository';
COMMENT ON COLUMN archon_configured_repositories.repository_url IS
'GitHub repository URL (must be https://github.com/owner/repo format)';
COMMENT ON COLUMN archon_configured_repositories.display_name IS
'Human-readable repository name extracted from GitHub API (e.g., "owner/repo-name")';
COMMENT ON COLUMN archon_configured_repositories.owner IS
'Repository owner/organization name extracted from GitHub API';
COMMENT ON COLUMN archon_configured_repositories.default_branch IS
'Default branch name extracted from GitHub API (typically "main" or "master")';
COMMENT ON COLUMN archon_configured_repositories.is_verified IS
'Boolean flag indicating if repository access has been verified via GitHub API';
COMMENT ON COLUMN archon_configured_repositories.last_verified_at IS
'Timestamp of last successful repository verification';
COMMENT ON COLUMN archon_configured_repositories.default_sandbox_type IS
'Default sandbox type for work orders: git_worktree (default), full_clone, or tmp_dir.
IMPORTANT: Intentionally restricted to production-ready types only.
Experimental types (git_branch, e2b, dagger) are blocked by CHECK constraint for safety and stability.';
COMMENT ON COLUMN archon_configured_repositories.default_commands IS
'JSONB array of default workflow commands for work orders (e.g., ["create-branch", "planning", "execute", "commit", "create-pr"])';
COMMENT ON COLUMN archon_configured_repositories.created_at IS
'Timestamp when repository configuration was created';
COMMENT ON COLUMN archon_configured_repositories.updated_at IS
'Timestamp when repository configuration was last updated (auto-managed by trigger)';
-- =====================================================
-- SECTION 6: VERIFICATION
-- =====================================================
-- Verify table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_configured_repositories'
) THEN
RAISE NOTICE '✓ Table archon_configured_repositories created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_configured_repositories was not created';
END IF;
END $$;
-- Verify indexes
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_configured_repositories'
) >= 4 THEN
RAISE NOTICE '✓ Indexes created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 4 indexes, found fewer';
END IF;
END $$;
-- Verify trigger
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = 'archon_configured_repositories'::regclass
AND tgname = 'update_configured_repositories_updated_at'
) THEN
RAISE NOTICE '✓ Trigger update_configured_repositories_updated_at created successfully';
ELSE
RAISE EXCEPTION '✗ Trigger update_configured_repositories_updated_at was not created';
END IF;
END $$;
-- Verify RLS policies
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_configured_repositories'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies, found fewer';
END IF;
END $$;
-- =====================================================
-- SECTION 7: ROLLBACK INSTRUCTIONS
-- =====================================================
/*
To rollback this migration, run the following commands:
-- Drop the table (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_configured_repositories CASCADE;
-- Verify table is dropped
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_configured_repositories';
-- Should return 0 rows
-- Note: The update_updated_at_column() function is shared and should NOT be dropped
*/
-- =====================================================
-- MIGRATION COMPLETE
-- =====================================================
-- The archon_configured_repositories table is now ready for use
-- Next steps:
-- 1. Restart Agent Work Orders service to detect the new table
-- 2. Test repository configuration via API endpoints
-- 3. Verify health endpoint shows table_exists=true
-- =====================================================

View File

@@ -0,0 +1,356 @@
-- =====================================================
-- Agent Work Orders - State Management
-- =====================================================
-- This migration creates tables for agent work order state persistence
-- in PostgreSQL, replacing file-based JSON storage with ACID-compliant
-- database backend.
--
-- Features:
-- - Atomic state updates with ACID guarantees
-- - Row-level locking for concurrent access control
-- - Foreign key constraints for referential integrity
-- - Indexes for fast queries by status, repository, and timestamp
-- - JSONB metadata for flexible storage
-- - Automatic timestamp management via triggers
-- - Step execution history with ordering
--
-- Run this in your Supabase SQL Editor
-- =====================================================
-- =====================================================
-- SECTION 1: CREATE TABLES
-- =====================================================
-- Create archon_agent_work_orders table
CREATE TABLE IF NOT EXISTS archon_agent_work_orders (
-- Primary identification (TEXT not UUID since generated by id_generator.py)
agent_work_order_id TEXT PRIMARY KEY,
-- Core state fields (frequently queried as separate columns)
repository_url TEXT NOT NULL,
sandbox_identifier TEXT NOT NULL,
git_branch_name TEXT,
agent_session_id TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
-- Flexible metadata (JSONB for infrequently queried fields)
-- Stores: sandbox_type, github_issue_number, current_phase, error_message, etc.
metadata JSONB DEFAULT '{}'::jsonb,
-- Timestamps (automatically managed)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create archon_agent_work_order_steps table
-- Stores step execution history with foreign key to work orders
CREATE TABLE IF NOT EXISTS archon_agent_work_order_steps (
-- Primary identification
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign key to work order (CASCADE delete when work order deleted)
agent_work_order_id TEXT NOT NULL REFERENCES archon_agent_work_orders(agent_work_order_id) ON DELETE CASCADE,
-- Step execution details
step TEXT NOT NULL, -- WorkflowStep enum value (e.g., "create-branch", "planning")
agent_name TEXT NOT NULL, -- Name of agent that executed step
success BOOLEAN NOT NULL, -- Whether step succeeded
output TEXT, -- Step output (nullable)
error_message TEXT, -- Error message if failed (nullable)
duration_seconds FLOAT NOT NULL, -- Execution duration
session_id TEXT, -- Agent session ID (nullable)
executed_at TIMESTAMP WITH TIME ZONE NOT NULL, -- When step was executed
step_order INT NOT NULL -- Order within work order (0-indexed for sorting)
);
-- =====================================================
-- SECTION 2: CREATE INDEXES
-- =====================================================
-- Indexes on archon_agent_work_orders for common queries
-- Index on status for filtering by work order status
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_status
ON archon_agent_work_orders(status);
-- Index on created_at for ordering by most recent
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_created_at
ON archon_agent_work_orders(created_at DESC);
-- Index on repository_url for filtering by repository
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_repository
ON archon_agent_work_orders(repository_url);
-- GIN index on metadata JSONB for flexible queries
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_metadata
ON archon_agent_work_orders USING GIN(metadata);
-- Indexes on archon_agent_work_order_steps for step history queries
-- Index on agent_work_order_id for retrieving all steps for a work order
CREATE INDEX IF NOT EXISTS idx_agent_work_order_steps_work_order_id
ON archon_agent_work_order_steps(agent_work_order_id);
-- Index on executed_at for temporal queries
CREATE INDEX IF NOT EXISTS idx_agent_work_order_steps_executed_at
ON archon_agent_work_order_steps(executed_at);
-- =====================================================
-- SECTION 3: CREATE TRIGGER
-- =====================================================
-- Apply auto-update trigger for updated_at timestamp
-- Reuses existing update_updated_at_column() function from Archon migrations
CREATE TRIGGER update_agent_work_orders_updated_at
BEFORE UPDATE ON archon_agent_work_orders
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =====================================================
-- SECTION 4: ROW LEVEL SECURITY
-- =====================================================
-- Enable Row Level Security on both tables
ALTER TABLE archon_agent_work_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE archon_agent_work_order_steps ENABLE ROW LEVEL SECURITY;
-- Policy 1: Service role has full access (for API operations)
CREATE POLICY "Allow service role full access to archon_agent_work_orders"
ON archon_agent_work_orders
FOR ALL
USING (auth.role() = 'service_role');
CREATE POLICY "Allow service role full access to archon_agent_work_order_steps"
ON archon_agent_work_order_steps
FOR ALL
USING (auth.role() = 'service_role');
-- Policy 2: Authenticated users can read and update (for frontend operations)
CREATE POLICY "Allow authenticated users to read and update archon_agent_work_orders"
ON archon_agent_work_orders
FOR ALL
TO authenticated
USING (true);
CREATE POLICY "Allow authenticated users to read and update archon_agent_work_order_steps"
ON archon_agent_work_order_steps
FOR ALL
TO authenticated
USING (true);
-- =====================================================
-- SECTION 5: TABLE COMMENTS
-- =====================================================
-- Comments on archon_agent_work_orders table
COMMENT ON TABLE archon_agent_work_orders IS
'Stores agent work order state and metadata with ACID guarantees for concurrent access';
COMMENT ON COLUMN archon_agent_work_orders.agent_work_order_id IS
'Unique work order identifier (TEXT format generated by id_generator.py)';
COMMENT ON COLUMN archon_agent_work_orders.repository_url IS
'GitHub repository URL for the work order';
COMMENT ON COLUMN archon_agent_work_orders.sandbox_identifier IS
'Unique identifier for sandbox environment (worktree directory name)';
COMMENT ON COLUMN archon_agent_work_orders.git_branch_name IS
'Git branch name created for work order (nullable if not yet created)';
COMMENT ON COLUMN archon_agent_work_orders.agent_session_id IS
'Agent session ID for tracking agent execution (nullable if not yet started)';
COMMENT ON COLUMN archon_agent_work_orders.status IS
'Current status: pending, running, completed, or failed';
COMMENT ON COLUMN archon_agent_work_orders.metadata IS
'JSONB metadata including sandbox_type, github_issue_number, current_phase, error_message, etc.';
COMMENT ON COLUMN archon_agent_work_orders.created_at IS
'Timestamp when work order was created';
COMMENT ON COLUMN archon_agent_work_orders.updated_at IS
'Timestamp when work order was last updated (auto-managed by trigger)';
-- Comments on archon_agent_work_order_steps table
COMMENT ON TABLE archon_agent_work_order_steps IS
'Stores step execution history for agent work orders with foreign key constraints';
COMMENT ON COLUMN archon_agent_work_order_steps.id IS
'Unique UUID identifier for step record';
COMMENT ON COLUMN archon_agent_work_order_steps.agent_work_order_id IS
'Foreign key to work order (CASCADE delete on work order deletion)';
COMMENT ON COLUMN archon_agent_work_order_steps.step IS
'WorkflowStep enum value (e.g., "create-branch", "planning", "execute")';
COMMENT ON COLUMN archon_agent_work_order_steps.agent_name IS
'Name of agent that executed the step';
COMMENT ON COLUMN archon_agent_work_order_steps.success IS
'Boolean indicating if step execution succeeded';
COMMENT ON COLUMN archon_agent_work_order_steps.output IS
'Step execution output (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.error_message IS
'Error message if step failed (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.duration_seconds IS
'Step execution duration in seconds';
COMMENT ON COLUMN archon_agent_work_order_steps.session_id IS
'Agent session ID for tracking (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.executed_at IS
'Timestamp when step was executed';
COMMENT ON COLUMN archon_agent_work_order_steps.step_order IS
'Order of step within work order (0-indexed for sorting)';
-- =====================================================
-- SECTION 6: VERIFICATION
-- =====================================================
-- Verify archon_agent_work_orders table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_agent_work_orders'
) THEN
RAISE NOTICE '✓ Table archon_agent_work_orders created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_agent_work_orders was not created';
END IF;
END $$;
-- Verify archon_agent_work_order_steps table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_agent_work_order_steps'
) THEN
RAISE NOTICE '✓ Table archon_agent_work_order_steps created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_agent_work_order_steps was not created';
END IF;
END $$;
-- Verify indexes on archon_agent_work_orders
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_agent_work_orders'
) >= 4 THEN
RAISE NOTICE '✓ Indexes on archon_agent_work_orders created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 4 indexes on archon_agent_work_orders, found fewer';
END IF;
END $$;
-- Verify indexes on archon_agent_work_order_steps
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_agent_work_order_steps'
) >= 2 THEN
RAISE NOTICE '✓ Indexes on archon_agent_work_order_steps created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 indexes on archon_agent_work_order_steps, found fewer';
END IF;
END $$;
-- Verify trigger
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = 'archon_agent_work_orders'::regclass
AND tgname = 'update_agent_work_orders_updated_at'
) THEN
RAISE NOTICE '✓ Trigger update_agent_work_orders_updated_at created successfully';
ELSE
RAISE EXCEPTION '✗ Trigger update_agent_work_orders_updated_at was not created';
END IF;
END $$;
-- Verify RLS policies on archon_agent_work_orders
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_agent_work_orders'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies on archon_agent_work_orders created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies on archon_agent_work_orders, found fewer';
END IF;
END $$;
-- Verify RLS policies on archon_agent_work_order_steps
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_agent_work_order_steps'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies on archon_agent_work_order_steps created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies on archon_agent_work_order_steps, found fewer';
END IF;
END $$;
-- Verify foreign key constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'archon_agent_work_order_steps'
AND constraint_type = 'FOREIGN KEY'
) THEN
RAISE NOTICE '✓ Foreign key constraint on archon_agent_work_order_steps created successfully';
ELSE
RAISE EXCEPTION '✗ Foreign key constraint on archon_agent_work_order_steps was not created';
END IF;
END $$;
-- =====================================================
-- SECTION 7: ROLLBACK INSTRUCTIONS
-- =====================================================
/*
To rollback this migration, run the following commands:
-- Drop tables (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_agent_work_order_steps CASCADE;
DROP TABLE IF EXISTS archon_agent_work_orders CASCADE;
-- Verify tables are dropped
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'archon_agent_work_order%';
-- Should return 0 rows
-- Note: The update_updated_at_column() function is shared and should NOT be dropped
*/
-- =====================================================
-- MIGRATION COMPLETE
-- =====================================================
-- The archon_agent_work_orders and archon_agent_work_order_steps tables
-- are now ready for use.
--
-- Next steps:
-- 1. Set STATE_STORAGE_TYPE=supabase in environment
-- 2. Restart Agent Work Orders service
-- 3. Verify health endpoint shows database status healthy
-- 4. Test work order creation via API
-- =====================================================

View File

@@ -0,0 +1,81 @@
# Create Git Commit
Create an atomic git commit with a properly formatted commit message following best practices for the uncommited changes or these specific files if specified.
Specific files (skip if not specified):
- File 1: $1
- File 2: $2
- File 3: $3
- File 4: $4
- File 5: $5
## Instructions
**Commit Message Format:**
- Use conventional commits: `<type>: <description>`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Present tense (e.g., "add", "fix", "update", not "added", "fixed", "updated")
- 50 characters or less for the subject line
- Lowercase subject line
- No period at the end
- Be specific and descriptive
**Examples:**
- `feat: add web search tool with structured logging`
- `fix: resolve type errors in middleware`
- `test: add unit tests for config module`
- `docs: update CLAUDE.md with testing guidelines`
- `refactor: simplify logging configuration`
- `chore: update dependencies`
**Atomic Commits:**
- One logical change per commit
- If you've made multiple unrelated changes, consider splitting into separate commits
- Commit should be self-contained and not break the build
**IMPORTANT**
- NEVER mention claude code, anthropic, co authored by or anything similar in the commit messages
## Run
1. Review changes: `git diff HEAD`
2. Check status: `git status`
3. Stage changes: `git add -A`
4. Create commit: `git commit -m "<type>: <description>"`
5. Push to remote: `git push -u origin $(git branch --show-current)`
6. Verify push: `git log origin/$(git branch --show-current) -1 --oneline`
## Report
Output in this format (plain text, no markdown):
Commit: <commit-hash>
Branch: <branch-name>
Message: <commit-message>
Pushed: Yes (or No if push failed)
Files: <number> files changed
Then list the files:
- <file1>
- <file2>
- ...
**Example:**
```
Commit: a3c2f1e
Branch: feat/add-user-auth
Message: feat: add user authentication system
Pushed: Yes
Files: 5 files changed
- src/auth/login.py
- src/auth/middleware.py
- tests/auth/test_login.py
- CLAUDE.md
- requirements.txt
```

View File

@@ -0,0 +1,104 @@
# Create Git Branch
Generate a conventional branch name based on user request and create a new git branch.
## Variables
User request: $1
## Instructions
**Step 1: Check Current Branch**
- Check current branch: `git branch --show-current`
- Check if on main/master:
```bash
CURRENT_BRANCH=$(git branch --show-current)
if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then
echo "Warning: Currently on branch '$CURRENT_BRANCH', not main/master"
echo "Proceeding with branch creation from current branch"
fi
```
- Note: We proceed regardless, but log the warning
**Step 2: Generate Branch Name**
Use conventional branch naming:
**Prefixes:**
- `feat/` - New feature or enhancement
- `fix/` - Bug fix
- `chore/` - Maintenance tasks (dependencies, configs, etc.)
- `docs/` - Documentation only changes
- `refactor/` - Code refactoring (no functionality change)
- `test/` - Adding or updating tests
- `perf/` - Performance improvements
**Naming Rules:**
- Use kebab-case (lowercase with hyphens)
- Be descriptive but concise (max 50 characters)
- Remove special characters except hyphens
- No spaces, use hyphens instead
**Examples:**
- "Add user authentication system" → `feat/add-user-auth`
- "Fix login redirect bug" → `fix/login-redirect`
- "Update README documentation" → `docs/update-readme`
- "Refactor database queries" → `refactor/database-queries`
- "Add unit tests for API" → `test/api-unit-tests`
**Branch Name Generation Logic:**
1. Analyze user request to determine type (feature/fix/chore/docs/refactor/test/perf)
2. Extract key action and subject
3. Convert to kebab-case
4. Truncate if needed to keep under 50 chars
5. Validate name is descriptive and follows conventions
**Step 3: Check Branch Exists**
- Check if branch name already exists:
```bash
if git show-ref --verify --quiet refs/heads/<branch-name>; then
echo "Branch <branch-name> already exists"
# Append version suffix
COUNTER=2
while git show-ref --verify --quiet refs/heads/<branch-name>-v$COUNTER; do
COUNTER=$((COUNTER + 1))
done
BRANCH_NAME="<branch-name>-v$COUNTER"
fi
```
- If exists, append `-v2`, `-v3`, etc. until unique
**Step 4: Create and Checkout Branch**
- Create and checkout new branch: `git checkout -b <branch-name>`
- Verify creation: `git branch --show-current`
- Ensure output matches expected branch name
**Step 5: Verify Branch State**
- Confirm branch created: `git branch --list <branch-name>`
- Confirm currently on branch: `[ "$(git branch --show-current)" = "<branch-name>" ]`
- Check remote tracking: `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "No upstream set"`
**Important Notes:**
- NEVER mention Claude Code, Anthropic, AI, or co-authoring in any output
- Branch should be created locally only (no push yet)
- Branch will be pushed later by commit.md command
- If user request is unclear, prefer `feat/` prefix as default
## Report
Output ONLY the branch name (no markdown, no explanations, no quotes):
<branch-name>
**Example outputs:**
```
feat/add-user-auth
fix/login-redirect-issue
docs/update-api-documentation
refactor/simplify-middleware
```

View File

@@ -0,0 +1,201 @@
# Create GitHub Pull Request
Create a GitHub pull request for the current branch with auto-generated description.
## Variables
- Branch name: $1
- PRP file path: $2 (optional - may be empty)
## Instructions
**Prerequisites Check:**
1. Verify gh CLI is authenticated:
```bash
gh auth status || {
echo "Error: gh CLI not authenticated. Run: gh auth login"
exit 1
}
```
2. Verify we're in a git repository:
```bash
git rev-parse --git-dir >/dev/null 2>&1 || {
echo "Error: Not in a git repository"
exit 1
}
```
3. Verify changes are pushed to remote:
```bash
BRANCH=$(git branch --show-current)
git rev-parse --verify origin/$BRANCH >/dev/null 2>&1 || {
echo "Error: Branch '$BRANCH' not pushed to remote. Run: git push -u origin $BRANCH"
exit 1
}
```
**Step 1: Gather Information**
1. Get current branch name:
```bash
BRANCH=$(git branch --show-current)
```
2. Get default base branch (usually main or master):
```bash
BASE=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
# Fallback to main if detection fails
[ -z "$BASE" ] && BASE="main"
```
3. Get repository info:
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
```
**Step 2: Generate PR Title**
Convert branch name to conventional commit format:
**Rules:**
- `feat/add-user-auth` → `feat: add user authentication`
- `fix/login-bug` → `fix: resolve login bug`
- `docs/update-readme` → `docs: update readme`
- Capitalize first letter after prefix
- Remove hyphens, replace with spaces
- Keep concise (under 72 characters)
**Step 3: Find PR Template**
Look for PR template in these locations (in order):
1. `.github/pull_request_template.md`
2. `.github/PULL_REQUEST_TEMPLATE.md`
3. `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`
4. `docs/pull_request_template.md`
```bash
PR_TEMPLATE=""
if [ -f ".github/pull_request_template.md" ]; then
PR_TEMPLATE=".github/pull_request_template.md"
elif [ -f ".github/PULL_REQUEST_TEMPLATE.md" ]; then
PR_TEMPLATE=".github/PULL_REQUEST_TEMPLATE.md"
elif [ -f ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md" ]; then
PR_TEMPLATE=".github/PULL_REQUEST_TEMPLATE/pull_request_template.md"
elif [ -f "docs/pull_request_template.md" ]; then
PR_TEMPLATE="docs/pull_request_template.md"
fi
```
**Step 4: Generate PR Body**
**If PR template exists:**
- Read template content
- Fill in placeholders if present
- If PRP file provided: Extract summary and insert into template
**If no PR template (use default):**
```markdown
## Summary
[Brief description of what this PR does]
## Changes
[Bullet list of key changes from git log]
## Implementation Details
[Reference PRP file if provided, otherwise summarize commits]
## Testing
- [ ] All existing tests pass
- [ ] New tests added (if applicable)
- [ ] Manual testing completed
## Related Issues
Closes #[issue number if applicable]
```
**Auto-fill logic:**
1. **Summary section:**
- If PRP file exists: Extract "Feature Description" section
- Otherwise: Use first commit message body
- Fallback: Summarize changes from `git diff --stat`
2. **Changes section:**
- Get commit messages: `git log $BASE..$BRANCH --pretty=format:"- %s"`
- List modified files: `git diff --name-only $BASE...$BRANCH`
- Format as bullet points
3. **Implementation Details:**
- If PRP file exists: Link to it with `See: $PRP_FILE_PATH`
- Extract key technical details from PRP "Solution Statement"
- Otherwise: Summarize from commit messages
4. **Testing section:**
- Check if new test files were added: `git diff --name-only $BASE...$BRANCH | grep test`
- Auto-check test boxes if tests exist
- Include validation results from execute.md if available
**Step 5: Create Pull Request**
```bash
gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "$BASE" \
--head "$BRANCH" \
--web
```
**Flags:**
- `--web`: Open PR in browser after creation
- If `--web` not desired, remove it
**Step 6: Capture PR URL**
```bash
PR_URL=$(gh pr view --json url -q .url)
```
**Step 7: Link to Issues (if applicable)**
If PRP file or commits mention issue numbers (#123), link them:
```bash
# Extract issue numbers from commits
ISSUES=$(git log $BASE..$BRANCH --pretty=format:"%s %b" | grep -oP '#\K\d+' | sort -u)
# Link issues to PR
for ISSUE in $ISSUES; do
gh pr comment $PR_URL --body "Relates to #$ISSUE"
done
```
**Important Notes:**
- NEVER mention Claude Code, Anthropic, AI, or co-authoring in PR
- PR title and body should be professional and clear
- Include all relevant context for reviewers
- Link to PRP file in repo if available
- Auto-check completed checkboxes in template
## Report
Output ONLY the PR URL (no markdown, no explanations, no quotes):
https://github.com/owner/repo/pull/123
**Example output:**
```
https://github.com/coleam00/archon/pull/456
```
## Error Handling
If PR creation fails:
- Check if PR already exists for branch: `gh pr list --head $BRANCH`
- If exists: Return existing PR URL
- If other error: Output error message with context

View File

@@ -0,0 +1,27 @@
# Execute PRP Plan
Implement a feature plan from the PRPs directory by following its Step by Step Tasks section.
## Variables
Plan file: $ARGUMENTS
## Instructions
- Read the entire plan file carefully
- Execute **every step** in the "Step by Step Tasks" section in order, top to bottom
- Follow the "Testing Strategy" to create proper unit and integration tests
- Complete all "Validation Commands" at the end
- Ensure all linters pass and all tests pass before finishing
- Follow CLAUDE.md guidelines for type safety, logging, and docstrings
## When done
- Move the PRP file to the completed directory in PRPs/features/completed
## Report
- Summarize completed work in a concise bullet point list
- Show files and lines changed: `git diff --stat`
- Confirm all validation commands passed
- Note any deviations from the plan (if any)

View File

@@ -0,0 +1,176 @@
# NOQA Analysis and Resolution
Find all noqa/type:ignore comments in the codebase, investigate why they exist, and provide recommendations for resolution or justification.
## Instructions
**Step 1: Find all NOQA comments**
- Use Grep tool to find all noqa comments: pattern `noqa|type:\s*ignore`
- Use output_mode "content" with line numbers (-n flag)
- Search across all Python files (type: "py")
- Document total count of noqa comments found
**Step 2: For EACH noqa comment (repeat this process):**
- Read the file containing the noqa comment with sufficient context (at least 10 lines before and after)
- Identify the specific linting rule or type error being suppressed
- Understand the code's purpose and why the suppression was added
- Investigate if the suppression is still necessary or can be resolved
**Step 3: Investigation checklist for each noqa:**
- What specific error/warning is being suppressed? (e.g., `type: ignore[arg-type]`, `noqa: F401`)
- Why was the suppression necessary? (legacy code, false positive, legitimate limitation, technical debt)
- Can the underlying issue be fixed? (refactor code, update types, improve imports)
- What would it take to remove the suppression? (effort estimate, breaking changes, architectural changes)
- Is the suppression justified long-term? (external library limitation, Python limitation, intentional design)
**Step 4: Research solutions:**
- Check if newer versions of tools (mypy, ruff) handle the case better
- Look for alternative code patterns that avoid the suppression
- Consider if type stubs or Protocol definitions could help
- Evaluate if refactoring would be worthwhile
## Report Format
Create a markdown report file (create the reports directory if not created yet): `PRPs/reports/noqa-analysis-{YYYY-MM-DD}.md`
Use this structure for the report:
````markdown
# NOQA Analysis Report
**Generated:** {date}
**Total NOQA comments found:** {count}
---
## Summary
- Total suppressions: {count}
- Can be removed: {count}
- Should remain: {count}
- Requires investigation: {count}
---
## Detailed Analysis
### 1. {File path}:{line number}
**Location:** `{file_path}:{line_number}`
**Suppression:** `{noqa comment or type: ignore}`
**Code context:**
```python
{relevant code snippet}
```
````
**Why it exists:**
{explanation of why the suppression was added}
**Options to resolve:**
1. {Option 1: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
2. {Option 2: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
**Tradeoffs:**
- {Tradeoff 1}
- {Tradeoff 2}
**Recommendation:** {Remove | Keep | Refactor}
{Justification for recommendation}
---
{Repeat for each noqa comment}
````
## Example Analysis Entry
```markdown
### 1. src/shared/config.py:45
**Location:** `src/shared/config.py:45`
**Suppression:** `# type: ignore[assignment]`
**Code context:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
return key # type: ignore[assignment]
````
**Why it exists:**
MyPy cannot infer that the ValueError prevents None from being returned, so it thinks the return type could be `str | None`.
**Options to resolve:**
1. Use assert to help mypy narrow the type
- Effort: Low
- Breaking: No
- Impact: Cleaner code, removes suppression
2. Add explicit cast with typing.cast()
- Effort: Low
- Breaking: No
- Impact: More verbose but type-safe
3. Refactor to use separate validation method
- Effort: Medium
- Breaking: No
- Impact: Better separation of concerns
**Tradeoffs:**
- Option 1 (assert) is cleanest but asserts can be disabled with -O flag
- Option 2 (cast) is most explicit but adds import and verbosity
- Option 3 is most robust but requires more refactoring
**Recommendation:** Remove (use Option 1)
Replace the type:ignore with an assert statement after the if check. This helps mypy understand the control flow while maintaining runtime safety. The assert will never fail in practice since the ValueError is raised first.
**Implementation:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
assert key is not None # Help mypy understand control flow
return key
```
```
## Report
After completing the analysis:
- Output the path to the generated report file
- Summarize findings:
- Total suppressions found
- How many can be removed immediately (low effort)
- How many should remain (justified)
- How many need deeper investigation or refactoring
- Highlight any quick wins (suppressions that can be removed with minimal effort)
```

View File

@@ -0,0 +1,176 @@
# Feature Planning
Create a new plan to implement the `PRP` using the exact specified markdown `PRP Format`. Follow the `Instructions` to create the plan use the `Relevant Files` to focus on the right files.
## Variables
FEATURE $1 $2
## Instructions
- IMPORTANT: You're writing a plan to implement a net new feature based on the `Feature` that will add value to the application.
- IMPORTANT: The `Feature` describes the feature that will be implemented but remember we're not implementing a new feature, we're creating the plan that will be used to implement the feature based on the `PRP Format` below.
- Create the plan in the `PRPs/features/` directory with filename: `{descriptive-name}.md`
- Replace `{descriptive-name}` with a short, descriptive name based on the feature (e.g., "add-auth-system", "implement-search", "create-dashboard")
- Use the `PRP Format` below to create the plan.
- Deeply research the codebase to understand existing patterns, architecture, and conventions before planning the feature.
- If no patterns are established or are unclear ask the user for clarifications while providing best recommendations and options
- IMPORTANT: Replace every <placeholder> in the `PRP Format` with the requested value. Add as much detail as needed to implement the feature successfully.
- Use your reasoning model: THINK HARD about the feature requirements, design, and implementation approach.
- Follow existing patterns and conventions in the codebase. Don't reinvent the wheel.
- Design for extensibility and maintainability.
- Deeply do web research to understand the latest trends and technologies in the field.
- Figure out latest best practices and library documentation.
- Include links to relevant resources and documentation with anchor tags for easy navigation.
- If you need a new library, use `uv add <package>` and report it in the `Notes` section.
- Read `CLAUDE.md` for project principles, logging rules, testing requirements, and docstring style.
- All code MUST have type annotations (strict mypy enforcement).
- Use Google-style docstrings for all functions, classes, and modules.
- Every new file in `src/` MUST have a corresponding test file in `tests/`.
- Respect requested files in the `Relevant Files` section.
## Relevant Files
Focus on the following files and vertical slice structure:
**Core Files:**
- `CLAUDE.md` - Project instructions, logging rules, testing requirements, docstring style
app/backend core files
app/frontend core files
## PRP Format
```md
# Feature: <feature name>
## Feature Description
<describe the feature in detail, including its purpose and value to users>
## User Story
As a <type of user>
I want to <action/goal>
So that <benefit/value>
## Problem Statement
<clearly define the specific problem or opportunity this feature addresses>
## Solution Statement
<describe the proposed solution approach and how it solves the problem>
## Relevant Files
Use these files to implement the feature:
<find and list the files that are relevant to the feature describe why they are relevant in bullet points. If there are new files that need to be created to implement the feature, list them in an h3 'New Files' section. include line numbers for the relevant sections>
## Relevant research docstring
Use these documentation files and links to help with understanding the technology to use:
- [Documentation Link 1](https://example.com/doc1)
- [Anchor tag]
- [Short summary]
- [Documentation Link 2](https://example.com/doc2)
- [Anchor tag]
- [Short summary]
## Implementation Plan
### Phase 1: Foundation
<describe the foundational work needed before implementing the main feature>
### Phase 2: Core Implementation
<describe the main implementation work for the feature>
### Phase 3: Integration
<describe how the feature will integrate with existing functionality>
## Step by Step Tasks
IMPORTANT: Execute every step in order, top to bottom.
<list step by step tasks as h3 headers plus bullet points. use as many h3 headers as needed to implement the feature. Order matters:
1. Start with foundational shared changes (schemas, types)
2. Implement core functionality with proper logging
3. Create corresponding test files (unit tests mirror src/ structure)
4. Add integration tests if feature interacts with multiple components
5. Verify linters pass: `uv run ruff check src/ && uv run mypy src/`
6. Ensure all tests pass: `uv run pytest tests/`
7. Your last step should be running the `Validation Commands`>
<For tool implementations:
- Define Pydantic schemas in `schemas.py`
- Implement tool with structured logging and type hints
- Register tool with Pydantic AI agent
- Create unit tests in `tests/tools/<name>/test_<module>.py`
- Add integration test in `tests/integration/` if needed>
## Testing Strategy
See `CLAUDE.md` for complete testing requirements. Every file in `src/` must have a corresponding test file in `tests/`.
### Unit Tests
<describe unit tests needed for the feature. Mark with @pytest.mark.unit. Test individual components in isolation.>
### Integration Tests
<if the feature interacts with multiple components, describe integration tests needed. Mark with @pytest.mark.integration. Place in tests/integration/ when testing full application stack.>
### Edge Cases
<list edge cases that need to be tested>
## Acceptance Criteria
<list specific, measurable criteria that must be met for the feature to be considered complete>
## Validation Commands
Execute every command to validate the feature works correctly with zero regressions.
<list commands you'll use to validate with 100% confidence the feature is implemented correctly with zero regressions. Include (example for BE Biome and TS checks are used for FE):
- Linting: `uv run ruff check src/`
- Type checking: `uv run mypy src/`
- Unit tests: `uv run pytest tests/ -m unit -v`
- Integration tests: `uv run pytest tests/ -m integration -v` (if applicable)
- Full test suite: `uv run pytest tests/ -v`
- Manual API testing if needed (curl commands, test requests)>
**Required validation commands:**
- `uv run ruff check src/` - Lint check must pass
- `uv run mypy src/` - Type check must pass
- `uv run pytest tests/ -v` - All tests must pass with zero regressions
**Run server and test core endpoints:**
- Start server: @.claude/start-server
- Test endpoints with curl (at minimum: health check, main functionality)
- Verify structured logs show proper correlation IDs and context
- Stop server after validation
## Notes
<optionally list any additional notes, future considerations, or context that are relevant to the feature that will be helpful to the developer>
```
## Feature
Extract the feature details from the `issue_json` variable (parse the JSON and use the title and body fields).
## Report
- Summarize the work you've just done in a concise bullet point list.
- Include the full path to the plan file you created (e.g., `PRPs/features/add-auth-system.md`)

View File

@@ -0,0 +1,28 @@
# Prime
Execute the following sections to understand the codebase before starting new work, then summarize your understanding.
## Run
- List all tracked files: `git ls-files`
- Show project structure: `tree -I '.venv|__pycache__|*.pyc|.pytest_cache|.mypy_cache|.ruff_cache' -L 3`
## Read
- `CLAUDE.md` - Core project instructions, principles, logging rules, testing requirements
- `python/src/agent_work_orders` - Project overview and setup (if exists)
- Identify core files in the agent work orders directory to understand what we are woerking on and its intent
## Report
Provide a concise summary of:
1. **Project Purpose**: What this application does
2. **Architecture**: Key patterns (vertical slice, FastAPI + Pydantic AI)
3. **Core Principles**: TYPE SAFETY, KISS, YAGNI
4. **Tech Stack**: Main dependencies and tools
5. **Key Requirements**: Logging, testing, type annotations
6. **Current State**: What's implemented
Keep the summary brief (5-10 bullet points) and focused on what you need to know to contribute effectively.

View File

@@ -0,0 +1,120 @@
# Review and Fix
Review implemented work against a PRP specification, identify issues, and automatically fix blocker/major problems before committing.
## Variables
Plan file: $ARGUMENTS (e.g., `PRPs/features/add-web-search.md`)
## Instructions
**Understand the Changes:**
- Check current branch: `git branch`
- Review changes: `git diff origin/main` (or `git diff HEAD` if not on a branch)
- Read the PRP plan file to understand requirements
**Code Quality Review:**
- **Type Safety**: Verify all functions have type annotations, mypy passes
- **Logging**: Check structured logging is used correctly (event names, context, exception handling)
- **Docstrings**: Ensure Google-style docstrings on all functions/classes
- **Testing**: Verify unit tests exist for all new files, integration tests if needed
- **Architecture**: Confirm vertical slice structure is followed
- **CLAUDE.md Compliance**: Check adherence to core principles (KISS, YAGNI, TYPE SAFETY)
**Validation Ruff for BE and Biome for FE:**
- Run linters: `uv run ruff check src/ && uv run mypy src/`
- Run tests: `uv run pytest tests/ -v`
- Start server and test endpoints with curl (if applicable)
- Verify structured logs show proper correlation IDs and context
**Issue Severity:**
- `blocker` - Must fix before merge (breaks build, missing tests, type errors, security issues)
- `major` - Should fix (missing logging, incomplete docstrings, poor patterns)
- `minor` - Nice to have (style improvements, optimization opportunities)
## Report
Return ONLY valid JSON (no markdown, no explanations) save to [report-#.json] in prps/reports directory create the directory if it doesn't exist. Output will be parsed with JSON.parse().
### Output Structure
```json
{
"success": "boolean - true if NO BLOCKER issues, false if BLOCKER issues exist",
"review_summary": "string - 2-4 sentences: what was built, does it match spec, quality assessment",
"review_issues": [
{
"issue_number": "number - issue index",
"file_path": "string - file with the issue (if applicable)",
"issue_description": "string - what's wrong",
"issue_resolution": "string - how to fix it",
"severity": "string - blocker|major|minor"
}
],
"validation_results": {
"linting_passed": "boolean",
"type_checking_passed": "boolean",
"tests_passed": "boolean",
"api_endpoints_tested": "boolean - true if endpoints were tested with curl"
}
}
```
## Example Success Review
```json
{
"success": true,
"review_summary": "The web search tool has been implemented with proper type annotations, structured logging, and comprehensive tests. The implementation follows the vertical slice architecture and matches all spec requirements. Code quality is high with proper error handling and documentation.",
"review_issues": [
{
"issue_number": 1,
"file_path": "src/tools/web_search/tool.py",
"issue_description": "Missing debug log for API response",
"issue_resolution": "Add logger.debug with response metadata",
"severity": "minor"
}
],
"validation_results": {
"linting_passed": true,
"type_checking_passed": true,
"tests_passed": true,
"api_endpoints_tested": true
}
}
```
## Fix Issues
After generating the review report, automatically fix blocker and major issues:
**Parse the Report:**
- Read the generated `PRPs/reports/report-#.json` file
- Extract all issues with severity "blocker" or "major"
**Apply Fixes:**
For each blocker/major issue:
1. Read the file mentioned in `file_path`
2. Apply the fix described in `issue_resolution`
3. Log what was fixed
**Re-validate:**
- Rerun linters: `uv run ruff check src/ --fix`
- Rerun type checker: `uv run mypy src/`
- Rerun tests: `uv run pytest tests/ -v`
**Report Results:**
- If all blockers fixed and validation passes → Output "✅ All critical issues fixed, validation passing"
- If fixes failed or validation still failing → Output "⚠️ Some issues remain" with details
- Minor issues can be left for manual review later
**Important:**
- Only fix blocker/major issues automatically
- Minor issues should be left in the report for human review
- If a fix might break something, skip it and note in output
- Run validation after ALL fixes applied, not after each individual fix

View File

@@ -0,0 +1,33 @@
# Start Servers
Start both the FastAPI backend and React frontend development servers with hot reload.
## Run
### Run in the background with bash tool
- Ensure you are in the right PWD
- Use the Bash tool to run the servers in the background so you can read the shell outputs
- IMPORTANT: run `git ls-files` first so you know where directories are located before you start
### Backend Server (FastAPI)
- Navigate to backend: `cd app/backend`
- Start server in background: `uv sync && uv run python run_api.py`
- Wait 2-3 seconds for startup
- Test health endpoint: `curl http://localhost:8000/health`
- Test products endpoint: `curl http://localhost:8000/api/products`
### Frontend Server (Bun + React)
- Navigate to frontend: `cd ../app/frontend`
- Start server in background: `bun install && bun dev`
- Wait 2-3 seconds for startup
- Frontend should be accessible at `http://localhost:3000`
## Report
- Confirm backend is running on `http://localhost:8000`
- Confirm frontend is running on `http://localhost:3000`
- Show the health check response from backend
- Mention: "Backend logs will show structured JSON logging for all requests"

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