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:
AndyMik90
2026-01-23 10:32:15 +01:00
56 changed files with 7241 additions and 672 deletions
@@ -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"
+37 -20
View File
@@ -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)"
+2 -1
View File
@@ -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
+69
View File
@@ -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*
+193
View File
@@ -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*
+224
View File
@@ -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*
+283
View File
@@ -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*
+204
View File
@@ -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*
+140
View File
@@ -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*
+219
View File
@@ -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*
+485
View File
@@ -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*
+6
View File
@@ -0,0 +1,6 @@
{
"mode": "yolo",
"depth": "comprehensive",
"parallelization": true,
"created": "2026-01-19"
}
+7 -7
View File
@@ -16,18 +16,18 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.4-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](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
View File
@@ -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);
}
+8 -3
View File
@@ -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;
+60 -14
View File
@@ -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);
}
+5 -1
View File
@@ -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."
}
}
+34 -4
View File
@@ -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;
}
/**
+8
View File
@@ -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;
}
+1 -1
View File
@@ -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';
}
}
+59 -8
View File
@@ -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
+3
View File
@@ -12,6 +12,9 @@
"apps/*",
"libs/*"
],
"dependencies": {
"lucide-react": "^0.562.0"
},
"devDependencies": {
"jsdom": "^27.4.0"
},
+3
View File
@@ -44,6 +44,9 @@
"devDependencies": {
"jsdom": "^27.4.0"
},
"dependencies": {
"lucide-react": "^0.562.0"
},
"overrides": {
"@electron/rebuild": "4.0.2"
}