Merge remote-tracking branch 'origin/develop' into feat/bmad-adopter-integration
# Conflicts: # .github/actions/setup-node-frontend/action.yml # apps/frontend/node_modules # apps/frontend/src/main/ipc-handlers/index.ts # apps/frontend/src/preload/api/index.ts # apps/frontend/src/shared/constants/ipc.ts # apps/frontend/src/shared/types/ipc.ts # apps/frontend/src/shared/types/project.ts
This commit is contained in:
@@ -80,11 +80,12 @@ runs:
|
||||
echo "Installed yq version:"
|
||||
yq --version
|
||||
|
||||
# Merge the files arrays from both manifests using eval-all
|
||||
# This avoids shell expansion issues with multiline YAML
|
||||
yq eval-all '
|
||||
select(fileIndex == 0) * {"files": ([.[].files] | add)}
|
||||
' "$intel_manifest" "$arm64_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
|
||||
# Merge the files arrays from both manifests using two-step approach
|
||||
# Step 1: Collect all files from both manifests into a temp file
|
||||
yq eval-all '[.files] | flatten' "$intel_manifest" "$arm64_manifest" > /tmp/merged-files.yml
|
||||
|
||||
# Step 2: Replace files array in first manifest with merged files
|
||||
yq eval '.files = load("/tmp/merged-files.yml")' "$intel_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
echo "Merged manifest contents:"
|
||||
cat "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
@@ -65,19 +65,34 @@ runs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove any existing node_modules in apps/frontend (npm workspaces may create partial one)
|
||||
if [ -d "apps/frontend/node_modules" ] && [ ! -L "apps/frontend/node_modules" ]; then
|
||||
echo "Removing partial node_modules created by npm workspaces..."
|
||||
rm -rf "apps/frontend/node_modules"
|
||||
# Remove any existing node_modules in apps/frontend
|
||||
# This handles: partial directories from npm workspaces, AND broken symlinks
|
||||
if [ -e "apps/frontend/node_modules" ] || [ -L "apps/frontend/node_modules" ]; then
|
||||
# Check if it's a valid symlink pointing to root node_modules
|
||||
if [ -L "apps/frontend/node_modules" ]; then
|
||||
target=$(readlink apps/frontend/node_modules 2>/dev/null || echo "")
|
||||
if [ "$target" = "../../node_modules" ] && [ -d "apps/frontend/node_modules" ]; then
|
||||
echo "Correct symlink already exists: apps/frontend/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "Removing incorrect/broken symlink (was: $target)..."
|
||||
rm -f "apps/frontend/node_modules"
|
||||
fi
|
||||
else
|
||||
echo "Removing partial node_modules directory created by npm workspaces..."
|
||||
rm -rf "apps/frontend/node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create link if it doesn't exist
|
||||
# Create link if it doesn't exist or was removed
|
||||
if [ ! -L "apps/frontend/node_modules" ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
# Use directory junction on Windows (works without admin privileges)
|
||||
cmd //c "mklink /J apps\\frontend\\node_modules ..\\..\\node_modules"
|
||||
# Use PowerShell's New-Item -ItemType Junction for reliable path handling
|
||||
abs_target=$(cygpath -w "$(pwd)/node_modules")
|
||||
link_path=$(cygpath -w "$(pwd)/apps/frontend/node_modules")
|
||||
powershell -Command "New-Item -ItemType Junction -Path '$link_path' -Target '$abs_target'" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Created junction: apps/frontend/node_modules -> ../../node_modules"
|
||||
echo "Created junction: apps/frontend/node_modules -> $abs_target"
|
||||
else
|
||||
echo "::error::Failed to create directory junction on Windows"
|
||||
exit 1
|
||||
@@ -91,19 +106,21 @@ runs:
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "apps/frontend/node_modules symlink already exists"
|
||||
fi
|
||||
|
||||
# Verify the link works
|
||||
if [ -L "apps/frontend/node_modules" ]; then
|
||||
target=$(readlink apps/frontend/node_modules 2>/dev/null || echo "junction")
|
||||
echo "Verified: apps/frontend/node_modules -> $target"
|
||||
# Check that the target resolves correctly
|
||||
if [ -d "apps/frontend/node_modules" ]; then
|
||||
count=$(ls apps/frontend/node_modules 2>/dev/null | wc -l)
|
||||
echo "Link resolves to directory with $count entries"
|
||||
else
|
||||
echo "::warning::Link exists but does not resolve to a valid directory"
|
||||
fi
|
||||
# Final verification - the link must exist and resolve correctly
|
||||
# Note: On Windows, junctions don't show as symlinks (-L), so we check if the directory exists
|
||||
# and can be listed. On Unix, we also verify it's a symlink.
|
||||
if [ "$RUNNER_OS" != "Windows" ] && [ ! -L "apps/frontend/node_modules" ]; then
|
||||
echo "::error::apps/frontend/node_modules symlink was not created"
|
||||
exit 1
|
||||
fi
|
||||
# Verify the link resolves to a valid directory with content
|
||||
if ! ls apps/frontend/node_modules/electron >/dev/null 2>&1; then
|
||||
echo "::error::apps/frontend/node_modules does not resolve correctly (electron not found)"
|
||||
ls -la apps/frontend/ || true
|
||||
ls apps/frontend/node_modules 2>&1 | head -5 || true
|
||||
exit 1
|
||||
fi
|
||||
count=$(ls apps/frontend/node_modules 2>/dev/null | wc -l)
|
||||
echo "Verified: apps/frontend/node_modules resolves correctly ($count entries)"
|
||||
|
||||
@@ -631,7 +631,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Use PAT_TOKEN to bypass branch protection rules on main
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Extract version and detect release type
|
||||
id: version
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# PR Review System Robustness
|
||||
|
||||
## What This Is
|
||||
|
||||
Improvements to Auto Claude's PR review system to make it trustworthy enough to replace human review. The system uses specialist agents (security, logic, quality, codebase-fit) with a finding-validator that re-investigates findings before presenting them. This milestone fixes gaps that cause false positives and missed context.
|
||||
|
||||
## Core Value
|
||||
|
||||
**When the system flags something, it's a real issue.** Trustworthy PR reviews that are faster, more thorough, and more accurate than human review.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
- ✓ Multi-agent PR review architecture — existing
|
||||
- ✓ Specialist agents (security, logic, quality, codebase-fit) — existing
|
||||
- ✓ Finding-validator for follow-up reviews — existing
|
||||
- ✓ Dismissal tracking with reasons — existing
|
||||
- ✓ CI status enforcement — existing
|
||||
- ✓ Context gathering (diff, comments, related files) — existing
|
||||
|
||||
### Active
|
||||
|
||||
- [ ] **REQ-001**: Finding-validator runs on initial reviews (not just follow-ups)
|
||||
- [ ] **REQ-002**: Fix line 1288 bug — include ai_reviews in follow-up context
|
||||
- [ ] **REQ-003**: Fetch formal PR reviews from `/pulls/{pr}/reviews` API
|
||||
- [ ] **REQ-004**: Add Read/Grep/Glob tool instructions to all specialist prompts
|
||||
- [ ] **REQ-005**: Expand JS/TS import analysis (path aliases, CommonJS, re-exports)
|
||||
- [ ] **REQ-006**: Add Python import analysis (currently skipped)
|
||||
- [ ] **REQ-007**: Increase related files limit from 20 to 50 with prioritization
|
||||
- [ ] **REQ-008**: Add reverse dependency analysis (what imports changed files)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Real-time review streaming — complexity, not needed for accuracy goal
|
||||
- Review caching/memoization — premature optimization
|
||||
- Custom specialist agents — current four dimensions sufficient
|
||||
|
||||
## Context
|
||||
|
||||
**Problem**: False positives in PR reviews erode trust. Users have to second-guess every finding, defeating the purpose of automated review.
|
||||
|
||||
**Root cause**: Finding-validator (which catches false positives) only runs during follow-up reviews. Initial reviews present unvalidated findings. Additionally, context gathering has bugs and gaps that cause the AI to make claims without complete information.
|
||||
|
||||
**Existing system**:
|
||||
- `apps/backend/runners/github/` — PR review orchestration
|
||||
- `apps/backend/runners/github/services/parallel_orchestrator_reviewer.py` — initial review
|
||||
- `apps/backend/runners/github/services/parallel_followup_reviewer.py` — follow-up review (has finding-validator)
|
||||
- `apps/backend/runners/github/context_gatherer.py` — gathers PR context
|
||||
- `apps/backend/prompts/github/pr_*.md` — specialist agent prompts
|
||||
|
||||
**Reference**: Full PRD at `docs/PR_REVIEW_SYSTEM_IMPROVEMENTS.md`
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Existing architecture**: Work within current multi-agent PR review structure
|
||||
- **Backward compatibility**: Don't break existing review workflows
|
||||
- **Performance**: Validation step should not significantly slow reviews (run in parallel where possible)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| Add finding-validator to initial reviews | Catches false positives before user sees them | — Pending |
|
||||
| Same validator for initial and follow-up | Consistency, proven approach from follow-up reviews | — Pending |
|
||||
| Expand import analysis incrementally | JS/TS first (REQ-005), Python second (REQ-006) | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-01-19 after initialization*
|
||||
@@ -0,0 +1,193 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Multi-Agent Orchestration with Electron Desktop UI
|
||||
|
||||
**Key Characteristics:**
|
||||
- Dual-app architecture: Python backend (CLI + agents) + Electron frontend (desktop UI)
|
||||
- Agent-based autonomous coding via Claude Agent SDK
|
||||
- Git worktree isolation for safe parallel development
|
||||
- Phase-based pipeline execution for spec creation and implementation
|
||||
- Event-driven IPC communication between frontend and backend
|
||||
|
||||
## Layers
|
||||
|
||||
**Frontend (Electron Main Process):**
|
||||
- Purpose: Desktop application shell, native OS integration, IPC coordination
|
||||
- Location: `apps/frontend/src/main/`
|
||||
- Contains: Window management, IPC handlers, service managers (terminal, python env, CLI tools)
|
||||
- Depends on: Backend Python CLI, Claude Code CLI
|
||||
- Used by: Renderer process via IPC
|
||||
|
||||
**Frontend (Renderer Process):**
|
||||
- Purpose: React-based user interface
|
||||
- Location: `apps/frontend/src/renderer/`
|
||||
- Contains: Components, Zustand stores, hooks, contexts
|
||||
- Depends on: Main process via preload IPC bridge
|
||||
- Used by: End users
|
||||
|
||||
**Backend Core:**
|
||||
- Purpose: Authentication, SDK client factory, security, workspace management
|
||||
- Location: `apps/backend/core/`
|
||||
- Contains: `client.py` (SDK factory), `auth.py`, `worktree.py`, `workspace.py`, security hooks
|
||||
- Depends on: Claude Agent SDK, project analyzer
|
||||
- Used by: Agents, CLI commands, runners
|
||||
|
||||
**Backend Agents:**
|
||||
- Purpose: AI agent implementations for autonomous coding
|
||||
- Location: `apps/backend/agents/`
|
||||
- Contains: Coder, planner, memory manager, session management
|
||||
- Depends on: Core client, prompts, phase config
|
||||
- Used by: CLI commands, QA loop
|
||||
|
||||
**Backend QA:**
|
||||
- Purpose: Quality assurance validation loop
|
||||
- Location: `apps/backend/qa/`
|
||||
- Contains: QA reviewer, QA fixer, criteria validation, issue tracking
|
||||
- Depends on: Agents, core client
|
||||
- Used by: CLI commands after build completion
|
||||
|
||||
**Backend Spec:**
|
||||
- Purpose: Spec creation pipeline with complexity-based phases
|
||||
- Location: `apps/backend/spec/`
|
||||
- Contains: Pipeline orchestrator, complexity assessment, validation
|
||||
- Depends on: Core client, agents
|
||||
- Used by: CLI spec commands, frontend task creation
|
||||
|
||||
**Backend Security:**
|
||||
- Purpose: Command validation, allowlist management, secrets scanning
|
||||
- Location: `apps/backend/security/`
|
||||
- Contains: Validators, hooks, command parser, secrets scanner
|
||||
- Depends on: Project analyzer
|
||||
- Used by: Core client via pre-tool-use hooks
|
||||
|
||||
**Backend CLI:**
|
||||
- Purpose: Command-line interface and argument routing
|
||||
- Location: `apps/backend/cli/`
|
||||
- Contains: Main entry, build/spec/workspace/QA commands
|
||||
- Depends on: All backend modules
|
||||
- Used by: Entry point (`run.py`), frontend terminal
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Spec Creation Flow:**
|
||||
1. User creates task via frontend or CLI (`--task "description"`)
|
||||
2. `SpecOrchestrator` (`spec/pipeline/orchestrator.py`) initializes
|
||||
3. Complexity assessment determines phase count (3-8 phases)
|
||||
4. `AgentRunner` executes phases: Discovery -> Requirements -> [Research] -> Context -> Spec -> Plan -> Validate
|
||||
5. Each phase uses Claude Agent SDK session with phase-specific prompts
|
||||
6. Output: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json`
|
||||
|
||||
**Implementation Flow:**
|
||||
1. CLI starts with `python run.py --spec 001`
|
||||
2. `run_autonomous_agent()` in `agents/coder.py` orchestrates
|
||||
3. Planner agent creates subtask-based `implementation_plan.json`
|
||||
4. Coder agent implements subtasks in iteration loop
|
||||
5. Each subtask runs as Claude Agent SDK session
|
||||
6. On completion, QA validation loop runs (`qa/loop.py`)
|
||||
7. QA reviewer validates -> QA fixer fixes issues -> loop until approved
|
||||
|
||||
**Frontend-Backend IPC Flow:**
|
||||
1. Renderer component dispatches action (e.g., start task)
|
||||
2. Zustand store calls `window.api.invoke('ipc-channel', args)`
|
||||
3. Preload script bridges to main process
|
||||
4. IPC handler in `ipc-handlers/` processes request
|
||||
5. Handler spawns Python subprocess or manages terminal
|
||||
6. Events streamed back via IPC to update stores
|
||||
|
||||
**State Management:**
|
||||
- Frontend: Zustand stores per domain (`task-store`, `project-store`, `settings-store`, etc.)
|
||||
- Backend: File-based state (`implementation_plan.json`, `qa_report.md`)
|
||||
- Session recovery: `RecoveryManager` tracks agent sessions for resumption
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**ClaudeSDKClient:**
|
||||
- Purpose: Configured Claude Agent SDK client with security hooks
|
||||
- Examples: `apps/backend/core/client.py:create_client()`
|
||||
- Pattern: Factory function with multi-layered security (sandbox, permissions, hooks)
|
||||
|
||||
**SpecOrchestrator:**
|
||||
- Purpose: Coordinates spec creation pipeline phases
|
||||
- Examples: `apps/backend/spec/pipeline/orchestrator.py`
|
||||
- Pattern: Orchestrator with dynamic phase selection based on complexity
|
||||
|
||||
**WorktreeManager:**
|
||||
- Purpose: Git worktree isolation for safe parallel builds
|
||||
- Examples: `apps/backend/core/worktree.py`
|
||||
- Pattern: Each spec gets isolated worktree branch (`auto-claude/{spec-name}`)
|
||||
|
||||
**SecurityProfile:**
|
||||
- Purpose: Dynamic command allowlist based on project analysis
|
||||
- Examples: `apps/backend/project_analyzer.py`, `apps/backend/security/`
|
||||
- Pattern: Base + stack-specific + custom commands cached in `.auto-claude-security.json`
|
||||
|
||||
**IPC Handlers:**
|
||||
- Purpose: Bridge between Electron renderer and backend services
|
||||
- Examples: `apps/frontend/src/main/ipc-handlers/`
|
||||
- Pattern: Domain-specific handler modules registered via `ipc-setup.ts`
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Backend CLI:**
|
||||
- Location: `apps/backend/run.py`
|
||||
- Triggers: Terminal, frontend subprocess spawn, direct invocation
|
||||
- Responsibilities: Argument parsing, command routing to `cli/` modules
|
||||
|
||||
**Electron Main:**
|
||||
- Location: `apps/frontend/src/main/index.ts`
|
||||
- Triggers: Application launch
|
||||
- Responsibilities: Window creation, IPC setup, service initialization
|
||||
|
||||
**Renderer Entry:**
|
||||
- Location: `apps/frontend/src/renderer/main.tsx`
|
||||
- Triggers: Window load
|
||||
- Responsibilities: React app mount, store initialization
|
||||
|
||||
**Spec Pipeline:**
|
||||
- Location: `apps/backend/spec/pipeline/orchestrator.py:SpecOrchestrator`
|
||||
- Triggers: CLI `--task`, frontend task creation
|
||||
- Responsibilities: Dynamic phase execution for spec creation
|
||||
|
||||
**Agent Loop:**
|
||||
- Location: `apps/backend/agents/coder.py:run_autonomous_agent()`
|
||||
- Triggers: CLI `--spec 001`, frontend build start
|
||||
- Responsibilities: Subtask iteration, session management, recovery
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Multi-level error handling with recovery support
|
||||
|
||||
**Patterns:**
|
||||
- Agent sessions: `RecoveryManager` tracks state for resumption after interruption
|
||||
- Security validation: Pre-tool-use hooks reject dangerous commands before execution
|
||||
- QA loop: Escalation to human review after max iterations (`MAX_QA_ITERATIONS`)
|
||||
- Git operations: Retry with exponential backoff for network errors
|
||||
- Frontend: Error boundaries with toast notifications
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- Backend: Python `logging` module with task-specific loggers (`task_logger/`)
|
||||
- Frontend: Electron app logger (`app-logger.ts`), Sentry integration
|
||||
|
||||
**Validation:**
|
||||
- Command security: `security/` validators with dynamic allowlists
|
||||
- Spec validation: `spec/validate_pkg/` for implementation plan schema
|
||||
- Tool input: `security/tool_input_validator.py` for Claude tool arguments
|
||||
|
||||
**Authentication:**
|
||||
- OAuth flow: `core/auth.py` manages Claude OAuth tokens
|
||||
- Token storage: Keychain (macOS), Credential Manager (Windows), encrypted file (Linux)
|
||||
- Token validation: Pre-SDK-call validation to prevent encrypted token errors
|
||||
|
||||
**Internationalization:**
|
||||
- Frontend: `react-i18next` with namespace-organized JSON files
|
||||
- Location: `apps/frontend/src/shared/i18n/locales/{en,fr}/`
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-01-19*
|
||||
@@ -0,0 +1,224 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Large File Complexity:**
|
||||
- Issue: Several core files exceed 1000+ lines, indicating potential need for further modularization
|
||||
- Files:
|
||||
- `apps/backend/core/workspace.py` (2096 lines) - Already refactored but remains large
|
||||
- `apps/backend/runners/github/orchestrator.py` (1607 lines)
|
||||
- `apps/backend/core/worktree.py` (1404 lines)
|
||||
- `apps/backend/runners/github/context_gatherer.py` (1292 lines)
|
||||
- `apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts` (3149 lines)
|
||||
- `apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts` (2874 lines)
|
||||
- Impact: Difficult to navigate, test, and maintain; increases risk of merge conflicts
|
||||
- Fix approach: Continue modular extraction pattern (workspace.py partially done); extract sub-modules for GitHub orchestrator
|
||||
|
||||
**Deprecated Modules Still in Codebase:**
|
||||
- Issue: Deprecated code remains active and produces warnings
|
||||
- Files:
|
||||
- `apps/backend/runners/github/confidence.py` - Marked deprecated, uses DeprecationWarning
|
||||
- `apps/frontend/src/main/terminal/terminal-manager.ts` - Contains deprecated sync methods
|
||||
- `apps/frontend/src/main/terminal/session-handler.ts` - persistAllSessions deprecated
|
||||
- Impact: Technical confusion, potential runtime warnings, maintenance burden
|
||||
- Fix approach: Remove deprecated modules or complete migration to evidence-based validation
|
||||
|
||||
**Global State / Module-Level Caches:**
|
||||
- Issue: Multiple modules use global variables and module-level caches that are not thread-safe
|
||||
- Files:
|
||||
- `apps/backend/security/profile.py` (5 global variables for caching)
|
||||
- `apps/backend/core/client.py` (_PROJECT_INDEX_CACHE, _CLAUDE_CLI_CACHE)
|
||||
- `apps/backend/core/io_utils.py` (_pipe_broken global)
|
||||
- `apps/backend/core/sentry.py` (_sentry_initialized, _sentry_enabled)
|
||||
- `apps/backend/task_logger/utils.py` (_current_logger global)
|
||||
- Impact: Potential race conditions in multi-threaded scenarios; difficult to test in isolation
|
||||
- Fix approach: Convert to class-based singletons with proper locking; use thread-local storage where appropriate
|
||||
|
||||
**Incomplete TODO Implementation:**
|
||||
- Issue: Critical features have TODO placeholders
|
||||
- Files:
|
||||
- `apps/backend/core/workspace.py:1578` - `_record_merge_completion` not implemented
|
||||
- `apps/backend/merge/conflict_analysis.py:272-283` - Advanced implicit conflict detection not implemented
|
||||
- `apps/frontend/src/renderer/stores/settings-store.ts:214` - i18n keys not implemented
|
||||
- `apps/frontend/src/renderer/components/ideation/EnvConfigModal.tsx:1` - Props interface not defined
|
||||
- Impact: Missing functionality, potential runtime issues
|
||||
- Fix approach: Implement or remove features; document if intentionally deferred
|
||||
|
||||
**Empty Exception Handlers:**
|
||||
- Issue: Many `pass` statements in exception handlers swallow errors silently
|
||||
- Files: 237+ instances of `pass` after exception handling across backend
|
||||
- Locations include:
|
||||
- `apps/backend/core/worktree.py:448`
|
||||
- `apps/backend/services/orchestrator.py:384, 396, 411, 423`
|
||||
- `apps/backend/cli/workspace_commands.py:339-359` (multiple)
|
||||
- `apps/backend/runners/github/memory_integration.py` (multiple)
|
||||
- Impact: Silent failures make debugging difficult; errors may propagate unexpectedly
|
||||
- Fix approach: Add logging to catch blocks; re-raise critical exceptions; document intentional suppressions
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Status Flip-Flop Bug (Task Store):**
|
||||
- Symptoms: Task status may incorrectly change between terminal states
|
||||
- Files: `apps/frontend/src/renderer/stores/task-store.ts:278, 282, 324, 346`
|
||||
- Trigger: Phase transitions in updateTaskFromPlan
|
||||
- Workaround: Multiple FIX comments added inline; logic guards terminal phases
|
||||
|
||||
**BulkPRDialog Error Detection:**
|
||||
- Symptoms: String-based error detection is fragile
|
||||
- Files: `apps/frontend/src/renderer/components/BulkPRDialog.tsx:32`
|
||||
- Trigger: API error messages changing format
|
||||
- Workaround: None - TODO comment acknowledges the issue
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Shell=True Usage:**
|
||||
- Risk: Command injection if inputs not properly sanitized
|
||||
- Files:
|
||||
- `apps/backend/core/git_executable.py:134` - Windows 'where' command
|
||||
- `apps/backend/core/gh_executable.py:61` - Windows 'where' command
|
||||
- Current mitigation: Limited to Windows platform detection, not user-controlled input
|
||||
- Recommendations: Document why shell=True is required; ensure no user input reaches these calls
|
||||
|
||||
**Subprocess Execution Spread Across Codebase:**
|
||||
- Risk: Inconsistent security validation; command injection if not properly controlled
|
||||
- Files: 50+ files with subprocess.run/Popen calls
|
||||
- Current mitigation: Security hooks in `apps/backend/security/hooks.py`; allowlist in project_analyzer
|
||||
- Recommendations: Consolidate subprocess calls through centralized wrappers; audit all subprocess calls
|
||||
|
||||
**Environment Variable Handling:**
|
||||
- Risk: Sensitive data exposure through env vars
|
||||
- Files: 100+ os.environ references across backend
|
||||
- Current mitigation: Token validation in `apps/backend/core/auth.py`; encrypted token detection
|
||||
- Recommendations: Audit all env var usage; ensure secrets are not logged; use secure storage APIs
|
||||
|
||||
**Token Decryption Not Implemented:**
|
||||
- Risk: Encrypted tokens fail silently, requiring manual workarounds
|
||||
- Files: `apps/backend/core/auth.py:103-228`
|
||||
- Current mitigation: Clear error messages directing users to alternatives
|
||||
- Recommendations: Implement cross-platform token decryption or improve error UX
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Blocking Sleep Calls:**
|
||||
- Problem: time.sleep() calls block threads
|
||||
- Files:
|
||||
- `apps/backend/core/workspace/models.py:129, 218`
|
||||
- `apps/backend/core/worktree.py:95, 106`
|
||||
- `apps/backend/services/orchestrator.py:451`
|
||||
- `apps/backend/runners/github/file_lock.py:172`
|
||||
- `apps/backend/runners/gitlab/glab_client.py:168`
|
||||
- Cause: Synchronous retry logic with exponential backoff
|
||||
- Improvement path: Convert to async operations where possible; use asyncio.sleep for async code
|
||||
|
||||
**Project Index Cache TTL:**
|
||||
- Problem: 5-minute TTL may cause stale data or unnecessary reloads
|
||||
- Files: `apps/backend/core/client.py:43` (_CACHE_TTL_SECONDS = 300)
|
||||
- Cause: Fixed TTL doesn't adapt to project activity
|
||||
- Improvement path: Implement file-watcher invalidation; make TTL configurable
|
||||
|
||||
**Security Profile Cache:**
|
||||
- Problem: Module-level cache with no size limits
|
||||
- Files: `apps/backend/security/profile.py:23-27`
|
||||
- Cause: Global state without eviction policy
|
||||
- Improvement path: Add LRU eviction; consider bounded cache
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Merge System:**
|
||||
- Files:
|
||||
- `apps/backend/core/workspace.py` (complex merge orchestration)
|
||||
- `apps/backend/merge/` directory (conflict detection, resolution)
|
||||
- Why fragile: Complex state machine for parallel merges; many edge cases in git operations
|
||||
- Safe modification: Always test with multiple concurrent specs; use DEBUG=true for verbose logging
|
||||
- Test coverage: Tests exist but may not cover all race conditions
|
||||
|
||||
**GitHub Integration:**
|
||||
- Files:
|
||||
- `apps/backend/runners/github/orchestrator.py`
|
||||
- `apps/backend/runners/github/rate_limiter.py`
|
||||
- `apps/backend/runners/github/gh_client.py`
|
||||
- Why fragile: External API dependencies; rate limiting complexity; async/await patterns
|
||||
- Safe modification: Mock external calls in tests; test rate limit scenarios explicitly
|
||||
- Test coverage: Good coverage in `tests/test_github_*.py`
|
||||
|
||||
**Terminal Integration (Frontend):**
|
||||
- Files:
|
||||
- `apps/frontend/src/renderer/stores/terminal-store.ts`
|
||||
- `apps/frontend/src/main/terminal/claude-integration-handler.ts`
|
||||
- Why fragile: Complex state management; IPC communication; PTY lifecycle
|
||||
- Safe modification: Test terminal creation/destruction cycles; watch for memory leaks
|
||||
- Test coverage: Tests exist in `__tests__/` directories
|
||||
|
||||
**Auth/Token Handling:**
|
||||
- Files: `apps/backend/core/auth.py` (898 lines)
|
||||
- Why fragile: Platform-specific code paths; external dependency on Claude CLI; keyring integration
|
||||
- Safe modification: Test on all platforms; verify OAuth flow end-to-end
|
||||
- Test coverage: `tests/test_auth.py` exists
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Concurrent Agent Sessions:**
|
||||
- Current capacity: Limited by Claude SDK rate limits and system resources
|
||||
- Limit: No explicit session pooling or queuing
|
||||
- Scaling path: Implement session pool; add retry queues for rate limits
|
||||
|
||||
**Graphiti Memory Database:**
|
||||
- Current capacity: LadybugDB (embedded Kuzu) - single-process access
|
||||
- Limit: No concurrent write support across multiple processes
|
||||
- Scaling path: Consider distributed graph database for multi-user scenarios
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**Deprecated Python Packages:**
|
||||
- Risk: `secretstorage` on Linux has complex DBus dependencies
|
||||
- Impact: Installation failures on minimal Linux systems
|
||||
- Migration plan: Document fallback to .env storage; improve error messages
|
||||
|
||||
**Platform-Specific Code:**
|
||||
- Risk: Windows/macOS/Linux code paths diverge
|
||||
- Impact: Platform-specific bugs (documented in CLAUDE.md)
|
||||
- Migration plan: Centralized platform abstraction in `apps/backend/core/platform/`
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**Implicit Conflict Detection:**
|
||||
- Problem: Function rename + usage conflicts not detected
|
||||
- Blocks: Accurate parallel merge conflict resolution
|
||||
- Files: `apps/backend/merge/conflict_analysis.py:272-283`
|
||||
|
||||
**_record_merge_completion:**
|
||||
- Problem: Merge completion not recorded for timeline tracking
|
||||
- Blocks: Full merge history audit trail
|
||||
- Files: `apps/backend/core/workspace.py:1578`
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Async Code Testing:**
|
||||
- What's not tested: Many async functions have limited coverage
|
||||
- Files: 70+ files with async functions, 92+ with await statements
|
||||
- Risk: Race conditions in async code may go unnoticed
|
||||
- Priority: High - async bugs are hard to reproduce
|
||||
|
||||
**Platform-Specific Paths:**
|
||||
- What's not tested: Windows-specific code paths on Linux CI
|
||||
- Files: Platform detection in `apps/backend/core/platform/__init__.py`
|
||||
- Risk: Windows-only bugs not caught until user reports
|
||||
- Priority: Medium - CI now runs on all platforms per CLAUDE.md
|
||||
|
||||
**Global State Reset:**
|
||||
- What's not tested: Cache invalidation edge cases
|
||||
- Files: All files with module-level caches
|
||||
- Risk: State leakage between tests
|
||||
- Priority: Medium - add cache reset fixtures
|
||||
|
||||
**Exception Handler Behavior:**
|
||||
- What's not tested: Error paths through empty except blocks
|
||||
- Files: 237+ `pass` statements in exception handlers
|
||||
- Risk: Silent failures in production
|
||||
- Priority: High - add tests that trigger exception paths
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-01-19*
|
||||
@@ -0,0 +1,283 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Python: `snake_case.py` (e.g., `project_analyzer.py`, `qa_report.py`)
|
||||
- TypeScript: `kebab-case.ts` or `PascalCase.tsx` for React components
|
||||
- Test files: `test_*.py` (Python), `*.test.ts` (TypeScript)
|
||||
- Config files: lowercase with extension (e.g., `ruff.toml`, `tsconfig.json`)
|
||||
|
||||
**Functions:**
|
||||
- Python: `snake_case` (e.g., `validate_command()`, `get_security_profile()`)
|
||||
- TypeScript: `camelCase` (e.g., `detectRateLimit()`, `parsePhaseEvent()`)
|
||||
|
||||
**Variables:**
|
||||
- Python: `snake_case` for locals, `UPPER_SNAKE_CASE` for constants
|
||||
- TypeScript: `camelCase` for locals, `UPPER_SNAKE_CASE` for constants
|
||||
|
||||
**Classes/Types:**
|
||||
- Python: `PascalCase` (e.g., `SecurityProfile`, `ClaudeSDKClient`)
|
||||
- TypeScript: `PascalCase` for types/interfaces (e.g., `ExecutionParserContext`)
|
||||
|
||||
**Constants:**
|
||||
- Module-level: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_UTILITY_MODEL`, `SAFE_COMMANDS`)
|
||||
- Private cache variables: `_UPPER_SNAKE_CASE` (e.g., `_PROJECT_INDEX_CACHE`)
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting - Python (Backend):**
|
||||
- Tool: Ruff (v0.14.10 via pre-commit)
|
||||
- Quote style: Double quotes
|
||||
- Indent style: Spaces (4 spaces per PEP 8)
|
||||
- Line endings: Auto
|
||||
- Key rules enabled:
|
||||
- `E`, `W` (pycodestyle)
|
||||
- `F` (Pyflakes)
|
||||
- `I` (isort)
|
||||
- `B` (flake8-bugbear)
|
||||
- `C4` (flake8-comprehensions)
|
||||
- `UP` (pyupgrade)
|
||||
|
||||
**Formatting - TypeScript (Frontend):**
|
||||
- Tool: Biome (v2.3.11)
|
||||
- Commands:
|
||||
```bash
|
||||
cd apps/frontend && npx biome check --write . # Lint + format
|
||||
```
|
||||
- TypeScript compiler: `tsc --noEmit` for type checking
|
||||
- Strict mode enabled in `tsconfig.json`
|
||||
|
||||
**Linting:**
|
||||
- Python: Ruff handles both linting and formatting
|
||||
- TypeScript: Biome handles both (replaced ESLint for 15-25x faster performance)
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Python Order (enforced by isort via Ruff):**
|
||||
1. Standard library imports (`import os`, `import json`)
|
||||
2. Third-party imports (`from claude_agent_sdk import ...`)
|
||||
3. Local imports (`from core.client import create_client`)
|
||||
|
||||
**TypeScript Order:**
|
||||
1. React/external library imports
|
||||
2. Local component imports
|
||||
3. Type imports
|
||||
|
||||
**Path Aliases (TypeScript):**
|
||||
```typescript
|
||||
// tsconfig.json paths
|
||||
"@/*": ["src/renderer/*"]
|
||||
"@shared/*": ["src/shared/*"]
|
||||
"@preload/*": ["src/preload/*"]
|
||||
"@features/*": ["src/renderer/features/*"]
|
||||
"@components/*": ["src/renderer/shared/components/*"]
|
||||
"@hooks/*": ["src/renderer/shared/hooks/*"]
|
||||
"@lib/*": ["src/renderer/shared/lib/*"]
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Python Patterns:**
|
||||
```python
|
||||
# Try-except with specific exceptions
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=5)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.debug(f"Operation failed: {e}")
|
||||
return None
|
||||
|
||||
# Validation with early return
|
||||
def validate_something(value: str) -> tuple[bool, str]:
|
||||
if not value:
|
||||
return False, "Value is required"
|
||||
if invalid_condition:
|
||||
return False, "Value is invalid because..."
|
||||
return True, ""
|
||||
```
|
||||
|
||||
**TypeScript Patterns:**
|
||||
```typescript
|
||||
// Result object pattern for detection functions
|
||||
interface DetectionResult {
|
||||
isDetected: boolean;
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function detectSomething(input: string): DetectionResult {
|
||||
if (!input) {
|
||||
return { isDetected: false };
|
||||
}
|
||||
// Detection logic...
|
||||
return { isDetected: true, message: "Detected condition X" };
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
**Python Framework:** Standard library `logging`
|
||||
|
||||
**Patterns:**
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Debug for verbose/diagnostic info
|
||||
logger.debug(f"Cache HIT for {key}")
|
||||
|
||||
# Info for significant operations
|
||||
logger.info(f"Found Claude CLI: {path} (v{version})")
|
||||
|
||||
# Warning for recoverable issues
|
||||
logger.warning(f"Invalid configuration: {value}, using default")
|
||||
|
||||
# Error with context
|
||||
logger.error(f"Failed to process {file}: {error}")
|
||||
```
|
||||
|
||||
**TypeScript Logging:** Console-based in development, suppressed in tests.
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- Public functions: Always document with docstrings/JSDoc
|
||||
- Complex algorithms: Explain the "why" not the "what"
|
||||
- Security-related code: Explain security implications
|
||||
- Workarounds: Reference issue numbers
|
||||
|
||||
**Python Docstrings:**
|
||||
```python
|
||||
def create_client(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
agent_type: str = "coder",
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
|
||||
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project (working directory)
|
||||
spec_dir: Directory containing the spec (for settings file)
|
||||
model: Claude model to use
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
```
|
||||
|
||||
**TypeScript JSDoc:**
|
||||
```typescript
|
||||
/**
|
||||
* Detect rate limit from CLI output.
|
||||
*
|
||||
* @param output - Raw CLI output string
|
||||
* @returns Detection result with isRateLimited flag and optional resetTime
|
||||
*/
|
||||
function detectRateLimit(output: string): RateLimitResult {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:** Keep functions focused on a single responsibility. Functions over 50 lines should be considered for splitting.
|
||||
|
||||
**Parameters:**
|
||||
- Python: Use type hints for all parameters
|
||||
- TypeScript: Use explicit types, avoid `any`
|
||||
- Default values for optional parameters
|
||||
- Keyword arguments for functions with 3+ parameters
|
||||
|
||||
**Return Values:**
|
||||
- Python: Use tuple for multiple returns `-> tuple[bool, str]`
|
||||
- TypeScript: Use result objects for complex returns
|
||||
- Always annotate return types
|
||||
|
||||
## Module Design
|
||||
|
||||
**Python Exports:**
|
||||
- Use `__all__` in `__init__.py` to control public API
|
||||
- Prefix internal functions/classes with underscore
|
||||
|
||||
**TypeScript Barrel Files:**
|
||||
```typescript
|
||||
// index.ts barrel export pattern
|
||||
export { ExecutionPhaseParser } from './execution-phase-parser';
|
||||
export { IdeationPhaseParser } from './ideation-phase-parser';
|
||||
export type { ExecutionParserContext } from './types';
|
||||
```
|
||||
|
||||
## Security Conventions
|
||||
|
||||
**Validation First:**
|
||||
```python
|
||||
# Always validate input before processing
|
||||
def _validate_custom_mcp_server(server: dict) -> bool:
|
||||
"""Validate a custom MCP server configuration for security."""
|
||||
if not isinstance(server, dict):
|
||||
return False
|
||||
|
||||
# Required fields
|
||||
required_fields = {"id", "name", "type"}
|
||||
if not all(field in server for field in required_fields):
|
||||
return False
|
||||
|
||||
# Blocklist dangerous commands
|
||||
DANGEROUS_COMMANDS = {"bash", "sh", "cmd", "powershell"}
|
||||
if command in DANGEROUS_COMMANDS:
|
||||
logger.warning(f"Rejected dangerous command: {command}")
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
**Sensitive Commands:** Always use allowlist approach, never blocklist alone.
|
||||
|
||||
## Internationalization (Frontend)
|
||||
|
||||
**Always use i18n for user-facing text:**
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { t } = useTranslation(['navigation', 'common']);
|
||||
|
||||
// Correct
|
||||
<span>{t('navigation:items.githubPRs')}</span>
|
||||
|
||||
// Wrong - hardcoded string
|
||||
<span>GitHub PRs</span>
|
||||
```
|
||||
|
||||
**Translation file structure:**
|
||||
- `apps/frontend/src/shared/i18n/locales/en/*.json`
|
||||
- `apps/frontend/src/shared/i18n/locales/fr/*.json`
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
**Use platform abstraction module:**
|
||||
```typescript
|
||||
// Correct - use abstraction
|
||||
import { isWindows, getPathDelimiter } from './platform';
|
||||
|
||||
// Wrong - direct check
|
||||
if (process.platform === 'win32') { ... }
|
||||
```
|
||||
|
||||
**Platform modules:**
|
||||
- Frontend: `apps/frontend/src/main/platform/`
|
||||
- Backend: `apps/backend/core/platform/`
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-01-19*
|
||||
@@ -0,0 +1,204 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Claude AI (Primary):**
|
||||
- Service: Anthropic Claude API via Claude Agent SDK
|
||||
- SDK: `claude-agent-sdk` >= 0.1.19 (Python backend)
|
||||
- Auth: OAuth tokens via system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
- Env: `CLAUDE_CODE_OAUTH_TOKEN` or auto-detected from system credential store
|
||||
- Implementation: `apps/backend/core/client.py`, `apps/backend/core/auth.py`
|
||||
|
||||
**CRITICAL: Never use `anthropic.Anthropic()` directly. Always use `create_client()` from `core.client`.**
|
||||
|
||||
**Context7 MCP (Documentation Lookup):**
|
||||
- Service: Upstash Context7 documentation retrieval
|
||||
- SDK: `@upstash/context7-mcp` (spawned via npx)
|
||||
- Auth: None (public MCP server)
|
||||
- Implementation: Configured in `apps/backend/core/client.py` MCP servers
|
||||
- Usage: Automatically available to agents for documentation queries
|
||||
|
||||
**Linear (Optional Project Management):**
|
||||
- Service: Linear issue tracking and project management
|
||||
- SDK: Linear MCP server (HTTP-based)
|
||||
- Auth: `LINEAR_API_KEY` (Bearer token)
|
||||
- Env: `LINEAR_API_KEY`, `LINEAR_TEAM_ID`, `LINEAR_PROJECT_ID`
|
||||
- Implementation: `apps/backend/integrations/linear/integration.py`
|
||||
- Features: Subtask-to-issue sync, progress tracking, stuck task escalation
|
||||
|
||||
**GitHub:**
|
||||
- Service: GitHub API for issues, PRs, releases
|
||||
- SDK: `gh` CLI (subprocess calls)
|
||||
- Auth: GitHub CLI auth (`gh auth login`)
|
||||
- Implementation: `apps/frontend/src/main/ipc-handlers/github/`
|
||||
- Features: Import issues, create PRs, manage releases, triage automation
|
||||
|
||||
**GitLab (Optional):**
|
||||
- Service: GitLab API for issues and merge requests
|
||||
- SDK: `glab` CLI or Personal Access Token
|
||||
- Auth: `glab auth login` or `GITLAB_TOKEN`
|
||||
- Env: `GITLAB_INSTANCE_URL`, `GITLAB_TOKEN`, `GITLAB_PROJECT`
|
||||
- Implementation: `apps/frontend/src/main/ipc-handlers/gitlab/`
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- LadybugDB (embedded graph database)
|
||||
- Connection: Local file at `~/.auto-claude/memories/{database_name}`
|
||||
- Client: `real_ladybug` Python package (requires Python 3.12+)
|
||||
- No Docker required - fully embedded
|
||||
- Provider-specific database naming to prevent embedding dimension mismatches
|
||||
|
||||
**File Storage:**
|
||||
- Local filesystem only
|
||||
- Project data: `.auto-claude/` directory per project
|
||||
- Specs: `.auto-claude/specs/{id}-{name}/`
|
||||
- Worktrees: `.auto-claude/worktrees/` (git worktree isolation)
|
||||
|
||||
**Caching:**
|
||||
- Project index cache (5 minute TTL, thread-safe)
|
||||
- CLI path cache (per-session)
|
||||
- Implementation: `apps/backend/core/client.py` (`_PROJECT_INDEX_CACHE`)
|
||||
|
||||
## Memory System (Graphiti)
|
||||
|
||||
**Graph Memory:**
|
||||
- Engine: Graphiti-core + LadybugDB
|
||||
- Purpose: Cross-session context retention, pattern learning
|
||||
- Data: Episodes (insights, discoveries, patterns, gotchas, outcomes)
|
||||
- Config: `apps/backend/integrations/graphiti/config.py`
|
||||
- Memory: `apps/backend/integrations/graphiti/memory.py`
|
||||
|
||||
**Multi-Provider Support:**
|
||||
|
||||
| Provider | LLM | Embedder | Env Vars |
|
||||
|----------|-----|----------|----------|
|
||||
| OpenAI | Yes | Yes | `OPENAI_API_KEY`, `OPENAI_MODEL` |
|
||||
| Anthropic | Yes | No | `ANTHROPIC_API_KEY`, `GRAPHITI_ANTHROPIC_MODEL` |
|
||||
| Azure OpenAI | Yes | Yes | `AZURE_OPENAI_*` (API_KEY, BASE_URL, deployments) |
|
||||
| Voyage AI | No | Yes | `VOYAGE_API_KEY`, `VOYAGE_EMBEDDING_MODEL` |
|
||||
| Google AI | Yes | Yes | `GOOGLE_API_KEY`, `GOOGLE_LLM_MODEL` |
|
||||
| Ollama | Yes | Yes | `OLLAMA_*` (BASE_URL, models, embedding dim) |
|
||||
| OpenRouter | Yes | Yes | `OPENROUTER_API_KEY`, `OPENROUTER_*_MODEL` |
|
||||
|
||||
**Provider Implementation:** `apps/backend/integrations/graphiti/providers_pkg/`
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Claude OAuth:**
|
||||
- Provider: Anthropic Claude Code OAuth
|
||||
- Implementation: `apps/backend/core/auth.py`
|
||||
- Storage:
|
||||
- macOS: Keychain (`/usr/bin/security find-generic-password`)
|
||||
- Windows: `~/.claude/.credentials.json` or Credential Manager
|
||||
- Linux: Secret Service API via DBus (`secretstorage` package)
|
||||
- Token format: `sk-ant-oat01-*` (OAuth access token)
|
||||
- Login flow: `claude` CLI with `/login` command (opens browser)
|
||||
|
||||
**GitHub Auth:**
|
||||
- Provider: GitHub CLI OAuth
|
||||
- Implementation: IPC handlers in frontend
|
||||
- Storage: Managed by `gh` CLI
|
||||
|
||||
**GitLab Auth:**
|
||||
- Provider: GitLab Personal Access Token or glab CLI OAuth
|
||||
- Implementation: `apps/frontend/src/main/ipc-handlers/gitlab/`
|
||||
- Storage: Managed by `glab` CLI or `.env` file
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- Service: Sentry (optional)
|
||||
- SDK: `@sentry/electron` 7.5.0
|
||||
- Auth: `SENTRY_DSN` (set in CI for official builds)
|
||||
- Env: `SENTRY_DSN`, `SENTRY_TRACES_SAMPLE_RATE`, `SENTRY_PROFILES_SAMPLE_RATE`
|
||||
- Implementation: `apps/frontend/src/main/sentry.ts`
|
||||
- Note: Disabled in forks unless SENTRY_DSN is explicitly set
|
||||
|
||||
**Logs:**
|
||||
- Backend: Python `logging` module (structured JSON in debug mode)
|
||||
- Frontend: `electron-log` (file + console)
|
||||
- Location: Platform-specific logs directory
|
||||
- Debug: Set `DEBUG=true` for verbose output
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Distribution: GitHub Releases (electron-updater compatible)
|
||||
- Auto-update: electron-updater checks GitHub releases
|
||||
|
||||
**CI Pipeline:**
|
||||
- Service: GitHub Actions
|
||||
- Workflow: `.github/workflows/ci.yml`
|
||||
- Matrix: Linux, Windows, macOS
|
||||
- Jobs: test-python, test-frontend, ci-complete (gate job)
|
||||
|
||||
**Release Pipeline:**
|
||||
- Workflow: `.github/workflows/release.yml` (triggered on tag)
|
||||
- Artifacts: DMG, ZIP (macOS), NSIS/ZIP (Windows), AppImage/DEB/Flatpak (Linux)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars (backend):**
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN # Or use system keychain
|
||||
GRAPHITI_ENABLED=true # Enable memory system
|
||||
```
|
||||
|
||||
**Optional env vars (backend):**
|
||||
```
|
||||
ANTHROPIC_BASE_URL # Custom API endpoint
|
||||
LINEAR_API_KEY # Linear integration
|
||||
ELECTRON_MCP_ENABLED # E2E testing
|
||||
DEBUG=true # Verbose logging
|
||||
```
|
||||
|
||||
**Required env vars (frontend):**
|
||||
```
|
||||
# None required - optional debug/Sentry settings
|
||||
```
|
||||
|
||||
**Secrets location:**
|
||||
- Development: `.env` files (gitignored)
|
||||
- CI/CD: GitHub Secrets
|
||||
- Production: System credential stores (no secrets in app bundle)
|
||||
|
||||
## MCP (Model Context Protocol) Servers
|
||||
|
||||
**Built-in MCP Servers:**
|
||||
|
||||
| Server | Purpose | Agent Access | Configuration |
|
||||
|--------|---------|--------------|---------------|
|
||||
| context7 | Documentation lookup | All agents | Auto-enabled |
|
||||
| linear | Project management | All agents | `LINEAR_API_KEY` |
|
||||
| electron | Desktop app automation | QA agents only | `ELECTRON_MCP_ENABLED` |
|
||||
| puppeteer | Web browser automation | QA agents only | Project capability detection |
|
||||
| graphiti-memory | Knowledge graph | All agents | `GRAPHITI_MCP_URL` |
|
||||
| auto-claude | Custom tools | Phase-specific | Auto-enabled |
|
||||
|
||||
**Custom MCP Servers:**
|
||||
- Config: `.auto-claude/.env` (`CUSTOM_MCP_SERVERS` JSON array)
|
||||
- Validation: `apps/backend/core/client.py` (`_validate_custom_mcp_server`)
|
||||
- Allowed commands: `npx`, `npm`, `node`, `python`, `python3`, `uv`, `uvx`
|
||||
|
||||
**Per-Agent MCP Overrides:**
|
||||
- Add servers: `AGENT_MCP_{agent}_ADD=server1,server2`
|
||||
- Remove servers: `AGENT_MCP_{agent}_REMOVE=server1,server2`
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- None (desktop application, no server)
|
||||
|
||||
**Outgoing:**
|
||||
- GitHub API calls (via `gh` CLI)
|
||||
- GitLab API calls (via `glab` CLI or REST)
|
||||
- Linear MCP server (HTTP)
|
||||
- Sentry error reports (if configured)
|
||||
- Auto-update checks (GitHub Releases API)
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-01-19*
|
||||
@@ -0,0 +1,140 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5.9.3 - Electron frontend (desktop UI, IPC handlers, state management)
|
||||
- Python 3.12+ - Backend agents, CLI, integrations, security
|
||||
|
||||
**Secondary:**
|
||||
- JavaScript (ES modules) - Build scripts, configuration
|
||||
- JSON - Configuration, data storage, IPC communication
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js >= 24.0.0 (Electron main/renderer processes)
|
||||
- Python 3.12+ (required for LadybugDB/Graphiti memory system)
|
||||
|
||||
**Package Manager:**
|
||||
- npm 10.0.0+ (root monorepo, frontend)
|
||||
- uv (Python backend - fast pip alternative)
|
||||
- Lockfiles: `package-lock.json` (present), Python deps in `requirements.txt`
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Electron 39.2.7 - Cross-platform desktop application shell
|
||||
- React 19.2.3 - UI components and state management
|
||||
- Claude Agent SDK >= 0.1.19 - AI agent orchestration (CRITICAL: NOT raw Anthropic API)
|
||||
|
||||
**Testing:**
|
||||
- Vitest 4.0.16 - Frontend unit tests
|
||||
- Playwright 1.52.0 - E2E testing for Electron
|
||||
- pytest 7.0.0+ - Backend Python tests
|
||||
- pytest-asyncio 0.21.0+ - Async test support
|
||||
|
||||
**Build/Dev:**
|
||||
- electron-vite 5.0.0 - Electron build toolchain
|
||||
- Vite 7.2.7 - Frontend bundler
|
||||
- electron-builder 26.4.0 - Cross-platform packaging (dmg, exe, AppImage, deb, flatpak)
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical (AI/Agent):**
|
||||
- `claude-agent-sdk` >= 0.1.19 - Core AI agent SDK (replaces direct Anthropic API)
|
||||
- `@anthropic-ai/sdk` 0.71.2 - Anthropic client (used by Graphiti providers)
|
||||
|
||||
**Infrastructure:**
|
||||
- `@lydell/node-pty` 1.1.0 - Terminal emulation (native module)
|
||||
- `@xterm/xterm` 6.0.0 - Terminal rendering
|
||||
- `electron-updater` 6.6.2 - Auto-update mechanism
|
||||
- `chokidar` 5.0.0 - File system watching
|
||||
- `zustand` 5.0.9 - React state management
|
||||
|
||||
**UI Components:**
|
||||
- `@radix-ui/*` - Accessible UI primitives (dialogs, dropdowns, tabs, etc.)
|
||||
- `tailwindcss` 4.1.17 - Utility-first CSS
|
||||
- `lucide-react` 0.562.0 - Icons
|
||||
- `motion` 12.23.26 - Animations
|
||||
|
||||
**Memory/Database:**
|
||||
- `real_ladybug` >= 0.13.0 - Embedded graph database (Python 3.12+, no Docker)
|
||||
- `graphiti-core` >= 0.5.0 - Knowledge graph memory layer
|
||||
|
||||
**Observability:**
|
||||
- `@sentry/electron` 7.5.0 - Error tracking (optional, requires SENTRY_DSN)
|
||||
- `electron-log` 5.4.3 - Structured logging
|
||||
|
||||
**Internationalization:**
|
||||
- `i18next` 25.7.3 + `react-i18next` 16.5.0 - Multi-language support (en, fr)
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment:**
|
||||
- Backend: `apps/backend/.env` (OAuth tokens, integrations, memory config)
|
||||
- Frontend: `apps/frontend/.env` (debug settings, Sentry DSN)
|
||||
- Example files: `.env.example` in both directories
|
||||
|
||||
**Key Backend Env Vars:**
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN # Required: OAuth token (or use system keychain)
|
||||
ANTHROPIC_BASE_URL # Optional: Custom API endpoint
|
||||
GRAPHITI_ENABLED # Required: true to enable memory
|
||||
GRAPHITI_LLM_PROVIDER # openai|anthropic|azure_openai|ollama|google|openrouter
|
||||
GRAPHITI_EMBEDDER_PROVIDER # openai|voyage|azure_openai|ollama|google|openrouter
|
||||
LINEAR_API_KEY # Optional: Linear integration
|
||||
ELECTRON_MCP_ENABLED # Optional: E2E testing via Electron MCP
|
||||
```
|
||||
|
||||
**Build:**
|
||||
- `apps/frontend/electron.vite.config.ts` - Electron/Vite build config
|
||||
- `apps/frontend/vitest.config.ts` - Test configuration
|
||||
- `apps/frontend/package.json` (build section) - electron-builder config
|
||||
- `ruff.toml` - Python linting/formatting
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- macOS, Windows, or Linux
|
||||
- Node.js 24+, Python 3.12+
|
||||
- Git (required for worktree isolation)
|
||||
- Git Bash (Windows only, for Claude Code CLI)
|
||||
|
||||
**Production:**
|
||||
- macOS: DMG/ZIP (arm64 + x64)
|
||||
- Windows: NSIS installer/ZIP
|
||||
- Linux: AppImage, DEB, Flatpak
|
||||
- Bundled Python runtime (downloaded via `scripts/download-python.cjs`)
|
||||
|
||||
**CI/CD:**
|
||||
- GitHub Actions (`.github/workflows/ci.yml`)
|
||||
- Matrix testing: Linux, Windows, macOS
|
||||
- Python 3.12 + 3.13 (Linux only)
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python (uv, requirements.txt)
|
||||
│ └── frontend/ # Electron/React (npm, package.json)
|
||||
├── tests/ # Shared test suite
|
||||
├── scripts/ # Build/release scripts
|
||||
└── package.json # Root workspace config
|
||||
```
|
||||
|
||||
**Workspace Commands:**
|
||||
```bash
|
||||
npm run install:all # Install both frontend and backend
|
||||
npm run dev # Start Electron in dev mode
|
||||
npm run build # Build frontend
|
||||
npm run package # Package for current platform
|
||||
npm run test:backend # Run Python tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-01-19*
|
||||
@@ -0,0 +1,219 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python backend - CLI, agents, core logic
|
||||
│ │ ├── agents/ # Agent implementations (coder, planner, memory)
|
||||
│ │ ├── cli/ # Command-line interface modules
|
||||
│ │ ├── core/ # Client factory, auth, worktree, security
|
||||
│ │ ├── integrations/ # External integrations (Graphiti, Linear)
|
||||
│ │ ├── memory/ # Memory system (sessions, patterns)
|
||||
│ │ ├── merge/ # Git merge conflict resolution
|
||||
│ │ ├── prompts/ # Agent system prompts (.md files)
|
||||
│ │ ├── qa/ # QA validation loop
|
||||
│ │ ├── runners/ # Feature runners (GitHub, GitLab, roadmap, spec)
|
||||
│ │ ├── security/ # Command validators, secrets scanning
|
||||
│ │ ├── spec/ # Spec creation pipeline
|
||||
│ │ └── ui/ # CLI output formatting
|
||||
│ └── frontend/ # Electron desktop app
|
||||
│ ├── src/
|
||||
│ │ ├── main/ # Electron main process
|
||||
│ │ ├── renderer/ # React renderer (components, stores)
|
||||
│ │ ├── preload/ # IPC bridge scripts
|
||||
│ │ └── shared/ # Shared types, constants, i18n
|
||||
│ └── resources/ # App icons, assets
|
||||
├── tests/ # Python test suite
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── docs/ # Documentation
|
||||
└── guides/ # User guides
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`apps/backend/`:**
|
||||
- Purpose: All Python backend code (CLI, agents, core infrastructure)
|
||||
- Contains: Agent implementations, CLI modules, security, integrations
|
||||
- Key files: `run.py` (entry point), `core/client.py` (SDK factory)
|
||||
|
||||
**`apps/backend/agents/`:**
|
||||
- Purpose: AI agent implementations for autonomous coding
|
||||
- Contains: Coder agent loop, planner, memory manager, session utilities
|
||||
- Key files: `coder.py`, `planner.py`, `memory_manager.py`, `session.py`
|
||||
|
||||
**`apps/backend/cli/`:**
|
||||
- Purpose: CLI command implementations
|
||||
- Contains: Build, spec, workspace, QA, batch commands
|
||||
- Key files: `main.py`, `build_commands.py`, `workspace_commands.py`
|
||||
|
||||
**`apps/backend/core/`:**
|
||||
- Purpose: Core infrastructure (client, auth, workspace, platform)
|
||||
- Contains: SDK client factory, OAuth, worktree manager, platform abstraction
|
||||
- Key files: `client.py`, `auth.py`, `worktree.py`, `workspace.py`
|
||||
|
||||
**`apps/backend/qa/`:**
|
||||
- Purpose: QA validation after build completion
|
||||
- Contains: QA loop, reviewer, fixer, criteria validation, issue tracking
|
||||
- Key files: `loop.py`, `reviewer.py`, `fixer.py`, `criteria.py`
|
||||
|
||||
**`apps/backend/spec/`:**
|
||||
- Purpose: Spec creation pipeline
|
||||
- Contains: Pipeline orchestrator, complexity assessment, validation
|
||||
- Key files: `pipeline/orchestrator.py`, `complexity.py`, `validate_pkg/`
|
||||
|
||||
**`apps/backend/security/`:**
|
||||
- Purpose: Bash command validation and security
|
||||
- Contains: Validators, hooks, command parser, secrets scanner
|
||||
- Key files: `hooks.py`, `validator.py`, `parser.py`, `scan_secrets.py`
|
||||
|
||||
**`apps/backend/prompts/`:**
|
||||
- Purpose: Agent system prompts (Markdown files)
|
||||
- Contains: Prompts for coder, planner, QA, spec agents
|
||||
- Key files: `coder.md`, `planner.md`, `qa_reviewer.md`, `spec_gatherer.md`
|
||||
|
||||
**`apps/backend/runners/`:**
|
||||
- Purpose: Feature-specific execution runners
|
||||
- Contains: GitHub PR review, roadmap generation, spec creation
|
||||
- Key files: `github/orchestrator.py`, `spec_runner.py`, `roadmap_runner.py`
|
||||
|
||||
**`apps/frontend/src/main/`:**
|
||||
- Purpose: Electron main process
|
||||
- Contains: Window management, IPC handlers, service managers
|
||||
- Key files: `index.ts`, `ipc-setup.ts`, `cli-tool-manager.ts`
|
||||
|
||||
**`apps/frontend/src/renderer/`:**
|
||||
- Purpose: React UI
|
||||
- Contains: Components, stores, hooks, contexts
|
||||
- Key files: `App.tsx`, `components/`, `stores/`
|
||||
|
||||
**`apps/frontend/src/shared/`:**
|
||||
- Purpose: Shared code between main and renderer
|
||||
- Contains: Types, constants, i18n, utilities
|
||||
- Key files: `types/`, `constants/`, `i18n/`
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `apps/backend/run.py`: Backend CLI entry point
|
||||
- `apps/frontend/src/main/index.ts`: Electron main entry
|
||||
- `apps/frontend/src/renderer/main.tsx`: React app entry
|
||||
|
||||
**Configuration:**
|
||||
- `apps/backend/.env`: Backend environment variables
|
||||
- `apps/backend/.env.example`: Backend env template
|
||||
- `apps/frontend/.env`: Frontend environment variables
|
||||
- `apps/backend/requirements.txt`: Python dependencies
|
||||
- `apps/frontend/package.json`: Frontend dependencies
|
||||
|
||||
**Core Logic:**
|
||||
- `apps/backend/core/client.py`: Claude SDK client factory
|
||||
- `apps/backend/core/auth.py`: OAuth token management
|
||||
- `apps/backend/core/worktree.py`: Git worktree isolation
|
||||
- `apps/backend/agents/coder.py`: Main agent loop
|
||||
- `apps/backend/spec/pipeline/orchestrator.py`: Spec creation pipeline
|
||||
|
||||
**Testing:**
|
||||
- `tests/`: All Python tests (pytest)
|
||||
- `tests/conftest.py`: Pytest fixtures and configuration
|
||||
- `apps/frontend/src/main/__tests__/`: Main process tests
|
||||
- `apps/frontend/src/renderer/__tests__/`: Renderer tests
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- Python modules: `snake_case.py` (e.g., `workspace_commands.py`)
|
||||
- TypeScript modules: `kebab-case.ts` (e.g., `cli-tool-manager.ts`)
|
||||
- React components: `PascalCase.tsx` (e.g., `KanbanBoard.tsx`)
|
||||
- Prompts: `snake_case.md` (e.g., `qa_reviewer.md`)
|
||||
- Tests: `test_*.py` (Python), `*.test.ts/tsx` (TypeScript)
|
||||
|
||||
**Directories:**
|
||||
- Python packages: `snake_case/` with `__init__.py`
|
||||
- TypeScript modules: `kebab-case/`
|
||||
- Package submodules: `*_pkg/` suffix (e.g., `tools_pkg/`, `queries_pkg/`)
|
||||
|
||||
**Classes and Functions:**
|
||||
- Python classes: `PascalCase` (e.g., `SpecOrchestrator`)
|
||||
- Python functions: `snake_case` (e.g., `run_autonomous_agent`)
|
||||
- TypeScript/React: `camelCase` functions, `PascalCase` components
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Agent Feature:**
|
||||
- Primary code: `apps/backend/agents/`
|
||||
- Prompt: `apps/backend/prompts/{agent_name}.md`
|
||||
- Tests: `tests/test_agent_*.py`
|
||||
|
||||
**New CLI Command:**
|
||||
- Implementation: `apps/backend/cli/{domain}_commands.py`
|
||||
- Registration: `apps/backend/cli/main.py` (argument parsing)
|
||||
- Tests: `tests/test_{command}.py`
|
||||
|
||||
**New Frontend Component:**
|
||||
- Implementation: `apps/frontend/src/renderer/components/{ComponentName}.tsx`
|
||||
- Translations: `apps/frontend/src/shared/i18n/locales/en/{namespace}.json`
|
||||
- Tests: `apps/frontend/src/renderer/components/__tests__/`
|
||||
|
||||
**New Frontend Store:**
|
||||
- Implementation: `apps/frontend/src/renderer/stores/{domain}-store.ts`
|
||||
- Pattern: Use Zustand with typed state and actions
|
||||
|
||||
**New IPC Handler:**
|
||||
- Handler module: `apps/frontend/src/main/ipc-handlers/{domain}-handlers.ts`
|
||||
- Registration: `apps/frontend/src/main/ipc-handlers/index.ts`
|
||||
- Types: `apps/frontend/src/shared/types/`
|
||||
|
||||
**New Security Validator:**
|
||||
- Implementation: `apps/backend/security/validator.py`
|
||||
- Registration: Add to `VALIDATORS` dict in same file
|
||||
- Tests: `tests/test_security.py`
|
||||
|
||||
**New Integration:**
|
||||
- Implementation: `apps/backend/integrations/{service}/`
|
||||
- Configuration: Add env vars to `.env.example`
|
||||
- Documentation: Update `CLAUDE.md`
|
||||
|
||||
**Utilities:**
|
||||
- Backend shared helpers: `apps/backend/core/` or domain-specific module
|
||||
- Frontend shared helpers: `apps/frontend/src/shared/utils/`
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`.auto-claude/`:**
|
||||
- Purpose: Per-project spec storage and build state
|
||||
- Generated: Yes (by backend during spec creation)
|
||||
- Committed: No (gitignored)
|
||||
- Contents: `specs/`, `worktrees/tasks/`, `insights/`
|
||||
|
||||
**`.worktrees/`:**
|
||||
- Purpose: Legacy worktree location (deprecated)
|
||||
- Generated: Yes (by worktree manager)
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`node_modules/`:**
|
||||
- Purpose: Frontend npm dependencies
|
||||
- Generated: Yes (by npm install)
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`.venv/`:**
|
||||
- Purpose: Python virtual environment
|
||||
- Generated: Yes (by uv venv)
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`dist/` and `out/`:**
|
||||
- Purpose: Build outputs
|
||||
- Generated: Yes (by build scripts)
|
||||
- Committed: No (gitignored)
|
||||
|
||||
**`.planning/`:**
|
||||
- Purpose: GSD planning documents
|
||||
- Generated: Yes (by GSD commands)
|
||||
- Committed: Optional (project choice)
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-01-19*
|
||||
@@ -0,0 +1,485 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-01-19
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Backend (Python):**
|
||||
- Runner: pytest (>=7.0.0)
|
||||
- Config: `tests/pytest.ini`
|
||||
- Async support: pytest-asyncio (>=0.21.0)
|
||||
- Coverage: pytest-cov (>=4.0.0)
|
||||
- Mocking: pytest-mock (>=3.0.0)
|
||||
|
||||
**Frontend (TypeScript):**
|
||||
- Runner: Vitest (v4.0.16)
|
||||
- Config: `apps/frontend/vitest.config.ts`
|
||||
- DOM testing: @testing-library/react, @testing-library/dom
|
||||
- Mocking: Vitest built-in `vi`
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
# Backend - all tests
|
||||
apps/backend/.venv/bin/pytest tests/ -v
|
||||
|
||||
# Backend - skip slow tests (recommended for development)
|
||||
apps/backend/.venv/bin/pytest tests/ -m "not slow" -v
|
||||
|
||||
# Backend - single test file
|
||||
apps/backend/.venv/bin/pytest tests/test_security.py -v
|
||||
|
||||
# Backend - specific test
|
||||
apps/backend/.venv/bin/pytest tests/test_security.py::test_bash_command_validation -v
|
||||
|
||||
# Frontend - all tests
|
||||
cd apps/frontend && npm test
|
||||
|
||||
# Frontend - watch mode
|
||||
cd apps/frontend && npm run test:watch
|
||||
|
||||
# Frontend - coverage
|
||||
cd apps/frontend && npm run test:coverage
|
||||
|
||||
# From root (convenience)
|
||||
npm run test:backend
|
||||
npm run test (frontend)
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Backend Location:** Co-located at root `tests/` directory
|
||||
```
|
||||
tests/
|
||||
├── pytest.ini # Pytest configuration
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_fixtures.py # Sample data constants
|
||||
├── review_fixtures.py # Review system fixtures
|
||||
├── qa_report_helpers.py # QA test helpers
|
||||
├── requirements-test.txt # Test dependencies
|
||||
├── test_security.py # Security module tests
|
||||
├── test_client.py # SDK client tests
|
||||
├── test_qa_loop.py # QA system tests
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Frontend Location:** Co-located with source, in `__tests__/` directories
|
||||
```
|
||||
apps/frontend/src/
|
||||
├── __tests__/
|
||||
│ ├── setup.ts # Test setup (mocks, globals)
|
||||
│ └── integration/ # Integration tests
|
||||
├── main/__tests__/ # Main process tests
|
||||
│ ├── parsers.test.ts
|
||||
│ ├── rate-limit-detector.test.ts
|
||||
│ └── ...
|
||||
├── renderer/__tests__/ # Renderer tests
|
||||
│ ├── task-store.test.ts
|
||||
│ └── ...
|
||||
└── renderer/components/__tests__/ # Component tests
|
||||
```
|
||||
|
||||
**Naming:**
|
||||
- Python: `test_*.py` (e.g., `test_security.py`)
|
||||
- TypeScript: `*.test.ts` or `*.test.tsx` (e.g., `parsers.test.ts`)
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Python - pytest Pattern:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Security System
|
||||
=========================
|
||||
|
||||
Tests the security.py module functionality including:
|
||||
- Command extraction and parsing
|
||||
- Command allowlist validation
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from security import validate_command, extract_commands
|
||||
|
||||
|
||||
class TestCommandExtraction:
|
||||
"""Tests for command extraction from shell strings."""
|
||||
|
||||
def test_simple_command(self):
|
||||
"""Extracts single command correctly."""
|
||||
commands = extract_commands("ls -la")
|
||||
assert commands == ["ls"]
|
||||
|
||||
def test_piped_commands(self):
|
||||
"""Extracts all commands from pipeline."""
|
||||
commands = extract_commands("cat file.txt | grep pattern | wc -l")
|
||||
assert commands == ["cat", "grep", "wc"]
|
||||
|
||||
|
||||
class TestValidateCommand:
|
||||
"""Tests for full command validation."""
|
||||
|
||||
def test_base_commands_allowed(self, temp_dir):
|
||||
"""Base commands are always allowed."""
|
||||
for cmd in ["ls", "cat", "grep"]:
|
||||
allowed, reason = validate_command(cmd, temp_dir)
|
||||
assert allowed is True, f"{cmd} should be allowed"
|
||||
```
|
||||
|
||||
**TypeScript - Vitest Pattern:**
|
||||
```typescript
|
||||
/**
|
||||
* Phase Parsers Tests
|
||||
* ====================
|
||||
* Unit tests for the specialized phase parsers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExecutionPhaseParser } from '../agent/parsers';
|
||||
|
||||
describe('ExecutionPhaseParser', () => {
|
||||
const parser = new ExecutionPhaseParser();
|
||||
|
||||
const makeContext = (currentPhase: string): ExecutionParserContext => ({
|
||||
currentPhase,
|
||||
isTerminal: currentPhase === 'complete'
|
||||
});
|
||||
|
||||
describe('structured event parsing', () => {
|
||||
it('should parse structured phase events', () => {
|
||||
const log = '__EXEC_PHASE__:{"phase":"coding","message":"Starting"}';
|
||||
const result = parser.parse(log, makeContext('planning'));
|
||||
|
||||
expect(result).toEqual({
|
||||
phase: 'coding',
|
||||
message: 'Starting',
|
||||
currentSubtask: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal state handling', () => {
|
||||
it('should not change phase when current phase is complete', () => {
|
||||
const log = 'Starting coder agent...';
|
||||
const result = parser.parse(log, makeContext('complete'));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
**Python - pytest fixtures and unittest.mock:**
|
||||
```python
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task_logger():
|
||||
"""Mock TaskLogger for testing PhaseExecutor."""
|
||||
logger = MagicMock()
|
||||
logger.log = MagicMock()
|
||||
logger.start_phase = MagicMock()
|
||||
logger.end_phase = MagicMock()
|
||||
return logger
|
||||
|
||||
# Using patch decorator
|
||||
@patch('core.client.find_claude_cli')
|
||||
def test_client_creation(mock_find_cli):
|
||||
mock_find_cli.return_value = '/usr/local/bin/claude'
|
||||
# Test code...
|
||||
|
||||
# Using monkeypatch fixture
|
||||
def test_with_env_var(monkeypatch):
|
||||
monkeypatch.setenv("CLAUDE_CLI_PATH", "/custom/path")
|
||||
# Test code...
|
||||
```
|
||||
|
||||
**TypeScript - Vitest vi.mock:**
|
||||
```typescript
|
||||
// Mock at module level (hoisted)
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(() => ({
|
||||
getActiveProfile: vi.fn(() => ({
|
||||
id: 'test-profile-id',
|
||||
name: 'Test Profile'
|
||||
})),
|
||||
recordRateLimitEvent: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Rate Limit Detector', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should detect rate limit', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
const result = detectRateLimit('Limit reached · resets Dec 17');
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- External APIs (Claude SDK, GitHub API)
|
||||
- File system operations in unit tests
|
||||
- Network requests
|
||||
- System time (for time-sensitive tests)
|
||||
- Heavy dependencies (databases, MCP servers)
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Pure functions under test
|
||||
- Simple data transformations
|
||||
- Validation logic
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Python Fixtures (conftest.py):**
|
||||
```python
|
||||
@pytest.fixture
|
||||
def temp_dir() -> Generator[Path, None, None]:
|
||||
"""Create a temporary directory that's cleaned up after the test."""
|
||||
temp_path = Path(tempfile.mkdtemp())
|
||||
yield temp_path
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary git repository with initial commit."""
|
||||
# Clear git environment variables to isolate from parent repo
|
||||
orig_env = {}
|
||||
git_vars_to_clear = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]
|
||||
for key in git_vars_to_clear:
|
||||
orig_env[key] = os.environ.get(key)
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
try:
|
||||
subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=temp_dir)
|
||||
# ...
|
||||
yield temp_dir
|
||||
finally:
|
||||
# Restore environment
|
||||
for key, value in orig_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
@pytest.fixture
|
||||
def python_project(temp_git_repo: Path) -> Path:
|
||||
"""Create a sample Python project structure."""
|
||||
(temp_git_repo / "pyproject.toml").write_text(toml_content)
|
||||
(temp_git_repo / "app" / "__init__.py").write_text("# App module\n")
|
||||
return temp_git_repo
|
||||
```
|
||||
|
||||
**TypeScript Setup (setup.ts):**
|
||||
```typescript
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock localStorage for tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
||||
clear: vi.fn(() => { store = {}; })
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock window.electronAPI for renderer tests
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).electronAPI = {
|
||||
getTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
```
|
||||
|
||||
**Sample Data (test_fixtures.py):**
|
||||
```python
|
||||
SAMPLE_REACT_COMPONENT = '''import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <div><h1>Hello World</h1></div>;
|
||||
}
|
||||
export default App;
|
||||
'''
|
||||
|
||||
SAMPLE_PYTHON_MODULE = '''"""Sample Python module."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def hello():
|
||||
"""Say hello."""
|
||||
print("Hello")
|
||||
'''
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** No enforced minimum threshold, but aim for meaningful coverage
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
# Backend
|
||||
apps/backend/.venv/bin/pytest tests/ --cov=apps/backend --cov-report=html
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend && npm run test:coverage
|
||||
```
|
||||
|
||||
**Coverage Output:**
|
||||
- Backend: `.coverage` file, HTML report in `htmlcov/`
|
||||
- Frontend: `coverage/` directory with JSON, text, and HTML reports
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- Test individual functions/classes in isolation
|
||||
- Mock external dependencies
|
||||
- Fast execution (sub-second)
|
||||
- Location: `tests/test_*.py`, `src/**/*.test.ts`
|
||||
|
||||
**Integration Tests:**
|
||||
- Test interactions between components
|
||||
- May use real file system, git repos
|
||||
- Slower execution
|
||||
- Markers: `@pytest.mark.integration` (Python)
|
||||
- Location: `tests/` (Python), `src/__tests__/integration/` (TypeScript)
|
||||
|
||||
**E2E Tests (Frontend):**
|
||||
- Framework: Playwright (configured but limited use)
|
||||
- Config: `apps/frontend/e2e/playwright.config.ts`
|
||||
- Run: `npm run test:e2e`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Testing (Python):**
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
result = await some_async_operation()
|
||||
assert result is not None
|
||||
|
||||
# pytest.ini enables asyncio_mode = auto
|
||||
# No need to manually mark simple async tests
|
||||
```
|
||||
|
||||
**Async Testing (TypeScript):**
|
||||
```typescript
|
||||
it('should handle async operation', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
const result = detectRateLimit('some output');
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
**Error Testing (Python):**
|
||||
```python
|
||||
def test_blocked_dangerous_command(self, temp_dir):
|
||||
"""Dangerous commands not in allowlist are blocked."""
|
||||
allowed, reason = validate_command("rm -rf /", temp_dir)
|
||||
assert allowed is False
|
||||
assert "not allowed for safety" in reason
|
||||
|
||||
def test_raises_on_invalid_input():
|
||||
"""Should raise ValueError on invalid input."""
|
||||
with pytest.raises(ValueError, match="Invalid configuration"):
|
||||
process_config(None)
|
||||
```
|
||||
|
||||
**Error Testing (TypeScript):**
|
||||
```typescript
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
const result = detectRateLimit('');
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle malformed input gracefully', () => {
|
||||
expect(() => parser.parse(null as any)).not.toThrow();
|
||||
});
|
||||
```
|
||||
|
||||
**Parameterized Tests (Python):**
|
||||
```python
|
||||
@pytest.mark.parametrize("cmd,expected", [
|
||||
("ls -la", ["ls"]),
|
||||
("cat file | grep pattern", ["cat", "grep"]),
|
||||
("", []),
|
||||
])
|
||||
def test_extract_commands(cmd, expected):
|
||||
assert extract_commands(cmd) == expected
|
||||
```
|
||||
|
||||
**Parameterized Tests (TypeScript):**
|
||||
```typescript
|
||||
const testCases = [
|
||||
'rate limit exceeded',
|
||||
'usage limit reached',
|
||||
'too many requests'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectRateLimit(output);
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-commit Testing
|
||||
|
||||
**Configuration:** `.pre-commit-config.yaml`
|
||||
|
||||
Tests run automatically on commit:
|
||||
- Python: `pytest -m "not slow and not integration"` (fast tests only)
|
||||
- TypeScript: Biome lint + TypeScript type check
|
||||
|
||||
Skipped tests in pre-commit:
|
||||
- `test_graphiti.py` (external dependencies)
|
||||
- `test_worktree.py` (git-sensitive)
|
||||
- `test_workspace.py` (Windows path issues)
|
||||
|
||||
## Test Markers (Python)
|
||||
|
||||
```python
|
||||
@pytest.mark.slow # Long-running tests
|
||||
@pytest.mark.integration # Integration tests
|
||||
@pytest.mark.asyncio # Async tests (auto-applied via config)
|
||||
```
|
||||
|
||||
**Run specific markers:**
|
||||
```bash
|
||||
# Skip slow tests
|
||||
pytest tests/ -m "not slow"
|
||||
|
||||
# Run only integration tests
|
||||
pytest tests/ -m "integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-01-19*
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"mode": "yolo",
|
||||
"depth": "comprehensive",
|
||||
"parallelization": true,
|
||||
"created": "2026-01-19"
|
||||
}
|
||||
@@ -16,18 +16,18 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../node_modules
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import { registerDebugHandlers } from './debug-handlers';
|
||||
import { registerClaudeCodeHandlers } from './claude-code-handlers';
|
||||
import { registerMcpHandlers } from './mcp-handlers';
|
||||
import { registerProfileHandlers } from './profile-handlers';
|
||||
import { registerScreenshotHandlers } from './screenshot-handlers';
|
||||
import { registerTerminalWorktreeIpcHandlers } from './terminal';
|
||||
import { registerCheckpointHandlers } from './checkpoint-handlers';
|
||||
import { registerMethodologyHandlers } from './methodology-handlers';
|
||||
@@ -120,6 +121,9 @@ export function setupIpcHandlers(
|
||||
// API Profile handlers (custom Anthropic-compatible endpoints)
|
||||
registerProfileHandlers();
|
||||
|
||||
// Screenshot capture handlers
|
||||
registerScreenshotHandlers();
|
||||
|
||||
// Checkpoint handlers (Semi-Auto execution mode - Story 5.4)
|
||||
registerCheckpointHandlers(agentManager, getMainWindow);
|
||||
|
||||
@@ -153,6 +157,7 @@ export {
|
||||
registerClaudeCodeHandlers,
|
||||
registerMcpHandlers,
|
||||
registerProfileHandlers,
|
||||
registerScreenshotHandlers,
|
||||
registerCheckpointHandlers,
|
||||
registerMethodologyHandlers
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Screenshot IPC Handlers
|
||||
*
|
||||
* Provides screenshot capture functionality using Electron's desktopCapturer API.
|
||||
* Users can capture screenshots of their entire screen or individual application windows.
|
||||
*/
|
||||
import { ipcMain } from 'electron';
|
||||
import { desktopCapturer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants/ipc';
|
||||
import type { ScreenshotSource, ScreenshotCaptureOptions } from '../../shared/types/screenshot';
|
||||
|
||||
/**
|
||||
* Register screenshot capture handlers
|
||||
*/
|
||||
export function registerScreenshotHandlers(): void {
|
||||
/**
|
||||
* Get available screenshot sources (screens and windows)
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.SCREENSHOT_GET_SOURCES, async () => {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: {
|
||||
width: 320,
|
||||
height: 240
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: sources.map((source): ScreenshotSource => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get screenshot sources:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get screenshot sources'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Capture screenshot from selected source
|
||||
* Returns full resolution screenshot as base64 PNG
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.SCREENSHOT_CAPTURE, async (_event, options: ScreenshotCaptureOptions) => {
|
||||
// Validate sourceId parameter
|
||||
if (!options?.sourceId || typeof options.sourceId !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid sourceId parameter'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: {
|
||||
// Capture at 2x resolution for retina display support
|
||||
width: 3840,
|
||||
height: 2160
|
||||
}
|
||||
});
|
||||
|
||||
const selectedSource = sources.find(s => s.id === options.sourceId);
|
||||
if (!selectedSource) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Source not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Return the thumbnail which is our high-res capture
|
||||
const dataUrl = selectedSource.thumbnail.toDataURL();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: dataUrl
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to capture screenshot'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.warn('[IPC] Screenshot handlers registered');
|
||||
}
|
||||
@@ -76,13 +76,13 @@ export function getPlanPath(project: Project, task: Task): string {
|
||||
*/
|
||||
export function mapStatusToPlanStatus(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case 'queue':
|
||||
return 'queued';
|
||||
case 'in_progress':
|
||||
return 'in_progress';
|
||||
case 'ai_review':
|
||||
case 'human_review':
|
||||
return 'review';
|
||||
case 'pr_created':
|
||||
return 'pr_created';
|
||||
case 'done':
|
||||
return 'completed';
|
||||
default:
|
||||
|
||||
@@ -1465,9 +1465,9 @@ async function updateTaskStatusAfterPRCreation(
|
||||
|
||||
// Await status persistence to ensure completion before resolving
|
||||
try {
|
||||
const persisted = await persistPlanStatus(planPath, 'pr_created');
|
||||
const persisted = await persistPlanStatus(planPath, 'done');
|
||||
result.mainProjectStatus = persisted;
|
||||
debug('Main project status persisted to pr_created:', persisted);
|
||||
debug('Main project status persisted to done:', persisted);
|
||||
} catch (err) {
|
||||
debug('Failed to persist main project status:', err);
|
||||
}
|
||||
@@ -1484,9 +1484,9 @@ async function updateTaskStatusAfterPRCreation(
|
||||
const worktreeMetadataPath = path.join(worktreePath, specsBaseDir, specId, 'task_metadata.json');
|
||||
|
||||
try {
|
||||
const persisted = await persistPlanStatus(worktreePlanPath, 'pr_created');
|
||||
const persisted = await persistPlanStatus(worktreePlanPath, 'done');
|
||||
result.worktreeStatus = persisted;
|
||||
debug('Worktree status persisted to pr_created:', persisted);
|
||||
debug('Worktree status persisted to done:', persisted);
|
||||
} catch (err) {
|
||||
debug('Failed to persist worktree status:', err);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { McpAPI, createMcpAPI } from './modules/mcp-api';
|
||||
import { ProfileAPI, createProfileAPI } from './profile-api';
|
||||
import { CheckpointAPI, createCheckpointAPI } from './modules/checkpoint-api';
|
||||
import { MethodologyAPI, createMethodologyAPI } from './modules/methodology-api';
|
||||
import { ScreenshotAPI, createScreenshotAPI } from './screenshot-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
ProjectAPI,
|
||||
@@ -31,7 +32,8 @@ export interface ElectronAPI extends
|
||||
ClaudeCodeAPI,
|
||||
McpAPI,
|
||||
ProfileAPI,
|
||||
MethodologyAPI {
|
||||
MethodologyAPI,
|
||||
ScreenshotAPI {
|
||||
github: GitHubAPI;
|
||||
checkpoints: CheckpointAPI;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createMcpAPI(),
|
||||
...createProfileAPI(),
|
||||
...createMethodologyAPI(),
|
||||
...createScreenshotAPI(),
|
||||
github: createGitHubAPI(),
|
||||
checkpoints: createCheckpointAPI()
|
||||
});
|
||||
@@ -69,7 +72,8 @@ export {
|
||||
createClaudeCodeAPI,
|
||||
createMcpAPI,
|
||||
createCheckpointAPI,
|
||||
createMethodologyAPI
|
||||
createMethodologyAPI,
|
||||
createScreenshotAPI
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -89,5 +93,6 @@ export type {
|
||||
ClaudeCodeAPI,
|
||||
McpAPI,
|
||||
CheckpointAPI,
|
||||
MethodologyAPI
|
||||
MethodologyAPI,
|
||||
ScreenshotAPI
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Screenshot API
|
||||
*
|
||||
* Provides screenshot capture functionality via IPC to the main process.
|
||||
* Uses Electron's desktopCapturer to capture screens and windows.
|
||||
*/
|
||||
import { IPC_CHANNELS } from '../../shared/constants/ipc';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import type { ScreenshotSource, ScreenshotCaptureOptions } from '../../shared/types/screenshot';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ScreenshotSource, ScreenshotCaptureOptions };
|
||||
|
||||
export interface ScreenshotAPI {
|
||||
getSources: () => Promise<{
|
||||
success: boolean;
|
||||
data?: ScreenshotSource[];
|
||||
error?: string;
|
||||
}>;
|
||||
capture: (options: ScreenshotCaptureOptions) => Promise<{
|
||||
success: boolean;
|
||||
data?: string; // base64 encoded PNG
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const createScreenshotAPI = (): ScreenshotAPI => ({
|
||||
getSources: () => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_GET_SOURCES),
|
||||
capture: (options) => ipcRenderer.invoke(IPC_CHANNELS.SCREENSHOT_CAPTURE, options)
|
||||
});
|
||||
@@ -27,11 +27,12 @@ function createTestTask(overrides: Partial<Task> = {}): Task {
|
||||
function createTestTaskOrder(overrides: Partial<TaskOrderState> = {}): TaskOrderState {
|
||||
return {
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: [],
|
||||
...overrides
|
||||
};
|
||||
@@ -96,7 +97,7 @@ describe('Task Order State Management', () => {
|
||||
in_progress: ['task-2'],
|
||||
ai_review: ['task-3'],
|
||||
human_review: ['task-4'],
|
||||
pr_created: ['task-5'],
|
||||
queue: ['task-5'],
|
||||
done: ['task-6']
|
||||
});
|
||||
|
||||
@@ -106,7 +107,7 @@ describe('Task Order State Management', () => {
|
||||
expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-2']);
|
||||
expect(useTaskStore.getState().taskOrder?.ai_review).toEqual(['task-3']);
|
||||
expect(useTaskStore.getState().taskOrder?.human_review).toEqual(['task-4']);
|
||||
expect(useTaskStore.getState().taskOrder?.pr_created).toEqual(['task-5']);
|
||||
expect(useTaskStore.getState().taskOrder?.queue).toEqual(['task-5']);
|
||||
expect(useTaskStore.getState().taskOrder?.done).toEqual(['task-6']);
|
||||
});
|
||||
});
|
||||
@@ -246,11 +247,12 @@ describe('Task Order State Management', () => {
|
||||
|
||||
expect(useTaskStore.getState().taskOrder).toEqual({
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
});
|
||||
});
|
||||
@@ -279,11 +281,12 @@ describe('Task Order State Management', () => {
|
||||
// Should fall back to empty order state
|
||||
expect(useTaskStore.getState().taskOrder).toEqual({
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to load task order:', expect.any(Error));
|
||||
@@ -306,11 +309,12 @@ describe('Task Order State Management', () => {
|
||||
// Should fall back to empty order state
|
||||
expect(useTaskStore.getState().taskOrder).toEqual({
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
});
|
||||
|
||||
@@ -498,8 +502,9 @@ describe('Task Order State Management', () => {
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
queue: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
} as TaskOrderState;
|
||||
useTaskStore.setState({ taskOrder: order });
|
||||
@@ -593,11 +598,12 @@ describe('Task Order State Management', () => {
|
||||
// Empty string causes JSON.parse to throw - should fall back to empty order
|
||||
expect(useTaskStore.getState().taskOrder).toEqual({
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
});
|
||||
|
||||
@@ -646,7 +652,7 @@ describe('Task Order State Management', () => {
|
||||
in_progress: ['task-4'],
|
||||
ai_review: [],
|
||||
human_review: ['task-5', 'task-6'],
|
||||
pr_created: [],
|
||||
queue: [],
|
||||
done: ['task-7', 'task-8', 'task-9', 'task-10']
|
||||
});
|
||||
useTaskStore.setState({ taskOrder: order });
|
||||
|
||||
@@ -1259,35 +1259,6 @@ describe('Task Store', () => {
|
||||
|
||||
// FIX (PR Review): Test coverage for terminal status downgrade prevention
|
||||
describe('terminal status downgrade prevention', () => {
|
||||
it('should NOT downgrade from pr_created to ai_review', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
id: 'task-1',
|
||||
status: 'pr_created',
|
||||
executionProgress: undefined
|
||||
})]
|
||||
});
|
||||
|
||||
const plan = createTestPlan({
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
name: 'Phase 1',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{ id: 'c1', description: 'Subtask 1', status: 'completed' },
|
||||
{ id: 'c2', description: 'Subtask 2', status: 'completed' }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskFromPlan('task-1', plan);
|
||||
|
||||
// Status should remain pr_created, not downgrade to ai_review
|
||||
expect(useTaskStore.getState().tasks[0].status).toBe('pr_created');
|
||||
});
|
||||
|
||||
it('should NOT downgrade from done to ai_review', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({
|
||||
|
||||
@@ -10,13 +10,44 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AuthStatusIndicator } from './AuthStatusIndicator';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import type { APIProfile } from '@shared/types/profile';
|
||||
import type { APIProfile } from '../../shared/types/profile';
|
||||
|
||||
// Mock the settings store
|
||||
vi.mock('../stores/settings-store', () => ({
|
||||
useSettingsStore: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock i18n translation function
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
// For translation keys, return values for testing
|
||||
const translations: Record<string, string> = {
|
||||
'common:usage.authentication': 'Authentication',
|
||||
'common:usage.oauth': 'OAuth',
|
||||
'common:usage.apiProfile': 'API Profile',
|
||||
'common:usage.provider': 'Provider',
|
||||
'common:usage.providerAnthropic': 'Anthropic',
|
||||
'common:usage.providerZai': 'z.ai',
|
||||
'common:usage.providerZhipu': 'ZHIPU AI',
|
||||
'common:usage.authenticationAriaLabel': 'Authentication: {{provider}}',
|
||||
'common:usage.profile': 'Profile',
|
||||
'common:usage.id': 'ID',
|
||||
'common:usage.apiEndpoint': 'API Endpoint'
|
||||
};
|
||||
// Handle interpolation (e.g., "Authentication: {{provider}}")
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const translated = translations[key] || key;
|
||||
if (translated.includes('{{provider}}')) {
|
||||
return translated.replace('{{provider}}', String(params.provider));
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
return translations[key] || key;
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
/**
|
||||
* Creates a mock settings store with optional overrides
|
||||
* @param overrides - Partial store state to override defaults
|
||||
@@ -65,12 +96,35 @@ const testProfiles: APIProfile[] = [
|
||||
models: undefined,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
{
|
||||
id: 'profile-3',
|
||||
name: 'z.ai Global',
|
||||
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||
apiKey: 'sk-zai-key-1234',
|
||||
models: undefined,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
{
|
||||
id: 'profile-4',
|
||||
name: 'ZHIPU China',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
apiKey: 'zhipu-key-5678',
|
||||
models: undefined,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
];
|
||||
|
||||
describe('AuthStatusIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.electronAPI usage functions
|
||||
(window as any).electronAPI = {
|
||||
onUsageUpdated: vi.fn(() => vi.fn()), // Returns unsubscribe function
|
||||
requestUsageUpdate: vi.fn().mockResolvedValue({ success: false, data: null })
|
||||
};
|
||||
});
|
||||
|
||||
describe('when using OAuth (no active profile)', () => {
|
||||
@@ -80,17 +134,17 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display OAuth with Lock icon', () => {
|
||||
it('should display Anthropic provider with Lock icon', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('OAuth')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication method: oauth/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct aria-label for OAuth', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: OAuth');
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,17 +155,17 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display profile name with Key icon', () => {
|
||||
it('should display provider label (Anthropic) with Key icon', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('Production API')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication method: production api/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /authentication: anthropic/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct aria-label for profile', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: Production API');
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: Anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,10 +176,63 @@ describe('AuthStatusIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to OAuth display', () => {
|
||||
it('should fallback to Anthropic provider display', () => {
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('OAuth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider detection for different API profiles', () => {
|
||||
it('should display z.ai provider label for z.ai profile', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-3' })
|
||||
);
|
||||
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('z.ai')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: z.ai');
|
||||
});
|
||||
|
||||
it('should display ZHIPU AI provider label for ZHIPU profile', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-4' })
|
||||
);
|
||||
|
||||
render(<AuthStatusIndicator />);
|
||||
|
||||
expect(screen.getByText('ZHIPU AI')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication: ZHIPU AI');
|
||||
});
|
||||
|
||||
it('should apply correct color classes for each provider', () => {
|
||||
// Test Anthropic (orange)
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-1' })
|
||||
);
|
||||
|
||||
const { rerender } = render(<AuthStatusIndicator />);
|
||||
const anthropicButton = screen.getByRole('button');
|
||||
expect(anthropicButton.className).toContain('text-orange-500');
|
||||
|
||||
// Test z.ai (blue)
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-3' })
|
||||
);
|
||||
|
||||
rerender(<AuthStatusIndicator />);
|
||||
const zaiButton = screen.getByRole('button');
|
||||
expect(zaiButton.className).toContain('text-blue-500');
|
||||
|
||||
// Test ZHIPU (purple)
|
||||
vi.mocked(useSettingsStore).mockReturnValue(
|
||||
createUseSettingsStoreMock({ activeProfileId: 'profile-4' })
|
||||
);
|
||||
|
||||
rerender(<AuthStatusIndicator />);
|
||||
const zhipuButton = screen.getByRole('button');
|
||||
expect(zhipuButton.className).toContain('text-purple-500');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,73 +1,299 @@
|
||||
/**
|
||||
* AuthStatusIndicator - Display current authentication method in header
|
||||
*
|
||||
* Shows the active authentication method:
|
||||
* - API Profile name with Key icon when a profile is active
|
||||
* - "OAuth" with Lock icon when using OAuth authentication
|
||||
* Shows the active authentication method and provider:
|
||||
* - OAuth: Shows "OAuth Anthropic" with Lock icon
|
||||
* - API Profile: Shows provider name (z.ai, ZHIPU AI) with Key icon and provider-specific colors
|
||||
*
|
||||
* Provider detection is based on the profile's baseUrl:
|
||||
* - api.anthropic.com → Anthropic
|
||||
* - api.z.ai → z.ai
|
||||
* - open.bigmodel.cn, dev.bigmodel.cn → ZHIPU AI
|
||||
*
|
||||
* Usage warning badge: Shows to the left of provider badge when usage exceeds 90%
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Key, Lock } from 'lucide-react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Key, Lock, Shield, Server, Fingerprint, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
|
||||
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
|
||||
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
|
||||
|
||||
/**
|
||||
* Type-safe mapping from ApiProvider to translation keys
|
||||
*/
|
||||
const PROVIDER_TRANSLATION_KEYS: Readonly<Record<ApiProvider, string>> = {
|
||||
anthropic: 'common:usage.providerAnthropic',
|
||||
zai: 'common:usage.providerZai',
|
||||
zhipu: 'common:usage.providerZhipu',
|
||||
unknown: 'common:usage.providerUnknown'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* OAuth fallback state when no profile is active or profile not found
|
||||
*/
|
||||
const OAUTH_FALLBACK = {
|
||||
type: 'oauth' as const,
|
||||
name: 'OAuth',
|
||||
provider: 'anthropic' as const,
|
||||
providerLabel: 'Anthropic',
|
||||
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
|
||||
} as const;
|
||||
|
||||
export function AuthStatusIndicator() {
|
||||
// Subscribe to profile state from settings store
|
||||
const { profiles, activeProfileId } = useSettingsStore();
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Compute auth status directly using useMemo to avoid unnecessary re-renders
|
||||
// Track usage data for warning badge
|
||||
const [usage, setUsage] = useState<ClaudeUsageSnapshot | null>(null);
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(true);
|
||||
|
||||
// Listen for usage updates
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI.onUsageUpdated((snapshot: ClaudeUsageSnapshot) => {
|
||||
setUsage(snapshot);
|
||||
setIsLoadingUsage(false);
|
||||
});
|
||||
|
||||
// Request initial usage
|
||||
window.electronAPI.requestUsageUpdate()
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setUsage(result.data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[AuthStatusIndicator] Failed to fetch usage:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingUsage(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Determine if usage warning badge should be shown
|
||||
const shouldShowUsageWarning = usage && !isLoadingUsage && (
|
||||
usage.sessionPercent >= 90 || usage.weeklyPercent >= 90
|
||||
);
|
||||
|
||||
// Get the higher usage percentage for the warning badge
|
||||
const warningBadgePercent = usage
|
||||
? Math.max(usage.sessionPercent, usage.weeklyPercent)
|
||||
: 0;
|
||||
|
||||
// Get formatted reset times (calculated dynamically from timestamps)
|
||||
// Only fall back to sessionResetTime if it doesn't contain placeholder/hardcoded text
|
||||
const sessionResetTime = usage?.sessionResetTimestamp
|
||||
? (formatTimeRemaining(usage.sessionResetTimestamp, t) ??
|
||||
(hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime))
|
||||
: (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime);
|
||||
|
||||
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
|
||||
const authStatus = useMemo(() => {
|
||||
if (activeProfileId) {
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
||||
if (activeProfile) {
|
||||
return { type: 'profile' as const, name: activeProfile.name };
|
||||
// Detect provider from profile's baseUrl
|
||||
const provider = detectProvider(activeProfile.baseUrl);
|
||||
const providerLabel = getProviderLabel(provider);
|
||||
return {
|
||||
type: 'profile' as const,
|
||||
name: activeProfile.name,
|
||||
id: activeProfile.id,
|
||||
baseUrl: activeProfile.baseUrl,
|
||||
createdAt: activeProfile.createdAt,
|
||||
provider,
|
||||
providerLabel,
|
||||
badgeColor: getProviderBadgeColor(provider)
|
||||
};
|
||||
}
|
||||
// Profile ID set but profile not found - fallback to OAuth
|
||||
return { type: 'oauth' as const, name: 'OAuth' };
|
||||
return OAUTH_FALLBACK;
|
||||
}
|
||||
return { type: 'oauth' as const, name: 'OAuth' };
|
||||
// No active profile - using OAuth
|
||||
return OAUTH_FALLBACK;
|
||||
}, [activeProfileId, profiles]);
|
||||
|
||||
// Helper function to truncate ID for display
|
||||
const truncateId = (id: string): string => {
|
||||
return id.slice(0, 8);
|
||||
};
|
||||
|
||||
// Get localized provider label for display
|
||||
// Uses type-safe mapping with fallback to getProviderLabel for unknown providers
|
||||
const getLocalizedProviderLabel = (provider: ApiProvider): string => {
|
||||
const translationKey = PROVIDER_TRANSLATION_KEYS[provider];
|
||||
|
||||
// If we have a translation key (including providerUnknown), use it
|
||||
if (translationKey) {
|
||||
const translated = t(translationKey);
|
||||
// If translation returns the key itself (not found), use getProviderLabel fallback
|
||||
if (translated !== translationKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to getProviderLabel for providers without translation keys
|
||||
return getProviderLabel(provider);
|
||||
};
|
||||
|
||||
const isOAuth = authStatus.type === 'oauth';
|
||||
const Icon = isOAuth ? Lock : Key;
|
||||
// Compute once and reuse for aria-label and displayed text
|
||||
const localizedProviderLabel = getLocalizedProviderLabel(authStatus.provider);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-primary/10 text-primary border-primary/20 transition-all hover:opacity-80 hover:bg-primary/15"
|
||||
aria-label={`Authentication method: ${authStatus.name}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">
|
||||
{authStatus.name}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground font-medium">Authentication</span>
|
||||
<span className="font-semibold">{isOAuth ? 'OAuth' : 'API Profile'}</span>
|
||||
</div>
|
||||
{!isOAuth && authStatus.name && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Usage Warning Badge (shown when usage >= 90%) */}
|
||||
{shouldShowUsageWarning && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-red-500/10 text-red-500 border-red-500/20">
|
||||
<AlertTriangle className="h-3.5 w-3.5 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground font-medium">{t('common:usage.usageAlert')}</span>
|
||||
<span className="font-semibold text-red-500">{Math.round(warningBadgePercent)}%</span>
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Using profile: <span className="text-foreground font-medium">{authStatus.name}</span>
|
||||
{t('common:usage.accountExceedsThreshold')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Provider Badge */}
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${authStatus.badgeColor}`}
|
||||
aria-label={t('common:usage.authenticationAriaLabel', { provider: localizedProviderLabel })}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">
|
||||
{localizedProviderLabel}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-xs p-0">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Header section */}
|
||||
<div className="flex items-center justify-between pb-2 border-b">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span className="font-semibold text-xs">{t('common:usage.authenticationDetails')}</span>
|
||||
</div>
|
||||
<div className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||
isOAuth
|
||||
? 'bg-orange-500/15 text-orange-500'
|
||||
: 'bg-primary/15 text-primary'
|
||||
}`}>
|
||||
{isOAuth ? t('common:usage.oauth') : t('common:usage.apiProfile')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-[11px]">{t('common:usage.provider')}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-xs">{localizedProviderLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Profile details for API profiles */}
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
{/* Profile name with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Key className="h-3 w-3" />
|
||||
<span className="text-[10px]">{t('common:usage.profile')}</span>
|
||||
</div>
|
||||
<span className="font-medium text-[10px]">{authStatus.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Profile ID with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Fingerprint className="h-3 w-3" />
|
||||
<span className="text-[10px]">{t('common:usage.id')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{truncateId(authStatus.id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* API Endpoint with better styling */}
|
||||
{authStatus.baseUrl && (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>{t('common:usage.apiEndpoint')}</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono bg-muted px-2 py-1.5 rounded break-all border">
|
||||
{authStatus.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* 5 Hour Usage Badge (shown when session usage >= 90%) */}
|
||||
{usage && !isLoadingUsage && usage.sessionPercent >= 90 && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-red-500/10 text-red-500 border-red-500/20 text-xs font-semibold">
|
||||
{Math.round(usage.sessionPercent)}%
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground font-medium">{localizeUsageWindowLabel(usage?.usageWindows?.sessionWindowLabel, t)}</span>
|
||||
<span className="font-semibold text-red-500">{Math.round(usage.sessionPercent)}%</span>
|
||||
</div>
|
||||
{sessionResetTime && (
|
||||
<>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{sessionResetTime}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, memo, useEffect, useCallback } from 'react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewState } from '../contexts/ViewStateContext';
|
||||
import {
|
||||
@@ -19,16 +19,18 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X } from 'lucide-react';
|
||||
import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus } from 'lucide-react';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { SortableTaskCard } from './SortableTaskCard';
|
||||
import { QueueSettingsModal } from './QueueSettingsModal';
|
||||
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
|
||||
import { cn } from '../lib/utils';
|
||||
import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store';
|
||||
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { WorktreeCleanupDialog } from './WorktreeCleanupDialog';
|
||||
import { BulkPRDialog } from './BulkPRDialog';
|
||||
@@ -68,6 +70,9 @@ interface DroppableColumnProps {
|
||||
isOver: boolean;
|
||||
onAddClick?: () => void;
|
||||
onArchiveAll?: () => void;
|
||||
onQueueSettings?: () => void;
|
||||
onQueueAll?: () => void;
|
||||
maxParallelTasks?: number;
|
||||
archivedCount?: number;
|
||||
showArchived?: boolean;
|
||||
onToggleArchived?: () => void;
|
||||
@@ -116,6 +121,9 @@ function droppableColumnPropsAreEqual(
|
||||
if (prevProps.onStatusChange !== nextProps.onStatusChange) return false;
|
||||
if (prevProps.onAddClick !== nextProps.onAddClick) return false;
|
||||
if (prevProps.onArchiveAll !== nextProps.onArchiveAll) return false;
|
||||
if (prevProps.onQueueSettings !== nextProps.onQueueSettings) return false;
|
||||
if (prevProps.onQueueAll !== nextProps.onQueueAll) return false;
|
||||
if (prevProps.maxParallelTasks !== nextProps.maxParallelTasks) return false;
|
||||
if (prevProps.archivedCount !== nextProps.archivedCount) return false;
|
||||
if (prevProps.showArchived !== nextProps.showArchived) return false;
|
||||
if (prevProps.onToggleArchived !== nextProps.onToggleArchived) return false;
|
||||
@@ -123,14 +131,14 @@ function droppableColumnPropsAreEqual(
|
||||
if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false;
|
||||
if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false;
|
||||
|
||||
// Compare selectedTaskIds Set
|
||||
if (prevProps.selectedTaskIds !== nextProps.selectedTaskIds) {
|
||||
// If one is undefined and other isn't, different
|
||||
if (!prevProps.selectedTaskIds || !nextProps.selectedTaskIds) return false;
|
||||
// Compare Set contents
|
||||
if (prevProps.selectedTaskIds.size !== nextProps.selectedTaskIds.size) return false;
|
||||
for (const id of prevProps.selectedTaskIds) {
|
||||
if (!nextProps.selectedTaskIds.has(id)) return false;
|
||||
// Compare selection props
|
||||
const prevSelected = prevProps.selectedTaskIds;
|
||||
const nextSelected = nextProps.selectedTaskIds;
|
||||
if (prevSelected !== nextSelected) {
|
||||
if (!prevSelected || !nextSelected) return false;
|
||||
if (prevSelected.size !== nextSelected.size) return false;
|
||||
for (const id of prevSelected) {
|
||||
if (!nextSelected.has(id)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +162,12 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
|
||||
message: t('kanban.emptyBacklog'),
|
||||
subtext: t('kanban.emptyBacklogHint')
|
||||
};
|
||||
case 'queue':
|
||||
return {
|
||||
icon: <Loader2 className="h-6 w-6 text-muted-foreground/50" />,
|
||||
message: t('kanban.emptyQueue'),
|
||||
subtext: t('kanban.emptyQueueHint')
|
||||
};
|
||||
case 'in_progress':
|
||||
return {
|
||||
icon: <Loader2 className="h-6 w-6 text-muted-foreground/50" />,
|
||||
@@ -186,7 +200,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
|
||||
}
|
||||
};
|
||||
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) {
|
||||
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: status
|
||||
@@ -267,6 +281,8 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
switch (status) {
|
||||
case 'backlog':
|
||||
return 'column-backlog';
|
||||
case 'queue':
|
||||
return 'column-queue';
|
||||
case 'in_progress':
|
||||
return 'column-in-progress';
|
||||
case 'ai_review':
|
||||
@@ -317,20 +333,55 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
|
||||
<h2 className="font-semibold text-sm text-foreground">
|
||||
{t(TASK_STATUS_LABELS[status])}
|
||||
</h2>
|
||||
<span className="column-count-badge">
|
||||
{tasks.length}
|
||||
</span>
|
||||
{status === 'in_progress' && maxParallelTasks ? (
|
||||
<span className={cn(
|
||||
"column-count-badge",
|
||||
tasks.length >= maxParallelTasks && "bg-warning/20 text-warning border-warning/30"
|
||||
)}>
|
||||
{tasks.length}/{maxParallelTasks}
|
||||
</span>
|
||||
) : (
|
||||
<span className="column-count-badge">
|
||||
{tasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'backlog' && onAddClick && (
|
||||
{status === 'backlog' && (
|
||||
<>
|
||||
{onQueueAll && tasks.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors"
|
||||
onClick={onQueueAll}
|
||||
title={t('queue.queueAll')}
|
||||
>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onAddClick && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
onClick={onAddClick}
|
||||
aria-label={t('kanban.addTaskAriaLabel')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === 'queue' && onQueueSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
onClick={onAddClick}
|
||||
aria-label={t('kanban.addTaskAriaLabel')}
|
||||
className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors"
|
||||
onClick={onQueueSettings}
|
||||
title={t('kanban.queueSettings')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{status === 'done' && onArchiveAll && tasks.length > 0 && !showArchived && (
|
||||
@@ -428,6 +479,22 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
const { showArchived, toggleShowArchived } = useViewState();
|
||||
|
||||
// Project store for queue settings
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
|
||||
// Get projectId from first task
|
||||
const projectId = tasks[0]?.projectId;
|
||||
const project = projectId ? projects.find((p) => p.id === projectId) : undefined;
|
||||
const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3;
|
||||
|
||||
// Queue settings modal state
|
||||
const [showQueueSettings, setShowQueueSettings] = useState(false);
|
||||
// Store projectId when modal opens to prevent modal from disappearing if tasks change
|
||||
const queueSettingsProjectIdRef = useRef<string | null>(null);
|
||||
|
||||
// Queue processing lock to prevent race conditions
|
||||
const isProcessingQueueRef = useRef(false);
|
||||
|
||||
// Selection state for bulk actions (Human Review column)
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -484,6 +551,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
// Note: error tasks are shown in the 'human_review' column since they need human attention
|
||||
const grouped: Record<typeof TASK_STATUS_COLUMNS[number], Task[]> = {
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
@@ -700,6 +768,144 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move all backlog tasks to queue
|
||||
*/
|
||||
const handleQueueAll = async () => {
|
||||
const backlogTasks = tasksByStatus.backlog;
|
||||
if (backlogTasks.length === 0) return;
|
||||
|
||||
let movedCount = 0;
|
||||
for (const task of backlogTasks) {
|
||||
const result = await persistTaskStatus(task.id, 'queue');
|
||||
if (result.success) {
|
||||
movedCount++;
|
||||
} else {
|
||||
console.error(`[Queue] Failed to move task ${task.id} to queue:`, result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-promote queued tasks to fill available capacity
|
||||
await processQueue();
|
||||
|
||||
toast({
|
||||
title: t('queue.queueAllSuccess', { count: movedCount }),
|
||||
variant: 'default'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Save queue settings (maxParallelTasks)
|
||||
*
|
||||
* Uses the stored ref value to ensure the save works even if tasks
|
||||
* change while the modal is open.
|
||||
*/
|
||||
const handleSaveQueueSettings = async (maxParallel: number) => {
|
||||
const savedProjectId = queueSettingsProjectIdRef.current || projectId;
|
||||
if (!savedProjectId) return;
|
||||
|
||||
const success = await updateProjectSettings(savedProjectId, { maxParallelTasks: maxParallel });
|
||||
if (success) {
|
||||
toast({
|
||||
title: t('queue.settings.saved'),
|
||||
variant: 'default'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('queue.settings.saveFailed'),
|
||||
description: t('queue.settings.retry'),
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically move tasks from Queue to In Progress to fill available capacity
|
||||
* Promotes multiple tasks if needed (e.g., after bulk queue)
|
||||
*/
|
||||
const processQueue = useCallback(async () => {
|
||||
// Prevent concurrent executions to avoid race conditions
|
||||
if (isProcessingQueueRef.current) {
|
||||
console.log('[Queue] Already processing queue, skipping duplicate call');
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = true;
|
||||
|
||||
try {
|
||||
// Track tasks we've already attempted to promote (to avoid infinite retries)
|
||||
const attemptedTaskIds = new Set<string>();
|
||||
let consecutiveFailures = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop
|
||||
|
||||
// Loop until capacity is full or queue is empty
|
||||
while (true) {
|
||||
// Get CURRENT state from store to ensure accuracy
|
||||
const currentTasks = useTaskStore.getState().tasks;
|
||||
const inProgressCount = currentTasks.filter((t) =>
|
||||
t.status === 'in_progress' && !t.metadata?.archivedAt
|
||||
).length;
|
||||
const queuedTasks = currentTasks.filter((t) =>
|
||||
t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id)
|
||||
);
|
||||
|
||||
// Stop if no capacity, no queued tasks, or too many consecutive failures
|
||||
if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the oldest task in queue (FIFO ordering)
|
||||
const nextTask = queuedTasks.sort((a, b) => {
|
||||
const dateA = new Date(a.createdAt).getTime();
|
||||
const dateB = new Date(b.createdAt).getTime();
|
||||
return dateA - dateB; // Ascending order (oldest first)
|
||||
})[0];
|
||||
|
||||
console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`);
|
||||
const result = await persistTaskStatus(nextTask.id, 'in_progress');
|
||||
|
||||
if (result.success) {
|
||||
// Reset consecutive failures on success
|
||||
consecutiveFailures = 0;
|
||||
} else {
|
||||
// If promotion failed, log error, mark as attempted, and skip to next task
|
||||
console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error);
|
||||
attemptedTaskIds.add(nextTask.id);
|
||||
consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log if we had failed tasks
|
||||
if (attemptedTaskIds.size > 0) {
|
||||
console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`);
|
||||
}
|
||||
} finally {
|
||||
isProcessingQueueRef.current = false;
|
||||
}
|
||||
}, [maxParallelTasks]);
|
||||
|
||||
// Register task status change listener for queue auto-promotion
|
||||
// This ensures processQueue() is called whenever a task leaves in_progress
|
||||
useEffect(() => {
|
||||
const unregister = useTaskStore.getState().registerTaskStatusChangeListener(
|
||||
(taskId, oldStatus, newStatus) => {
|
||||
// When a task leaves in_progress (e.g., goes to human_review), process the queue
|
||||
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
|
||||
console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Cleanup: unregister listener when component unmounts
|
||||
return unregister;
|
||||
}, [processQueue]);
|
||||
|
||||
// Get task order actions from store
|
||||
const reorderTasksInColumn = useTaskStore((state) => state.reorderTasksInColumn);
|
||||
const moveTaskToColumnTop = useTaskStore((state) => state.moveTaskToColumnTop);
|
||||
@@ -707,9 +913,6 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
const loadTaskOrder = useTaskStore((state) => state.loadTaskOrder);
|
||||
const setTaskOrder = useTaskStore((state) => state.setTaskOrder);
|
||||
|
||||
// Get projectId from tasks (all tasks in KanbanBoard share the same project)
|
||||
const projectId = useMemo(() => tasks[0]?.projectId ?? null, [tasks]);
|
||||
|
||||
const saveTaskOrder = useCallback((projectIdToSave: string) => {
|
||||
const success = saveTaskOrderToStorage(projectIdToSave);
|
||||
if (!success) {
|
||||
@@ -741,11 +944,12 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
let hasStaleIds = false;
|
||||
const cleanedOrder: typeof taskOrder = {
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
};
|
||||
|
||||
@@ -768,7 +972,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
}
|
||||
}, [tasks, taskOrder, projectId, setTaskOrder, saveTaskOrder]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
setOverColumnId(null);
|
||||
@@ -778,77 +982,103 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
const activeTaskId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Determine target status
|
||||
let newStatus: TaskStatus | null = null;
|
||||
let oldStatus: TaskStatus | null = null;
|
||||
|
||||
// Get the task being dragged
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
if (!task) return;
|
||||
oldStatus = task.status;
|
||||
|
||||
// Check if dropped on a column
|
||||
if (isValidDropColumn(overId)) {
|
||||
const newStatus = overId;
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
newStatus = overId;
|
||||
} else {
|
||||
// Check if dropped on another task - move to that task's column
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (overTask) {
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
if (!task) return;
|
||||
|
||||
if (task && task.status !== newStatus) {
|
||||
// Move task to top of target column's order array
|
||||
moveTaskToColumnTop(activeTaskId, newStatus, task.status);
|
||||
// Compare visual columns
|
||||
const taskVisualColumn = getVisualColumn(task.status);
|
||||
const overTaskVisualColumn = getVisualColumn(overTask.status);
|
||||
|
||||
// Same visual column: reorder within column
|
||||
if (taskVisualColumn === overTaskVisualColumn) {
|
||||
// Ensure both tasks are in the order array before reordering
|
||||
// This handles tasks that existed before ordering was enabled
|
||||
const currentColumnOrder = taskOrder?.[taskVisualColumn] ?? [];
|
||||
const activeInOrder = currentColumnOrder.includes(activeTaskId);
|
||||
const overInOrder = currentColumnOrder.includes(overId);
|
||||
|
||||
if (!activeInOrder || !overInOrder) {
|
||||
// Sync the current visual order to the stored order
|
||||
// This ensures existing tasks can be reordered
|
||||
const visualOrder = tasksByStatus[taskVisualColumn].map(t => t.id);
|
||||
setTaskOrder({
|
||||
...taskOrder,
|
||||
[taskVisualColumn]: visualOrder
|
||||
} as TaskOrderState);
|
||||
}
|
||||
|
||||
// Reorder tasks within the same column using the visual column key
|
||||
reorderTasksInColumn(taskVisualColumn, activeTaskId, overId);
|
||||
|
||||
if (projectId) {
|
||||
saveTaskOrder(projectId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Different visual column: move to that task's column (status change)
|
||||
// Use the visual column key for ordering to ensure consistency
|
||||
newStatus = overTask.status;
|
||||
moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn);
|
||||
|
||||
// Persist task order
|
||||
if (projectId) {
|
||||
saveTaskOrder(projectId);
|
||||
}
|
||||
|
||||
// Persist status change to file and update local state
|
||||
handleStatusChange(activeTaskId, newStatus, task).catch((err) =>
|
||||
console.error('[KanbanBoard] Status change failed:', err)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dropped on another task
|
||||
const overTask = tasks.find((t) => t.id === overId);
|
||||
if (overTask) {
|
||||
const task = tasks.find((t) => t.id === activeTaskId);
|
||||
if (!task) return;
|
||||
if (!newStatus || newStatus === oldStatus) return;
|
||||
|
||||
// Compare visual columns (pr_created maps to 'done' visually)
|
||||
const taskVisualColumn = getVisualColumn(task.status);
|
||||
const overTaskVisualColumn = getVisualColumn(overTask.status);
|
||||
// ============================================
|
||||
// QUEUE SYSTEM: Enforce parallel task limit
|
||||
// ============================================
|
||||
if (newStatus === 'in_progress') {
|
||||
// Get CURRENT state from store directly to avoid stale prop/memo issues during rapid dragging
|
||||
const currentTasks = useTaskStore.getState().tasks;
|
||||
const inProgressCount = currentTasks.filter((t) =>
|
||||
t.status === 'in_progress' && !t.metadata?.archivedAt
|
||||
).length;
|
||||
|
||||
// Same visual column: reorder within column
|
||||
if (taskVisualColumn === overTaskVisualColumn) {
|
||||
// Ensure both tasks are in the order array before reordering
|
||||
// This handles tasks that existed before ordering was enabled
|
||||
const currentColumnOrder = taskOrder?.[taskVisualColumn] ?? [];
|
||||
const activeInOrder = currentColumnOrder.includes(activeTaskId);
|
||||
const overInOrder = currentColumnOrder.includes(overId);
|
||||
// If limit reached, move to queue instead
|
||||
if (inProgressCount >= maxParallelTasks) {
|
||||
// Only bypass the capacity check if coming from queue AND queue is NOT being processed
|
||||
// This prevents race condition where both auto-promotion and manual drag exceed the limit
|
||||
const isAutoPromotionInProgress = oldStatus === 'queue' && isProcessingQueueRef.current;
|
||||
|
||||
if (!activeInOrder || !overInOrder) {
|
||||
// Sync the current visual order to the stored order
|
||||
// This ensures existing tasks can be reordered
|
||||
const visualOrder = tasksByStatus[taskVisualColumn].map(t => t.id);
|
||||
setTaskOrder({
|
||||
...taskOrder,
|
||||
[taskVisualColumn]: visualOrder
|
||||
} as TaskOrderState);
|
||||
if (!isAutoPromotionInProgress) {
|
||||
console.log(`[Queue] In Progress full (${inProgressCount}/${maxParallelTasks}), moving task to Queue`);
|
||||
newStatus = 'queue';
|
||||
}
|
||||
|
||||
// Reorder tasks within the same column using the visual column key
|
||||
reorderTasksInColumn(taskVisualColumn, activeTaskId, overId);
|
||||
|
||||
if (projectId) {
|
||||
saveTaskOrder(projectId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Different visual column: move to that task's column (status change)
|
||||
// Use the visual column key for ordering to ensure consistency
|
||||
moveTaskToColumnTop(activeTaskId, overTaskVisualColumn, taskVisualColumn);
|
||||
// Persist status change to file and update local state
|
||||
// Use handleStatusChange to properly handle worktree cleanup dialog
|
||||
await handleStatusChange(activeTaskId, newStatus, task);
|
||||
|
||||
// Persist task order
|
||||
if (projectId) {
|
||||
saveTaskOrder(projectId);
|
||||
}
|
||||
|
||||
handleStatusChange(activeTaskId, overTask.status, task).catch((err) =>
|
||||
console.error('[KanbanBoard] Status change failed:', err)
|
||||
);
|
||||
// ============================================
|
||||
// QUEUE SYSTEM: Auto-process queue when slot opens
|
||||
// ============================================
|
||||
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
|
||||
// A task left In Progress - check if we can promote from queue
|
||||
await processQueue();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -887,7 +1117,15 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
onStatusChange={handleStatusChange}
|
||||
isOver={overColumnId === status}
|
||||
onAddClick={status === 'backlog' ? onNewTaskClick : undefined}
|
||||
onQueueAll={status === 'backlog' ? handleQueueAll : undefined}
|
||||
onQueueSettings={status === 'queue' ? () => {
|
||||
// Only open modal if we have a valid projectId
|
||||
if (!projectId) return;
|
||||
queueSettingsProjectIdRef.current = projectId;
|
||||
setShowQueueSettings(true);
|
||||
} : undefined}
|
||||
onArchiveAll={status === 'done' ? handleArchiveAll : undefined}
|
||||
maxParallelTasks={status === 'in_progress' ? maxParallelTasks : undefined}
|
||||
archivedCount={status === 'done' ? archivedCount : undefined}
|
||||
showArchived={status === 'done' ? showArchived : undefined}
|
||||
onToggleArchived={status === 'done' ? toggleShowArchived : undefined}
|
||||
@@ -953,6 +1191,22 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
onConfirm={handleWorktreeCleanupConfirm}
|
||||
/>
|
||||
|
||||
{/* Queue Settings Modal */}
|
||||
{(queueSettingsProjectIdRef.current || projectId) && (
|
||||
<QueueSettingsModal
|
||||
open={showQueueSettings}
|
||||
onOpenChange={(open) => {
|
||||
setShowQueueSettings(open);
|
||||
if (!open) {
|
||||
queueSettingsProjectIdRef.current = null;
|
||||
}
|
||||
}}
|
||||
projectId={queueSettingsProjectIdRef.current || projectId || ''}
|
||||
currentMaxParallel={maxParallelTasks}
|
||||
onSave={handleSaveQueueSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk PR creation dialog */}
|
||||
<BulkPRDialog
|
||||
open={bulkPRDialogOpen}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { SortableProjectTab } from './SortableProjectTab';
|
||||
import { UsageIndicator } from './UsageIndicator';
|
||||
import { AuthStatusIndicator } from './AuthStatusIndicator';
|
||||
import type { Project } from '../../shared/types';
|
||||
|
||||
interface ProjectTabBarProps {
|
||||
@@ -112,6 +113,7 @@ export function ProjectTabBar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<AuthStatusIndicator />
|
||||
<UsageIndicator />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
/**
|
||||
* Props for QueueSettingsModal component
|
||||
*/
|
||||
interface QueueSettingsModalProps {
|
||||
/** Whether the modal is currently open */
|
||||
open: boolean;
|
||||
/** Callback to control modal open state */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The project ID to update settings for */
|
||||
projectId: string;
|
||||
/** Current maximum parallel tasks setting (default: 3) */
|
||||
currentMaxParallel?: number;
|
||||
/** Callback when user saves the new max parallel value */
|
||||
onSave: (maxParallel: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueSettingsModal - Modal for configuring queue parallel task limits
|
||||
*
|
||||
* Allows users to adjust the maximum number of tasks that can run in parallel
|
||||
* for a specific project. Validates input between 1-10 tasks.
|
||||
*/
|
||||
export function QueueSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
currentMaxParallel = 3,
|
||||
onSave
|
||||
}: QueueSettingsModalProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const [maxParallel, setMaxParallel] = useState(currentMaxParallel);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset to current value when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMaxParallel(currentMaxParallel);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, currentMaxParallel]);
|
||||
|
||||
/**
|
||||
* Validates and saves the max parallel tasks setting
|
||||
*
|
||||
* Validates that the value is between 1-10, sets an error message
|
||||
* if invalid, otherwise calls onSave and closes the modal.
|
||||
*/
|
||||
const handleSave = () => {
|
||||
// Validate the input
|
||||
if (maxParallel < 1) {
|
||||
setError(t('tasks:queue.settings.minValueError'));
|
||||
return;
|
||||
}
|
||||
if (maxParallel > 10) {
|
||||
setError(t('tasks:queue.settings.maxValueError'));
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(maxParallel);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles input field changes for the max parallel tasks value
|
||||
*
|
||||
* Parses the input value, validates it's a number, and updates state.
|
||||
* Allows empty input for editing purposes (will fail validation on save).
|
||||
*
|
||||
* @param e - The input change event from the number input field
|
||||
*/
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
// Handle empty input - allow clearing the field
|
||||
if (inputValue === '') {
|
||||
setMaxParallel(0); // Reset to 0 (will fail validation, but allows re-entry)
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = parseInt(inputValue, 10);
|
||||
if (!isNaN(value)) {
|
||||
setMaxParallel(value);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tasks:queue.settings.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('tasks:queue.settings.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxParallel">
|
||||
{t('tasks:queue.settings.maxParallelLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="maxParallel"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxParallel}
|
||||
onChange={handleInputChange}
|
||||
className="w-full"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('tasks:queue.settings.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common:buttons.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{t('common:buttons.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* ScreenshotCapture - Modal for capturing screenshots
|
||||
*
|
||||
* Displays available screens and windows in a grid, allowing users to
|
||||
* select a source and capture a screenshot.
|
||||
*
|
||||
* Features:
|
||||
* - Grid layout with thumbnail previews
|
||||
* - Visual selection with hover effects and checkmarks
|
||||
* - High-resolution capture support (handles retina displays)
|
||||
* - Loading states and error handling
|
||||
* - Refresh button to reload available sources
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCw, Monitor, Frame, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScreenshotSource } from '../../shared/types/screenshot';
|
||||
|
||||
interface ScreenshotCaptureProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCapture: (imageData: string) => void; // base64 encoded PNG
|
||||
}
|
||||
|
||||
export function ScreenshotCapture({ open, onOpenChange, onCapture }: ScreenshotCaptureProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const [sources, setSources] = useState<ScreenshotSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch available screenshot sources
|
||||
*/
|
||||
const fetchSources = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.getSources();
|
||||
if (result.success && result.data) {
|
||||
setSources(result.data);
|
||||
setSelectedSource(null);
|
||||
} else {
|
||||
setError(result.error || t('tasks:screenshot.errors.getSources'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch screenshot sources:', err);
|
||||
setError(err instanceof Error ? err.message : t('tasks:screenshot.errors.fetchSources'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch sources when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchSources();
|
||||
}
|
||||
}, [open, fetchSources]);
|
||||
|
||||
/**
|
||||
* Handle capture button click
|
||||
*/
|
||||
const handleCapture = async () => {
|
||||
if (!selectedSource) return;
|
||||
|
||||
setIsCapturing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.capture({ sourceId: selectedSource });
|
||||
if (result.success && result.data) {
|
||||
onCapture(result.data);
|
||||
onOpenChange(false);
|
||||
setSelectedSource(null);
|
||||
} else {
|
||||
setError(result.error || t('tasks:screenshot.errors.capture'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to capture screenshot:', err);
|
||||
setError(err instanceof Error ? err.message : t('tasks:screenshot.errors.captureFailed'));
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a source is a screen or window based on name
|
||||
*/
|
||||
const isScreenSource = (source: ScreenshotSource): boolean => {
|
||||
return source.name.toLowerCase().includes('screen') ||
|
||||
source.name.toLowerCase().includes('display') ||
|
||||
source.name.match(/^\d+:/) !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('tasks:screenshot.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('tasks:screenshot.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg mb-4">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSources}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && sources.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources Grid */}
|
||||
{!isLoading && sources.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-1">
|
||||
{sources.map((source) => {
|
||||
const isSelected = selectedSource === source.id;
|
||||
const isScreen = isScreenSource(source);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSource(source.id)}
|
||||
className={`
|
||||
relative group rounded-lg border-2 overflow-hidden
|
||||
transition-all duration-200
|
||||
${isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-border hover:border-primary/50 hover:ring-2 hover:ring-primary/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-muted relative">
|
||||
{source.thumbnail ? (
|
||||
<img
|
||||
src={source.thumbnail}
|
||||
alt={source.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{isScreen ? (
|
||||
<Monitor className="h-12 w-12 text-muted-foreground" />
|
||||
) : (
|
||||
<Frame className="h-12 w-12 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
|
||||
<div className="w-12 h-12 rounded-full bg-primary flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-primary-foreground"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Icon */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<div className={`
|
||||
p-1.5 rounded-md
|
||||
${isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background/80 text-foreground backdrop-blur-sm'
|
||||
}
|
||||
`}>
|
||||
{isScreen ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : (
|
||||
<Frame className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Name */}
|
||||
<div className="p-2 bg-background/95 backdrop-blur-sm">
|
||||
<p className="text-sm font-medium truncate text-foreground">
|
||||
{source.name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && sources.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{t('tasks:screenshot.noSources')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchSources}
|
||||
className="mt-4"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('common:buttons.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCapturing}
|
||||
>
|
||||
{t('common:buttons.cancel')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={fetchSources}
|
||||
disabled={isLoading || isCapturing}
|
||||
title={t('common:buttons.refresh')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCapture}
|
||||
disabled={!selectedSource || isCapturing}
|
||||
>
|
||||
{isCapturing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('tasks:screenshot.capturing')}
|
||||
</>
|
||||
) : (
|
||||
t('tasks:screenshot.capture')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -318,8 +318,6 @@ export const TaskCard = memo(function TaskCard({
|
||||
return 'warning';
|
||||
case 'human_review':
|
||||
return 'purple';
|
||||
case 'pr_created':
|
||||
return 'success';
|
||||
case 'done':
|
||||
return 'success';
|
||||
default:
|
||||
@@ -335,8 +333,6 @@ export const TaskCard = memo(function TaskCard({
|
||||
return t('labels.aiReview');
|
||||
case 'human_review':
|
||||
return t('labels.needsReview');
|
||||
case 'pr_created':
|
||||
return t('columns.pr_created');
|
||||
case 'done':
|
||||
return t('status.complete');
|
||||
default:
|
||||
@@ -454,7 +450,7 @@ export const TaskCard = memo(function TaskCard({
|
||||
{/* Status badge - hide when execution phase badge is showing */}
|
||||
{!hasActiveExecution && (
|
||||
<>
|
||||
{task.status === 'pr_created' ? (
|
||||
{task.status === 'done' ? (
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(task.status)}
|
||||
className="text-[10px] px-1.5 py-0.5"
|
||||
@@ -590,7 +586,7 @@ export const TaskCard = memo(function TaskCard({
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
{t('actions.resume')}
|
||||
</Button>
|
||||
) : task.status === 'pr_created' ? (
|
||||
) : task.status === 'done' && task.metadata?.prUrl ? (
|
||||
<div className="flex gap-1">
|
||||
{task.metadata?.prUrl && (
|
||||
<Button
|
||||
|
||||
@@ -6,32 +6,90 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Activity, TrendingUp, AlertCircle } from 'lucide-react';
|
||||
import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
|
||||
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
|
||||
|
||||
export function UsageIndicator() {
|
||||
const { t, i18n } = useTranslation(['common']);
|
||||
const [usage, setUsage] = useState<ClaudeUsageSnapshot | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAvailable, setIsAvailable] = useState(false);
|
||||
|
||||
/**
|
||||
* Helper function to format large numbers with locale-aware compact notation
|
||||
*
|
||||
* Returns undefined for null/undefined values. The caller (JSX conditional guards)
|
||||
* is responsible for checking values before calling this function.
|
||||
*
|
||||
* @param value - The number to format (undefined, null, or number)
|
||||
* @returns Formatted compact number string (e.g., "1.2K", "3.4M"), or undefined if input is null/undefined
|
||||
*
|
||||
* @example
|
||||
* formatUsageValue(1234) // "1.2K" (en-US)
|
||||
* formatUsageValue(null) // undefined
|
||||
* formatUsageValue(undefined) // undefined
|
||||
*/
|
||||
const formatUsageValue = (value?: number | null): string | undefined => {
|
||||
if (value == null) return undefined;
|
||||
|
||||
// Use Intl.NumberFormat for locale-aware compact number formatting
|
||||
// Fallback to toString() if Intl is not available
|
||||
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
|
||||
try {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
} catch {
|
||||
// Intl may fail in some environments, fall back to toString()
|
||||
}
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
// Get formatted reset times (calculated dynamically from timestamps)
|
||||
// Only fall back to sessionResetTime/weeklyResetTime if they don't contain placeholder/hardcoded text
|
||||
const sessionResetTime = usage?.sessionResetTimestamp
|
||||
? (formatTimeRemaining(usage.sessionResetTimestamp, t) ??
|
||||
(hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime))
|
||||
: (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime);
|
||||
const weeklyResetTime = usage?.weeklyResetTimestamp
|
||||
? (formatTimeRemaining(usage.weeklyResetTimestamp, t) ??
|
||||
(hasHardcodedText(usage?.weeklyResetTime) ? undefined : usage?.weeklyResetTime))
|
||||
: (hasHardcodedText(usage?.weeklyResetTime) ? undefined : usage?.weeklyResetTime);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for usage updates from main process
|
||||
const unsubscribe = window.electronAPI.onUsageUpdated((snapshot: ClaudeUsageSnapshot) => {
|
||||
setUsage(snapshot);
|
||||
setIsVisible(true);
|
||||
setIsAvailable(true);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Request initial usage on mount
|
||||
window.electronAPI.requestUsageUpdate().then((result) => {
|
||||
setIsLoading(false);
|
||||
if (result.success && result.data) {
|
||||
setUsage(result.data);
|
||||
setIsVisible(true);
|
||||
setIsAvailable(true);
|
||||
} else {
|
||||
// No usage data available (endpoint not supported or error)
|
||||
setIsAvailable(false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Handle errors (IPC failure, network issues, etc.)
|
||||
console.warn('[UsageIndicator] Failed to fetch initial usage:', error);
|
||||
setIsLoading(false);
|
||||
setIsAvailable(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -39,19 +97,65 @@ export function UsageIndicator() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isVisible || !usage) {
|
||||
return null;
|
||||
// Always show the badge, but display different states
|
||||
// Show loading state initially
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground">
|
||||
<Activity className="h-3.5 w-3.5 motion-safe:animate-pulse" />
|
||||
<span className="text-xs font-semibold">{t('common:usage.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine color based on highest usage percentage
|
||||
const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
|
||||
// Show unavailable state when endpoint doesn't return data
|
||||
if (!isAvailable || !usage) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-muted/50 text-muted-foreground cursor-help">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">{t('common:usage.notAvailable')}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs w-64">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('common:usage.dataUnavailable')}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{t('common:usage.dataUnavailableDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const colorClasses =
|
||||
maxUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' :
|
||||
maxUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' :
|
||||
maxUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' :
|
||||
// Determine color based on session usage (5-hour window)
|
||||
// This is what should be shown on the badge per QA feedback
|
||||
const badgeUsage = usage.sessionPercent;
|
||||
const badgeColorClasses =
|
||||
badgeUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' :
|
||||
badgeUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' :
|
||||
badgeUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' :
|
||||
'text-green-500 bg-green-500/10 border-green-500/20';
|
||||
|
||||
// Get window labels for display
|
||||
// Map backend-provided labels to localized versions with appropriate defaults
|
||||
const sessionLabel = localizeUsageWindowLabel(
|
||||
usage?.usageWindows?.sessionWindowLabel,
|
||||
t,
|
||||
'common:usage.sessionDefault'
|
||||
);
|
||||
const weeklyLabel = localizeUsageWindowLabel(
|
||||
usage?.usageWindows?.weeklyWindowLabel,
|
||||
t,
|
||||
'common:usage.weeklyDefault'
|
||||
);
|
||||
|
||||
// For icon, use the highest of the two windows
|
||||
const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent);
|
||||
const Icon =
|
||||
maxUsage >= 91 ? AlertCircle :
|
||||
maxUsage >= 71 ? TrendingUp :
|
||||
@@ -62,75 +166,129 @@ export function UsageIndicator() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${colorClasses}`}
|
||||
aria-label="Claude usage status"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border transition-all hover:opacity-80 ${badgeColorClasses}`}
|
||||
aria-label={t('common:usage.usageStatusAriaLabel')}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold font-mono">
|
||||
{Math.round(maxUsage)}%
|
||||
{Math.round(badgeUsage)}%
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs w-64">
|
||||
<div className="space-y-2">
|
||||
{/* Session usage */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 mb-1">
|
||||
<span className="text-muted-foreground font-medium">Session Usage</span>
|
||||
<span className="font-semibold tabular-nums">{Math.round(usage.sessionPercent)}%</span>
|
||||
<TooltipContent side="bottom" className="text-xs w-72 p-0">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Header with overall status */}
|
||||
<div className="flex items-center pb-2 border-b">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="font-semibold text-xs">{t('common:usage.usageBreakdown')}</span>
|
||||
</div>
|
||||
|
||||
{/* Session/5-hour usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{sessionLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${
|
||||
usage.sessionPercent >= 95 ? 'text-red-500' :
|
||||
usage.sessionPercent >= 91 ? 'text-orange-500' :
|
||||
usage.sessionPercent >= 71 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{Math.round(usage.sessionPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{usage.sessionResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Resets: {usage.sessionResetTime}
|
||||
{sessionResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{sessionResetTime}
|
||||
</div>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
{/* Enhanced progress bar with gradient */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
usage.sessionPercent >= 95 ? 'bg-red-500' :
|
||||
usage.sessionPercent >= 91 ? 'bg-orange-500' :
|
||||
usage.sessionPercent >= 71 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
|
||||
usage.sessionPercent >= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' :
|
||||
usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
|
||||
usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
|
||||
'bg-gradient-to-r from-green-600 to-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }}
|
||||
/>
|
||||
>
|
||||
{/* Subtle shine effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Weekly usage */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 mb-1">
|
||||
<span className="text-muted-foreground font-medium">Weekly Usage</span>
|
||||
<span className="font-semibold tabular-nums">{Math.round(usage.weeklyPercent)}%</span>
|
||||
</div>
|
||||
{usage.weeklyResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Resets: {usage.weeklyResetTime}
|
||||
{/* Raw usage value with better styling */}
|
||||
{usage.sessionUsageValue != null && usage.sessionUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.sessionUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.sessionUsageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
usage.weeklyPercent >= 99 ? 'bg-red-500' :
|
||||
usage.weeklyPercent >= 91 ? 'bg-orange-500' :
|
||||
usage.weeklyPercent >= 71 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
{/* Weekly/Monthly usage */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-medium text-[11px] flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{weeklyLabel}
|
||||
</span>
|
||||
<span className={`font-semibold tabular-nums text-xs ${
|
||||
usage.weeklyPercent >= 99 ? 'text-red-500' :
|
||||
usage.weeklyPercent >= 91 ? 'text-orange-500' :
|
||||
usage.weeklyPercent >= 71 ? 'text-yellow-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{Math.round(usage.weeklyPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
{weeklyResetTime && (
|
||||
<div className="text-[10px] text-muted-foreground pl-4 flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
{weeklyResetTime}
|
||||
</div>
|
||||
)}
|
||||
{/* Enhanced progress bar with gradient */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out relative overflow-hidden ${
|
||||
usage.weeklyPercent >= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' :
|
||||
usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' :
|
||||
usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' :
|
||||
'bg-gradient-to-r from-green-600 to-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }}
|
||||
>
|
||||
{/* Subtle shine effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Raw usage value with better styling */}
|
||||
{usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">{t('common:usage.used')}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatUsageValue(usage.weeklyUsageValue)} <span className="text-muted-foreground mx-1">/</span> {formatUsageValue(usage.weeklyUsageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active profile */}
|
||||
<div className="flex items-center justify-between gap-4 pt-1">
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">Active Account</span>
|
||||
<span className="font-semibold text-primary">{usage.profileName}</span>
|
||||
{/* Active account footer */}
|
||||
<div className="pt-2 border-t flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{t('common:usage.activeAccount')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-primary">
|
||||
<span>{usage.profileName}</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
|
||||
if (result.data.success && result.data.prUrl && !result.data.alreadyExists) {
|
||||
// Update task in store
|
||||
useTaskStore.getState().updateTask(prTask.id, {
|
||||
status: 'pr_created',
|
||||
status: 'done',
|
||||
metadata: { ...prTask.metadata, prUrl: result.data.prUrl }
|
||||
});
|
||||
}
|
||||
@@ -630,7 +630,7 @@ export function Worktrees({ projectId }: WorktreesProps) {
|
||||
{t('common:buttons.createPR')}
|
||||
</Button>
|
||||
)}
|
||||
{task?.status === 'pr_created' && task.metadata?.prUrl && (
|
||||
{task?.status === 'done' && task.metadata?.prUrl && (
|
||||
<Button
|
||||
variant="info"
|
||||
size="sm"
|
||||
|
||||
@@ -188,7 +188,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
// Update single task in store with new status and prUrl (more efficient than reloading all tasks)
|
||||
if (result.data.success && result.data.prUrl && !result.data.alreadyExists) {
|
||||
useTaskStore.getState().updateTask(task.id, {
|
||||
status: 'pr_created',
|
||||
status: 'done',
|
||||
metadata: { ...task.metadata, prUrl: result.data.prUrl }
|
||||
});
|
||||
}
|
||||
@@ -221,7 +221,6 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
if (isStuck) return 'warning';
|
||||
switch (status) {
|
||||
case 'done':
|
||||
case 'pr_created':
|
||||
return 'success';
|
||||
case 'human_review':
|
||||
return 'purple';
|
||||
@@ -295,16 +294,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'done') {
|
||||
return (
|
||||
<div className="completion-state text-sm flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium">{t('tasks:status.complete')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'pr_created') {
|
||||
if (task.status === 'done' && task.metadata?.prUrl) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="completion-state text-sm flex items-center gap-2 text-success">
|
||||
@@ -325,6 +315,15 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'done') {
|
||||
return (
|
||||
<div className="completion-state text-sm flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium">{t('tasks:status.complete')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
* - Title (optional)
|
||||
* - Agent profile selector
|
||||
* - Classification fields (collapsible)
|
||||
* - Image thumbnails
|
||||
* - Reference Images section (collapsible, with screenshot capture)
|
||||
* - Review requirement checkbox
|
||||
*/
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
import { useRef, useState, useEffect, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronUp, Image as ImageIcon, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Image as ImageIcon, X, Camera } from 'lucide-react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { Button } from '../ui/button';
|
||||
import { AgentProfileSelector } from '../AgentProfileSelector';
|
||||
import { ClassificationFields } from './ClassificationFields';
|
||||
import { useImageUpload, type FileReferenceData } from './useImageUpload';
|
||||
import { createThumbnail } from '../ImageUpload';
|
||||
import { ScreenshotCapture } from '../ScreenshotCapture';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { MAX_IMAGES_PER_TASK } from '../../../shared/constants';
|
||||
import type {
|
||||
TaskCategory,
|
||||
TaskPriority,
|
||||
@@ -137,6 +141,20 @@ export function TaskFormFields({
|
||||
const descriptionRef = externalDescriptionRef || internalDescriptionRef;
|
||||
const prefix = idPrefix ? `${idPrefix}-` : '';
|
||||
|
||||
// Reference Images section state
|
||||
const [showReferenceImages, setShowReferenceImages] = useState(false);
|
||||
const [screenshotModalOpen, setScreenshotModalOpen] = useState(false);
|
||||
|
||||
// Auto-expand reference images section when images are added via paste/drop/capture
|
||||
const prevImagesLengthRef = useRef(images.length);
|
||||
useEffect(() => {
|
||||
if (images.length > 0 && images.length > prevImagesLengthRef.current) {
|
||||
// Images were added, expand the section
|
||||
setShowReferenceImages(true);
|
||||
}
|
||||
prevImagesLengthRef.current = images.length;
|
||||
}, [images.length]);
|
||||
|
||||
// Use the shared image upload hook with translated error messages
|
||||
const {
|
||||
isDragOver,
|
||||
@@ -160,193 +178,293 @@ export function TaskFormFields({
|
||||
onFileReferenceDrop
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Description (Primary - Required) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.description')} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
{/* Optional overlay (e.g., @ mention highlighting) */}
|
||||
{descriptionOverlay}
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
id={`${prefix}description`}
|
||||
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
rows={6}
|
||||
disabled={disabled}
|
||||
aria-required="true"
|
||||
aria-describedby={`${prefix}description-help`}
|
||||
className={cn(
|
||||
'resize-y min-h-[150px] max-h-[400px] relative',
|
||||
descriptionOverlay && 'bg-transparent',
|
||||
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
|
||||
)}
|
||||
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
|
||||
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
|
||||
</p>
|
||||
/**
|
||||
* Handle screenshot capture from modal
|
||||
*
|
||||
* Validates the max images limit and creates a thumbnail for the screenshot.
|
||||
*/
|
||||
const handleScreenshotCapture = async (imageData: string) => {
|
||||
// Check max images limit
|
||||
if (images.length >= MAX_IMAGES_PER_TASK) {
|
||||
onError?.(t('tasks:form.errors.maxImagesReached'));
|
||||
return;
|
||||
}
|
||||
|
||||
{/* Image Thumbnails - displayed inline below description */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
style={{ width: '72px', height: '72px' }}
|
||||
title={image.filename}
|
||||
>
|
||||
{image.thumbnail ? (
|
||||
<img
|
||||
src={image.thumbnail}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
// Calculate size from base64 string (approximate)
|
||||
const base64Length = imageData.length;
|
||||
const sizeInBytes = Math.round(base64Length * 0.75); // Base64 is ~33% larger than binary
|
||||
|
||||
// Create thumbnail from full resolution screenshot
|
||||
const thumbnail = await createThumbnail(imageData);
|
||||
|
||||
const newImage: ImageAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
filename: `screenshot-${Date.now()}.png`,
|
||||
data: imageData,
|
||||
thumbnail,
|
||||
mimeType: 'image/png',
|
||||
size: sizeInBytes
|
||||
};
|
||||
onImagesChange([...images, newImage]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenshotCapture
|
||||
open={screenshotModalOpen}
|
||||
onOpenChange={setScreenshotModalOpen}
|
||||
onCapture={handleScreenshotCapture}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Description (Primary - Required) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}description`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.description')} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
{/* Optional overlay (e.g., @ mention highlighting) */}
|
||||
{descriptionOverlay}
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
id={`${prefix}description`}
|
||||
placeholder={descriptionPlaceholder || t('tasks:form.descriptionPlaceholder')}
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
rows={6}
|
||||
disabled={disabled}
|
||||
aria-required="true"
|
||||
aria-describedby={`${prefix}description-help`}
|
||||
className={cn(
|
||||
'resize-y min-h-[150px] max-h-[400px] relative',
|
||||
descriptionOverlay && 'bg-transparent',
|
||||
isDragOver && !disabled && 'border-primary bg-primary/5 ring-2 ring-primary/20'
|
||||
)}
|
||||
style={descriptionOverlay ? { caretColor: 'auto' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
<p id={`${prefix}description-help`} className="text-xs text-muted-foreground">
|
||||
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
|
||||
</p>
|
||||
|
||||
{/* Optional children (e.g., @ mention autocomplete) */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t('tasks:form.imageAddedSuccess')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional children (e.g., @ mention autocomplete) */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t('tasks:form.imageAddedSuccess')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}title`}
|
||||
placeholder={t('tasks:form.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:form.titleHelpText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={onProfileChange}
|
||||
onModelChange={onModelChange}
|
||||
onThinkingLevelChange={onThinkingLevelChange}
|
||||
onPhaseModelsChange={onPhaseModelsChange}
|
||||
onPhaseThinkingChange={onPhaseThinkingChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Classification Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowClassificationChange(!showClassification)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
aria-expanded={showClassification}
|
||||
aria-controls={`${prefix}classification-section`}
|
||||
>
|
||||
<span>{t('tasks:form.classificationOptional')}</span>
|
||||
{showClassification ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Classification Fields */}
|
||||
{showClassification && (
|
||||
<div id={`${prefix}classification-section`}>
|
||||
<ClassificationFields
|
||||
category={category}
|
||||
priority={priority}
|
||||
complexity={complexity}
|
||||
impact={impact}
|
||||
onCategoryChange={onCategoryChange}
|
||||
onPriorityChange={onPriorityChange}
|
||||
onComplexityChange={onComplexityChange}
|
||||
onImpactChange={onImpactChange}
|
||||
disabled={disabled}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Requirement Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<Checkbox
|
||||
id={`${prefix}require-review`}
|
||||
checked={requireReviewBeforeCoding}
|
||||
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label
|
||||
htmlFor={`${prefix}require-review`}
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
{t('tasks:form.requireReviewLabel')}
|
||||
{/* Title (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}title`} className="text-sm font-medium text-foreground">
|
||||
{t('tasks:form.taskTitle')} <span className="text-muted-foreground font-normal">({t('common:labels.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}title`}
|
||||
placeholder={t('tasks:form.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:form.requireReviewDescription')}
|
||||
{t('tasks:form.titleHelpText')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
|
||||
<X className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={onProfileChange}
|
||||
onModelChange={onModelChange}
|
||||
onThinkingLevelChange={onThinkingLevelChange}
|
||||
onPhaseModelsChange={onPhaseModelsChange}
|
||||
onPhaseThinkingChange={onPhaseThinkingChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Reference Images Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReferenceImages(!showReferenceImages)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
aria-expanded={showReferenceImages}
|
||||
aria-controls={`${prefix}reference-images-section`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t('tasks:referenceImages.title')}
|
||||
{images.length > 0 && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{images.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showReferenceImages ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reference Images Section */}
|
||||
{showReferenceImages && (
|
||||
<div id={`${prefix}reference-images-section`} className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:referenceImages.description')}
|
||||
</p>
|
||||
|
||||
{/* Capture Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScreenshotModalOpen(true)}
|
||||
disabled={disabled}
|
||||
className="gap-2"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t('tasks:screenshot.capture')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('images.pasteHint', { shortcut: navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-border overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
style={{ width: '72px', height: '72px' }}
|
||||
title={image.filename}
|
||||
>
|
||||
{image.thumbnail ? (
|
||||
<img
|
||||
src={image.thumbnail}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0.5 h-5 w-5 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeImage(image.id);
|
||||
}}
|
||||
aria-label={t('images.removeImageAriaLabel', { filename: image.filename })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length === 0 && (
|
||||
<div className="flex items-center justify-center py-6 border-2 border-dashed border-border rounded-md">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('tasks:feedback.dragDropHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowClassificationChange(!showClassification)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
aria-expanded={showClassification}
|
||||
aria-controls={`${prefix}classification-section`}
|
||||
>
|
||||
<span>{t('tasks:form.classificationOptional')}</span>
|
||||
{showClassification ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Classification Fields */}
|
||||
{showClassification && (
|
||||
<div id={`${prefix}classification-section`}>
|
||||
<ClassificationFields
|
||||
category={category}
|
||||
priority={priority}
|
||||
complexity={complexity}
|
||||
impact={impact}
|
||||
onCategoryChange={onCategoryChange}
|
||||
onPriorityChange={onPriorityChange}
|
||||
onComplexityChange={onComplexityChange}
|
||||
onImpactChange={onImpactChange}
|
||||
disabled={disabled}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Requirement Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<Checkbox
|
||||
id={`${prefix}require-review`}
|
||||
checked={requireReviewBeforeCoding}
|
||||
onCheckedChange={(checked) => onRequireReviewChange(checked === true)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label
|
||||
htmlFor={`${prefix}require-review`}
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
{t('tasks:form.requireReviewLabel')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('tasks:form.requireReviewDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
|
||||
<X className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -336,6 +336,16 @@ const browserMockAPI: ElectronAPI = {
|
||||
}
|
||||
}),
|
||||
|
||||
// Screenshot capture operations
|
||||
getSources: async () => ({
|
||||
success: true,
|
||||
data: []
|
||||
}),
|
||||
capture: async (_options: { sourceId: string }) => ({
|
||||
success: false,
|
||||
error: 'Screenshot capture not available in browser mode'
|
||||
}),
|
||||
|
||||
// Debug Operations
|
||||
getDebugInfo: async () => ({
|
||||
systemInfo: {
|
||||
|
||||
@@ -391,8 +391,10 @@ export async function updateProjectSettings(
|
||||
if (result.success) {
|
||||
const project = store.projects.find((p) => p.id === projectId);
|
||||
if (project) {
|
||||
// Merge settings properly, handling the case where project.settings might be undefined
|
||||
const currentSettings = project.settings || {};
|
||||
store.updateProject(projectId, {
|
||||
settings: { ...project.settings, ...settings }
|
||||
settings: { ...currentSettings, ...settings }
|
||||
});
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -32,6 +32,9 @@ interface TaskState {
|
||||
saveTaskOrder: (projectId: string) => boolean;
|
||||
clearTaskOrder: (projectId: string) => void;
|
||||
|
||||
// Task status change listeners (for queue auto-promotion)
|
||||
registerTaskStatusChangeListener: (listener: (taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus) => void) => () => void;
|
||||
|
||||
// Selectors
|
||||
getSelectedTask: () => Task | undefined;
|
||||
getTasksByStatus: (status: TaskStatus) => Task[];
|
||||
@@ -45,6 +48,25 @@ function findTaskIndex(tasks: Task[], taskId: string): number {
|
||||
return tasks.findIndex((t) => t.id === taskId || t.specId === taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task status change listeners for queue auto-promotion
|
||||
* Stored outside the store to avoid triggering re-renders
|
||||
*/
|
||||
const taskStatusChangeListeners = new Set<(taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus) => void>();
|
||||
|
||||
/**
|
||||
* Notify all registered listeners when a task status changes
|
||||
*/
|
||||
function notifyTaskStatusChange(taskId: string, oldStatus: TaskStatus | undefined, newStatus: TaskStatus): void {
|
||||
for (const listener of taskStatusChangeListeners) {
|
||||
try {
|
||||
listener(taskId, oldStatus, newStatus);
|
||||
} catch (error) {
|
||||
console.error('[TaskStore] Error in task status change listener:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update a single task efficiently.
|
||||
* Uses slice instead of map to avoid iterating all tasks.
|
||||
@@ -120,11 +142,12 @@ function getTaskOrderKey(projectId: string): string {
|
||||
function createEmptyTaskOrder(): TaskOrderState {
|
||||
return {
|
||||
backlog: [],
|
||||
queue: [],
|
||||
in_progress: [],
|
||||
ai_review: [],
|
||||
human_review: [],
|
||||
pr_created: [],
|
||||
done: [],
|
||||
pr_created: [],
|
||||
error: []
|
||||
};
|
||||
}
|
||||
@@ -178,14 +201,22 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
updateTaskStatus: (taskId, status) =>
|
||||
set((state) => {
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) {
|
||||
debugLog('[updateTaskStatus] Task not found:', taskId);
|
||||
return state;
|
||||
}
|
||||
updateTaskStatus: (taskId, status) => {
|
||||
// Capture old status before update
|
||||
const state = get();
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) {
|
||||
debugLog('[updateTaskStatus] Task not found:', taskId);
|
||||
return;
|
||||
}
|
||||
const oldTask = state.tasks[index];
|
||||
const oldStatus = oldTask.status;
|
||||
|
||||
// Skip if status is the same
|
||||
if (oldStatus === status) return;
|
||||
|
||||
// Perform the state update
|
||||
set((state) => {
|
||||
return {
|
||||
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
|
||||
// Determine execution progress based on status transition
|
||||
@@ -218,7 +249,13 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
return { ...t, status, executionProgress, updatedAt: new Date() };
|
||||
})
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
// Notify listeners after state update (schedule after current tick)
|
||||
queueMicrotask(() => {
|
||||
notifyTaskStatusChange(taskId, oldStatus, status);
|
||||
});
|
||||
},
|
||||
|
||||
updateTaskFromPlan: (taskId, plan) =>
|
||||
set((state) => {
|
||||
@@ -317,7 +354,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
// 1. Subtasks array is properly populated (not empty)
|
||||
// 2. All subtasks are actually completed (for 'done' and 'ai_review' statuses)
|
||||
const hasSubtasks = subtasks.length > 0;
|
||||
const terminalStatuses: TaskStatus[] = ['human_review', 'pr_created', 'done'];
|
||||
const terminalStatuses: TaskStatus[] = ['human_review', 'done'];
|
||||
|
||||
// If task is currently in a terminal status, validate subtasks before allowing downgrade
|
||||
// This prevents flip-flop when plan file is written with incomplete data
|
||||
@@ -328,8 +365,8 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
if (newStatus === 'ai_review' && (!allCompleted || !hasSubtasks)) {
|
||||
return true;
|
||||
}
|
||||
// For done and pr_created, all subtasks must be completed
|
||||
if ((newStatus === 'done' || newStatus === 'pr_created') && (!allCompleted || !hasSubtasks)) {
|
||||
// For done, all subtasks must be completed
|
||||
if (newStatus === 'done' && (!allCompleted || !hasSubtasks)) {
|
||||
return true;
|
||||
}
|
||||
// For human_review with 'completed' reason, all subtasks must be done
|
||||
@@ -350,7 +387,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
if (!isInActivePhase && !isInTerminalPhase && !isInTerminalStatus && !isExplicitHumanReview) {
|
||||
if (allCompleted && hasSubtasks) {
|
||||
// FIX (Flip-Flop Bug): Don't downgrade from terminal statuses to ai_review
|
||||
// Once a task reaches human_review, pr_created, or done, it should stay there
|
||||
// Once a task reaches human_review or done, it should stay there
|
||||
// unless explicitly changed (these are finalized workflow states)
|
||||
if (!terminalStatuses.includes(t.status)) {
|
||||
status = 'ai_review';
|
||||
@@ -565,11 +602,12 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
const emptyOrder = createEmptyTaskOrder();
|
||||
const validatedOrder: TaskOrderState = {
|
||||
backlog: isValidColumnArray(parsed.backlog) ? parsed.backlog : emptyOrder.backlog,
|
||||
queue: isValidColumnArray(parsed.queue) ? parsed.queue : emptyOrder.queue,
|
||||
in_progress: isValidColumnArray(parsed.in_progress) ? parsed.in_progress : emptyOrder.in_progress,
|
||||
ai_review: isValidColumnArray(parsed.ai_review) ? parsed.ai_review : emptyOrder.ai_review,
|
||||
human_review: isValidColumnArray(parsed.human_review) ? parsed.human_review : emptyOrder.human_review,
|
||||
pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created,
|
||||
done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done,
|
||||
pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created,
|
||||
error: isValidColumnArray(parsed.error) ? parsed.error : emptyOrder.error
|
||||
};
|
||||
|
||||
@@ -618,6 +656,14 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
getTasksByStatus: (status) => {
|
||||
const state = get();
|
||||
return state.tasks.filter((t) => t.status === status);
|
||||
},
|
||||
|
||||
registerTaskStatusChangeListener: (listener) => {
|
||||
taskStatusChangeListeners.add(listener);
|
||||
// Return cleanup function to unregister
|
||||
return () => {
|
||||
taskStatusChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1140,6 +1140,10 @@ body {
|
||||
border-top-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.column-queue {
|
||||
border-top-color: #22d3ee;
|
||||
}
|
||||
|
||||
.column-in-progress {
|
||||
border-top-color: var(--info);
|
||||
}
|
||||
|
||||
@@ -555,5 +555,9 @@ export const IPC_CHANNELS = {
|
||||
METHODOLOGY_GET_CONFIG: 'methodology:getConfig', // Get project's methodology config
|
||||
METHODOLOGY_SAVE_CONFIG: 'methodology:saveConfig', // Save project's methodology config
|
||||
METHODOLOGY_LIST_AVAILABLE: 'methodology:listAvailable', // List available methodologies with their sources
|
||||
METHODOLOGY_CHECK_COMPATIBILITY: 'methodology:checkCompatibility' // Check version compatibility
|
||||
METHODOLOGY_CHECK_COMPATIBILITY: 'methodology:checkCompatibility', // Check version compatibility
|
||||
|
||||
// Screenshot capture
|
||||
SCREENSHOT_GET_SOURCES: 'screenshot:getSources', // Get available screens/windows
|
||||
SCREENSHOT_CAPTURE: 'screenshot:capture' // Capture screenshot from source
|
||||
} as const;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// Task status columns in Kanban board order
|
||||
export const TASK_STATUS_COLUMNS = [
|
||||
'backlog',
|
||||
'queue',
|
||||
'in_progress',
|
||||
'ai_review',
|
||||
'human_review',
|
||||
@@ -23,6 +24,7 @@ export type TaskStatusColumn = typeof TASK_STATUS_COLUMNS[number];
|
||||
// Note: error maps to 'human_review' column in Kanban view (errors need human attention)
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created' | 'error', string> = {
|
||||
backlog: 'columns.backlog',
|
||||
queue: 'columns.queue',
|
||||
in_progress: 'columns.in_progress',
|
||||
ai_review: 'columns.ai_review',
|
||||
human_review: 'columns.human_review',
|
||||
@@ -36,6 +38,7 @@ export const TASK_STATUS_LABELS: Record<TaskStatusColumn | 'pr_created' | 'error
|
||||
// Note: error maps to 'human_review' column in Kanban view (errors need human attention)
|
||||
export const TASK_STATUS_COLORS: Record<TaskStatusColumn | 'pr_created' | 'error', string> = {
|
||||
backlog: 'bg-muted text-muted-foreground',
|
||||
queue: 'bg-cyan-500/10 text-cyan-400',
|
||||
in_progress: 'bg-info/10 text-info',
|
||||
ai_review: 'bg-warning/10 text-warning',
|
||||
human_review: 'bg-purple-500/10 text-purple-400',
|
||||
|
||||
@@ -408,6 +408,41 @@
|
||||
"scrollForMore": "Scroll for more",
|
||||
"allLoaded": "All issues loaded"
|
||||
},
|
||||
"usage": {
|
||||
"dataUnavailable": "Usage data unavailable",
|
||||
"dataUnavailableDescription": "The usage monitoring endpoint for this provider is not available or not supported.",
|
||||
"activeAccount": "Active Account",
|
||||
"usageAlert": "Usage Alert",
|
||||
"accountExceedsThreshold": "Account usage exceeds 90% threshold",
|
||||
"authentication": "Authentication",
|
||||
"authenticationAriaLabel": "Authentication: {{provider}}",
|
||||
"authenticationDetails": "Authentication Details",
|
||||
"apiProfile": "API Profile",
|
||||
"oauth": "OAuth",
|
||||
"provider": "Provider",
|
||||
"providerAnthropic": "Anthropic",
|
||||
"providerZai": "z.ai",
|
||||
"providerZhipu": "ZHIPU AI",
|
||||
"providerUnknown": "Unknown",
|
||||
"profile": "Profile",
|
||||
"id": "ID",
|
||||
"created": "Created",
|
||||
"apiEndpoint": "API Endpoint",
|
||||
"sessionQuota": "Session Quota",
|
||||
"notAvailable": "N/A",
|
||||
"usageStatusAriaLabel": "Usage status",
|
||||
"usageBreakdown": "Usage Breakdown",
|
||||
"used": "used",
|
||||
"loading": "Loading...",
|
||||
"sessionDefault": "Session",
|
||||
"weeklyDefault": "Weekly",
|
||||
"resetsInHours": "Resets in {{hours}}h {{minutes}}m",
|
||||
"resetsInDays": "Resets in {{days}}d {{hours}}h",
|
||||
"window5Hour": "5-hour window",
|
||||
"window7Day": "7-day window",
|
||||
"window5HoursQuota": "5 Hours Quota",
|
||||
"windowMonthlyToolsQuota": "Monthly Tools Quota"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Manual Code Entry (Fallback)",
|
||||
"enterCodeDescription": "This dialog is only needed if the browser didn't redirect automatically. If authentication already completed in your browser, you can close this dialog.",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"refreshTasks": "Refresh Tasks",
|
||||
"status": {
|
||||
"backlog": "Backlog",
|
||||
"queue": "Queue",
|
||||
"todo": "To Do",
|
||||
"in_progress": "In Progress",
|
||||
"review": "Review",
|
||||
@@ -61,6 +62,7 @@
|
||||
},
|
||||
"columns": {
|
||||
"backlog": "Planning",
|
||||
"queue": "Queue",
|
||||
"in_progress": "In Progress",
|
||||
"ai_review": "AI Review",
|
||||
"human_review": "Human Review",
|
||||
@@ -71,8 +73,10 @@
|
||||
"kanban": {
|
||||
"emptyBacklog": "No tasks planned",
|
||||
"emptyBacklogHint": "Add a task to get started",
|
||||
"emptyQueue": "Queue is empty",
|
||||
"emptyQueueHint": "Tasks will wait here when parallel task limit is reached",
|
||||
"emptyInProgress": "Nothing running",
|
||||
"emptyInProgressHint": "Start a task from Backlog",
|
||||
"emptyInProgressHint": "Start a task from Planning",
|
||||
"emptyAiReview": "No tasks in review",
|
||||
"emptyAiReviewHint": "AI will review completed tasks",
|
||||
"emptyHumanReview": "Nothing to review",
|
||||
@@ -83,6 +87,7 @@
|
||||
"dropHere": "Drop here",
|
||||
"showArchived": "Show archived",
|
||||
"addTaskAriaLabel": "Add new task to backlog",
|
||||
"queueAllAriaLabel": "Move all tasks to queue",
|
||||
"closeTaskDetailsAriaLabel": "Close task details",
|
||||
"editTask": "Edit task",
|
||||
"cannotEditWhileRunning": "Cannot edit while task is running",
|
||||
@@ -92,6 +97,7 @@
|
||||
"keepWorktree": "Keep Worktree",
|
||||
"deleteWorktree": "Delete Worktree & Mark Done",
|
||||
"refreshTasks": "Refresh Tasks",
|
||||
"queueSettings": "Queue Settings",
|
||||
"orderSaveFailedTitle": "Reorder not saved",
|
||||
"orderSaveFailedDescription": "Your task order change was applied but couldn't be saved to storage. It will be lost on refresh.",
|
||||
"selectAll": "Select all",
|
||||
@@ -102,6 +108,25 @@
|
||||
"createPRs": "Create PRs",
|
||||
"clearSelection": "Clear Selection"
|
||||
},
|
||||
"queue": {
|
||||
"limitReached": "Parallel task limit reached ({{current}}/{{max}}). Task moved to queue.",
|
||||
"movedToQueue": "Task moved to queue.",
|
||||
"autoPromoted": "Task auto-promoted from queue to In Progress.",
|
||||
"capacityAvailable": "{{count}} slot(s) available in In Progress.",
|
||||
"queueAll": "Add All to Queue",
|
||||
"queueAllSuccess": "Moved {{count}} tasks to queue.",
|
||||
"settings": {
|
||||
"title": "Queue Settings",
|
||||
"description": "Configure the maximum number of tasks that can run in parallel in the \"In Progress\" board",
|
||||
"maxParallelLabel": "Max Parallel Tasks",
|
||||
"minValueError": "Must be at least 1",
|
||||
"maxValueError": "Cannot exceed 10",
|
||||
"hint": "When this limit is reached, new tasks will wait in the queue before moving to \"In Progress\"",
|
||||
"saved": "Queue settings saved",
|
||||
"saveFailed": "Failed to save queue settings",
|
||||
"retry": "Please try again"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"phases": {
|
||||
"idle": "Idle",
|
||||
@@ -343,5 +368,22 @@
|
||||
"noTasksToSelect": "No tasks available to select",
|
||||
"confirmBulkAction": "Confirm bulk action for {{count}} tasks",
|
||||
"processingTasks": "Processing selected tasks..."
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "Capture Screenshot",
|
||||
"description": "Select a screen or window to capture as a reference image",
|
||||
"capture": "Capture",
|
||||
"capturing": "Capturing...",
|
||||
"noSources": "No screens or windows found",
|
||||
"errors": {
|
||||
"getSources": "Failed to get screenshot sources",
|
||||
"fetchSources": "Failed to fetch screenshot sources",
|
||||
"capture": "Failed to capture screenshot",
|
||||
"captureFailed": "Failed to capture screenshot"
|
||||
}
|
||||
},
|
||||
"referenceImages": {
|
||||
"title": "Reference Images (optional)",
|
||||
"description": "Add visual references like screenshots or designs to help the AI understand your requirements."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,41 @@
|
||||
"scrollForMore": "Défiler pour plus",
|
||||
"allLoaded": "Toutes les issues chargées"
|
||||
},
|
||||
"usage": {
|
||||
"dataUnavailable": "Données d'utilisation non disponibles",
|
||||
"dataUnavailableDescription": "Le point de terminaison de surveillance d'utilisation pour ce fournisseur n'est pas disponible ou n'est pas pris en charge.",
|
||||
"activeAccount": "Compte actif",
|
||||
"usageAlert": "Alerte d'utilisation",
|
||||
"accountExceedsThreshold": "L'utilisation du compte dépasse le seuil de 90 %",
|
||||
"authentication": "Authentification",
|
||||
"authenticationAriaLabel": "Authentification : {{provider}}",
|
||||
"authenticationDetails": "Détails de l'authentification",
|
||||
"apiProfile": "Profil API",
|
||||
"oauth": "OAuth",
|
||||
"provider": "Fournisseur",
|
||||
"providerAnthropic": "Anthropic",
|
||||
"providerZai": "z.ai",
|
||||
"providerZhipu": "ZHIPU AI",
|
||||
"providerUnknown": "Inconnu",
|
||||
"profile": "Profil",
|
||||
"id": "ID",
|
||||
"created": "Créé",
|
||||
"apiEndpoint": "Point de terminaison API",
|
||||
"sessionQuota": "Quota de session",
|
||||
"notAvailable": "N/A",
|
||||
"usageStatusAriaLabel": "Statut d'utilisation",
|
||||
"usageBreakdown": "Répartition de l'utilisation",
|
||||
"used": "utilisé",
|
||||
"loading": "Chargement...",
|
||||
"sessionDefault": "Session",
|
||||
"weeklyDefault": "Hebdomadaire",
|
||||
"resetsInHours": "Réinitialisation dans {{hours}}h {{minutes}}m",
|
||||
"resetsInDays": "Réinitialisation dans {{days}}j {{hours}}h",
|
||||
"window5Hour": "Fenêtre de 5 heures",
|
||||
"window7Day": "Fenêtre de 7 jours",
|
||||
"window5HoursQuota": "Quota de 5 heures",
|
||||
"windowMonthlyToolsQuota": "Quota mensuel d'outils"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Saisie manuelle du code (secours)",
|
||||
"enterCodeDescription": "Ce dialogue n'est nécessaire que si le navigateur n'a pas redirigé automatiquement. Si l'authentification est déjà terminée dans votre navigateur, vous pouvez fermer ce dialogue.",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
},
|
||||
"columns": {
|
||||
"backlog": "Planification",
|
||||
"queue": "File d'attente",
|
||||
"in_progress": "En cours",
|
||||
"ai_review": "Révision IA",
|
||||
"human_review": "Révision humaine",
|
||||
@@ -71,6 +72,8 @@
|
||||
"kanban": {
|
||||
"emptyBacklog": "Aucune tâche planifiée",
|
||||
"emptyBacklogHint": "Ajoutez une tâche pour commencer",
|
||||
"emptyQueue": "La file d'attente est vide",
|
||||
"emptyQueueHint": "Les tâches attendront ici lorsque la limite de tâches parallèles sera atteinte",
|
||||
"emptyInProgress": "Rien en cours",
|
||||
"emptyInProgressHint": "Démarrez une tâche depuis le Backlog",
|
||||
"emptyAiReview": "Aucune tâche en révision",
|
||||
@@ -83,6 +86,7 @@
|
||||
"dropHere": "Déposer ici",
|
||||
"showArchived": "Afficher les archivées",
|
||||
"addTaskAriaLabel": "Ajouter une nouvelle tâche au backlog",
|
||||
"queueAllAriaLabel": "Déplacer toutes les tâches vers la file d'attente",
|
||||
"closeTaskDetailsAriaLabel": "Fermer les détails de la tâche",
|
||||
"editTask": "Modifier la tâche",
|
||||
"cannotEditWhileRunning": "Impossible de modifier pendant l'exécution",
|
||||
@@ -92,6 +96,7 @@
|
||||
"keepWorktree": "Garder le Worktree",
|
||||
"deleteWorktree": "Supprimer le Worktree & Marquer Terminé",
|
||||
"refreshTasks": "Actualiser les tâches",
|
||||
"queueSettings": "Paramètres de la file d'attente",
|
||||
"orderSaveFailedTitle": "Réorganisation non enregistrée",
|
||||
"orderSaveFailedDescription": "Votre changement d'ordre des tâches a été appliqué mais n'a pas pu être sauvegardé. Il sera perdu lors du rafraîchissement.",
|
||||
"selectAll": "Tout sélectionner",
|
||||
@@ -102,6 +107,25 @@
|
||||
"createPRs": "Créer les PRs",
|
||||
"clearSelection": "Effacer la sélection"
|
||||
},
|
||||
"queue": {
|
||||
"limitReached": "Limite de tâches parallèles atteinte ({{current}}/{{max}}). Tâche déplacée vers la file d'attente.",
|
||||
"movedToQueue": "Tâche déplacée vers la file d'attente.",
|
||||
"autoPromoted": "Tâche auto-promue de la file d'attente vers En cours.",
|
||||
"capacityAvailable": "{{count}} emplacement(s) disponible(s) dans En cours.",
|
||||
"queueAll": "Tout ajouter à la file d'attente",
|
||||
"queueAllSuccess": "{{count}} tâches déplacées vers la file d'attente.",
|
||||
"settings": {
|
||||
"title": "Paramètres de la file d'attente",
|
||||
"description": "Configurer le nombre maximal de tâches pouvant s'exécuter en parallèle dans le tableau \"En cours\"",
|
||||
"maxParallelLabel": "Tâches parallèles maximales",
|
||||
"minValueError": "Doit être au moins 1",
|
||||
"maxValueError": "Ne peut pas dépasser 10",
|
||||
"hint": "Lorsque cette limite est atteinte, les nouvelles tâches attendront dans la file avant de passer à \"En cours\"",
|
||||
"saved": "Paramètres de la file d'attente enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres",
|
||||
"retry": "Veuillez réessayer"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"phases": {
|
||||
"idle": "Inactif",
|
||||
@@ -343,5 +367,22 @@
|
||||
"noTasksToSelect": "Aucune tâche disponible à sélectionner",
|
||||
"confirmBulkAction": "Confirmer l'action groupée pour {{count}} tâches",
|
||||
"processingTasks": "Traitement des tâches sélectionnées..."
|
||||
},
|
||||
"screenshot": {
|
||||
"title": "Prendre une capture d'écran",
|
||||
"description": "Sélectionnez un écran ou une fenêtre à capturer comme image de référence",
|
||||
"capture": "Capturer",
|
||||
"capturing": "Capture...",
|
||||
"noSources": "Aucun écran ou fenêtre trouvé",
|
||||
"errors": {
|
||||
"getSources": "Échec de l'obtention des sources de capture d'écran",
|
||||
"fetchSources": "Échec de la récupération des sources de capture d'écran",
|
||||
"capture": "Échec de la capture d'écran",
|
||||
"captureFailed": "Échec de la capture d'écran"
|
||||
}
|
||||
},
|
||||
"referenceImages": {
|
||||
"title": "Images de référence (facultatif)",
|
||||
"description": "Ajoutez des références visuelles comme des captures d'écran ou des conceptions pour aider l'IA à comprendre vos exigences."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,29 @@ export interface ClaudeUsageData {
|
||||
* Returned from API or CLI usage check
|
||||
*/
|
||||
export interface ClaudeUsageSnapshot {
|
||||
/** Session usage percentage (0-100) */
|
||||
/** Session usage percentage (0-100) - represents 5-hour window for most providers */
|
||||
sessionPercent: number;
|
||||
/** Weekly usage percentage (0-100) */
|
||||
/** Weekly usage percentage (0-100) - represents 7-day window for Anthropic, monthly for z.ai */
|
||||
weeklyPercent: number;
|
||||
/** When the session limit resets (human-readable or ISO) */
|
||||
/**
|
||||
* When the session limit resets (human-readable or ISO)
|
||||
*
|
||||
* NOTE: This value may contain hardcoded English strings ('Unknown', 'Expired', 'Resets in ...')
|
||||
* from the main process. Renderer components should use the sessionResetTimestamp field
|
||||
* with formatTimeRemaining() to generate localized countdown text when available.
|
||||
*/
|
||||
sessionResetTime?: string;
|
||||
/** When the weekly limit resets (human-readable or ISO) */
|
||||
/**
|
||||
* When the weekly limit resets (human-readable or ISO)
|
||||
*
|
||||
* NOTE: This value may contain hardcoded English strings ('Unknown', '1st of January', etc.)
|
||||
* from the main process. Renderer components should localize these values before display.
|
||||
*/
|
||||
weeklyResetTime?: string;
|
||||
/** ISO timestamp of when the session limit resets (for dynamic countdown calculation) */
|
||||
sessionResetTimestamp?: string;
|
||||
/** ISO timestamp of when the weekly limit resets (for dynamic countdown calculation) */
|
||||
weeklyResetTimestamp?: string;
|
||||
/** Profile ID this snapshot belongs to */
|
||||
profileId: string;
|
||||
/** Profile name for display */
|
||||
@@ -45,6 +60,21 @@ export interface ClaudeUsageSnapshot {
|
||||
fetchedAt: Date;
|
||||
/** Which limit is closest to threshold ('session' or 'weekly') */
|
||||
limitType?: 'session' | 'weekly';
|
||||
/** Usage window types for this provider */
|
||||
usageWindows?: {
|
||||
/** Label for the session window (e.g., '5-hour', '5-hour window') */
|
||||
sessionWindowLabel: string;
|
||||
/** Label for the weekly window (e.g., '7-day', 'monthly', 'calendar month') */
|
||||
weeklyWindowLabel: string;
|
||||
};
|
||||
/** Raw session usage value (e.g., tokens used) */
|
||||
sessionUsageValue?: number;
|
||||
/** Session usage limit (total quota) */
|
||||
sessionUsageLimit?: number;
|
||||
/** Raw weekly usage value (e.g., tools used) */
|
||||
weeklyUsageValue?: number;
|
||||
/** Weekly usage limit (total quota) */
|
||||
weeklyUsageLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -862,6 +862,14 @@ export interface ElectronAPI {
|
||||
maxVersion?: string;
|
||||
}>>>;
|
||||
checkMethodologyCompatibility: (name: string, version: string) => Promise<IPCResult<MethodologyCompatibilityResult>>;
|
||||
|
||||
// Screenshot capture operations
|
||||
getSources: () => Promise<IPCResult<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}>>>;
|
||||
capture: (options: { sourceId: string }) => Promise<IPCResult<string>>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ProjectSettings {
|
||||
useClaudeMd?: boolean;
|
||||
/** Default methodology name for this project (default: 'native') */
|
||||
methodology?: string;
|
||||
/** Maximum parallel tasks allowed (default: 3) */
|
||||
maxParallelTasks?: number;
|
||||
|
||||
// ============================================
|
||||
// Agent Profile Settings (Project-Level)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Screenshot capture types
|
||||
*
|
||||
* Shared types for screenshot functionality across main, preload, and renderer processes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a screenshot source (screen or window) available for capture
|
||||
*/
|
||||
export interface ScreenshotSource {
|
||||
/** Unique identifier for the source */
|
||||
id: string;
|
||||
/** Display name of the source (e.g., "Screen 1", "Chrome") */
|
||||
name: string;
|
||||
/** Base64 encoded PNG thumbnail preview */
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for capturing a screenshot
|
||||
*/
|
||||
export interface ScreenshotCaptureOptions {
|
||||
/** The ID of the source to capture */
|
||||
sourceId: string;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
|
||||
import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../constants/phase-protocol';
|
||||
|
||||
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done' | 'error';
|
||||
export type TaskStatus = 'backlog' | 'queue' | 'in_progress' | 'ai_review' | 'human_review' | 'done' | 'pr_created' | 'error';
|
||||
|
||||
// Maps task status columns to ordered task IDs for kanban board reordering
|
||||
export type TaskOrderState = Record<TaskStatus, string[]>;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Time Formatting Utilities
|
||||
*
|
||||
* Shared utilities for formatting time differences and durations.
|
||||
* Designed for use with i18n translation functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Known hardcoded English patterns from main process to filter out
|
||||
*
|
||||
* The main process may send these sentinel values when time data is unavailable.
|
||||
* This helper is used to filter them out before displaying to users.
|
||||
*
|
||||
* @param text - The text to check
|
||||
* @returns true if text is a hardcoded sentinel value (undefined, null, 'Unknown', 'Expired', or whitespace-only)
|
||||
*
|
||||
* @example
|
||||
* hasHardcodedText('Unknown') // true
|
||||
* hasHardcodedText('Expired') // true
|
||||
* hasHardcodedText(' ') // true (whitespace-only)
|
||||
* hasHardcodedText('Resets in 2h') // false
|
||||
*/
|
||||
export function hasHardcodedText(text?: string | null): boolean {
|
||||
// Trim whitespace before checking - whitespace-only strings are treated as empty
|
||||
const trimmed = text?.trim();
|
||||
return !trimmed || trimmed === 'Unknown' || trimmed === 'Expired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation key mapping for backend usage window labels
|
||||
* Maps backend-provided English strings to i18n translation keys
|
||||
*/
|
||||
const USAGE_WINDOW_LABEL_MAP: Readonly<Record<string, string>> = {
|
||||
'5-hour window': 'window5Hour',
|
||||
'7-day window': 'window7Day',
|
||||
'5 Hours Quota': 'window5HoursQuota',
|
||||
'Monthly Tools Quota': 'windowMonthlyToolsQuota'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Map backend-provided usage window labels to localized translation keys
|
||||
*
|
||||
* The backend now provides i18n translation keys like "common:usage.window5Hour".
|
||||
* For backward compatibility, also handles legacy English strings like "5-hour window".
|
||||
*
|
||||
* @param backendLabel - The translation key or legacy English label from the backend API
|
||||
* @param t - i18next translation function
|
||||
* @param defaultKey - Optional default translation key (default: 'common:usage.sessionDefault')
|
||||
* @returns Localized label string
|
||||
*
|
||||
* @example
|
||||
* localizeUsageWindowLabel('common:usage.window5Hour', t)
|
||||
* // Returns: t('common:usage.window5Hour') → "5-hour window" (en) or localized equivalent
|
||||
*
|
||||
* @example
|
||||
* // Legacy backward compatibility
|
||||
* localizeUsageWindowLabel('5-hour window', t)
|
||||
* // Returns: t('common:usage.window5Hour') → "5-hour window" (en) or localized equivalent
|
||||
*
|
||||
* @example
|
||||
* localizeUsageWindowLabel('Unknown Label', t, 'common:usage.weeklyDefault')
|
||||
* // Returns: t('common:usage.weeklyDefault') → localized fallback, not the raw backend label
|
||||
*/
|
||||
export function localizeUsageWindowLabel(
|
||||
backendLabel: string | undefined,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
defaultKey: string = 'common:usage.sessionDefault'
|
||||
): string {
|
||||
if (!backendLabel) return t(defaultKey);
|
||||
|
||||
// Check if backendLabel is already a translation key (contains colon)
|
||||
// New format: backend sends "common:usage.window5Hour" directly
|
||||
if (backendLabel.includes(':')) {
|
||||
const translated = t(backendLabel);
|
||||
// If translation returns the key itself (not found), use default
|
||||
return translated === backendLabel ? t(defaultKey) : translated;
|
||||
}
|
||||
|
||||
// Legacy backward compatibility: map old hardcoded English strings to translation keys
|
||||
const translationKey = USAGE_WINDOW_LABEL_MAP[backendLabel];
|
||||
if (translationKey) {
|
||||
const translated = t(`common:usage.${translationKey}`);
|
||||
// If translation returns the key itself (not found), use backend label as fallback
|
||||
return translated === `common:usage.${translationKey}` ? backendLabel : translated;
|
||||
}
|
||||
|
||||
// Unknown label - use localized default instead of raw backend text
|
||||
return t(defaultKey);
|
||||
}
|
||||
|
||||
export interface FormatTimeRemainingOptions {
|
||||
/** Translation key for hours/minutes format (default: 'common:usage.resetsInHours') */
|
||||
hoursKey?: string;
|
||||
/** Translation key for days/hours format (default: 'common:usage.resetsInDays') */
|
||||
daysKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as a human-readable "time remaining" string
|
||||
*
|
||||
* Calculates the time difference between the given timestamp and now,
|
||||
* then formats it using the provided translation function.
|
||||
*
|
||||
* @param timestamp - ISO timestamp string to format
|
||||
* @param t - i18next translation function
|
||||
* @param options - Optional configuration
|
||||
* @returns Formatted time string, or undefined if timestamp is invalid
|
||||
*
|
||||
* @example
|
||||
* formatTimeRemaining('2025-01-20T15:00:00Z', t)
|
||||
* // Returns: "Resets in 2h 30m" or "Resets in 3d 5h" depending on time difference
|
||||
*
|
||||
* @example
|
||||
* formatTimeRemaining('2025-01-20T15:00:00Z', t, {
|
||||
* hoursKey: 'common:usage.resetsInHours',
|
||||
* daysKey: 'common:usage.resetsInDays'
|
||||
* })
|
||||
*/
|
||||
export function formatTimeRemaining(
|
||||
timestamp: string | undefined,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
options: FormatTimeRemainingOptions = {}
|
||||
): string | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
|
||||
const { hoursKey = 'common:usage.resetsInHours', daysKey = 'common:usage.resetsInDays' } = options;
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// Handle invalid dates (isNaN check before using getTime())
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
|
||||
// Handle past dates
|
||||
if (diffMs < 0) {
|
||||
// Return undefined for past dates - caller can provide fallback
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (diffHours < 24) {
|
||||
return t(hoursKey, { hours: diffHours, minutes: diffMins });
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const remainingHours = diffHours % 24;
|
||||
return t(daysKey, { days: diffDays, hours: remainingHours });
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple time formatting for main process (no i18n)
|
||||
*
|
||||
* Used in usage-monitor.ts for backend time formatting.
|
||||
* Returns simple "2h 30m" or "3d 5h" format.
|
||||
*
|
||||
* NOTE: This function returns hardcoded English strings ('Unknown', 'Expired')
|
||||
* because i18n is not available in the main process. These sentinel values
|
||||
* flow into ClaudeUsageSnapshot and should be replaced with localized text
|
||||
* in the renderer process before displaying to users.
|
||||
*
|
||||
* FUTURE: Consider returning structured data (e.g., { status: 'unknown' })
|
||||
* instead of strings to allow renderer-side localization.
|
||||
*
|
||||
* @param timestamp - ISO timestamp string
|
||||
* @returns Formatted time string, or 'Unknown'/'Expired' for special cases
|
||||
*/
|
||||
export function formatTimeRemainingSimple(timestamp: string | undefined): string {
|
||||
if (!timestamp) return 'Unknown';
|
||||
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// Handle invalid dates
|
||||
if (isNaN(date.getTime())) return 'Unknown';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
|
||||
// Handle past dates
|
||||
if (diffMs < 0) return 'Expired';
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h ${diffMins}m`;
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const remainingHours = diffHours % 24;
|
||||
return `${diffDays}d ${remainingHours}h`;
|
||||
} catch (_error) {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for provider detection utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectProvider, getProviderLabel, getProviderBadgeColor } from './provider-detection';
|
||||
|
||||
describe('provider-detection', () => {
|
||||
describe('detectProvider', () => {
|
||||
describe('Anthropic provider', () => {
|
||||
it('should detect Anthropic from api.anthropic.com', () => {
|
||||
const result = detectProvider('https://api.anthropic.com');
|
||||
expect(result).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should detect Anthropic with path', () => {
|
||||
const result = detectProvider('https://api.anthropic.com/v1/messages');
|
||||
expect(result).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should handle subdomain of Anthropic correctly', () => {
|
||||
const result = detectProvider('https://sub.api.anthropic.com');
|
||||
expect(result).toBe('anthropic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('z.ai provider', () => {
|
||||
it('should detect z.ai from api.z.ai', () => {
|
||||
const result = detectProvider('https://api.z.ai/api/anthropic');
|
||||
expect(result).toBe('zai');
|
||||
});
|
||||
|
||||
it('should detect z.ai from z.ai domain', () => {
|
||||
const result = detectProvider('https://z.ai/api/anthropic');
|
||||
expect(result).toBe('zai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZHIPU provider', () => {
|
||||
it('should detect ZHIPU from open.bigmodel.cn', () => {
|
||||
const result = detectProvider('https://open.bigmodel.cn/api/paas/v4');
|
||||
expect(result).toBe('zhipu');
|
||||
});
|
||||
|
||||
it('should detect ZHIPU from dev.bigmodel.cn', () => {
|
||||
const result = detectProvider('https://dev.bigmodel.cn/api/paas/v4');
|
||||
expect(result).toBe('zhipu');
|
||||
});
|
||||
|
||||
it('should detect ZHIPU from bigmodel.cn', () => {
|
||||
const result = detectProvider('https://bigmodel.cn/api/paas/v4');
|
||||
expect(result).toBe('zhipu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown provider', () => {
|
||||
it('should return unknown for unrecognized domain', () => {
|
||||
const result = detectProvider('https://unknown.com/api');
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle invalid URL gracefully', () => {
|
||||
const result = detectProvider('not-a-url');
|
||||
expect(result).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderLabel', () => {
|
||||
it('should return correct label for Anthropic', () => {
|
||||
expect(getProviderLabel('anthropic')).toBe('Anthropic');
|
||||
});
|
||||
|
||||
it('should return correct label for z.ai', () => {
|
||||
expect(getProviderLabel('zai')).toBe('z.ai');
|
||||
});
|
||||
|
||||
it('should return correct label for ZHIPU', () => {
|
||||
expect(getProviderLabel('zhipu')).toBe('ZHIPU AI');
|
||||
});
|
||||
|
||||
it('should return Unknown for unknown provider', () => {
|
||||
expect(getProviderLabel('unknown')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderBadgeColor', () => {
|
||||
it('should return orange colors for Anthropic', () => {
|
||||
const color = getProviderBadgeColor('anthropic');
|
||||
expect(color).toContain('orange');
|
||||
expect(color).toContain('bg-orange-500/10');
|
||||
expect(color).toContain('text-orange-500');
|
||||
expect(color).toContain('border-orange-500/20');
|
||||
});
|
||||
|
||||
it('should return blue colors for z.ai', () => {
|
||||
const color = getProviderBadgeColor('zai');
|
||||
expect(color).toContain('blue');
|
||||
expect(color).toContain('bg-blue-500/10');
|
||||
expect(color).toContain('text-blue-500');
|
||||
expect(color).toContain('border-blue-500/20');
|
||||
});
|
||||
|
||||
it('should return purple colors for ZHIPU', () => {
|
||||
const color = getProviderBadgeColor('zhipu');
|
||||
expect(color).toContain('purple');
|
||||
expect(color).toContain('bg-purple-500/10');
|
||||
expect(color).toContain('text-purple-500');
|
||||
expect(color).toContain('border-purple-500/20');
|
||||
});
|
||||
|
||||
it('should return gray colors for unknown', () => {
|
||||
const color = getProviderBadgeColor('unknown');
|
||||
expect(color).toContain('gray');
|
||||
expect(color).toContain('bg-gray-500/10');
|
||||
expect(color).toContain('text-gray-500');
|
||||
expect(color).toContain('border-gray-500/20');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Provider Detection Utilities
|
||||
*
|
||||
* Detects API provider type from baseUrl patterns.
|
||||
* Mirrors the logic from usage-monitor.ts for use in renderer process.
|
||||
*
|
||||
* NOTE: Keep this in sync with usage-monitor.ts provider detection logic
|
||||
*/
|
||||
|
||||
/**
|
||||
* API Provider type for usage monitoring
|
||||
* Determines which usage endpoint to query and how to normalize responses
|
||||
*/
|
||||
export type ApiProvider = 'anthropic' | 'zai' | 'zhipu' | 'unknown';
|
||||
|
||||
/**
|
||||
* Provider detection patterns
|
||||
* Maps baseUrl patterns to provider types
|
||||
*/
|
||||
interface ProviderPattern {
|
||||
provider: ApiProvider;
|
||||
domainPatterns: string[];
|
||||
}
|
||||
|
||||
const PROVIDER_PATTERNS: readonly ProviderPattern[] = [
|
||||
{
|
||||
provider: 'anthropic',
|
||||
domainPatterns: ['api.anthropic.com']
|
||||
},
|
||||
{
|
||||
provider: 'zai',
|
||||
domainPatterns: ['api.z.ai', 'z.ai']
|
||||
},
|
||||
{
|
||||
provider: 'zhipu',
|
||||
domainPatterns: ['open.bigmodel.cn', 'dev.bigmodel.cn', 'bigmodel.cn']
|
||||
}
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Detect API provider from baseUrl
|
||||
* Extracts domain and matches against known provider patterns
|
||||
*
|
||||
* @param baseUrl - The API base URL (e.g., 'https://api.z.ai/api/anthropic')
|
||||
* @returns The detected provider type ('anthropic' | 'zai' | 'zhipu' | 'unknown')
|
||||
*
|
||||
* @example
|
||||
* detectProvider('https://api.anthropic.com') // returns 'anthropic'
|
||||
* detectProvider('https://api.z.ai/api/anthropic') // returns 'zai'
|
||||
* detectProvider('https://open.bigmodel.cn/api/paas/v4') // returns 'zhipu'
|
||||
* detectProvider('https://unknown.com/api') // returns 'unknown'
|
||||
*/
|
||||
export function detectProvider(baseUrl: string): ApiProvider {
|
||||
try {
|
||||
// Extract domain from URL
|
||||
const url = new URL(baseUrl);
|
||||
const domain = url.hostname;
|
||||
|
||||
// Match against provider patterns
|
||||
for (const pattern of PROVIDER_PATTERNS) {
|
||||
for (const patternDomain of pattern.domainPatterns) {
|
||||
if (domain === patternDomain || domain.endsWith(`.${patternDomain}`)) {
|
||||
return pattern.provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return 'unknown';
|
||||
} catch (_error) {
|
||||
// Invalid URL format
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable provider label
|
||||
*
|
||||
* @param provider - The provider type
|
||||
* @returns Display label for the provider
|
||||
*/
|
||||
export function getProviderLabel(provider: ApiProvider): string {
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'zai':
|
||||
return 'z.ai';
|
||||
case 'zhipu':
|
||||
return 'ZHIPU AI';
|
||||
case 'unknown':
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider badge color scheme
|
||||
*
|
||||
* @param provider - The provider type
|
||||
* @returns CSS classes for badge styling
|
||||
*/
|
||||
export function getProviderBadgeColor(provider: ApiProvider): string {
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15';
|
||||
case 'zai':
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20 hover:bg-blue-500/15';
|
||||
case 'zhipu':
|
||||
return 'bg-purple-500/10 text-purple-500 border-purple-500/20 hover:bg-purple-500/15';
|
||||
case 'unknown':
|
||||
return 'bg-gray-500/10 text-gray-500 border-gray-500/20 hover:bg-gray-500/15';
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,81 @@
|
||||
{
|
||||
"spec_id": "025-improving-task-card-title-readability",
|
||||
"spec_id": "045-add-api-profile-providers-usage-endpoints-support-",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "Restructure TaskCard header: Remove flex wrapper around title, make title standalone with full width",
|
||||
"title": "Implement provider detection from baseUrl (Anthropic, z.ai, ZHIPU)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "Relocate status badges from header to metadata section",
|
||||
"title": "Implement usage endpoint routing based on provider type",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"title": "Add localization for security severity badge label",
|
||||
"title": "Implement response normalization for z.ai quota/limit endpoint",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"title": "Implement response normalization for ZHIPU quota/limit endpoint",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"title": "Implement authentication handling for API profiles (apiKey vs OAuth token)",
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"qa_signoff": {
|
||||
"status": "fixes_applied",
|
||||
"timestamp": "2026-01-01T11:58:40Z",
|
||||
"fix_session": 1,
|
||||
"timestamp": "2026-01-18T01:30:00Z",
|
||||
"fix_session": 5,
|
||||
"issues_fixed": [
|
||||
{
|
||||
"title": "Missing localization for hardcoded 'severity' string in TaskCard",
|
||||
"fix_commit": "de0c8e4"
|
||||
"title": "Usage values not displaying correctly for z.ai and ZHIPU providers",
|
||||
"fix_commit": "df81cca8",
|
||||
"description": "Changed from model-usage endpoint to quota/limit endpoint and updated response parsing to extract limits array"
|
||||
},
|
||||
{
|
||||
"title": "Usage labels and reset times not user-friendly",
|
||||
"fix_commit": "6331d11c",
|
||||
"description": "Updated session label to '5 Hours Quota', weekly label to 'Total Monthly Tools Quota', calculated actual reset times for 5-hour window, and formatted monthly reset as '1st of <Month>'"
|
||||
},
|
||||
{
|
||||
"title": "Additional percentage display needs to be removed",
|
||||
"fix_commit": "94afd21e",
|
||||
"description": "Removed percentage text from usage warning badge; now only shows AlertTriangle icon with percentage in tooltip"
|
||||
},
|
||||
{
|
||||
"title": "Countdown timer needs to move to right of usage badge",
|
||||
"fix_commit": "94afd21e",
|
||||
"description": "Moved countdown timer from tooltip to visible blue badge positioned to the right of provider badge"
|
||||
},
|
||||
{
|
||||
"title": "Duplicate 'Resets:' word in tooltip",
|
||||
"fix_commit": "037fa6a1",
|
||||
"description": "Removed duplicate 'Resets:' prefix from tooltips in UsageIndicator and AuthStatusIndicator components"
|
||||
},
|
||||
{
|
||||
"title": "Monthly Tools badge should show 5 hour usage instead",
|
||||
"fix_commit": "037fa6a1",
|
||||
"description": "Replaced countdown timer badge with 5 hour usage badge that shows session percentage"
|
||||
},
|
||||
{
|
||||
"title": "5 hour usage badge should only show when >= 90% and in red",
|
||||
"fix_commit": "037fa6a1",
|
||||
"description": "Badge is hidden until session usage reaches 90% threshold, then displays in red with percentage"
|
||||
},
|
||||
{
|
||||
"title": "Time synchronization issue with reset countdown",
|
||||
"fix_commit": "037fa6a1",
|
||||
"description": "Store ISO timestamps and calculate relative time dynamically in UI instead of at fetch time, ensuring countdown stays accurate"
|
||||
},
|
||||
{
|
||||
"title": "5-hour window reset time showing duration from start instead of time remaining",
|
||||
"fix_commit": "52b53f83",
|
||||
"description": "Fixed sessionResetTimestamp calculation to align with 5-hour interval boundaries (0:00, 5:00, 10:00, 15:00, 20:00) instead of just next hour. The tooltip now correctly shows time remaining until the window resets. Verified >=90% badge is based on actual usage percentage from API, not time-based calculation."
|
||||
}
|
||||
],
|
||||
"ready_for_qa_revalidation": true
|
||||
|
||||
Generated
+3
@@ -12,6 +12,9 @@
|
||||
"apps/*",
|
||||
"libs/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.562.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.562.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@electron/rebuild": "4.0.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user