mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
391
.claude/commands/workflow/tools/code-validation-gate.md
Normal file
391
.claude/commands/workflow/tools/code-validation-gate.md
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
---
|
||||||
|
name: code-validation-gate
|
||||||
|
description: Validate AI-generated code for common errors (imports, variables, types) before test execution
|
||||||
|
argument-hint: "--session WFS-test-session-id [--fix] [--strict]"
|
||||||
|
examples:
|
||||||
|
- /workflow:tools:code-validation-gate --session WFS-test-auth
|
||||||
|
- /workflow:tools:code-validation-gate --session WFS-test-auth --fix
|
||||||
|
- /workflow:tools:code-validation-gate --session WFS-test-auth --strict
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Validation Gate Command
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Pre-test validation gate that checks AI-generated code for common errors before test execution. This prevents wasted test cycles on code with fundamental issues like import errors, variable conflicts, and type mismatches.
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
|
||||||
|
- **Fail Fast**: Catch fundamental errors before expensive test execution
|
||||||
|
- **AI-Aware**: Specifically targets common AI code generation mistakes
|
||||||
|
- **Auto-Remediation**: Attempt safe fixes before failing
|
||||||
|
- **Clear Feedback**: Provide actionable fix suggestions for manual intervention
|
||||||
|
|
||||||
|
## Target Error Categories
|
||||||
|
|
||||||
|
### L0.1: Compilation Errors
|
||||||
|
- TypeScript compilation failures
|
||||||
|
- Syntax errors
|
||||||
|
- Module resolution failures
|
||||||
|
|
||||||
|
### L0.2: Import Errors
|
||||||
|
- Unresolved module imports (hallucinated packages)
|
||||||
|
- Circular dependencies
|
||||||
|
- Duplicate imports
|
||||||
|
- Unused imports
|
||||||
|
|
||||||
|
### L0.3: Variable Errors
|
||||||
|
- Variable redeclaration
|
||||||
|
- Scope conflicts (shadowing)
|
||||||
|
- Undefined variable usage
|
||||||
|
- Unused variables
|
||||||
|
|
||||||
|
### L0.4: Type Errors (TypeScript)
|
||||||
|
- Type mismatches
|
||||||
|
- Missing type definitions
|
||||||
|
- Excessive `any` usage
|
||||||
|
- Implicit `any` types
|
||||||
|
|
||||||
|
### L0.5: AI-Specific Patterns
|
||||||
|
- Placeholder code (`// TODO: implement`)
|
||||||
|
- Hallucinated package imports
|
||||||
|
- Mock code in production files
|
||||||
|
- Inconsistent naming patterns
|
||||||
|
|
||||||
|
## Execution Process
|
||||||
|
|
||||||
|
```
|
||||||
|
Input Parsing:
|
||||||
|
├─ Parse flags: --session (required), --fix, --strict
|
||||||
|
└─ Load test-quality-config.json
|
||||||
|
|
||||||
|
Phase 1: Context Loading
|
||||||
|
├─ Load session metadata
|
||||||
|
├─ Identify target files (from IMPL-001 output or context-package)
|
||||||
|
└─ Detect project configuration (tsconfig, eslint, etc.)
|
||||||
|
|
||||||
|
Phase 2: Validation Execution
|
||||||
|
├─ L0.1: Run TypeScript compilation check
|
||||||
|
├─ L0.2: Run import validation
|
||||||
|
├─ L0.3: Run variable validation
|
||||||
|
├─ L0.4: Run type validation
|
||||||
|
└─ L0.5: Run AI-specific checks
|
||||||
|
|
||||||
|
Phase 3: Result Analysis
|
||||||
|
├─ Aggregate all findings by severity
|
||||||
|
├─ Calculate pass/fail status
|
||||||
|
└─ Generate fix suggestions
|
||||||
|
|
||||||
|
Phase 4: Auto-Fix (if --fix enabled)
|
||||||
|
├─ Apply safe auto-fixes (imports, formatting)
|
||||||
|
├─ Re-run validation
|
||||||
|
└─ Report remaining issues
|
||||||
|
|
||||||
|
Phase 5: Gate Decision
|
||||||
|
├─ PASS: Proceed to IMPL-001.5
|
||||||
|
├─ SOFT_FAIL: Auto-fix applied, needs re-validation
|
||||||
|
└─ HARD_FAIL: Block with detailed report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Lifecycle
|
||||||
|
|
||||||
|
### Phase 1: Context Loading
|
||||||
|
|
||||||
|
**Load session and identify validation targets.**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Load session metadata
|
||||||
|
Read(".workflow/active/{session_id}/workflow-session.json")
|
||||||
|
|
||||||
|
// Load context package for target files
|
||||||
|
Read(".workflow/active/{session_id}/.process/context-package.json")
|
||||||
|
// OR
|
||||||
|
Read(".workflow/active/{session_id}/.process/test-context-package.json")
|
||||||
|
|
||||||
|
// Identify files to validate:
|
||||||
|
// 1. Source files from context.implementation_files
|
||||||
|
// 2. Test files from IMPL-001 output (if exists)
|
||||||
|
// 3. All modified files since session start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target File Discovery**:
|
||||||
|
- Source files: `context.focus_paths` from context-package
|
||||||
|
- Generated tests: `.workflow/active/{session_id}/.task/IMPL-001-output/`
|
||||||
|
- All TypeScript/JavaScript in target directories
|
||||||
|
|
||||||
|
### Phase 2: Validation Execution
|
||||||
|
|
||||||
|
**Execute validation checks in order of dependency.**
|
||||||
|
|
||||||
|
#### L0.1: TypeScript Compilation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Primary check - catches most fundamental errors
|
||||||
|
npx tsc --noEmit --skipLibCheck --project tsconfig.json 2>&1
|
||||||
|
|
||||||
|
# Parse output for errors
|
||||||
|
# Critical: Any compilation error blocks further validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Patterns**:
|
||||||
|
```
|
||||||
|
error TS2307: Cannot find module 'xxx'
|
||||||
|
error TS2451: Cannot redeclare block-scoped variable 'xxx'
|
||||||
|
error TS2322: Type 'xxx' is not assignable to type 'yyy'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### L0.2: Import Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for circular dependencies
|
||||||
|
npx madge --circular --extensions ts,tsx,js,jsx {target_dirs}
|
||||||
|
|
||||||
|
# ESLint import rules
|
||||||
|
npx eslint --rule 'import/no-duplicates: error' --rule 'import/no-unresolved: error' {files}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hallucinated Package Check**:
|
||||||
|
```javascript
|
||||||
|
// Extract all imports from files
|
||||||
|
// Verify each package exists in package.json or node_modules
|
||||||
|
// Flag any unresolvable imports as "hallucinated"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### L0.3: Variable Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ESLint variable rules
|
||||||
|
npx eslint --rule 'no-shadow: error' --rule 'no-undef: error' --rule 'no-redeclare: error' {files}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### L0.4: Type Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript strict checks
|
||||||
|
npx tsc --noEmit --strict {files}
|
||||||
|
|
||||||
|
# Check for any abuse
|
||||||
|
npx eslint --rule '@typescript-eslint/no-explicit-any: warn' {files}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### L0.5: AI-Specific Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for placeholder code
|
||||||
|
grep -rn "// TODO: implement\|// Add your code here\|throw new Error.*Not implemented" {files}
|
||||||
|
|
||||||
|
# Check for mock code in production files
|
||||||
|
grep -rn "jest\.mock\|sinon\.\|vi\.mock" {source_files_only}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Result Analysis
|
||||||
|
|
||||||
|
**Aggregate and categorize findings.**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const findings = {
|
||||||
|
critical: [], // Blocks all progress
|
||||||
|
error: [], // Blocks with threshold
|
||||||
|
warning: [] // Advisory only
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply thresholds from config
|
||||||
|
const config = loadConfig("test-quality-config.json");
|
||||||
|
const thresholds = config.code_validation.severity_thresholds;
|
||||||
|
|
||||||
|
// Gate decision
|
||||||
|
if (findings.critical.length > thresholds.critical) {
|
||||||
|
decision = "HARD_FAIL";
|
||||||
|
} else if (findings.error.length > thresholds.error) {
|
||||||
|
decision = "SOFT_FAIL"; // Try auto-fix
|
||||||
|
} else {
|
||||||
|
decision = "PASS";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Auto-Fix (Optional)
|
||||||
|
|
||||||
|
**Apply safe automatic fixes when --fix flag provided.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Safe fixes only
|
||||||
|
npx eslint --fix --rule 'import/no-duplicates: error' --rule 'unused-imports/no-unused-imports: error' {files}
|
||||||
|
|
||||||
|
# Re-run validation after fixes
|
||||||
|
# Report what was fixed vs what remains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Safe Fix Categories**:
|
||||||
|
- Remove unused imports
|
||||||
|
- Remove duplicate imports
|
||||||
|
- Fix import ordering
|
||||||
|
- Remove unused variables (with caution)
|
||||||
|
- Formatting fixes
|
||||||
|
|
||||||
|
**Unsafe (Manual Only)**:
|
||||||
|
- Missing imports (need to determine correct package)
|
||||||
|
- Type errors (need to understand intent)
|
||||||
|
- Variable shadowing (need to understand scope intent)
|
||||||
|
|
||||||
|
### Phase 5: Gate Decision
|
||||||
|
|
||||||
|
**Determine next action based on results.**
|
||||||
|
|
||||||
|
| Decision | Condition | Action |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| **PASS** | critical=0, error<=3, warning<=10 | Proceed to IMPL-001.5 |
|
||||||
|
| **SOFT_FAIL** | critical=0, error>3 OR fixable issues | Auto-fix and retry (max 2) |
|
||||||
|
| **HARD_FAIL** | critical>0 OR max retries exceeded | Block with report |
|
||||||
|
|
||||||
|
## Output Artifacts
|
||||||
|
|
||||||
|
### Validation Report
|
||||||
|
|
||||||
|
**File**: `.workflow/active/{session_id}/.process/code-validation-report.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Code Validation Report
|
||||||
|
|
||||||
|
**Session**: {session_id}
|
||||||
|
**Timestamp**: {timestamp}
|
||||||
|
**Status**: PASS | SOFT_FAIL | HARD_FAIL
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- Files Validated: {count}
|
||||||
|
- Critical Issues: {count}
|
||||||
|
- Errors: {count}
|
||||||
|
- Warnings: {count}
|
||||||
|
|
||||||
|
## Critical Issues (Must Fix)
|
||||||
|
### Import Errors
|
||||||
|
- `src/auth/service.ts:5` - Cannot find module 'non-existent-package'
|
||||||
|
- **Suggestion**: Check if package exists, may be hallucinated by AI
|
||||||
|
|
||||||
|
### Variable Conflicts
|
||||||
|
- `src/utils/helper.ts:12` - Cannot redeclare block-scoped variable 'config'
|
||||||
|
- **Suggestion**: Rename one of the variables or merge declarations
|
||||||
|
|
||||||
|
## Errors (Should Fix)
|
||||||
|
...
|
||||||
|
|
||||||
|
## Warnings (Consider Fixing)
|
||||||
|
...
|
||||||
|
|
||||||
|
## Auto-Fix Applied
|
||||||
|
- Removed 3 unused imports in `src/auth/service.ts`
|
||||||
|
- Fixed import ordering in `src/utils/index.ts`
|
||||||
|
|
||||||
|
## Remaining Issues Requiring Manual Fix
|
||||||
|
...
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- [ ] Fix critical issues before proceeding
|
||||||
|
- [ ] Review error suggestions
|
||||||
|
- [ ] Re-run validation: `/workflow:tools:code-validation-gate --session {session_id}`
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Report (Machine-Readable)
|
||||||
|
|
||||||
|
**File**: `.workflow/active/{session_id}/.process/code-validation-report.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "WFS-test-xxx",
|
||||||
|
"timestamp": "2025-01-30T10:00:00Z",
|
||||||
|
"status": "HARD_FAIL",
|
||||||
|
"summary": {
|
||||||
|
"files_validated": 15,
|
||||||
|
"critical": 2,
|
||||||
|
"error": 5,
|
||||||
|
"warning": 8
|
||||||
|
},
|
||||||
|
"findings": {
|
||||||
|
"critical": [
|
||||||
|
{
|
||||||
|
"category": "import",
|
||||||
|
"file": "src/auth/service.ts",
|
||||||
|
"line": 5,
|
||||||
|
"message": "Cannot find module 'non-existent-package'",
|
||||||
|
"suggestion": "Check if package exists in package.json",
|
||||||
|
"auto_fixable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error": [...],
|
||||||
|
"warning": [...]
|
||||||
|
},
|
||||||
|
"auto_fixes_applied": [...],
|
||||||
|
"gate_decision": "HARD_FAIL",
|
||||||
|
"retry_count": 0,
|
||||||
|
"max_retries": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `--session` | Test session ID (required) | - |
|
||||||
|
| `--fix` | Enable auto-fix for safe issues | false |
|
||||||
|
| `--strict` | Use strict thresholds (0 errors allowed) | false |
|
||||||
|
| `--files` | Specific files to validate (comma-separated) | All target files |
|
||||||
|
| `--skip-types` | Skip TypeScript type checks | false |
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
### Command Chain
|
||||||
|
|
||||||
|
- **Called By**: `/workflow:test-fix-gen` (after IMPL-001)
|
||||||
|
- **Requires**: IMPL-001 output OR context-package.json
|
||||||
|
- **Followed By**: IMPL-001.5 (Test Quality Gate) on PASS
|
||||||
|
|
||||||
|
### Task JSON Integration
|
||||||
|
|
||||||
|
When used in test-fix workflow, generates task:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "IMPL-001.3-validation",
|
||||||
|
"meta": {
|
||||||
|
"type": "code-validation",
|
||||||
|
"agent": "@test-fix-agent"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"depends_on": ["IMPL-001"],
|
||||||
|
"requirements": "Validate generated code for AI common errors"
|
||||||
|
},
|
||||||
|
"flow_control": {
|
||||||
|
"validation_config": "~/.claude/workflows/test-quality-config.json",
|
||||||
|
"max_retries": 2,
|
||||||
|
"auto_fix_enabled": true
|
||||||
|
},
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Zero critical issues",
|
||||||
|
"Maximum 3 error issues",
|
||||||
|
"All imports resolvable",
|
||||||
|
"No variable redeclarations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| tsconfig.json not found | Use default compiler options |
|
||||||
|
| ESLint not installed | Skip ESLint checks, use tsc only |
|
||||||
|
| madge not installed | Skip circular dependency check |
|
||||||
|
| No files to validate | Return PASS (nothing to check) |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Run Early**: Execute validation immediately after code generation
|
||||||
|
2. **Use --fix First**: Let auto-fix resolve trivial issues
|
||||||
|
3. **Review Suggestions**: AI fix suggestions may need human judgment
|
||||||
|
4. **Don't Skip Critical**: Never proceed with critical errors
|
||||||
|
5. **Track Patterns**: Common failures indicate prompt improvement opportunities
|
||||||
|
|
||||||
|
## Related Commands
|
||||||
|
|
||||||
|
- `/workflow:test-fix-gen` - Parent workflow that invokes this command
|
||||||
|
- `/workflow:tools:test-quality-gate` - Next phase (IMPL-001.5) for test quality
|
||||||
|
- `/workflow:test-cycle-execute` - Execute tests after validation passes
|
||||||
@@ -143,7 +143,7 @@ Determine CLI tool usage per-step based on user's task description:
|
|||||||
(Detailed specifications in your agent definition)
|
(Detailed specifications in your agent definition)
|
||||||
|
|
||||||
### Task Structure Requirements
|
### Task Structure Requirements
|
||||||
- Minimum 2 tasks: IMPL-001 (test generation) + IMPL-002 (test execution & fix)
|
- Minimum 4 tasks: IMPL-001 (test generation) + IMPL-001.3 (code validation) + IMPL-001.5 (test quality) + IMPL-002 (test execution & fix)
|
||||||
- Expandable for complex projects: Add IMPL-003+ (per-module, integration, E2E tests)
|
- Expandable for complex projects: Add IMPL-003+ (per-module, integration, E2E tests)
|
||||||
|
|
||||||
Task Configuration:
|
Task Configuration:
|
||||||
@@ -154,9 +154,29 @@ Task Configuration:
|
|||||||
- flow_control: Test generation strategy from TEST_ANALYSIS_RESULTS.md
|
- flow_control: Test generation strategy from TEST_ANALYSIS_RESULTS.md
|
||||||
- CLI execution: Add `command` field when user requests (determined semantically)
|
- CLI execution: Add `command` field when user requests (determined semantically)
|
||||||
|
|
||||||
|
IMPL-001.3 (Code Validation Gate) ← NEW:
|
||||||
|
- meta.type: "code-validation"
|
||||||
|
- meta.agent: "@test-fix-agent"
|
||||||
|
- context.depends_on: ["IMPL-001"]
|
||||||
|
- context.validation_config: "~/.claude/workflows/test-quality-config.json"
|
||||||
|
- flow_control.validation_phases: ["compilation", "imports", "variables", "types", "ai_specific"]
|
||||||
|
- flow_control.auto_fix_enabled: true
|
||||||
|
- flow_control.max_retries: 2
|
||||||
|
- flow_control.severity_thresholds: { critical: 0, error: 3, warning: 10 }
|
||||||
|
- acceptance_criteria: Zero compilation errors, all imports resolvable, no variable redeclarations
|
||||||
|
|
||||||
|
IMPL-001.5 (Test Quality Gate):
|
||||||
|
- meta.type: "test-quality-review"
|
||||||
|
- meta.agent: "@test-fix-agent"
|
||||||
|
- context.depends_on: ["IMPL-001", "IMPL-001.3"]
|
||||||
|
- context.quality_config: "~/.claude/workflows/test-quality-config.json"
|
||||||
|
- flow_control: Static analysis, coverage analysis, anti-pattern detection
|
||||||
|
- acceptance_criteria: Coverage ≥ 80%, zero critical anti-patterns
|
||||||
|
|
||||||
IMPL-002+ (Test Execution & Fix):
|
IMPL-002+ (Test Execution & Fix):
|
||||||
- meta.type: "test-fix"
|
- meta.type: "test-fix"
|
||||||
- meta.agent: "@test-fix-agent"
|
- meta.agent: "@test-fix-agent"
|
||||||
|
- context.depends_on: ["IMPL-001", "IMPL-001.3", "IMPL-001.5"]
|
||||||
- flow_control: Test-fix cycle with iteration limits and diagnosis configuration
|
- flow_control: Test-fix cycle with iteration limits and diagnosis configuration
|
||||||
- CLI execution: Add `command` field when user requests (determined semantically)
|
- CLI execution: Add `command` field when user requests (determined semantically)
|
||||||
|
|
||||||
@@ -190,10 +210,17 @@ PRIMARY requirements source - extract and map to task JSONs:
|
|||||||
- Implementation targets → context.files_to_test (absolute paths)
|
- Implementation targets → context.files_to_test (absolute paths)
|
||||||
|
|
||||||
## EXPECTED DELIVERABLES
|
## EXPECTED DELIVERABLES
|
||||||
1. Test Task JSON Files (.task/IMPL-*.json)
|
1. Test Task JSON Files (.task/IMPL-*.json) - Minimum 4 required:
|
||||||
|
- IMPL-001.json: Test generation task
|
||||||
|
- IMPL-001.3-validation.json: Code validation gate (AI error detection) ← NEW
|
||||||
|
- IMPL-001.5-review.json: Test quality gate
|
||||||
|
- IMPL-002.json: Test execution & fix cycle
|
||||||
|
|
||||||
|
Each task includes:
|
||||||
- 6-field schema with quantified requirements from TEST_ANALYSIS_RESULTS.md
|
- 6-field schema with quantified requirements from TEST_ANALYSIS_RESULTS.md
|
||||||
- Test-specific metadata: type, agent, test_framework, coverage_target
|
- Test-specific metadata: type, agent, test_framework, coverage_target
|
||||||
- flow_control includes: reusable_test_tools, test_commands (from project config)
|
- flow_control includes: reusable_test_tools, test_commands (from project config)
|
||||||
|
- Validation config reference for IMPL-001.3: ~/.claude/workflows/test-quality-config.json
|
||||||
- CLI execution via `command` field when user requests (determined semantically)
|
- CLI execution via `command` field when user requests (determined semantically)
|
||||||
- Artifact references from test-context-package.json
|
- Artifact references from test-context-package.json
|
||||||
- Absolute paths in context.files_to_test
|
- Absolute paths in context.files_to_test
|
||||||
@@ -211,7 +238,7 @@ PRIMARY requirements source - extract and map to task JSONs:
|
|||||||
|
|
||||||
## QUALITY STANDARDS
|
## QUALITY STANDARDS
|
||||||
Hard Constraints:
|
Hard Constraints:
|
||||||
- Task count: minimum 2, maximum 18
|
- Task count: minimum 4, maximum 18 (IMPL-001, IMPL-001.3, IMPL-001.5, IMPL-002 required)
|
||||||
- All requirements quantified from TEST_ANALYSIS_RESULTS.md
|
- All requirements quantified from TEST_ANALYSIS_RESULTS.md
|
||||||
- Test framework matches existing project framework
|
- Test framework matches existing project framework
|
||||||
- flow_control includes reusable_test_tools and test_commands from project
|
- flow_control includes reusable_test_tools and test_commands from project
|
||||||
@@ -249,7 +276,11 @@ CLI tool usage is determined semantically from user's task description:
|
|||||||
- Default: Agent execution (no `command` field)
|
- Default: Agent execution (no `command` field)
|
||||||
|
|
||||||
### Output
|
### Output
|
||||||
- Test task JSON files in `.task/` directory (minimum 2)
|
- Test task JSON files in `.task/` directory (minimum 4):
|
||||||
- IMPL_PLAN.md with test strategy and fix cycle specification
|
- IMPL-001.json (test generation)
|
||||||
|
- IMPL-001.3-validation.json (code validation gate)
|
||||||
|
- IMPL-001.5-review.json (test quality gate)
|
||||||
|
- IMPL-002.json (test execution & fix)
|
||||||
|
- IMPL_PLAN.md with test strategy, validation gates, and fix cycle specification
|
||||||
- TODO_LIST.md with test phase indicators
|
- TODO_LIST.md with test phase indicators
|
||||||
- Session ready for test execution
|
- Session ready for test execution
|
||||||
|
|||||||
251
.claude/workflows/test-quality-config.json
Normal file
251
.claude/workflows/test-quality-config.json
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test quality and code validation configuration for AI-generated code",
|
||||||
|
|
||||||
|
"code_validation": {
|
||||||
|
"description": "Pre-test validation for AI-generated code common errors",
|
||||||
|
"enabled": true,
|
||||||
|
"phases": {
|
||||||
|
"L0_compilation": {
|
||||||
|
"description": "TypeScript/JavaScript compilation check",
|
||||||
|
"enabled": true,
|
||||||
|
"commands": {
|
||||||
|
"typescript": "npx tsc --noEmit --skipLibCheck",
|
||||||
|
"javascript": "node --check"
|
||||||
|
},
|
||||||
|
"critical": true,
|
||||||
|
"failure_blocks_tests": true
|
||||||
|
},
|
||||||
|
"L0_imports": {
|
||||||
|
"description": "Import statement validation",
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "unresolved_imports",
|
||||||
|
"description": "Check for unresolved module imports",
|
||||||
|
"pattern": "Cannot find module|Module not found|Unable to resolve",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "circular_imports",
|
||||||
|
"description": "Check for circular dependencies",
|
||||||
|
"tool": "madge",
|
||||||
|
"command": "npx madge --circular --extensions ts,tsx,js,jsx",
|
||||||
|
"severity": "warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "duplicate_imports",
|
||||||
|
"description": "Check for duplicate imports",
|
||||||
|
"eslint_rule": "import/no-duplicates",
|
||||||
|
"severity": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unused_imports",
|
||||||
|
"description": "Check for unused imports",
|
||||||
|
"eslint_rule": "unused-imports/no-unused-imports",
|
||||||
|
"severity": "warning"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"L0_variables": {
|
||||||
|
"description": "Variable declaration validation",
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "redeclaration",
|
||||||
|
"description": "Check for variable redeclaration",
|
||||||
|
"pattern": "Cannot redeclare|Duplicate identifier|has already been declared",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "scope_conflict",
|
||||||
|
"description": "Check for scope conflicts",
|
||||||
|
"eslint_rule": "no-shadow",
|
||||||
|
"severity": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "undefined_vars",
|
||||||
|
"description": "Check for undefined variables",
|
||||||
|
"eslint_rule": "no-undef",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unused_vars",
|
||||||
|
"description": "Check for unused variables",
|
||||||
|
"eslint_rule": "@typescript-eslint/no-unused-vars",
|
||||||
|
"severity": "warning"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"L0_types": {
|
||||||
|
"description": "TypeScript type validation",
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "type_mismatch",
|
||||||
|
"description": "Check for type mismatches",
|
||||||
|
"pattern": "Type .* is not assignable to type",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "missing_types",
|
||||||
|
"description": "Check for missing type definitions",
|
||||||
|
"pattern": "Could not find a declaration file",
|
||||||
|
"severity": "warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "any_abuse",
|
||||||
|
"description": "Check for excessive any type usage",
|
||||||
|
"eslint_rule": "@typescript-eslint/no-explicit-any",
|
||||||
|
"severity": "warning",
|
||||||
|
"max_occurrences": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "implicit_any",
|
||||||
|
"description": "Check for implicit any",
|
||||||
|
"pattern": "implicitly has an 'any' type",
|
||||||
|
"severity": "error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"severity_thresholds": {
|
||||||
|
"critical": 0,
|
||||||
|
"error": 3,
|
||||||
|
"warning": 10
|
||||||
|
},
|
||||||
|
"max_retries": 2,
|
||||||
|
"auto_fix": {
|
||||||
|
"enabled": true,
|
||||||
|
"safe_fixes_only": true,
|
||||||
|
"fixable_categories": ["imports", "formatting", "unused_vars"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"test_quality": {
|
||||||
|
"description": "Test file quality validation (IMPL-001.5)",
|
||||||
|
"enabled": true,
|
||||||
|
"coverage": {
|
||||||
|
"minimum_threshold": 80,
|
||||||
|
"branch_threshold": 70,
|
||||||
|
"function_threshold": 80,
|
||||||
|
"line_threshold": 80
|
||||||
|
},
|
||||||
|
"anti_patterns": {
|
||||||
|
"empty_test_body": {
|
||||||
|
"pattern": "it\\(['\"].*['\"],\\s*\\(\\)\\s*=>\\s*\\{\\s*\\}\\)",
|
||||||
|
"severity": "critical",
|
||||||
|
"description": "Test with empty body"
|
||||||
|
},
|
||||||
|
"missing_assertion": {
|
||||||
|
"pattern": "it\\(['\"].*['\"],.*\\{[^}]*\\}\\)(?![\\s\\S]*expect)",
|
||||||
|
"severity": "critical",
|
||||||
|
"description": "Test without expect() assertion"
|
||||||
|
},
|
||||||
|
"skipped_without_reason": {
|
||||||
|
"pattern": "(it|describe)\\.skip\\(['\"][^'\"]*['\"](?!.*\\/\\/ )",
|
||||||
|
"severity": "error",
|
||||||
|
"description": "Skipped test without comment explaining why"
|
||||||
|
},
|
||||||
|
"todo_test": {
|
||||||
|
"pattern": "(it|test)\\.todo\\(",
|
||||||
|
"severity": "warning",
|
||||||
|
"description": "TODO test placeholder"
|
||||||
|
},
|
||||||
|
"only_test": {
|
||||||
|
"pattern": "(it|describe)\\.only\\(",
|
||||||
|
"severity": "critical",
|
||||||
|
"description": "Focused test (will skip other tests)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required_test_types": {
|
||||||
|
"unit": {
|
||||||
|
"min_per_function": 1,
|
||||||
|
"must_include": ["happy_path"]
|
||||||
|
},
|
||||||
|
"negative": {
|
||||||
|
"min_per_public_api": 1,
|
||||||
|
"description": "Error handling tests for public APIs"
|
||||||
|
},
|
||||||
|
"edge_case": {
|
||||||
|
"required_scenarios": ["null", "undefined", "empty_string", "empty_array", "boundary_values"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"ai_specific_checks": {
|
||||||
|
"description": "Checks specifically for AI-generated code patterns",
|
||||||
|
"enabled": true,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"id": "hallucinated_imports",
|
||||||
|
"description": "Check for imports of non-existent packages",
|
||||||
|
"validation": "npm_package_exists",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inconsistent_naming",
|
||||||
|
"description": "Check for naming inconsistencies within file",
|
||||||
|
"pattern": "function (\\w+).*\\1(?!\\()",
|
||||||
|
"severity": "warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "placeholder_code",
|
||||||
|
"description": "Check for AI placeholder comments",
|
||||||
|
"patterns": [
|
||||||
|
"// TODO: implement",
|
||||||
|
"// Add your code here",
|
||||||
|
"// Implementation pending",
|
||||||
|
"throw new Error\\(['\"]Not implemented['\"]\\)"
|
||||||
|
],
|
||||||
|
"severity": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mock_in_production",
|
||||||
|
"description": "Check for mock/stub code in production files",
|
||||||
|
"patterns": [
|
||||||
|
"jest\\.mock\\(",
|
||||||
|
"sinon\\.",
|
||||||
|
"vi\\.mock\\("
|
||||||
|
],
|
||||||
|
"exclude_paths": ["**/*.test.*", "**/*.spec.*", "**/test/**", "**/__tests__/**"],
|
||||||
|
"severity": "critical"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"validation_commands": {
|
||||||
|
"typescript_check": {
|
||||||
|
"command": "npx tsc --noEmit --skipLibCheck",
|
||||||
|
"timeout": 60000,
|
||||||
|
"parse_errors": true
|
||||||
|
},
|
||||||
|
"eslint_check": {
|
||||||
|
"command": "npx eslint --format json",
|
||||||
|
"timeout": 60000,
|
||||||
|
"auto_fix_command": "npx eslint --fix"
|
||||||
|
},
|
||||||
|
"circular_deps_check": {
|
||||||
|
"command": "npx madge --circular --extensions ts,tsx,js,jsx",
|
||||||
|
"timeout": 30000
|
||||||
|
},
|
||||||
|
"package_validation": {
|
||||||
|
"command": "npm ls --json",
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"gate_decisions": {
|
||||||
|
"pass_criteria": {
|
||||||
|
"critical_issues": 0,
|
||||||
|
"error_issues": "<=3",
|
||||||
|
"warning_issues": "<=10"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pass": "Proceed to IMPL-001.5 (Test Quality Gate)",
|
||||||
|
"soft_fail": "Auto-fix and retry (max 2 attempts)",
|
||||||
|
"hard_fail": "Block and report to user with fix suggestions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ccw/frontend/index.html
Normal file
13
ccw/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CCW Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body class="font-sans bg-background text-foreground antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4100
ccw/frontend/package-lock.json
generated
Normal file
4100
ccw/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
ccw/frontend/package.json
Normal file
42
ccw/frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "ccw-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"zustand": "^5.0.0",
|
||||||
|
"@tanstack/react-query": "^5.60.0",
|
||||||
|
"@xyflow/react": "^12.3.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.0",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.5.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"lucide-react": "^0.460.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ccw/frontend/postcss.config.js
Normal file
6
ccw/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
17
ccw/frontend/src/App.tsx
Normal file
17
ccw/frontend/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// ========================================
|
||||||
|
// App Component
|
||||||
|
// ========================================
|
||||||
|
// Root application component with Router provider
|
||||||
|
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root App component
|
||||||
|
* Provides routing and global providers
|
||||||
|
*/
|
||||||
|
function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
111
ccw/frontend/src/components/layout/AppShell.tsx
Normal file
111
ccw/frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ========================================
|
||||||
|
// AppShell Component
|
||||||
|
// ========================================
|
||||||
|
// Root layout component combining Header, Sidebar, and MainContent
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { MainContent } from './MainContent';
|
||||||
|
|
||||||
|
export interface AppShellProps {
|
||||||
|
/** Initial sidebar collapsed state */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/** Current project path to display in header */
|
||||||
|
projectPath?: string;
|
||||||
|
/** Callback for refresh action */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
/** Whether refresh is in progress */
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
/** Children to render in main content area */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local storage key for sidebar state
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = 'ccw-sidebar-collapsed';
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
defaultCollapsed = false,
|
||||||
|
projectPath = '',
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing = false,
|
||||||
|
children,
|
||||||
|
}: AppShellProps) {
|
||||||
|
// Sidebar collapse state (persisted)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : defaultCollapsed;
|
||||||
|
}
|
||||||
|
return defaultCollapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile sidebar open state
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
// Persist sidebar state
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(sidebarCollapsed));
|
||||||
|
}, [sidebarCollapsed]);
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change or resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMenuClick = useCallback(() => {
|
||||||
|
setMobileOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMobileClose = useCallback(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
||||||
|
setSidebarCollapsed(collapsed);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-background">
|
||||||
|
{/* Header - fixed at top */}
|
||||||
|
<Header
|
||||||
|
onMenuClick={handleMenuClick}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main layout - sidebar + content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapsedChange={handleCollapsedChange}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={handleMobileClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<MainContent
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
// Adjust padding on mobile when sidebar is hidden
|
||||||
|
'md:ml-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MainContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppShell;
|
||||||
164
ccw/frontend/src/components/layout/Header.tsx
Normal file
164
ccw/frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// ========================================
|
||||||
|
// Header Component
|
||||||
|
// ========================================
|
||||||
|
// Top navigation bar with theme toggle and user menu
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Workflow,
|
||||||
|
Menu,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useTheme } from '@/hooks';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
/** Callback to toggle mobile sidebar */
|
||||||
|
onMenuClick?: () => void;
|
||||||
|
/** Current project path */
|
||||||
|
projectPath?: string;
|
||||||
|
/** Callback for refresh action */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
/** Whether refresh is in progress */
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
onMenuClick,
|
||||||
|
projectPath = '',
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing = false,
|
||||||
|
}: HeaderProps) {
|
||||||
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (onRefresh && !isRefreshing) {
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
|
}, [onRefresh, isRefreshing]);
|
||||||
|
|
||||||
|
// Get display path (truncate if too long)
|
||||||
|
const displayPath = projectPath.length > 40
|
||||||
|
? '...' + projectPath.slice(-37)
|
||||||
|
: projectPath || 'No project selected';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
|
||||||
|
role="banner"
|
||||||
|
>
|
||||||
|
{/* Left side - Menu button (mobile) and Logo */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Mobile menu toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="md:hidden"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
aria-label="Toggle navigation menu"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Logo / Brand */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 text-lg font-semibold text-primary hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Workflow className="w-6 h-6" />
|
||||||
|
<span className="hidden sm:inline">Claude Code Workflow</span>
|
||||||
|
<span className="sm:hidden">CCW</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Project path indicator */}
|
||||||
|
{projectPath && (
|
||||||
|
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm text-muted-foreground max-w-[300px]">
|
||||||
|
<span className="truncate" title={projectPath}>
|
||||||
|
{displayPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Refresh button */}
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
aria-label="Refresh workspace"
|
||||||
|
title="Refresh workspace"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn('w-5 h-5', isRefreshing && 'animate-spin')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<Sun className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* User menu dropdown - simplified version */}
|
||||||
|
<div className="relative group">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full"
|
||||||
|
aria-label="User menu"
|
||||||
|
title="User menu"
|
||||||
|
>
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-48 bg-card border border-border rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||||
|
<div className="py-1">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground hover:bg-hover transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</Link>
|
||||||
|
<hr className="my-1 border-border" />
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground hover:bg-hover hover:text-foreground transition-colors w-full text-left"
|
||||||
|
onClick={() => {
|
||||||
|
// Placeholder for logout action
|
||||||
|
console.log('Logout clicked');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span>Exit Dashboard</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
31
ccw/frontend/src/components/layout/MainContent.tsx
Normal file
31
ccw/frontend/src/components/layout/MainContent.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// ========================================
|
||||||
|
// MainContent Component
|
||||||
|
// ========================================
|
||||||
|
// Main content area with scrollable container and Outlet for routes
|
||||||
|
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface MainContentProps {
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
/** Children to render instead of Outlet */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainContent({ className, children }: MainContentProps) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'flex-1 overflow-y-auto min-w-0',
|
||||||
|
'p-4 md:p-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="main"
|
||||||
|
>
|
||||||
|
{children ?? <Outlet />}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainContent;
|
||||||
184
ccw/frontend/src/components/layout/Sidebar.tsx
Normal file
184
ccw/frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ========================================
|
||||||
|
// Sidebar Component
|
||||||
|
// ========================================
|
||||||
|
// Collapsible navigation sidebar with route links
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
FolderKanban,
|
||||||
|
Workflow,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
Sparkles,
|
||||||
|
Terminal,
|
||||||
|
Brain,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
/** Whether sidebar is collapsed */
|
||||||
|
collapsed?: boolean;
|
||||||
|
/** Callback when collapse state changes */
|
||||||
|
onCollapsedChange?: (collapsed: boolean) => void;
|
||||||
|
/** Whether sidebar is open on mobile */
|
||||||
|
mobileOpen?: boolean;
|
||||||
|
/** Callback to close mobile sidebar */
|
||||||
|
onMobileClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
badge?: number | string;
|
||||||
|
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home', icon: Home },
|
||||||
|
{ path: '/sessions', label: 'Sessions', icon: FolderKanban },
|
||||||
|
{ path: '/orchestrator', label: 'Orchestrator', icon: Workflow },
|
||||||
|
{ path: '/loops', label: 'Loop Monitor', icon: RefreshCw },
|
||||||
|
{ path: '/issues', label: 'Issues', icon: AlertCircle },
|
||||||
|
{ path: '/skills', label: 'Skills', icon: Sparkles },
|
||||||
|
{ path: '/commands', label: 'Commands', icon: Terminal },
|
||||||
|
{ path: '/memory', label: 'Memory', icon: Brain },
|
||||||
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
{ path: '/help', label: 'Help', icon: HelpCircle },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
collapsed = false,
|
||||||
|
onCollapsedChange,
|
||||||
|
mobileOpen = false,
|
||||||
|
onMobileClose,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
|
||||||
|
|
||||||
|
const isCollapsed = onCollapsedChange ? collapsed : internalCollapsed;
|
||||||
|
|
||||||
|
const handleToggleCollapse = useCallback(() => {
|
||||||
|
if (onCollapsedChange) {
|
||||||
|
onCollapsedChange(!collapsed);
|
||||||
|
} else {
|
||||||
|
setInternalCollapsed(!internalCollapsed);
|
||||||
|
}
|
||||||
|
}, [collapsed, internalCollapsed, onCollapsedChange]);
|
||||||
|
|
||||||
|
const handleNavClick = useCallback(() => {
|
||||||
|
// Close mobile sidebar when navigating
|
||||||
|
if (onMobileClose) {
|
||||||
|
onMobileClose();
|
||||||
|
}
|
||||||
|
}, [onMobileClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||||
|
onClick={onMobileClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'bg-sidebar-background border-r border-border flex flex-col transition-all duration-300',
|
||||||
|
// Desktop styles
|
||||||
|
'hidden md:flex sticky top-14 h-[calc(100vh-56px)]',
|
||||||
|
isCollapsed ? 'w-16' : 'w-64',
|
||||||
|
// Mobile styles
|
||||||
|
'md:translate-x-0',
|
||||||
|
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
|
||||||
|
)}
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<nav className="flex-1 py-3 overflow-y-auto">
|
||||||
|
<ul className="space-y-1 px-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname === item.path ||
|
||||||
|
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.path}>
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
onClick={handleNavClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors',
|
||||||
|
'hover:bg-hover hover:text-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
isCollapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{item.badge !== undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
item.badgeVariant === 'success' && 'bg-success-light text-success',
|
||||||
|
item.badgeVariant === 'warning' && 'bg-warning-light text-warning',
|
||||||
|
item.badgeVariant === 'info' && 'bg-info-light text-info',
|
||||||
|
(!item.badgeVariant || item.badgeVariant === 'default') &&
|
||||||
|
'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Sidebar footer - collapse toggle */}
|
||||||
|
<div className="p-3 border-t border-border hidden md:block">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggleCollapse}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 text-muted-foreground hover:text-foreground',
|
||||||
|
isCollapsed && 'justify-center'
|
||||||
|
)}
|
||||||
|
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<PanelLeftOpen className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
|
<span>Collapse</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
16
ccw/frontend/src/components/layout/index.ts
Normal file
16
ccw/frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// ========================================
|
||||||
|
// Layout Components Barrel Export
|
||||||
|
// ========================================
|
||||||
|
// Re-export all layout components for convenient imports
|
||||||
|
|
||||||
|
export { AppShell } from './AppShell';
|
||||||
|
export type { AppShellProps } from './AppShell';
|
||||||
|
|
||||||
|
export { Header } from './Header';
|
||||||
|
export type { HeaderProps } from './Header';
|
||||||
|
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export type { SidebarProps } from './Sidebar';
|
||||||
|
|
||||||
|
export { MainContent } from './MainContent';
|
||||||
|
export type { MainContentProps } from './MainContent';
|
||||||
238
ccw/frontend/src/components/shared/IssueCard.tsx
Normal file
238
ccw/frontend/src/components/shared/IssueCard.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// ========================================
|
||||||
|
// IssueCard Component
|
||||||
|
// ========================================
|
||||||
|
// Card component for displaying issues with actions
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
MoreVertical,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '@/components/ui/Dropdown';
|
||||||
|
import type { Issue } from '@/lib/api';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface IssueCardProps {
|
||||||
|
issue: Issue;
|
||||||
|
onEdit?: (issue: Issue) => void;
|
||||||
|
onDelete?: (issue: Issue) => void;
|
||||||
|
onClick?: (issue: Issue) => void;
|
||||||
|
onStatusChange?: (issue: Issue, status: Issue['status']) => void;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
draggableProps?: Record<string, unknown>;
|
||||||
|
dragHandleProps?: Record<string, unknown>;
|
||||||
|
innerRef?: React.Ref<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Priority Helpers ==========
|
||||||
|
|
||||||
|
const priorityConfig: Record<Issue['priority'], { icon: React.ElementType; color: string; label: string }> = {
|
||||||
|
critical: { icon: AlertCircle, color: 'destructive', label: 'Critical' },
|
||||||
|
high: { icon: AlertTriangle, color: 'warning', label: 'High' },
|
||||||
|
medium: { icon: Info, color: 'info', label: 'Medium' },
|
||||||
|
low: { icon: Info, color: 'secondary', label: 'Low' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig: Record<Issue['status'], { icon: React.ElementType; color: string; label: string }> = {
|
||||||
|
open: { icon: AlertCircle, color: 'info', label: 'Open' },
|
||||||
|
in_progress: { icon: Clock, color: 'warning', label: 'In Progress' },
|
||||||
|
resolved: { icon: CheckCircle, color: 'success', label: 'Resolved' },
|
||||||
|
closed: { icon: XCircle, color: 'muted', label: 'Closed' },
|
||||||
|
completed: { icon: CheckCircle, color: 'success', label: 'Completed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Priority Badge ==========
|
||||||
|
|
||||||
|
export function PriorityBadge({ priority }: { priority: Issue['priority'] }) {
|
||||||
|
const config = priorityConfig[priority];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={config.color as 'default' | 'secondary' | 'destructive' | 'outline'} className="gap-1">
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Status Badge ==========
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: Issue['status'] }) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main IssueCard Component ==========
|
||||||
|
|
||||||
|
export function IssueCard({
|
||||||
|
issue,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onClick,
|
||||||
|
onStatusChange,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
showActions = true,
|
||||||
|
draggableProps,
|
||||||
|
dragHandleProps,
|
||||||
|
innerRef,
|
||||||
|
}: IssueCardProps) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isMenuOpen) {
|
||||||
|
onClick?.(issue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
onEdit?.(issue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
onDelete?.(issue);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={innerRef}
|
||||||
|
{...draggableProps}
|
||||||
|
{...dragHandleProps}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||||
|
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{issue.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">#{issue.id}</p>
|
||||||
|
</div>
|
||||||
|
<PriorityBadge priority={issue.priority} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={innerRef}
|
||||||
|
{...draggableProps}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0" {...dragHandleProps}>
|
||||||
|
<h3 className="text-sm font-medium text-foreground line-clamp-2">
|
||||||
|
{issue.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">#{issue.id}</p>
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<Dropdown open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||||
|
<DropdownTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownContent align="end">
|
||||||
|
<DropdownItem onClick={handleEdit}>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => onStatusChange?.(issue, 'in_progress')}>
|
||||||
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
|
Start Progress
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => onStatusChange?.(issue, 'resolved')}>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Mark Resolved
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={handleDelete} className="text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context Preview */}
|
||||||
|
{issue.context && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||||
|
{issue.context}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{issue.labels && issue.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{issue.labels.slice(0, 3).map((label) => (
|
||||||
|
<Badge key={label} variant="outline" className="text-xs">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{issue.labels.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{issue.labels.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
|
<PriorityBadge priority={issue.priority} />
|
||||||
|
<StatusBadge status={issue.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solutions Count */}
|
||||||
|
{issue.solutions && issue.solutions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{issue.solutions.length} solution{issue.solutions.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IssueCard;
|
||||||
265
ccw/frontend/src/components/shared/KanbanBoard.tsx
Normal file
265
ccw/frontend/src/components/shared/KanbanBoard.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// ========================================
|
||||||
|
// KanbanBoard Component
|
||||||
|
// ========================================
|
||||||
|
// Drag-and-drop kanban board for loops and tasks
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Droppable,
|
||||||
|
Draggable,
|
||||||
|
type DropResult,
|
||||||
|
type DraggableProvided,
|
||||||
|
type DroppableProvided,
|
||||||
|
} from '@hello-pangea/dnd';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface KanbanItem {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanColumn<T extends KanbanItem = KanbanItem> {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: T[];
|
||||||
|
color?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanBoardProps<T extends KanbanItem = KanbanItem> {
|
||||||
|
columns: KanbanColumn<T>[];
|
||||||
|
onDragEnd?: (result: DropResult, sourceColumn: string, destColumn: string) => void;
|
||||||
|
onItemClick?: (item: T) => void;
|
||||||
|
renderItem?: (item: T, provided: DraggableProvided) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
columnClassName?: string;
|
||||||
|
itemClassName?: string;
|
||||||
|
emptyColumnMessage?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Default Item Renderer ==========
|
||||||
|
|
||||||
|
function DefaultItemRenderer<T extends KanbanItem>({
|
||||||
|
item,
|
||||||
|
provided,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: T;
|
||||||
|
provided: DraggableProvided;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer',
|
||||||
|
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{item.title || item.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Column Component ==========
|
||||||
|
|
||||||
|
function KanbanColumnComponent<T extends KanbanItem>({
|
||||||
|
column,
|
||||||
|
onItemClick,
|
||||||
|
renderItem,
|
||||||
|
itemClassName,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
column: KanbanColumn<T>;
|
||||||
|
onItemClick?: (item: T) => void;
|
||||||
|
renderItem?: (item: T, provided: DraggableProvided) => React.ReactNode;
|
||||||
|
itemClassName?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Droppable droppableId={column.id}>
|
||||||
|
{(provided: DroppableProvided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[200px] p-2 space-y-2 rounded-lg transition-colors',
|
||||||
|
snapshot.isDraggingOver && 'bg-primary/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.items.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-muted-foreground py-8">
|
||||||
|
{emptyMessage || 'No items'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
column.items.map((item, index) => (
|
||||||
|
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||||
|
{(dragProvided: DraggableProvided) =>
|
||||||
|
renderItem ? (
|
||||||
|
renderItem(item, dragProvided)
|
||||||
|
) : (
|
||||||
|
<DefaultItemRenderer
|
||||||
|
item={item}
|
||||||
|
provided={dragProvided}
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
className={itemClassName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Draggable>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Kanban Board Component ==========
|
||||||
|
|
||||||
|
export function KanbanBoard<T extends KanbanItem = KanbanItem>({
|
||||||
|
columns,
|
||||||
|
onDragEnd,
|
||||||
|
onItemClick,
|
||||||
|
renderItem,
|
||||||
|
className,
|
||||||
|
columnClassName,
|
||||||
|
itemClassName,
|
||||||
|
emptyColumnMessage,
|
||||||
|
isLoading = false,
|
||||||
|
}: KanbanBoardProps<T>) {
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const { source, destination } = result;
|
||||||
|
if (
|
||||||
|
source.droppableId === destination.droppableId &&
|
||||||
|
source.index === destination.index
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragEnd?.(result, source.droppableId, destination.droppableId);
|
||||||
|
},
|
||||||
|
[onDragEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-4', className)} style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<Card key={column.id} className={cn('p-4', columnClassName)}>
|
||||||
|
<div className="h-6 w-24 bg-muted animate-pulse rounded mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<div
|
||||||
|
className={cn('grid gap-4', className)}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<Card key={column.id} className={cn('p-4', columnClassName)}>
|
||||||
|
{/* Column Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{column.icon}
|
||||||
|
<h3 className="font-medium text-foreground">{column.title}</h3>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(column.color && `bg-${column.color}/10 text-${column.color}`)}
|
||||||
|
>
|
||||||
|
{column.items.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Content */}
|
||||||
|
<KanbanColumnComponent
|
||||||
|
column={column}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
renderItem={renderItem}
|
||||||
|
itemClassName={itemClassName}
|
||||||
|
emptyMessage={emptyColumnMessage}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Loop-specific Kanban ==========
|
||||||
|
|
||||||
|
export interface LoopKanbanItem extends KanbanItem {
|
||||||
|
status: 'created' | 'running' | 'paused' | 'completed' | 'failed';
|
||||||
|
currentStep?: number;
|
||||||
|
totalSteps?: number;
|
||||||
|
prompt?: string;
|
||||||
|
tool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoopKanbanColumns(loopsByStatus: Record<string, LoopKanbanItem[]>): KanbanColumn<LoopKanbanItem>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
title: 'Pending',
|
||||||
|
items: loopsByStatus.created || [],
|
||||||
|
color: 'muted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'running',
|
||||||
|
title: 'Running',
|
||||||
|
items: loopsByStatus.running || [],
|
||||||
|
color: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paused',
|
||||||
|
title: 'Paused',
|
||||||
|
items: loopsByStatus.paused || [],
|
||||||
|
color: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completed',
|
||||||
|
title: 'Completed',
|
||||||
|
items: loopsByStatus.completed || [],
|
||||||
|
color: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed',
|
||||||
|
title: 'Failed',
|
||||||
|
items: loopsByStatus.failed || [],
|
||||||
|
color: 'destructive',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanBoard;
|
||||||
287
ccw/frontend/src/components/shared/SessionCard.tsx
Normal file
287
ccw/frontend/src/components/shared/SessionCard.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// ========================================
|
||||||
|
// SessionCard Component
|
||||||
|
// ========================================
|
||||||
|
// Session card with status badge and action menu
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/Dropdown';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
ListChecks,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Archive,
|
||||||
|
Trash2,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { SessionMetadata } from '@/types/store';
|
||||||
|
|
||||||
|
export interface SessionCardProps {
|
||||||
|
/** Session data */
|
||||||
|
session: SessionMetadata;
|
||||||
|
/** Called when view action is triggered */
|
||||||
|
onView?: (sessionId: string) => void;
|
||||||
|
/** Called when archive action is triggered */
|
||||||
|
onArchive?: (sessionId: string) => void;
|
||||||
|
/** Called when delete action is triggered */
|
||||||
|
onDelete?: (sessionId: string) => void;
|
||||||
|
/** Called when card is clicked */
|
||||||
|
onClick?: (sessionId: string) => void;
|
||||||
|
/** Optional className */
|
||||||
|
className?: string;
|
||||||
|
/** Show actions dropdown */
|
||||||
|
showActions?: boolean;
|
||||||
|
/** Disabled state for actions */
|
||||||
|
actionsDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge configuration
|
||||||
|
const statusConfig: Record<
|
||||||
|
SessionMetadata['status'],
|
||||||
|
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' }
|
||||||
|
> = {
|
||||||
|
planning: { label: 'Planning', variant: 'info' },
|
||||||
|
in_progress: { label: 'In Progress', variant: 'warning' },
|
||||||
|
completed: { label: 'Completed', variant: 'success' },
|
||||||
|
archived: { label: 'Archived', variant: 'secondary' },
|
||||||
|
paused: { label: 'Paused', variant: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to localized string
|
||||||
|
*/
|
||||||
|
function formatDate(dateString: string | undefined): string {
|
||||||
|
if (!dateString) return 'Unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 'Invalid date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate progress percentage from tasks
|
||||||
|
*/
|
||||||
|
function calculateProgress(tasks: SessionMetadata['tasks']): {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percentage: number;
|
||||||
|
} {
|
||||||
|
if (!tasks || tasks.length === 0) {
|
||||||
|
return { completed: 0, total: 0, percentage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||||
|
const total = tasks.length;
|
||||||
|
const percentage = Math.round((completed / total) * 100);
|
||||||
|
|
||||||
|
return { completed, total, percentage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionCard component for displaying session information
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <SessionCard
|
||||||
|
* session={session}
|
||||||
|
* onView={(id) => navigate(`/sessions/${id}`)}
|
||||||
|
* onArchive={(id) => archiveSession(id)}
|
||||||
|
* onDelete={(id) => deleteSession(id)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function SessionCard({
|
||||||
|
session,
|
||||||
|
onView,
|
||||||
|
onArchive,
|
||||||
|
onDelete,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
showActions = true,
|
||||||
|
actionsDisabled = false,
|
||||||
|
}: SessionCardProps) {
|
||||||
|
const { label: statusLabel, variant: statusVariant } = statusConfig[session.status] || {
|
||||||
|
label: 'Unknown',
|
||||||
|
variant: 'default' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = calculateProgress(session.tasks);
|
||||||
|
const isPlanning = session.status === 'planning';
|
||||||
|
const isArchived = session.status === 'archived' || session.location === 'archived';
|
||||||
|
|
||||||
|
const handleCardClick = (e: React.MouseEvent) => {
|
||||||
|
// Don't trigger if clicking on dropdown
|
||||||
|
if ((e.target as HTMLElement).closest('[data-radix-popper-content-wrapper]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClick?.(session.session_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
action: 'view' | 'archive' | 'delete'
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
switch (action) {
|
||||||
|
case 'view':
|
||||||
|
onView?.(session.session_id);
|
||||||
|
break;
|
||||||
|
case 'archive':
|
||||||
|
onArchive?.(session.session_id);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
onDelete?.(session.session_id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/30',
|
||||||
|
isPlanning && 'border-info/30 bg-info/5',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-card-foreground truncate">
|
||||||
|
{session.title || session.session_id}
|
||||||
|
</h3>
|
||||||
|
{session.title && session.title !== session.session_id && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{session.session_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||||
|
{showActions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={(e) => handleAction(e, 'view')}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!isArchived && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={(e) => handleAction(e, 'archive')}>
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleAction(e, 'delete')}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{formatDate(session.created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
{progress.total} tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar (only show if not planning and has tasks) */}
|
||||||
|
{progress.total > 0 && !isPlanning && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-muted-foreground">Progress</span>
|
||||||
|
<span className="text-card-foreground font-medium">
|
||||||
|
{progress.completed}/{progress.total} ({progress.percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description (if exists) */}
|
||||||
|
{session.description && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{session.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for SessionCard
|
||||||
|
*/
|
||||||
|
export function SessionCardSkeleton({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('animate-pulse', className)}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-5 w-32 rounded bg-muted" />
|
||||||
|
<div className="mt-1 h-3 w-24 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-16 rounded-full bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-4">
|
||||||
|
<div className="h-4 w-20 rounded bg-muted" />
|
||||||
|
<div className="h-4 w-16 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-muted" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
ccw/frontend/src/components/shared/SkillCard.tsx
Normal file
257
ccw/frontend/src/components/shared/SkillCard.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
// ========================================
|
||||||
|
// SkillCard Component
|
||||||
|
// ========================================
|
||||||
|
// Card component for displaying skills with enable/disable toggle
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
MoreVertical,
|
||||||
|
Info,
|
||||||
|
Settings,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Dropdown, DropdownTrigger, DropdownContent, DropdownItem } from '@/components/ui/Dropdown';
|
||||||
|
import type { Skill } from '@/lib/api';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface SkillCardProps {
|
||||||
|
skill: Skill;
|
||||||
|
onToggle?: (skill: Skill, enabled: boolean) => void;
|
||||||
|
onClick?: (skill: Skill) => void;
|
||||||
|
onConfigure?: (skill: Skill) => void;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
isToggling?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Source Badge ==========
|
||||||
|
|
||||||
|
const sourceConfig: Record<NonNullable<Skill['source']>, { color: string; label: string }> = {
|
||||||
|
builtin: { color: 'default', label: 'Built-in' },
|
||||||
|
custom: { color: 'secondary', label: 'Custom' },
|
||||||
|
community: { color: 'outline', label: 'Community' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SourceBadge({ source }: { source?: Skill['source'] }) {
|
||||||
|
const config = sourceConfig[source ?? 'builtin'];
|
||||||
|
return (
|
||||||
|
<Badge variant={config.color as 'default' | 'secondary' | 'destructive' | 'outline'}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main SkillCard Component ==========
|
||||||
|
|
||||||
|
export function SkillCard({
|
||||||
|
skill,
|
||||||
|
onToggle,
|
||||||
|
onClick,
|
||||||
|
onConfigure,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
showActions = true,
|
||||||
|
isToggling = false,
|
||||||
|
}: SkillCardProps) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!isMenuOpen) {
|
||||||
|
onClick?.(skill);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle?.(skill, !skill.enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigure = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
onConfigure?.(skill);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||||
|
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
!skill.enabled && 'opacity-60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Sparkles className={cn('w-4 h-4 flex-shrink-0', skill.enabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">{skill.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={skill.enabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
>
|
||||||
|
{skill.enabled ? (
|
||||||
|
<>
|
||||||
|
<Power className="w-3 h-3 mr-1" />
|
||||||
|
On
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PowerOff className="w-3 h-3 mr-1" />
|
||||||
|
Off
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
!skill.enabled && 'opacity-75',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
<div className={cn(
|
||||||
|
'p-2 rounded-lg flex-shrink-0',
|
||||||
|
skill.enabled ? 'bg-primary/10' : 'bg-muted'
|
||||||
|
)}>
|
||||||
|
<Sparkles className={cn('w-5 h-5', skill.enabled ? 'text-primary' : 'text-muted-foreground')} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">{skill.name}</h3>
|
||||||
|
{skill.version && (
|
||||||
|
<p className="text-xs text-muted-foreground">v{skill.version}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<Dropdown open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||||
|
<DropdownTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownContent align="end">
|
||||||
|
<DropdownItem onClick={() => onClick?.(skill)}>
|
||||||
|
<Info className="w-4 h-4 mr-2" />
|
||||||
|
View Details
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={handleConfigure}>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Configure
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={handleToggle}>
|
||||||
|
{skill.enabled ? (
|
||||||
|
<>
|
||||||
|
<PowerOff className="w-4 h-4 mr-2" />
|
||||||
|
Disable
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power className="w-4 h-4 mr-2" />
|
||||||
|
Enable
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-muted-foreground mt-3 line-clamp-2">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Triggers */}
|
||||||
|
{skill.triggers && skill.triggers.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
Triggers
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{skill.triggers.slice(0, 4).map((trigger) => (
|
||||||
|
<Badge key={trigger} variant="outline" className="text-xs">
|
||||||
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{skill.triggers.length > 4 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{skill.triggers.length - 4}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SourceBadge source={skill.source} />
|
||||||
|
{skill.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{skill.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={skill.enabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
>
|
||||||
|
{skill.enabled ? (
|
||||||
|
<>
|
||||||
|
<Power className="w-4 h-4 mr-1" />
|
||||||
|
Enabled
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PowerOff className="w-4 h-4 mr-1" />
|
||||||
|
Disabled
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author */}
|
||||||
|
{skill.author && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
{skill.author}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkillCard;
|
||||||
161
ccw/frontend/src/components/shared/StatCard.tsx
Normal file
161
ccw/frontend/src/components/shared/StatCard.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// ========================================
|
||||||
|
// StatCard Component
|
||||||
|
// ========================================
|
||||||
|
// Reusable stat card for dashboard metrics
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent } from '@/components/ui/Card';
|
||||||
|
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const statCardVariants = cva(
|
||||||
|
'transition-all duration-200 hover:shadow-md',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-border',
|
||||||
|
primary: 'border-primary/30 bg-primary/5',
|
||||||
|
success: 'border-success/30 bg-success/5',
|
||||||
|
warning: 'border-warning/30 bg-warning/5',
|
||||||
|
danger: 'border-destructive/30 bg-destructive/5',
|
||||||
|
info: 'border-info/30 bg-info/5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconContainerVariants = cva(
|
||||||
|
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-muted text-muted-foreground',
|
||||||
|
primary: 'bg-primary/10 text-primary',
|
||||||
|
success: 'bg-success/10 text-success',
|
||||||
|
warning: 'bg-warning/10 text-warning',
|
||||||
|
danger: 'bg-destructive/10 text-destructive',
|
||||||
|
info: 'bg-info/10 text-info',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface StatCardProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof statCardVariants> {
|
||||||
|
/** Card title */
|
||||||
|
title: string;
|
||||||
|
/** Stat value to display */
|
||||||
|
value: number | string;
|
||||||
|
/** Optional icon component */
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** Optional trend direction */
|
||||||
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
/** Optional trend value (e.g., "+12%") */
|
||||||
|
trendValue?: string;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Optional description */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatCard component for displaying dashboard metrics
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <StatCard
|
||||||
|
* title="Total Sessions"
|
||||||
|
* value={42}
|
||||||
|
* icon={FolderIcon}
|
||||||
|
* variant="primary"
|
||||||
|
* trend="up"
|
||||||
|
* trendValue="+5"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function StatCard({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
isLoading = false,
|
||||||
|
description,
|
||||||
|
...props
|
||||||
|
}: StatCardProps) {
|
||||||
|
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
|
||||||
|
const trendColor =
|
||||||
|
trend === 'up'
|
||||||
|
? 'text-success'
|
||||||
|
: trend === 'down'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-muted-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn(statCardVariants({ variant }), className)} {...props}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground truncate">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl font-semibold text-card-foreground">
|
||||||
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{trend && trendValue && !isLoading && (
|
||||||
|
<span className={cn('flex items-center text-xs font-medium', trendColor)}>
|
||||||
|
<TrendIcon className="mr-0.5 h-3 w-3" />
|
||||||
|
{trendValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<div className={cn(iconContainerVariants({ variant }))}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for StatCard
|
||||||
|
*/
|
||||||
|
export function StatCardSkeleton({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('animate-pulse', className)}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 w-20 rounded bg-muted" />
|
||||||
|
<div className="mt-3 h-8 w-16 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-muted" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
ccw/frontend/src/components/ui/Badge.tsx
Normal file
42
ccw/frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
|
outline:
|
||||||
|
"text-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-success text-white",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-warning text-white",
|
||||||
|
info:
|
||||||
|
"border-transparent bg-info text-white",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
56
ccw/frontend/src/components/ui/Button.tsx
Normal file
56
ccw/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link:
|
||||||
|
"text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
85
ccw/frontend/src/components/ui/Card.tsx
Normal file
85
ccw/frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
119
ccw/frontend/src/components/ui/Dialog.tsx
Normal file
119
ccw/frontend/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight text-card-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
197
ccw/frontend/src/components/ui/Dropdown.tsx
Normal file
197
ccw/frontend/src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
27
ccw/frontend/src/components/ui/Input.tsx
Normal file
27
ccw/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, error, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
error && "border-destructive focus-visible:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
156
ccw/frontend/src/components/ui/Select.tsx
Normal file
156
ccw/frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-card-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
52
ccw/frontend/src/components/ui/Tabs.tsx
Normal file
52
ccw/frontend/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
128
ccw/frontend/src/components/ui/Toast.tsx
Normal file
128
ccw/frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-border bg-card text-card-foreground",
|
||||||
|
success: "border-success bg-success text-white",
|
||||||
|
warning: "border-warning bg-warning text-white",
|
||||||
|
error: "border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
info: "border-info bg-info text-white",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.success]:border-white/20 group-[.success]:hover:bg-white/20 group-[.warning]:border-white/20 group-[.warning]:hover:bg-white/20 group-[.error]:border-white/20 group-[.error]:hover:bg-white/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.success]:text-white/50 group-[.success]:hover:text-white group-[.warning]:text-white/50 group-[.warning]:hover:text-white group-[.error]:text-white/50 group-[.error]:hover:text-white",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
87
ccw/frontend/src/components/ui/index.ts
Normal file
87
ccw/frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// UI Component Library - Barrel Export
|
||||||
|
// All components follow shadcn/ui patterns with Radix UI primitives and Tailwind CSS
|
||||||
|
|
||||||
|
// Button
|
||||||
|
export { Button, buttonVariants } from "./Button";
|
||||||
|
export type { ButtonProps } from "./Button";
|
||||||
|
|
||||||
|
// Input
|
||||||
|
export { Input } from "./Input";
|
||||||
|
export type { InputProps } from "./Input";
|
||||||
|
|
||||||
|
// Select (Radix)
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
} from "./Select";
|
||||||
|
|
||||||
|
// Dialog (Radix)
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "./Dialog";
|
||||||
|
|
||||||
|
// Dropdown (Radix)
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
} from "./Dropdown";
|
||||||
|
|
||||||
|
// Tabs (Radix)
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs";
|
||||||
|
|
||||||
|
// Card
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "./Card";
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
export { Badge, badgeVariants } from "./Badge";
|
||||||
|
export type { BadgeProps } from "./Badge";
|
||||||
|
|
||||||
|
// Toast (Radix)
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
} from "./Toast";
|
||||||
121
ccw/frontend/src/hooks/index.ts
Normal file
121
ccw/frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ========================================
|
||||||
|
// Hooks Barrel Export
|
||||||
|
// ========================================
|
||||||
|
// Re-export all custom hooks for convenient imports
|
||||||
|
|
||||||
|
export { useTheme } from './useTheme';
|
||||||
|
export type { UseThemeReturn } from './useTheme';
|
||||||
|
|
||||||
|
export { useSession } from './useSession';
|
||||||
|
export type { UseSessionReturn } from './useSession';
|
||||||
|
|
||||||
|
export { useConfig } from './useConfig';
|
||||||
|
export type { UseConfigReturn } from './useConfig';
|
||||||
|
|
||||||
|
export { useNotifications } from './useNotifications';
|
||||||
|
export type { UseNotificationsReturn, ToastOptions } from './useNotifications';
|
||||||
|
|
||||||
|
export { useDashboardStats, usePrefetchDashboardStats, dashboardStatsKeys } from './useDashboardStats';
|
||||||
|
export type { UseDashboardStatsOptions, UseDashboardStatsReturn } from './useDashboardStats';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useSessions,
|
||||||
|
useCreateSession,
|
||||||
|
useUpdateSession,
|
||||||
|
useArchiveSession,
|
||||||
|
useDeleteSession,
|
||||||
|
useSessionMutations,
|
||||||
|
usePrefetchSessions,
|
||||||
|
sessionsKeys,
|
||||||
|
} from './useSessions';
|
||||||
|
export type {
|
||||||
|
SessionsFilter,
|
||||||
|
UseSessionsOptions,
|
||||||
|
UseSessionsReturn,
|
||||||
|
UseCreateSessionReturn,
|
||||||
|
UseUpdateSessionReturn,
|
||||||
|
UseArchiveSessionReturn,
|
||||||
|
UseDeleteSessionReturn,
|
||||||
|
} from './useSessions';
|
||||||
|
|
||||||
|
// ========== Loops ==========
|
||||||
|
export {
|
||||||
|
useLoops,
|
||||||
|
useLoop,
|
||||||
|
useCreateLoop,
|
||||||
|
useUpdateLoopStatus,
|
||||||
|
useDeleteLoop,
|
||||||
|
useLoopMutations,
|
||||||
|
loopsKeys,
|
||||||
|
} from './useLoops';
|
||||||
|
export type {
|
||||||
|
LoopsFilter,
|
||||||
|
UseLoopsOptions,
|
||||||
|
UseLoopsReturn,
|
||||||
|
UseCreateLoopReturn,
|
||||||
|
UseUpdateLoopStatusReturn,
|
||||||
|
UseDeleteLoopReturn,
|
||||||
|
} from './useLoops';
|
||||||
|
|
||||||
|
// ========== Issues ==========
|
||||||
|
export {
|
||||||
|
useIssues,
|
||||||
|
useIssueQueue,
|
||||||
|
useCreateIssue,
|
||||||
|
useUpdateIssue,
|
||||||
|
useDeleteIssue,
|
||||||
|
useIssueMutations,
|
||||||
|
issuesKeys,
|
||||||
|
} from './useIssues';
|
||||||
|
export type {
|
||||||
|
IssuesFilter,
|
||||||
|
UseIssuesOptions,
|
||||||
|
UseIssuesReturn,
|
||||||
|
UseCreateIssueReturn,
|
||||||
|
UseUpdateIssueReturn,
|
||||||
|
UseDeleteIssueReturn,
|
||||||
|
} from './useIssues';
|
||||||
|
|
||||||
|
// ========== Skills ==========
|
||||||
|
export {
|
||||||
|
useSkills,
|
||||||
|
useToggleSkill,
|
||||||
|
useSkillMutations,
|
||||||
|
skillsKeys,
|
||||||
|
} from './useSkills';
|
||||||
|
export type {
|
||||||
|
SkillsFilter,
|
||||||
|
UseSkillsOptions,
|
||||||
|
UseSkillsReturn,
|
||||||
|
UseToggleSkillReturn,
|
||||||
|
} from './useSkills';
|
||||||
|
|
||||||
|
// ========== Commands ==========
|
||||||
|
export {
|
||||||
|
useCommands,
|
||||||
|
useCommandSearch,
|
||||||
|
commandsKeys,
|
||||||
|
} from './useCommands';
|
||||||
|
export type {
|
||||||
|
CommandsFilter,
|
||||||
|
UseCommandsOptions,
|
||||||
|
UseCommandsReturn,
|
||||||
|
} from './useCommands';
|
||||||
|
|
||||||
|
// ========== Memory ==========
|
||||||
|
export {
|
||||||
|
useMemory,
|
||||||
|
useCreateMemory,
|
||||||
|
useUpdateMemory,
|
||||||
|
useDeleteMemory,
|
||||||
|
useMemoryMutations,
|
||||||
|
memoryKeys,
|
||||||
|
} from './useMemory';
|
||||||
|
export type {
|
||||||
|
MemoryFilter,
|
||||||
|
UseMemoryOptions,
|
||||||
|
UseMemoryReturn,
|
||||||
|
UseCreateMemoryReturn,
|
||||||
|
UseUpdateMemoryReturn,
|
||||||
|
UseDeleteMemoryReturn,
|
||||||
|
} from './useMemory';
|
||||||
128
ccw/frontend/src/hooks/useCommands.ts
Normal file
128
ccw/frontend/src/hooks/useCommands.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// ========================================
|
||||||
|
// useCommands Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for commands management
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchCommands,
|
||||||
|
type Command,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const commandsKeys = {
|
||||||
|
all: ['commands'] as const,
|
||||||
|
lists: () => [...commandsKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: CommandsFilter) => [...commandsKeys.lists(), filters] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 10 minutes (commands are static)
|
||||||
|
const STALE_TIME = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface CommandsFilter {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
source?: Command['source'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCommandsOptions {
|
||||||
|
filter?: CommandsFilter;
|
||||||
|
staleTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCommandsReturn {
|
||||||
|
commands: Command[];
|
||||||
|
categories: string[];
|
||||||
|
commandsByCategory: Record<string, Command[]>;
|
||||||
|
totalCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and filtering commands
|
||||||
|
*/
|
||||||
|
export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn {
|
||||||
|
const { filter, staleTime = STALE_TIME, enabled = true } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: commandsKeys.list(filter),
|
||||||
|
queryFn: fetchCommands,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCommands = query.data?.commands ?? [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredCommands = (() => {
|
||||||
|
let commands = allCommands;
|
||||||
|
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
commands = commands.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchLower) ||
|
||||||
|
c.description.toLowerCase().includes(searchLower) ||
|
||||||
|
c.aliases?.some((a) => a.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.category) {
|
||||||
|
commands = commands.filter((c) => c.category === filter.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.source) {
|
||||||
|
commands = commands.filter((c) => c.source === filter.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const commandsByCategory: Record<string, Command[]> = {};
|
||||||
|
const categories = new Set<string>();
|
||||||
|
|
||||||
|
for (const command of allCommands) {
|
||||||
|
const category = command.category || 'Uncategorized';
|
||||||
|
categories.add(category);
|
||||||
|
if (!commandsByCategory[category]) {
|
||||||
|
commandsByCategory[category] = [];
|
||||||
|
}
|
||||||
|
commandsByCategory[category].push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: commandsKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands: filteredCommands,
|
||||||
|
categories: Array.from(categories).sort(),
|
||||||
|
commandsByCategory,
|
||||||
|
totalCount: allCommands.length,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to search commands by name or alias
|
||||||
|
*/
|
||||||
|
export function useCommandSearch(searchTerm: string) {
|
||||||
|
const { commands } = useCommands({ filter: { search: searchTerm } });
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
143
ccw/frontend/src/hooks/useConfig.ts
Normal file
143
ccw/frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// ========================================
|
||||||
|
// useConfig Hook
|
||||||
|
// ========================================
|
||||||
|
// Convenient hook for configuration management
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
useConfigStore,
|
||||||
|
selectCliTools,
|
||||||
|
selectDefaultCliTool,
|
||||||
|
selectApiEndpoints,
|
||||||
|
selectUserPreferences,
|
||||||
|
selectFeatureFlags,
|
||||||
|
getFirstEnabledCliTool,
|
||||||
|
} from '../stores/configStore';
|
||||||
|
import type { CliToolConfig, ApiEndpoints, UserPreferences, ConfigState } from '../types/store';
|
||||||
|
|
||||||
|
export interface UseConfigReturn {
|
||||||
|
/** CLI tools configuration */
|
||||||
|
cliTools: Record<string, CliToolConfig>;
|
||||||
|
/** Default CLI tool ID */
|
||||||
|
defaultCliTool: string;
|
||||||
|
/** First enabled CLI tool (fallback) */
|
||||||
|
firstEnabledTool: string;
|
||||||
|
/** API endpoints */
|
||||||
|
apiEndpoints: ApiEndpoints;
|
||||||
|
/** User preferences */
|
||||||
|
userPreferences: UserPreferences;
|
||||||
|
/** Feature flags */
|
||||||
|
featureFlags: Record<string, boolean>;
|
||||||
|
/** Update CLI tool config */
|
||||||
|
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => void;
|
||||||
|
/** Set default CLI tool */
|
||||||
|
setDefaultCliTool: (toolId: string) => void;
|
||||||
|
/** Update user preferences */
|
||||||
|
setUserPreferences: (prefs: Partial<UserPreferences>) => void;
|
||||||
|
/** Reset user preferences to defaults */
|
||||||
|
resetUserPreferences: () => void;
|
||||||
|
/** Set a feature flag */
|
||||||
|
setFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||||
|
/** Check if a feature is enabled */
|
||||||
|
isFeatureEnabled: (flag: string) => boolean;
|
||||||
|
/** Load full config */
|
||||||
|
loadConfig: (config: Partial<ConfigState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing configuration state
|
||||||
|
* @returns Config state and actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { cliTools, defaultCliTool, userPreferences, setUserPreferences } = useConfig();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <SettingsPanel
|
||||||
|
* preferences={userPreferences}
|
||||||
|
* onUpdate={setUserPreferences}
|
||||||
|
* />
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useConfig(): UseConfigReturn {
|
||||||
|
const cliTools = useConfigStore(selectCliTools);
|
||||||
|
const defaultCliTool = useConfigStore(selectDefaultCliTool);
|
||||||
|
const apiEndpoints = useConfigStore(selectApiEndpoints);
|
||||||
|
const userPreferences = useConfigStore(selectUserPreferences);
|
||||||
|
const featureFlags = useConfigStore(selectFeatureFlags);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const updateCliToolAction = useConfigStore((state) => state.updateCliTool);
|
||||||
|
const setDefaultCliToolAction = useConfigStore((state) => state.setDefaultCliTool);
|
||||||
|
const setUserPreferencesAction = useConfigStore((state) => state.setUserPreferences);
|
||||||
|
const resetUserPreferencesAction = useConfigStore((state) => state.resetUserPreferences);
|
||||||
|
const setFeatureFlagAction = useConfigStore((state) => state.setFeatureFlag);
|
||||||
|
const loadConfigAction = useConfigStore((state) => state.loadConfig);
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const firstEnabledTool = getFirstEnabledCliTool(cliTools);
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
const updateCliTool = useCallback(
|
||||||
|
(toolId: string, updates: Partial<CliToolConfig>) => {
|
||||||
|
updateCliToolAction(toolId, updates);
|
||||||
|
},
|
||||||
|
[updateCliToolAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setDefaultCliTool = useCallback(
|
||||||
|
(toolId: string) => {
|
||||||
|
setDefaultCliToolAction(toolId);
|
||||||
|
},
|
||||||
|
[setDefaultCliToolAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setUserPreferences = useCallback(
|
||||||
|
(prefs: Partial<UserPreferences>) => {
|
||||||
|
setUserPreferencesAction(prefs);
|
||||||
|
},
|
||||||
|
[setUserPreferencesAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetUserPreferences = useCallback(() => {
|
||||||
|
resetUserPreferencesAction();
|
||||||
|
}, [resetUserPreferencesAction]);
|
||||||
|
|
||||||
|
const setFeatureFlag = useCallback(
|
||||||
|
(flag: string, enabled: boolean) => {
|
||||||
|
setFeatureFlagAction(flag, enabled);
|
||||||
|
},
|
||||||
|
[setFeatureFlagAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFeatureEnabled = useCallback(
|
||||||
|
(flag: string): boolean => {
|
||||||
|
return featureFlags[flag] ?? false;
|
||||||
|
},
|
||||||
|
[featureFlags]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(
|
||||||
|
(config: Partial<ConfigState>) => {
|
||||||
|
loadConfigAction(config);
|
||||||
|
},
|
||||||
|
[loadConfigAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cliTools,
|
||||||
|
defaultCliTool,
|
||||||
|
firstEnabledTool,
|
||||||
|
apiEndpoints,
|
||||||
|
userPreferences,
|
||||||
|
featureFlags,
|
||||||
|
updateCliTool,
|
||||||
|
setDefaultCliTool,
|
||||||
|
setUserPreferences,
|
||||||
|
resetUserPreferences,
|
||||||
|
setFeatureFlag,
|
||||||
|
isFeatureEnabled,
|
||||||
|
loadConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
ccw/frontend/src/hooks/useDashboardStats.ts
Normal file
111
ccw/frontend/src/hooks/useDashboardStats.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ========================================
|
||||||
|
// useDashboardStats Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hook for dashboard statistics
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { fetchDashboardStats, type DashboardStats } from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const dashboardStatsKeys = {
|
||||||
|
all: ['dashboardStats'] as const,
|
||||||
|
detail: () => [...dashboardStatsKeys.all, 'detail'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 30 seconds
|
||||||
|
const STALE_TIME = 30 * 1000;
|
||||||
|
|
||||||
|
export interface UseDashboardStatsOptions {
|
||||||
|
/** Override default stale time (ms) */
|
||||||
|
staleTime?: number;
|
||||||
|
/** Enable/disable the query */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Refetch interval (ms), 0 to disable */
|
||||||
|
refetchInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDashboardStatsReturn {
|
||||||
|
/** Dashboard statistics data */
|
||||||
|
stats: DashboardStats | undefined;
|
||||||
|
/** Loading state for initial fetch */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Fetching state (initial or refetch) */
|
||||||
|
isFetching: boolean;
|
||||||
|
/** Error object if query failed */
|
||||||
|
error: Error | null;
|
||||||
|
/** Whether data is stale */
|
||||||
|
isStale: boolean;
|
||||||
|
/** Manually refetch data */
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
/** Invalidate and refetch stats */
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and managing dashboard statistics
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { stats, isLoading, error } = useDashboardStats();
|
||||||
|
*
|
||||||
|
* if (isLoading) return <LoadingSpinner />;
|
||||||
|
* if (error) return <ErrorMessage error={error} />;
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <StatsGrid>
|
||||||
|
* <StatCard title="Sessions" value={stats.totalSessions} />
|
||||||
|
* <StatCard title="Tasks" value={stats.totalTasks} />
|
||||||
|
* </StatsGrid>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useDashboardStats(
|
||||||
|
options: UseDashboardStatsOptions = {}
|
||||||
|
): UseDashboardStatsReturn {
|
||||||
|
const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: dashboardStatsKeys.detail(),
|
||||||
|
queryFn: fetchDashboardStats,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: query.data,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
isStale: query.isStale,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to prefetch dashboard stats
|
||||||
|
* Use this to prefetch data before navigating to home page
|
||||||
|
*/
|
||||||
|
export function usePrefetchDashboardStats() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: dashboardStatsKeys.detail(),
|
||||||
|
queryFn: fetchDashboardStats,
|
||||||
|
staleTime: STALE_TIME,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
295
ccw/frontend/src/hooks/useFlows.ts
Normal file
295
ccw/frontend/src/hooks/useFlows.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
// ========================================
|
||||||
|
// useFlows Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for flow API operations
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { Flow } from '../types/flow';
|
||||||
|
|
||||||
|
// API base URL
|
||||||
|
const API_BASE = '/api/orchestrator';
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const flowKeys = {
|
||||||
|
all: ['flows'] as const,
|
||||||
|
lists: () => [...flowKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: Record<string, unknown>) => [...flowKeys.lists(), filters] as const,
|
||||||
|
details: () => [...flowKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...flowKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
interface FlowsListResponse {
|
||||||
|
flows: Flow[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionStartResponse {
|
||||||
|
execId: string;
|
||||||
|
flowId: string;
|
||||||
|
status: 'running';
|
||||||
|
startedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionControlResponse {
|
||||||
|
execId: string;
|
||||||
|
status: 'paused' | 'running' | 'stopped';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Fetch Functions ==========
|
||||||
|
|
||||||
|
async function fetchFlows(): Promise<FlowsListResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch flows: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFlow(id: string): Promise<Flow> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFlow(flow: Omit<Flow, 'id' | 'created_at' | 'updated_at'>): Promise<Flow> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(flow),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to create flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFlow(id: string, flow: Partial<Flow>): Promise<Flow> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(flow),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFlow(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateFlow(id: string): Promise<Flow> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}/duplicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to duplicate flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Execution Functions ==========
|
||||||
|
|
||||||
|
async function executeFlow(flowId: string): Promise<ExecutionStartResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${flowId}/execute`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to execute flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseExecution(execId: string): Promise<ExecutionControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/executions/${execId}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to pause execution: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeExecution(execId: string): Promise<ExecutionControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/executions/${execId}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to resume execution: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopExecution(execId: string): Promise<ExecutionControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/executions/${execId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to stop execution: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Query Hooks ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all flows
|
||||||
|
*/
|
||||||
|
export function useFlows() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: flowKeys.lists(),
|
||||||
|
queryFn: fetchFlows,
|
||||||
|
staleTime: 30000, // 30 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single flow by ID
|
||||||
|
*/
|
||||||
|
export function useFlow(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: flowKeys.detail(id ?? ''),
|
||||||
|
queryFn: () => fetchFlow(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutation Hooks ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new flow
|
||||||
|
*/
|
||||||
|
export function useCreateFlow() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createFlow,
|
||||||
|
onSuccess: (newFlow) => {
|
||||||
|
// Optimistically add to list
|
||||||
|
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
|
||||||
|
if (!old) return { flows: [newFlow], total: 1 };
|
||||||
|
return {
|
||||||
|
flows: [...old.flows, newFlow],
|
||||||
|
total: old.total + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Invalidate to refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: flowKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing flow
|
||||||
|
*/
|
||||||
|
export function useUpdateFlow() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, flow }: { id: string; flow: Partial<Flow> }) => updateFlow(id, flow),
|
||||||
|
onSuccess: (updatedFlow) => {
|
||||||
|
// Update in cache
|
||||||
|
queryClient.setQueryData<Flow>(flowKeys.detail(updatedFlow.id), updatedFlow);
|
||||||
|
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
flows: old.flows.map((f) => (f.id === updatedFlow.id ? updatedFlow : f)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a flow
|
||||||
|
*/
|
||||||
|
export function useDeleteFlow() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteFlow,
|
||||||
|
onSuccess: (_, deletedId) => {
|
||||||
|
// Remove from cache
|
||||||
|
queryClient.removeQueries({ queryKey: flowKeys.detail(deletedId) });
|
||||||
|
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
flows: old.flows.filter((f) => f.id !== deletedId),
|
||||||
|
total: old.total - 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a flow
|
||||||
|
*/
|
||||||
|
export function useDuplicateFlow() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: duplicateFlow,
|
||||||
|
onSuccess: (newFlow) => {
|
||||||
|
// Add to list
|
||||||
|
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
|
||||||
|
if (!old) return { flows: [newFlow], total: 1 };
|
||||||
|
return {
|
||||||
|
flows: [...old.flows, newFlow],
|
||||||
|
total: old.total + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: flowKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Execution Mutation Hooks ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a flow
|
||||||
|
*/
|
||||||
|
export function useExecuteFlow() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: executeFlow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause execution
|
||||||
|
*/
|
||||||
|
export function usePauseExecution() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: pauseExecution,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume execution
|
||||||
|
*/
|
||||||
|
export function useResumeExecution() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: resumeExecution,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop execution
|
||||||
|
*/
|
||||||
|
export function useStopExecution() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: stopExecution,
|
||||||
|
});
|
||||||
|
}
|
||||||
297
ccw/frontend/src/hooks/useIssues.ts
Normal file
297
ccw/frontend/src/hooks/useIssues.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// ========================================
|
||||||
|
// useIssues Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for issues with queue management
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchIssues,
|
||||||
|
fetchIssueHistory,
|
||||||
|
fetchIssueQueue,
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
deleteIssue,
|
||||||
|
type Issue,
|
||||||
|
type IssuesResponse,
|
||||||
|
type IssueQueue,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const issuesKeys = {
|
||||||
|
all: ['issues'] as const,
|
||||||
|
lists: () => [...issuesKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: IssuesFilter) => [...issuesKeys.lists(), filters] as const,
|
||||||
|
history: () => [...issuesKeys.all, 'history'] as const,
|
||||||
|
queue: () => [...issuesKeys.all, 'queue'] as const,
|
||||||
|
details: () => [...issuesKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...issuesKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 30 seconds
|
||||||
|
const STALE_TIME = 30 * 1000;
|
||||||
|
|
||||||
|
export interface IssuesFilter {
|
||||||
|
status?: Issue['status'][];
|
||||||
|
priority?: Issue['priority'][];
|
||||||
|
search?: string;
|
||||||
|
includeHistory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseIssuesOptions {
|
||||||
|
filter?: IssuesFilter;
|
||||||
|
projectPath?: string;
|
||||||
|
staleTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
refetchInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseIssuesReturn {
|
||||||
|
issues: Issue[];
|
||||||
|
historyIssues: Issue[];
|
||||||
|
allIssues: Issue[];
|
||||||
|
issuesByStatus: Record<Issue['status'], Issue[]>;
|
||||||
|
issuesByPriority: Record<Issue['priority'], Issue[]>;
|
||||||
|
openCount: number;
|
||||||
|
criticalCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and filtering issues
|
||||||
|
*/
|
||||||
|
export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
|
||||||
|
const { filter, projectPath, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const issuesQuery = useQuery({
|
||||||
|
queryKey: issuesKeys.list(filter),
|
||||||
|
queryFn: () => fetchIssues(projectPath),
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: issuesKeys.history(),
|
||||||
|
queryFn: () => fetchIssueHistory(projectPath),
|
||||||
|
staleTime,
|
||||||
|
enabled: enabled && (filter?.includeHistory ?? false),
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allIssues = issuesQuery.data?.issues ?? [];
|
||||||
|
const historyIssues = historyQuery.data?.issues ?? [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredIssues = (() => {
|
||||||
|
let issues = [...allIssues];
|
||||||
|
|
||||||
|
if (filter?.includeHistory) {
|
||||||
|
issues = [...issues, ...historyIssues];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.status && filter.status.length > 0) {
|
||||||
|
issues = issues.filter((i) => filter.status!.includes(i.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.priority && filter.priority.length > 0) {
|
||||||
|
issues = issues.filter((i) => filter.priority!.includes(i.priority));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
issues = issues.filter(
|
||||||
|
(i) =>
|
||||||
|
i.id.toLowerCase().includes(searchLower) ||
|
||||||
|
i.title.toLowerCase().includes(searchLower) ||
|
||||||
|
i.context?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Group by status
|
||||||
|
const issuesByStatus: Record<Issue['status'], Issue[]> = {
|
||||||
|
open: [],
|
||||||
|
in_progress: [],
|
||||||
|
resolved: [],
|
||||||
|
closed: [],
|
||||||
|
completed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const issue of allIssues) {
|
||||||
|
issuesByStatus[issue.status].push(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by priority
|
||||||
|
const issuesByPriority: Record<Issue['priority'], Issue[]> = {
|
||||||
|
low: [],
|
||||||
|
medium: [],
|
||||||
|
high: [],
|
||||||
|
critical: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const issue of allIssues) {
|
||||||
|
issuesByPriority[issue.priority].push(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await Promise.all([issuesQuery.refetch(), historyQuery.refetch()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: issuesKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues: filteredIssues,
|
||||||
|
historyIssues,
|
||||||
|
allIssues,
|
||||||
|
issuesByStatus,
|
||||||
|
issuesByPriority,
|
||||||
|
openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length,
|
||||||
|
criticalCount: issuesByPriority.critical.length,
|
||||||
|
isLoading: issuesQuery.isLoading,
|
||||||
|
isFetching: issuesQuery.isFetching || historyQuery.isFetching,
|
||||||
|
error: issuesQuery.error || historyQuery.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching issue queue
|
||||||
|
*/
|
||||||
|
export function useIssueQueue(projectPath?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: issuesKeys.queue(),
|
||||||
|
queryFn: () => fetchIssueQueue(projectPath),
|
||||||
|
staleTime: STALE_TIME,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutations ==========
|
||||||
|
|
||||||
|
export interface UseCreateIssueReturn {
|
||||||
|
createIssue: (input: { title: string; context?: string; priority?: Issue['priority'] }) => Promise<Issue>;
|
||||||
|
isCreating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateIssue(): UseCreateIssueReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createIssue,
|
||||||
|
onSuccess: (newIssue) => {
|
||||||
|
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
|
||||||
|
if (!old) return { issues: [newIssue] };
|
||||||
|
return {
|
||||||
|
issues: [newIssue, ...old.issues],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createIssue: mutation.mutateAsync,
|
||||||
|
isCreating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateIssueReturn {
|
||||||
|
updateIssue: (issueId: string, input: Partial<Issue>) => Promise<Issue>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateIssue(): UseUpdateIssueReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ issueId, input }: { issueId: string; input: Partial<Issue> }) =>
|
||||||
|
updateIssue(issueId, input),
|
||||||
|
onSuccess: (updatedIssue) => {
|
||||||
|
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
issues: old.issues.map((i) => (i.id === updatedIssue.id ? updatedIssue : i)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateIssue: (issueId, input) => mutation.mutateAsync({ issueId, input }),
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDeleteIssueReturn {
|
||||||
|
deleteIssue: (issueId: string) => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteIssue(): UseDeleteIssueReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: deleteIssue,
|
||||||
|
onMutate: async (issueId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: issuesKeys.all });
|
||||||
|
const previousIssues = queryClient.getQueryData<IssuesResponse>(issuesKeys.list());
|
||||||
|
|
||||||
|
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
issues: old.issues.filter((i) => i.id !== issueId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousIssues };
|
||||||
|
},
|
||||||
|
onError: (_error, _issueId, context) => {
|
||||||
|
if (context?.previousIssues) {
|
||||||
|
queryClient.setQueryData(issuesKeys.list(), context.previousIssues);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: issuesKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteIssue: mutation.mutateAsync,
|
||||||
|
isDeleting: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hook for all issue mutations
|
||||||
|
*/
|
||||||
|
export function useIssueMutations() {
|
||||||
|
const create = useCreateIssue();
|
||||||
|
const update = useUpdateIssue();
|
||||||
|
const remove = useDeleteIssue();
|
||||||
|
|
||||||
|
return {
|
||||||
|
createIssue: create.createIssue,
|
||||||
|
updateIssue: update.updateIssue,
|
||||||
|
deleteIssue: remove.deleteIssue,
|
||||||
|
isCreating: create.isCreating,
|
||||||
|
isUpdating: update.isUpdating,
|
||||||
|
isDeleting: remove.isDeleting,
|
||||||
|
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
|
||||||
|
};
|
||||||
|
}
|
||||||
262
ccw/frontend/src/hooks/useLoops.ts
Normal file
262
ccw/frontend/src/hooks/useLoops.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
// ========================================
|
||||||
|
// useLoops Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for loops with real-time updates
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchLoops,
|
||||||
|
fetchLoop,
|
||||||
|
createLoop,
|
||||||
|
updateLoopStatus,
|
||||||
|
deleteLoop,
|
||||||
|
type Loop,
|
||||||
|
type LoopsResponse,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const loopsKeys = {
|
||||||
|
all: ['loops'] as const,
|
||||||
|
lists: () => [...loopsKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: LoopsFilter) => [...loopsKeys.lists(), filters] as const,
|
||||||
|
details: () => [...loopsKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...loopsKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 10 seconds (loops update frequently)
|
||||||
|
const STALE_TIME = 10 * 1000;
|
||||||
|
|
||||||
|
export interface LoopsFilter {
|
||||||
|
status?: Loop['status'][];
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLoopsOptions {
|
||||||
|
filter?: LoopsFilter;
|
||||||
|
staleTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
refetchInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseLoopsReturn {
|
||||||
|
loops: Loop[];
|
||||||
|
loopsByStatus: Record<Loop['status'], Loop[]>;
|
||||||
|
runningCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and filtering loops
|
||||||
|
*/
|
||||||
|
export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
|
||||||
|
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: loopsKeys.list(filter),
|
||||||
|
queryFn: fetchLoops,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allLoops = query.data?.loops ?? [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredLoops = (() => {
|
||||||
|
let loops = allLoops;
|
||||||
|
|
||||||
|
if (filter?.status && filter.status.length > 0) {
|
||||||
|
loops = loops.filter((l) => filter.status!.includes(l.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
loops = loops.filter(
|
||||||
|
(l) =>
|
||||||
|
l.id.toLowerCase().includes(searchLower) ||
|
||||||
|
l.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
l.prompt?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loops;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Group by status for Kanban
|
||||||
|
const loopsByStatus: Record<Loop['status'], Loop[]> = {
|
||||||
|
created: [],
|
||||||
|
running: [],
|
||||||
|
paused: [],
|
||||||
|
completed: [],
|
||||||
|
failed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const loop of allLoops) {
|
||||||
|
loopsByStatus[loop.status].push(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: loopsKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loops: filteredLoops,
|
||||||
|
loopsByStatus,
|
||||||
|
runningCount: loopsByStatus.running.length,
|
||||||
|
completedCount: loopsByStatus.completed.length,
|
||||||
|
failedCount: loopsByStatus.failed.length,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching a single loop
|
||||||
|
*/
|
||||||
|
export function useLoop(loopId: string, options: { enabled?: boolean } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: loopsKeys.detail(loopId),
|
||||||
|
queryFn: () => fetchLoop(loopId),
|
||||||
|
enabled: options.enabled ?? !!loopId,
|
||||||
|
staleTime: STALE_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutations ==========
|
||||||
|
|
||||||
|
export interface UseCreateLoopReturn {
|
||||||
|
createLoop: (input: { prompt: string; tool?: string; mode?: string }) => Promise<Loop>;
|
||||||
|
isCreating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLoop(): UseCreateLoopReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createLoop,
|
||||||
|
onSuccess: (newLoop) => {
|
||||||
|
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||||
|
if (!old) return { loops: [newLoop], total: 1 };
|
||||||
|
return {
|
||||||
|
loops: [newLoop, ...old.loops],
|
||||||
|
total: old.total + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createLoop: mutation.mutateAsync,
|
||||||
|
isCreating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateLoopStatusReturn {
|
||||||
|
updateStatus: (loopId: string, action: 'pause' | 'resume' | 'stop') => Promise<Loop>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLoopStatus(): UseUpdateLoopStatusReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ loopId, action }: { loopId: string; action: 'pause' | 'resume' | 'stop' }) =>
|
||||||
|
updateLoopStatus(loopId, action),
|
||||||
|
onSuccess: (updatedLoop) => {
|
||||||
|
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
loops: old.loops.map((l) => (l.id === updatedLoop.id ? updatedLoop : l)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(loopsKeys.detail(updatedLoop.id), updatedLoop);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateStatus: (loopId, action) => mutation.mutateAsync({ loopId, action }),
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDeleteLoopReturn {
|
||||||
|
deleteLoop: (loopId: string) => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteLoop(): UseDeleteLoopReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: deleteLoop,
|
||||||
|
onMutate: async (loopId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: loopsKeys.all });
|
||||||
|
const previousLoops = queryClient.getQueryData<LoopsResponse>(loopsKeys.list());
|
||||||
|
|
||||||
|
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
loops: old.loops.filter((l) => l.id !== loopId),
|
||||||
|
total: old.total - 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousLoops };
|
||||||
|
},
|
||||||
|
onError: (_error, _loopId, context) => {
|
||||||
|
if (context?.previousLoops) {
|
||||||
|
queryClient.setQueryData(loopsKeys.list(), context.previousLoops);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: loopsKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteLoop: mutation.mutateAsync,
|
||||||
|
isDeleting: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hook for all loop mutations
|
||||||
|
*/
|
||||||
|
export function useLoopMutations() {
|
||||||
|
const create = useCreateLoop();
|
||||||
|
const update = useUpdateLoopStatus();
|
||||||
|
const remove = useDeleteLoop();
|
||||||
|
|
||||||
|
return {
|
||||||
|
createLoop: create.createLoop,
|
||||||
|
updateStatus: update.updateStatus,
|
||||||
|
deleteLoop: remove.deleteLoop,
|
||||||
|
isCreating: create.isCreating,
|
||||||
|
isUpdating: update.isUpdating,
|
||||||
|
isDeleting: remove.isDeleting,
|
||||||
|
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
|
||||||
|
};
|
||||||
|
}
|
||||||
244
ccw/frontend/src/hooks/useMemory.ts
Normal file
244
ccw/frontend/src/hooks/useMemory.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// ========================================
|
||||||
|
// useMemory Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for core memory management
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchMemories,
|
||||||
|
createMemory,
|
||||||
|
updateMemory,
|
||||||
|
deleteMemory,
|
||||||
|
type CoreMemory,
|
||||||
|
type MemoryResponse,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const memoryKeys = {
|
||||||
|
all: ['memory'] as const,
|
||||||
|
lists: () => [...memoryKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: MemoryFilter) => [...memoryKeys.lists(), filters] as const,
|
||||||
|
details: () => [...memoryKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...memoryKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 1 minute
|
||||||
|
const STALE_TIME = 60 * 1000;
|
||||||
|
|
||||||
|
export interface MemoryFilter {
|
||||||
|
search?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMemoryOptions {
|
||||||
|
filter?: MemoryFilter;
|
||||||
|
staleTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMemoryReturn {
|
||||||
|
memories: CoreMemory[];
|
||||||
|
totalSize: number;
|
||||||
|
claudeMdCount: number;
|
||||||
|
allTags: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and filtering memories
|
||||||
|
*/
|
||||||
|
export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
|
||||||
|
const { filter, staleTime = STALE_TIME, enabled = true } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: memoryKeys.list(filter),
|
||||||
|
queryFn: fetchMemories,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMemories = query.data?.memories ?? [];
|
||||||
|
const totalSize = query.data?.totalSize ?? 0;
|
||||||
|
const claudeMdCount = query.data?.claudeMdCount ?? 0;
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredMemories = (() => {
|
||||||
|
let memories = allMemories;
|
||||||
|
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
memories = memories.filter(
|
||||||
|
(m) =>
|
||||||
|
m.content.toLowerCase().includes(searchLower) ||
|
||||||
|
m.source?.toLowerCase().includes(searchLower) ||
|
||||||
|
m.tags?.some((t) => t.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.tags && filter.tags.length > 0) {
|
||||||
|
memories = memories.filter((m) =>
|
||||||
|
filter.tags!.some((tag) => m.tags?.includes(tag))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memories;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Collect all unique tags
|
||||||
|
const allTags = Array.from(
|
||||||
|
new Set(allMemories.flatMap((m) => m.tags ?? []))
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: memoryKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
memories: filteredMemories,
|
||||||
|
totalSize,
|
||||||
|
claudeMdCount,
|
||||||
|
allTags,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutations ==========
|
||||||
|
|
||||||
|
export interface UseCreateMemoryReturn {
|
||||||
|
createMemory: (input: { content: string; tags?: string[] }) => Promise<CoreMemory>;
|
||||||
|
isCreating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMemory(): UseCreateMemoryReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createMemory,
|
||||||
|
onSuccess: (newMemory) => {
|
||||||
|
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
|
||||||
|
if (!old) return { memories: [newMemory], totalSize: 0, claudeMdCount: 0 };
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
memories: [newMemory, ...old.memories],
|
||||||
|
totalSize: old.totalSize + (newMemory.size ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createMemory: mutation.mutateAsync,
|
||||||
|
isCreating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateMemoryReturn {
|
||||||
|
updateMemory: (memoryId: string, input: Partial<CoreMemory>) => Promise<CoreMemory>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMemory(): UseUpdateMemoryReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ memoryId, input }: { memoryId: string; input: Partial<CoreMemory> }) =>
|
||||||
|
updateMemory(memoryId, input),
|
||||||
|
onSuccess: (updatedMemory) => {
|
||||||
|
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
memories: old.memories.map((m) =>
|
||||||
|
m.id === updatedMemory.id ? updatedMemory : m
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateMemory: (memoryId, input) => mutation.mutateAsync({ memoryId, input }),
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDeleteMemoryReturn {
|
||||||
|
deleteMemory: (memoryId: string) => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMemory(): UseDeleteMemoryReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: deleteMemory,
|
||||||
|
onMutate: async (memoryId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: memoryKeys.all });
|
||||||
|
const previousMemories = queryClient.getQueryData<MemoryResponse>(memoryKeys.list());
|
||||||
|
|
||||||
|
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const removedMemory = old.memories.find((m) => m.id === memoryId);
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
memories: old.memories.filter((m) => m.id !== memoryId),
|
||||||
|
totalSize: old.totalSize - (removedMemory?.size ?? 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousMemories };
|
||||||
|
},
|
||||||
|
onError: (_error, _memoryId, context) => {
|
||||||
|
if (context?.previousMemories) {
|
||||||
|
queryClient.setQueryData(memoryKeys.list(), context.previousMemories);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: memoryKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteMemory: mutation.mutateAsync,
|
||||||
|
isDeleting: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hook for all memory mutations
|
||||||
|
*/
|
||||||
|
export function useMemoryMutations() {
|
||||||
|
const create = useCreateMemory();
|
||||||
|
const update = useUpdateMemory();
|
||||||
|
const remove = useDeleteMemory();
|
||||||
|
|
||||||
|
return {
|
||||||
|
createMemory: create.createMemory,
|
||||||
|
updateMemory: update.updateMemory,
|
||||||
|
deleteMemory: remove.deleteMemory,
|
||||||
|
isCreating: create.isCreating,
|
||||||
|
isUpdating: update.isUpdating,
|
||||||
|
isDeleting: remove.isDeleting,
|
||||||
|
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
|
||||||
|
};
|
||||||
|
}
|
||||||
232
ccw/frontend/src/hooks/useNotifications.ts
Normal file
232
ccw/frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// ========================================
|
||||||
|
// useNotifications Hook
|
||||||
|
// ========================================
|
||||||
|
// Convenient hook for notification and toast management
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
useNotificationStore,
|
||||||
|
selectToasts,
|
||||||
|
selectWsStatus,
|
||||||
|
selectWsLastMessage,
|
||||||
|
selectIsPanelVisible,
|
||||||
|
selectPersistentNotifications,
|
||||||
|
} from '../stores/notificationStore';
|
||||||
|
import type { Toast, ToastType, WebSocketStatus, WebSocketMessage } from '../types/store';
|
||||||
|
|
||||||
|
export interface UseNotificationsReturn {
|
||||||
|
/** Current toast queue */
|
||||||
|
toasts: Toast[];
|
||||||
|
/** WebSocket connection status */
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
/** Last WebSocket message received */
|
||||||
|
wsLastMessage: WebSocketMessage | null;
|
||||||
|
/** Whether WebSocket is connected */
|
||||||
|
isWsConnected: boolean;
|
||||||
|
/** Whether notification panel is visible */
|
||||||
|
isPanelVisible: boolean;
|
||||||
|
/** Persistent notifications (stored in localStorage) */
|
||||||
|
persistentNotifications: Toast[];
|
||||||
|
/** Add a toast notification */
|
||||||
|
addToast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => string;
|
||||||
|
/** Show info toast */
|
||||||
|
info: (title: string, message?: string) => string;
|
||||||
|
/** Show success toast */
|
||||||
|
success: (title: string, message?: string) => string;
|
||||||
|
/** Show warning toast */
|
||||||
|
warning: (title: string, message?: string) => string;
|
||||||
|
/** Show error toast (persistent by default) */
|
||||||
|
error: (title: string, message?: string) => string;
|
||||||
|
/** Remove a toast */
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
/** Clear all toasts */
|
||||||
|
clearAllToasts: () => void;
|
||||||
|
/** Set WebSocket status */
|
||||||
|
setWsStatus: (status: WebSocketStatus) => void;
|
||||||
|
/** Set last WebSocket message */
|
||||||
|
setWsLastMessage: (message: WebSocketMessage | null) => void;
|
||||||
|
/** Toggle notification panel */
|
||||||
|
togglePanel: () => void;
|
||||||
|
/** Set panel visibility */
|
||||||
|
setPanelVisible: (visible: boolean) => void;
|
||||||
|
/** Add persistent notification */
|
||||||
|
addPersistentNotification: (type: ToastType, title: string, message?: string) => void;
|
||||||
|
/** Remove persistent notification */
|
||||||
|
removePersistentNotification: (id: string) => void;
|
||||||
|
/** Clear all persistent notifications */
|
||||||
|
clearPersistentNotifications: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
/** Duration in ms (0 = persistent) */
|
||||||
|
duration?: number;
|
||||||
|
/** Whether toast can be dismissed */
|
||||||
|
dismissible?: boolean;
|
||||||
|
/** Action button */
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing notifications and toasts
|
||||||
|
* @returns Notification state and actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { toasts, info, success, error, removeToast } = useNotifications();
|
||||||
|
*
|
||||||
|
* const handleSave = async () => {
|
||||||
|
* try {
|
||||||
|
* await save();
|
||||||
|
* success('Saved', 'Your changes have been saved');
|
||||||
|
* } catch (e) {
|
||||||
|
* error('Error', 'Failed to save changes');
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useNotifications(): UseNotificationsReturn {
|
||||||
|
const toasts = useNotificationStore(selectToasts);
|
||||||
|
const wsStatus = useNotificationStore(selectWsStatus);
|
||||||
|
const wsLastMessage = useNotificationStore(selectWsLastMessage);
|
||||||
|
const isPanelVisible = useNotificationStore(selectIsPanelVisible);
|
||||||
|
const persistentNotifications = useNotificationStore(selectPersistentNotifications);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const addToastAction = useNotificationStore((state) => state.addToast);
|
||||||
|
const removeToastAction = useNotificationStore((state) => state.removeToast);
|
||||||
|
const clearAllToastsAction = useNotificationStore((state) => state.clearAllToasts);
|
||||||
|
const setWsStatusAction = useNotificationStore((state) => state.setWsStatus);
|
||||||
|
const setWsLastMessageAction = useNotificationStore((state) => state.setWsLastMessage);
|
||||||
|
const togglePanelAction = useNotificationStore((state) => state.togglePanel);
|
||||||
|
const setPanelVisibleAction = useNotificationStore((state) => state.setPanelVisible);
|
||||||
|
const addPersistentAction = useNotificationStore((state) => state.addPersistentNotification);
|
||||||
|
const removePersistentAction = useNotificationStore((state) => state.removePersistentNotification);
|
||||||
|
const clearPersistentAction = useNotificationStore((state) => state.clearPersistentNotifications);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const isWsConnected = wsStatus === 'connected';
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
const addToast = useCallback(
|
||||||
|
(type: ToastType, title: string, message?: string, options?: ToastOptions): string => {
|
||||||
|
return addToastAction({
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration: options?.duration,
|
||||||
|
dismissible: options?.dismissible,
|
||||||
|
action: options?.action,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[addToastAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const info = useCallback(
|
||||||
|
(title: string, message?: string): string => {
|
||||||
|
return addToast('info', title, message);
|
||||||
|
},
|
||||||
|
[addToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = useCallback(
|
||||||
|
(title: string, message?: string): string => {
|
||||||
|
return addToast('success', title, message);
|
||||||
|
},
|
||||||
|
[addToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const warning = useCallback(
|
||||||
|
(title: string, message?: string): string => {
|
||||||
|
return addToast('warning', title, message);
|
||||||
|
},
|
||||||
|
[addToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = useCallback(
|
||||||
|
(title: string, message?: string): string => {
|
||||||
|
// Error toasts are persistent by default
|
||||||
|
return addToast('error', title, message, { duration: 0 });
|
||||||
|
},
|
||||||
|
[addToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeToast = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
removeToastAction(id);
|
||||||
|
},
|
||||||
|
[removeToastAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearAllToasts = useCallback(() => {
|
||||||
|
clearAllToastsAction();
|
||||||
|
}, [clearAllToastsAction]);
|
||||||
|
|
||||||
|
const setWsStatus = useCallback(
|
||||||
|
(status: WebSocketStatus) => {
|
||||||
|
setWsStatusAction(status);
|
||||||
|
},
|
||||||
|
[setWsStatusAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWsLastMessage = useCallback(
|
||||||
|
(message: WebSocketMessage | null) => {
|
||||||
|
setWsLastMessageAction(message);
|
||||||
|
},
|
||||||
|
[setWsLastMessageAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePanel = useCallback(() => {
|
||||||
|
togglePanelAction();
|
||||||
|
}, [togglePanelAction]);
|
||||||
|
|
||||||
|
const setPanelVisible = useCallback(
|
||||||
|
(visible: boolean) => {
|
||||||
|
setPanelVisibleAction(visible);
|
||||||
|
},
|
||||||
|
[setPanelVisibleAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addPersistentNotification = useCallback(
|
||||||
|
(type: ToastType, title: string, message?: string) => {
|
||||||
|
addPersistentAction({ type, title, message });
|
||||||
|
},
|
||||||
|
[addPersistentAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removePersistentNotification = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
removePersistentAction(id);
|
||||||
|
},
|
||||||
|
[removePersistentAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearPersistentNotifications = useCallback(() => {
|
||||||
|
clearPersistentAction();
|
||||||
|
}, [clearPersistentAction]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
wsStatus,
|
||||||
|
wsLastMessage,
|
||||||
|
isWsConnected,
|
||||||
|
isPanelVisible,
|
||||||
|
persistentNotifications,
|
||||||
|
addToast,
|
||||||
|
info,
|
||||||
|
success,
|
||||||
|
warning,
|
||||||
|
error,
|
||||||
|
removeToast,
|
||||||
|
clearAllToasts,
|
||||||
|
setWsStatus,
|
||||||
|
setWsLastMessage,
|
||||||
|
togglePanel,
|
||||||
|
setPanelVisible,
|
||||||
|
addPersistentNotification,
|
||||||
|
removePersistentNotification,
|
||||||
|
clearPersistentNotifications,
|
||||||
|
};
|
||||||
|
}
|
||||||
155
ccw/frontend/src/hooks/useSession.ts
Normal file
155
ccw/frontend/src/hooks/useSession.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// ========================================
|
||||||
|
// useSession Hook
|
||||||
|
// ========================================
|
||||||
|
// Convenient hook for session management
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useWorkflowStore, selectActiveSessionId } from '../stores/workflowStore';
|
||||||
|
import type { SessionMetadata, TaskData } from '../types/store';
|
||||||
|
|
||||||
|
export interface UseSessionReturn {
|
||||||
|
/** Currently active session ID */
|
||||||
|
activeSessionId: string | null;
|
||||||
|
/** Currently active session data */
|
||||||
|
activeSession: SessionMetadata | null;
|
||||||
|
/** All active sessions */
|
||||||
|
activeSessions: SessionMetadata[];
|
||||||
|
/** All archived sessions */
|
||||||
|
archivedSessions: SessionMetadata[];
|
||||||
|
/** Filtered sessions based on current filters */
|
||||||
|
filteredSessions: SessionMetadata[];
|
||||||
|
/** Set the active session */
|
||||||
|
setActiveSession: (sessionId: string | null) => void;
|
||||||
|
/** Add a new session */
|
||||||
|
addSession: (session: SessionMetadata) => void;
|
||||||
|
/** Update a session */
|
||||||
|
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => void;
|
||||||
|
/** Archive a session */
|
||||||
|
archiveSession: (sessionId: string) => void;
|
||||||
|
/** Remove a session */
|
||||||
|
removeSession: (sessionId: string) => void;
|
||||||
|
/** Add a task to a session */
|
||||||
|
addTask: (sessionId: string, task: TaskData) => void;
|
||||||
|
/** Update a task */
|
||||||
|
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => void;
|
||||||
|
/** Get session by key */
|
||||||
|
getSessionByKey: (key: string) => SessionMetadata | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing session state
|
||||||
|
* @returns Session state and actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { activeSession, activeSessions, setActiveSession } = useSession();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <SessionList
|
||||||
|
* sessions={activeSessions}
|
||||||
|
* onSelect={(id) => setActiveSession(id)}
|
||||||
|
* />
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSession(): UseSessionReturn {
|
||||||
|
const activeSessionId = useWorkflowStore(selectActiveSessionId);
|
||||||
|
const workflowData = useWorkflowStore((state) => state.workflowData);
|
||||||
|
const sessionDataStore = useWorkflowStore((state) => state.sessionDataStore);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setActiveSessionId = useWorkflowStore((state) => state.setActiveSessionId);
|
||||||
|
const addSessionAction = useWorkflowStore((state) => state.addSession);
|
||||||
|
const updateSessionAction = useWorkflowStore((state) => state.updateSession);
|
||||||
|
const archiveSessionAction = useWorkflowStore((state) => state.archiveSession);
|
||||||
|
const removeSessionAction = useWorkflowStore((state) => state.removeSession);
|
||||||
|
const addTaskAction = useWorkflowStore((state) => state.addTask);
|
||||||
|
const updateTaskAction = useWorkflowStore((state) => state.updateTask);
|
||||||
|
const getFilteredSessionsAction = useWorkflowStore((state) => state.getFilteredSessions);
|
||||||
|
const getSessionByKeyAction = useWorkflowStore((state) => state.getSessionByKey);
|
||||||
|
|
||||||
|
// Memoized active session
|
||||||
|
const activeSession = useMemo(() => {
|
||||||
|
if (!activeSessionId) return null;
|
||||||
|
const key = `session-${activeSessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
|
return sessionDataStore[key] || null;
|
||||||
|
}, [activeSessionId, sessionDataStore]);
|
||||||
|
|
||||||
|
// Memoized filtered sessions
|
||||||
|
const filteredSessions = useMemo(() => {
|
||||||
|
return getFilteredSessionsAction();
|
||||||
|
}, [getFilteredSessionsAction, workflowData]);
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
const setActiveSession = useCallback(
|
||||||
|
(sessionId: string | null) => {
|
||||||
|
setActiveSessionId(sessionId);
|
||||||
|
},
|
||||||
|
[setActiveSessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSession = useCallback(
|
||||||
|
(session: SessionMetadata) => {
|
||||||
|
addSessionAction(session);
|
||||||
|
},
|
||||||
|
[addSessionAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSession = useCallback(
|
||||||
|
(sessionId: string, updates: Partial<SessionMetadata>) => {
|
||||||
|
updateSessionAction(sessionId, updates);
|
||||||
|
},
|
||||||
|
[updateSessionAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const archiveSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
archiveSessionAction(sessionId);
|
||||||
|
},
|
||||||
|
[archiveSessionAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSession = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
removeSessionAction(sessionId);
|
||||||
|
},
|
||||||
|
[removeSessionAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addTask = useCallback(
|
||||||
|
(sessionId: string, task: TaskData) => {
|
||||||
|
addTaskAction(sessionId, task);
|
||||||
|
},
|
||||||
|
[addTaskAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTask = useCallback(
|
||||||
|
(sessionId: string, taskId: string, updates: Partial<TaskData>) => {
|
||||||
|
updateTaskAction(sessionId, taskId, updates);
|
||||||
|
},
|
||||||
|
[updateTaskAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSessionByKey = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
return getSessionByKeyAction(key);
|
||||||
|
},
|
||||||
|
[getSessionByKeyAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSessionId,
|
||||||
|
activeSession,
|
||||||
|
activeSessions: workflowData.activeSessions,
|
||||||
|
archivedSessions: workflowData.archivedSessions,
|
||||||
|
filteredSessions,
|
||||||
|
setActiveSession,
|
||||||
|
addSession,
|
||||||
|
updateSession,
|
||||||
|
archiveSession,
|
||||||
|
removeSession,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
getSessionByKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
373
ccw/frontend/src/hooks/useSessions.ts
Normal file
373
ccw/frontend/src/hooks/useSessions.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
// ========================================
|
||||||
|
// useSessions Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for sessions with optimistic updates
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchSessions,
|
||||||
|
createSession,
|
||||||
|
updateSession,
|
||||||
|
archiveSession,
|
||||||
|
deleteSession,
|
||||||
|
type SessionsResponse,
|
||||||
|
type CreateSessionInput,
|
||||||
|
type UpdateSessionInput,
|
||||||
|
} from '../lib/api';
|
||||||
|
import type { SessionMetadata } from '../types/store';
|
||||||
|
import { dashboardStatsKeys } from './useDashboardStats';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const sessionsKeys = {
|
||||||
|
all: ['sessions'] as const,
|
||||||
|
lists: () => [...sessionsKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: SessionsFilter) => [...sessionsKeys.lists(), filters] as const,
|
||||||
|
details: () => [...sessionsKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...sessionsKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 30 seconds
|
||||||
|
const STALE_TIME = 30 * 1000;
|
||||||
|
|
||||||
|
export interface SessionsFilter {
|
||||||
|
status?: SessionMetadata['status'][];
|
||||||
|
search?: string;
|
||||||
|
location?: 'active' | 'archived' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSessionsOptions {
|
||||||
|
/** Filter options */
|
||||||
|
filter?: SessionsFilter;
|
||||||
|
/** Override default stale time (ms) */
|
||||||
|
staleTime?: number;
|
||||||
|
/** Enable/disable the query */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Refetch interval (ms), 0 to disable */
|
||||||
|
refetchInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSessionsReturn {
|
||||||
|
/** All sessions data */
|
||||||
|
sessions: SessionsResponse | undefined;
|
||||||
|
/** Active sessions */
|
||||||
|
activeSessions: SessionMetadata[];
|
||||||
|
/** Archived sessions */
|
||||||
|
archivedSessions: SessionMetadata[];
|
||||||
|
/** Filtered sessions based on filter options */
|
||||||
|
filteredSessions: SessionMetadata[];
|
||||||
|
/** Loading state for initial fetch */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Fetching state (initial or refetch) */
|
||||||
|
isFetching: boolean;
|
||||||
|
/** Error object if query failed */
|
||||||
|
error: Error | null;
|
||||||
|
/** Manually refetch data */
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
/** Invalidate and refetch sessions */
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching sessions data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { activeSessions, isLoading } = useSessions({
|
||||||
|
* filter: { location: 'active' }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn {
|
||||||
|
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: sessionsKeys.list(filter),
|
||||||
|
queryFn: fetchSessions,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
||||||
|
retry: 2,
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSessions = query.data?.activeSessions ?? [];
|
||||||
|
const archivedSessions = query.data?.archivedSessions ?? [];
|
||||||
|
|
||||||
|
// Apply client-side filtering
|
||||||
|
const filteredSessions = (() => {
|
||||||
|
let sessions: SessionMetadata[] = [];
|
||||||
|
|
||||||
|
if (!filter?.location || filter.location === 'all') {
|
||||||
|
sessions = [...activeSessions, ...archivedSessions];
|
||||||
|
} else if (filter.location === 'active') {
|
||||||
|
sessions = activeSessions;
|
||||||
|
} else {
|
||||||
|
sessions = archivedSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (filter?.status && filter.status.length > 0) {
|
||||||
|
sessions = sessions.filter((s) => filter.status!.includes(s.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
sessions = sessions.filter(
|
||||||
|
(s) =>
|
||||||
|
s.session_id.toLowerCase().includes(searchLower) ||
|
||||||
|
s.title?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.description?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: query.data,
|
||||||
|
activeSessions,
|
||||||
|
archivedSessions,
|
||||||
|
filteredSessions,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutations ==========
|
||||||
|
|
||||||
|
export interface UseCreateSessionReturn {
|
||||||
|
createSession: (input: CreateSessionInput) => Promise<SessionMetadata>;
|
||||||
|
isCreating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for creating a new session
|
||||||
|
*/
|
||||||
|
export function useCreateSession(): UseCreateSessionReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createSession,
|
||||||
|
onSuccess: (newSession) => {
|
||||||
|
// Update sessions cache
|
||||||
|
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
|
||||||
|
if (!old) return { activeSessions: [newSession], archivedSessions: [] };
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
activeSessions: [newSession, ...old.activeSessions],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Invalidate dashboard stats
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSession: mutation.mutateAsync,
|
||||||
|
isCreating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateSessionReturn {
|
||||||
|
updateSession: (sessionId: string, input: UpdateSessionInput) => Promise<SessionMetadata>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for updating a session
|
||||||
|
*/
|
||||||
|
export function useUpdateSession(): UseUpdateSessionReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ sessionId, input }: { sessionId: string; input: UpdateSessionInput }) =>
|
||||||
|
updateSession(sessionId, input),
|
||||||
|
onSuccess: (updatedSession) => {
|
||||||
|
// Update sessions cache
|
||||||
|
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
activeSessions: old.activeSessions.map((s) =>
|
||||||
|
s.session_id === updatedSession.session_id ? updatedSession : s
|
||||||
|
),
|
||||||
|
archivedSessions: old.archivedSessions.map((s) =>
|
||||||
|
s.session_id === updatedSession.session_id ? updatedSession : s
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateSession: (sessionId, input) => mutation.mutateAsync({ sessionId, input }),
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseArchiveSessionReturn {
|
||||||
|
archiveSession: (sessionId: string) => Promise<SessionMetadata>;
|
||||||
|
isArchiving: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for archiving a session with optimistic update
|
||||||
|
*/
|
||||||
|
export function useArchiveSession(): UseArchiveSessionReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: archiveSession,
|
||||||
|
onMutate: async (sessionId) => {
|
||||||
|
// Cancel outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
|
||||||
|
|
||||||
|
// Snapshot previous value
|
||||||
|
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
|
||||||
|
|
||||||
|
// Optimistically update
|
||||||
|
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const session = old.activeSessions.find((s) => s.session_id === sessionId);
|
||||||
|
if (!session) return old;
|
||||||
|
|
||||||
|
const archivedSession: SessionMetadata = {
|
||||||
|
...session,
|
||||||
|
status: 'archived',
|
||||||
|
location: 'archived',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
|
||||||
|
archivedSessions: [archivedSession, ...old.archivedSessions],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousSessions };
|
||||||
|
},
|
||||||
|
onError: (_error, _sessionId, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousSessions) {
|
||||||
|
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Invalidate to ensure sync with server
|
||||||
|
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
archiveSession: mutation.mutateAsync,
|
||||||
|
isArchiving: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDeleteSessionReturn {
|
||||||
|
deleteSession: (sessionId: string) => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for deleting a session with optimistic update
|
||||||
|
*/
|
||||||
|
export function useDeleteSession(): UseDeleteSessionReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: deleteSession,
|
||||||
|
onMutate: async (sessionId) => {
|
||||||
|
// Cancel outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
|
||||||
|
|
||||||
|
// Snapshot previous value
|
||||||
|
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
|
||||||
|
|
||||||
|
// Optimistically remove
|
||||||
|
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
|
||||||
|
archivedSessions: old.archivedSessions.filter((s) => s.session_id !== sessionId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousSessions };
|
||||||
|
},
|
||||||
|
onError: (_error, _sessionId, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousSessions) {
|
||||||
|
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Invalidate to ensure sync with server
|
||||||
|
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteSession: mutation.mutateAsync,
|
||||||
|
isDeleting: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hook for all session mutations
|
||||||
|
*/
|
||||||
|
export function useSessionMutations() {
|
||||||
|
const create = useCreateSession();
|
||||||
|
const update = useUpdateSession();
|
||||||
|
const archive = useArchiveSession();
|
||||||
|
const remove = useDeleteSession();
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSession: create.createSession,
|
||||||
|
updateSession: update.updateSession,
|
||||||
|
archiveSession: archive.archiveSession,
|
||||||
|
deleteSession: remove.deleteSession,
|
||||||
|
isCreating: create.isCreating,
|
||||||
|
isUpdating: update.isUpdating,
|
||||||
|
isArchiving: archive.isArchiving,
|
||||||
|
isDeleting: remove.isDeleting,
|
||||||
|
isMutating: create.isCreating || update.isUpdating || archive.isArchiving || remove.isDeleting,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to prefetch sessions data
|
||||||
|
*/
|
||||||
|
export function usePrefetchSessions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return (filter?: SessionsFilter) => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: sessionsKeys.list(filter),
|
||||||
|
queryFn: fetchSessions,
|
||||||
|
staleTime: STALE_TIME,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
193
ccw/frontend/src/hooks/useSkills.ts
Normal file
193
ccw/frontend/src/hooks/useSkills.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// ========================================
|
||||||
|
// useSkills Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for skills management
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
fetchSkills,
|
||||||
|
toggleSkill,
|
||||||
|
type Skill,
|
||||||
|
type SkillsResponse,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
// Query key factory
|
||||||
|
export const skillsKeys = {
|
||||||
|
all: ['skills'] as const,
|
||||||
|
lists: () => [...skillsKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: SkillsFilter) => [...skillsKeys.lists(), filters] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default stale time: 5 minutes (skills don't change frequently)
|
||||||
|
const STALE_TIME = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface SkillsFilter {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
source?: Skill['source'];
|
||||||
|
enabledOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSkillsOptions {
|
||||||
|
filter?: SkillsFilter;
|
||||||
|
staleTime?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSkillsReturn {
|
||||||
|
skills: Skill[];
|
||||||
|
enabledSkills: Skill[];
|
||||||
|
categories: string[];
|
||||||
|
skillsByCategory: Record<string, Skill[]>;
|
||||||
|
totalCount: number;
|
||||||
|
enabledCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
invalidate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and filtering skills
|
||||||
|
*/
|
||||||
|
export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
||||||
|
const { filter, staleTime = STALE_TIME, enabled = true } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: skillsKeys.list(filter),
|
||||||
|
queryFn: fetchSkills,
|
||||||
|
staleTime,
|
||||||
|
enabled,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSkills = query.data?.skills ?? [];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredSkills = (() => {
|
||||||
|
let skills = allSkills;
|
||||||
|
|
||||||
|
if (filter?.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
skills = skills.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(searchLower) ||
|
||||||
|
s.description.toLowerCase().includes(searchLower) ||
|
||||||
|
s.triggers.some((t) => t.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.category) {
|
||||||
|
skills = skills.filter((s) => s.category === filter.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.source) {
|
||||||
|
skills = skills.filter((s) => s.source === filter.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter?.enabledOnly) {
|
||||||
|
skills = skills.filter((s) => s.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const skillsByCategory: Record<string, Skill[]> = {};
|
||||||
|
const categories = new Set<string>();
|
||||||
|
|
||||||
|
for (const skill of allSkills) {
|
||||||
|
const category = skill.category || 'Uncategorized';
|
||||||
|
categories.add(category);
|
||||||
|
if (!skillsByCategory[category]) {
|
||||||
|
skillsByCategory[category] = [];
|
||||||
|
}
|
||||||
|
skillsByCategory[category].push(skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledSkills = allSkills.filter((s) => s.enabled);
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: skillsKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
skills: filteredSkills,
|
||||||
|
enabledSkills,
|
||||||
|
categories: Array.from(categories).sort(),
|
||||||
|
skillsByCategory,
|
||||||
|
totalCount: allSkills.length,
|
||||||
|
enabledCount: enabledSkills.length,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutations ==========
|
||||||
|
|
||||||
|
export interface UseToggleSkillReturn {
|
||||||
|
toggleSkill: (skillName: string, enabled: boolean) => Promise<Skill>;
|
||||||
|
isToggling: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleSkill(): UseToggleSkillReturn {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ skillName, enabled }: { skillName: string; enabled: boolean }) =>
|
||||||
|
toggleSkill(skillName, enabled),
|
||||||
|
onMutate: async ({ skillName, enabled }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: skillsKeys.all });
|
||||||
|
const previousSkills = queryClient.getQueryData<SkillsResponse>(skillsKeys.list());
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
queryClient.setQueryData<SkillsResponse>(skillsKeys.list(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
skills: old.skills.map((s) =>
|
||||||
|
s.name === skillName ? { ...s, enabled } : s
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousSkills };
|
||||||
|
},
|
||||||
|
onError: (_error, _vars, context) => {
|
||||||
|
if (context?.previousSkills) {
|
||||||
|
queryClient.setQueryData(skillsKeys.list(), context.previousSkills);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: skillsKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleSkill: (skillName, enabled) => mutation.mutateAsync({ skillName, enabled }),
|
||||||
|
isToggling: mutation.isPending,
|
||||||
|
error: mutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hook for all skill mutations
|
||||||
|
*/
|
||||||
|
export function useSkillMutations() {
|
||||||
|
const toggle = useToggleSkill();
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleSkill: toggle.toggleSkill,
|
||||||
|
isToggling: toggle.isToggling,
|
||||||
|
isMutating: toggle.isToggling,
|
||||||
|
};
|
||||||
|
}
|
||||||
184
ccw/frontend/src/hooks/useTemplates.ts
Normal file
184
ccw/frontend/src/hooks/useTemplates.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ========================================
|
||||||
|
// useTemplates Hook
|
||||||
|
// ========================================
|
||||||
|
// TanStack Query hooks for template API operations
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { FlowTemplate, TemplateInstallRequest, TemplateExportRequest } from '../types/execution';
|
||||||
|
import type { Flow } from '../types/flow';
|
||||||
|
|
||||||
|
// API base URL
|
||||||
|
const API_BASE = '/api/orchestrator';
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const templateKeys = {
|
||||||
|
all: ['templates'] as const,
|
||||||
|
lists: () => [...templateKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: Record<string, unknown>) => [...templateKeys.lists(), filters] as const,
|
||||||
|
details: () => [...templateKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...templateKeys.details(), id] as const,
|
||||||
|
categories: () => [...templateKeys.all, 'categories'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
interface TemplatesListResponse {
|
||||||
|
templates: FlowTemplate[];
|
||||||
|
total: number;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateDetailResponse extends FlowTemplate {
|
||||||
|
flow: Flow;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallTemplateResponse {
|
||||||
|
flow: Flow;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportTemplateResponse {
|
||||||
|
template: FlowTemplate;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Fetch Functions ==========
|
||||||
|
|
||||||
|
async function fetchTemplates(category?: string): Promise<TemplatesListResponse> {
|
||||||
|
const url = category
|
||||||
|
? `${API_BASE}/templates?category=${encodeURIComponent(category)}`
|
||||||
|
: `${API_BASE}/templates`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch templates: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplate(id: string): Promise<TemplateDetailResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/templates/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch template: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installTemplate(request: TemplateInstallRequest): Promise<InstallTemplateResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/templates/install`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to install template: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportTemplate(request: TemplateExportRequest): Promise<ExportTemplateResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/templates/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to export template: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTemplate(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/templates/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete template: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Query Hooks ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all templates
|
||||||
|
*/
|
||||||
|
export function useTemplates(category?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: templateKeys.list({ category }),
|
||||||
|
queryFn: () => fetchTemplates(category),
|
||||||
|
staleTime: 60000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single template by ID
|
||||||
|
*/
|
||||||
|
export function useTemplate(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: templateKeys.detail(id ?? ''),
|
||||||
|
queryFn: () => fetchTemplate(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mutation Hooks ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a template as a new flow
|
||||||
|
*/
|
||||||
|
export function useInstallTemplate() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: installTemplate,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate flows list to show the new flow
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flows'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a flow as a template
|
||||||
|
*/
|
||||||
|
export function useExportTemplate() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: exportTemplate,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// Add to templates list
|
||||||
|
queryClient.setQueryData<TemplatesListResponse>(templateKeys.lists(), (old) => {
|
||||||
|
if (!old) return { templates: [result.template], total: 1, categories: [] };
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
templates: [...old.templates, result.template],
|
||||||
|
total: old.total + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: templateKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a template
|
||||||
|
*/
|
||||||
|
export function useDeleteTemplate() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteTemplate,
|
||||||
|
onSuccess: (_, deletedId) => {
|
||||||
|
// Remove from cache
|
||||||
|
queryClient.removeQueries({ queryKey: templateKeys.detail(deletedId) });
|
||||||
|
queryClient.setQueryData<TemplatesListResponse>(templateKeys.lists(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
templates: old.templates.filter((t) => t.id !== deletedId),
|
||||||
|
total: old.total - 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
62
ccw/frontend/src/hooks/useTheme.ts
Normal file
62
ccw/frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// ========================================
|
||||||
|
// useTheme Hook
|
||||||
|
// ========================================
|
||||||
|
// Convenient hook for theme management
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAppStore, selectTheme, selectResolvedTheme } from '../stores/appStore';
|
||||||
|
import type { Theme } from '../types/store';
|
||||||
|
|
||||||
|
export interface UseThemeReturn {
|
||||||
|
/** Current theme preference ('light', 'dark', 'system') */
|
||||||
|
theme: Theme;
|
||||||
|
/** Resolved theme based on preference and system settings */
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
/** Whether the resolved theme is dark */
|
||||||
|
isDark: boolean;
|
||||||
|
/** Set theme preference */
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
/** Toggle between light and dark (ignores system) */
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing theme state
|
||||||
|
* @returns Theme state and actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { theme, isDark, setTheme, toggleTheme } = useTheme();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <button onClick={toggleTheme}>
|
||||||
|
* {isDark ? 'Switch to Light' : 'Switch to Dark'}
|
||||||
|
* </button>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTheme(): UseThemeReturn {
|
||||||
|
const theme = useAppStore(selectTheme);
|
||||||
|
const resolvedTheme = useAppStore(selectResolvedTheme);
|
||||||
|
const setThemeAction = useAppStore((state) => state.setTheme);
|
||||||
|
const toggleThemeAction = useAppStore((state) => state.toggleTheme);
|
||||||
|
|
||||||
|
const setTheme = useCallback(
|
||||||
|
(newTheme: Theme) => {
|
||||||
|
setThemeAction(newTheme);
|
||||||
|
},
|
||||||
|
[setThemeAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
toggleThemeAction();
|
||||||
|
}, [toggleThemeAction]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
isDark: resolvedTheme === 'dark',
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
};
|
||||||
|
}
|
||||||
254
ccw/frontend/src/hooks/useWebSocket.ts
Normal file
254
ccw/frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// ========================================
|
||||||
|
// useWebSocket Hook
|
||||||
|
// ========================================
|
||||||
|
// Typed WebSocket connection management with auto-reconnect
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import {
|
||||||
|
OrchestratorMessageSchema,
|
||||||
|
type OrchestratorWebSocketMessage,
|
||||||
|
type ExecutionLog,
|
||||||
|
} from '../types/execution';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const RECONNECT_DELAY_BASE = 1000; // 1 second
|
||||||
|
const RECONNECT_DELAY_MAX = 30000; // 30 seconds
|
||||||
|
const RECONNECT_DELAY_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
|
interface UseWebSocketOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
onMessage?: (message: OrchestratorWebSocketMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWebSocketReturn {
|
||||||
|
isConnected: boolean;
|
||||||
|
send: (message: unknown) => void;
|
||||||
|
reconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
|
||||||
|
const { enabled = true, onMessage } = options;
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const reconnectDelayRef = useRef(RECONNECT_DELAY_BASE);
|
||||||
|
|
||||||
|
// Notification store for connection status
|
||||||
|
const setWsStatus = useNotificationStore((state) => state.setWsStatus);
|
||||||
|
const setWsLastMessage = useNotificationStore((state) => state.setWsLastMessage);
|
||||||
|
const incrementReconnectAttempts = useNotificationStore((state) => state.incrementReconnectAttempts);
|
||||||
|
const resetReconnectAttempts = useNotificationStore((state) => state.resetReconnectAttempts);
|
||||||
|
|
||||||
|
// Execution store for state updates
|
||||||
|
const setExecutionStatus = useExecutionStore((state) => state.setExecutionStatus);
|
||||||
|
const setNodeStarted = useExecutionStore((state) => state.setNodeStarted);
|
||||||
|
const setNodeCompleted = useExecutionStore((state) => state.setNodeCompleted);
|
||||||
|
const setNodeFailed = useExecutionStore((state) => state.setNodeFailed);
|
||||||
|
const addLog = useExecutionStore((state) => state.addLog);
|
||||||
|
const completeExecution = useExecutionStore((state) => state.completeExecution);
|
||||||
|
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||||
|
|
||||||
|
// Flow store for node status updates on canvas
|
||||||
|
const updateNode = useFlowStore((state) => state.updateNode);
|
||||||
|
|
||||||
|
// Handle incoming WebSocket messages
|
||||||
|
const handleMessage = useCallback(
|
||||||
|
(event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Store last message for debugging
|
||||||
|
setWsLastMessage(data);
|
||||||
|
|
||||||
|
// Check if this is an orchestrator message
|
||||||
|
if (!data.type?.startsWith('ORCHESTRATOR_')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message with zod schema
|
||||||
|
const parsed = OrchestratorMessageSchema.safeParse(data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.warn('[WebSocket] Invalid orchestrator message:', parsed.error.issues);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast validated data to our TypeScript interface
|
||||||
|
const message = parsed.data as OrchestratorWebSocketMessage;
|
||||||
|
|
||||||
|
// Only process messages for current execution
|
||||||
|
if (currentExecution && message.execId !== currentExecution.execId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch to execution store based on message type
|
||||||
|
switch (message.type) {
|
||||||
|
case 'ORCHESTRATOR_STATE_UPDATE':
|
||||||
|
setExecutionStatus(message.status, message.currentNodeId);
|
||||||
|
// Check for completion
|
||||||
|
if (message.status === 'completed' || message.status === 'failed') {
|
||||||
|
completeExecution(message.status);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORCHESTRATOR_NODE_STARTED':
|
||||||
|
setNodeStarted(message.nodeId);
|
||||||
|
// Update canvas node status
|
||||||
|
updateNode(message.nodeId, { executionStatus: 'running' });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORCHESTRATOR_NODE_COMPLETED':
|
||||||
|
setNodeCompleted(message.nodeId, message.result);
|
||||||
|
// Update canvas node status
|
||||||
|
updateNode(message.nodeId, {
|
||||||
|
executionStatus: 'completed',
|
||||||
|
executionResult: message.result,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORCHESTRATOR_NODE_FAILED':
|
||||||
|
setNodeFailed(message.nodeId, message.error);
|
||||||
|
// Update canvas node status
|
||||||
|
updateNode(message.nodeId, {
|
||||||
|
executionStatus: 'failed',
|
||||||
|
executionError: message.error,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORCHESTRATOR_LOG':
|
||||||
|
addLog(message.log as ExecutionLog);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call custom message handler if provided
|
||||||
|
onMessage?.(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSocket] Failed to parse message:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentExecution,
|
||||||
|
setWsLastMessage,
|
||||||
|
setExecutionStatus,
|
||||||
|
setNodeStarted,
|
||||||
|
setNodeCompleted,
|
||||||
|
setNodeFailed,
|
||||||
|
addLog,
|
||||||
|
completeExecution,
|
||||||
|
updateNode,
|
||||||
|
onMessage,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Connect to WebSocket
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// Construct WebSocket URL
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setWsStatus('connecting');
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[WebSocket] Connected');
|
||||||
|
setWsStatus('connected');
|
||||||
|
resetReconnectAttempts();
|
||||||
|
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = handleMessage;
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('[WebSocket] Disconnected');
|
||||||
|
setWsStatus('disconnected');
|
||||||
|
wsRef.current = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('[WebSocket] Error:', error);
|
||||||
|
setWsStatus('error');
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSocket] Failed to connect:', error);
|
||||||
|
setWsStatus('error');
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
}, [enabled, handleMessage, setWsStatus, resetReconnectAttempts]);
|
||||||
|
|
||||||
|
// Schedule reconnection with exponential backoff
|
||||||
|
const scheduleReconnect = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = reconnectDelayRef.current;
|
||||||
|
console.log(`[WebSocket] Reconnecting in ${delay}ms...`);
|
||||||
|
|
||||||
|
setWsStatus('reconnecting');
|
||||||
|
incrementReconnectAttempts();
|
||||||
|
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// Increase delay for next attempt (exponential backoff)
|
||||||
|
reconnectDelayRef.current = Math.min(
|
||||||
|
reconnectDelayRef.current * RECONNECT_DELAY_MULTIPLIER,
|
||||||
|
RECONNECT_DELAY_MAX
|
||||||
|
);
|
||||||
|
}, [connect, setWsStatus, incrementReconnectAttempts]);
|
||||||
|
|
||||||
|
// Send message through WebSocket
|
||||||
|
const send = useCallback((message: unknown) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
console.warn('[WebSocket] Cannot send message: not connected');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Manual reconnect
|
||||||
|
const reconnect = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
||||||
|
connect();
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
// Check connection status
|
||||||
|
const isConnected = wsRef.current?.readyState === WebSocket.OPEN;
|
||||||
|
|
||||||
|
// Connect on mount, cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [enabled, connect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
send,
|
||||||
|
reconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useWebSocket;
|
||||||
117
ccw/frontend/src/index.css
Normal file
117
ccw/frontend/src/index.css
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* CSS Custom Properties - Light Mode */
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 0 0% 13%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 13%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 220 65% 50%;
|
||||||
|
--primary: 220 65% 50%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--primary-light: 220 65% 95%;
|
||||||
|
--secondary: 220 60% 65%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--accent: 220 40% 95%;
|
||||||
|
--accent-foreground: 0 0% 13%;
|
||||||
|
--destructive: 8 75% 55%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--sidebar-background: 0 0% 97%;
|
||||||
|
--sidebar-foreground: 0 0% 13%;
|
||||||
|
--hover: 0 0% 93%;
|
||||||
|
--success: 142 71% 45%;
|
||||||
|
--success-light: 142 76% 90%;
|
||||||
|
--warning: 38 92% 50%;
|
||||||
|
--warning-light: 48 96% 89%;
|
||||||
|
--info: 210 80% 55%;
|
||||||
|
--info-light: 210 80% 92%;
|
||||||
|
--indigo: 239 65% 60%;
|
||||||
|
--indigo-light: 239 65% 92%;
|
||||||
|
--orange: 25 90% 55%;
|
||||||
|
--orange-light: 25 90% 92%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--background: 220 13% 10%;
|
||||||
|
--foreground: 0 0% 90%;
|
||||||
|
--card: 220 13% 14%;
|
||||||
|
--card-foreground: 0 0% 90%;
|
||||||
|
--border: 220 13% 20%;
|
||||||
|
--input: 220 13% 20%;
|
||||||
|
--ring: 220 65% 55%;
|
||||||
|
--primary: 220 65% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--primary-light: 220 50% 25%;
|
||||||
|
--secondary: 220 60% 60%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--accent: 220 30% 20%;
|
||||||
|
--accent-foreground: 0 0% 90%;
|
||||||
|
--destructive: 8 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--muted: 220 13% 18%;
|
||||||
|
--muted-foreground: 0 0% 55%;
|
||||||
|
--sidebar-background: 220 13% 12%;
|
||||||
|
--sidebar-foreground: 0 0% 90%;
|
||||||
|
--hover: 220 13% 22%;
|
||||||
|
--success: 142 71% 40%;
|
||||||
|
--success-light: 142 50% 20%;
|
||||||
|
--warning: 38 85% 45%;
|
||||||
|
--warning-light: 40 50% 20%;
|
||||||
|
--info: 210 75% 50%;
|
||||||
|
--info-light: 210 50% 20%;
|
||||||
|
--indigo: 239 60% 55%;
|
||||||
|
--indigo-light: 239 40% 20%;
|
||||||
|
--orange: 25 85% 50%;
|
||||||
|
--orange-light: 25 50% 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--border));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
604
ccw/frontend/src/lib/api.ts
Normal file
604
ccw/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
// ========================================
|
||||||
|
// API Client
|
||||||
|
// ========================================
|
||||||
|
// Typed fetch functions for API communication with CSRF token handling
|
||||||
|
|
||||||
|
import type { SessionMetadata, TaskData } from '../types/store';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalSessions: number;
|
||||||
|
activeSessions: number;
|
||||||
|
archivedSessions: number;
|
||||||
|
totalTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
pendingTasks: number;
|
||||||
|
failedTasks: number;
|
||||||
|
todayActivity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionsResponse {
|
||||||
|
activeSessions: SessionMetadata[];
|
||||||
|
archivedSessions: SessionMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionInput {
|
||||||
|
session_id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: 'workflow' | 'review' | 'lite-plan' | 'lite-fix';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSessionInput {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: SessionMetadata['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CSRF Token Handling ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token from cookie
|
||||||
|
*/
|
||||||
|
function getCsrfToken(): string | null {
|
||||||
|
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Base Fetch Wrapper ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base fetch wrapper with CSRF token and error handling
|
||||||
|
*/
|
||||||
|
async function fetchApi<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
|
||||||
|
// Add CSRF token for mutating requests
|
||||||
|
if (options.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
headers.set('X-CSRF-Token', csrfToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type for JSON requests
|
||||||
|
if (options.body && typeof options.body === 'string') {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = {
|
||||||
|
message: response.statusText || 'Request failed',
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.message) error.message = body.message;
|
||||||
|
if (body.code) error.code = body.code;
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle no-content responses
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Dashboard API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch dashboard statistics
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||||
|
const data = await fetchApi<{ statistics?: DashboardStats }>('/api/data');
|
||||||
|
|
||||||
|
// Extract statistics from response, with defaults
|
||||||
|
return {
|
||||||
|
totalSessions: data.statistics?.totalSessions ?? 0,
|
||||||
|
activeSessions: data.statistics?.activeSessions ?? 0,
|
||||||
|
archivedSessions: data.statistics?.archivedSessions ?? 0,
|
||||||
|
totalTasks: data.statistics?.totalTasks ?? 0,
|
||||||
|
completedTasks: data.statistics?.completedTasks ?? 0,
|
||||||
|
pendingTasks: data.statistics?.pendingTasks ?? 0,
|
||||||
|
failedTasks: data.statistics?.failedTasks ?? 0,
|
||||||
|
todayActivity: data.statistics?.todayActivity ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Sessions API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all sessions (active and archived)
|
||||||
|
*/
|
||||||
|
export async function fetchSessions(): Promise<SessionsResponse> {
|
||||||
|
const data = await fetchApi<{
|
||||||
|
activeSessions?: SessionMetadata[];
|
||||||
|
archivedSessions?: SessionMetadata[];
|
||||||
|
}>('/api/data');
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSessions: data.activeSessions ?? [],
|
||||||
|
archivedSessions: data.archivedSessions ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single session by ID
|
||||||
|
*/
|
||||||
|
export async function fetchSession(sessionId: string): Promise<SessionMetadata> {
|
||||||
|
return fetchApi<SessionMetadata>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session
|
||||||
|
*/
|
||||||
|
export async function createSession(input: CreateSessionInput): Promise<SessionMetadata> {
|
||||||
|
return fetchApi<SessionMetadata>('/api/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a session
|
||||||
|
*/
|
||||||
|
export async function updateSession(
|
||||||
|
sessionId: string,
|
||||||
|
input: UpdateSessionInput
|
||||||
|
): Promise<SessionMetadata> {
|
||||||
|
return fetchApi<SessionMetadata>(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a session
|
||||||
|
*/
|
||||||
|
export async function archiveSession(sessionId: string): Promise<SessionMetadata> {
|
||||||
|
return fetchApi<SessionMetadata>(`/api/sessions/${encodeURIComponent(sessionId)}/archive`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a session
|
||||||
|
*/
|
||||||
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
|
return fetchApi<void>(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Tasks API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch tasks for a session
|
||||||
|
*/
|
||||||
|
export async function fetchSessionTasks(sessionId: string): Promise<TaskData[]> {
|
||||||
|
return fetchApi<TaskData[]>(`/api/sessions/${encodeURIComponent(sessionId)}/tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a task status
|
||||||
|
*/
|
||||||
|
export async function updateTask(
|
||||||
|
sessionId: string,
|
||||||
|
taskId: string,
|
||||||
|
updates: Partial<TaskData>
|
||||||
|
): Promise<TaskData> {
|
||||||
|
return fetchApi<TaskData>(
|
||||||
|
`/api/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Path Management API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent paths
|
||||||
|
*/
|
||||||
|
export async function fetchRecentPaths(): Promise<string[]> {
|
||||||
|
const data = await fetchApi<{ paths?: string[] }>('/api/recent-paths');
|
||||||
|
return data.paths ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a recent path
|
||||||
|
*/
|
||||||
|
export async function removeRecentPath(path: string): Promise<string[]> {
|
||||||
|
const data = await fetchApi<{ paths: string[] }>('/api/remove-recent-path', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
return data.paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different project path and load its data
|
||||||
|
*/
|
||||||
|
export async function loadDashboardData(path: string): Promise<{
|
||||||
|
activeSessions: SessionMetadata[];
|
||||||
|
archivedSessions: SessionMetadata[];
|
||||||
|
statistics: DashboardStats;
|
||||||
|
projectPath: string;
|
||||||
|
recentPaths: string[];
|
||||||
|
}> {
|
||||||
|
return fetchApi(`/api/data?path=${encodeURIComponent(path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Loops API ==========
|
||||||
|
|
||||||
|
export interface Loop {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
status: 'created' | 'running' | 'paused' | 'completed' | 'failed';
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
prompt?: string;
|
||||||
|
tool?: string;
|
||||||
|
error?: string;
|
||||||
|
context?: {
|
||||||
|
workingDir?: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopsResponse {
|
||||||
|
loops: Loop[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all loops
|
||||||
|
*/
|
||||||
|
export async function fetchLoops(): Promise<LoopsResponse> {
|
||||||
|
const data = await fetchApi<{ loops?: Loop[] }>('/api/loops');
|
||||||
|
return {
|
||||||
|
loops: data.loops ?? [],
|
||||||
|
total: data.loops?.length ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single loop by ID
|
||||||
|
*/
|
||||||
|
export async function fetchLoop(loopId: string): Promise<Loop> {
|
||||||
|
return fetchApi<Loop>(`/api/loops/${encodeURIComponent(loopId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new loop
|
||||||
|
*/
|
||||||
|
export async function createLoop(input: {
|
||||||
|
prompt: string;
|
||||||
|
tool?: string;
|
||||||
|
mode?: string;
|
||||||
|
}): Promise<Loop> {
|
||||||
|
return fetchApi<Loop>('/api/loops', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a loop's status (pause, resume, stop)
|
||||||
|
*/
|
||||||
|
export async function updateLoopStatus(
|
||||||
|
loopId: string,
|
||||||
|
action: 'pause' | 'resume' | 'stop'
|
||||||
|
): Promise<Loop> {
|
||||||
|
return fetchApi<Loop>(`/api/loops/${encodeURIComponent(loopId)}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a loop
|
||||||
|
*/
|
||||||
|
export async function deleteLoop(loopId: string): Promise<void> {
|
||||||
|
return fetchApi<void>(`/api/loops/${encodeURIComponent(loopId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Issues API ==========
|
||||||
|
|
||||||
|
export interface IssueSolution {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
approach?: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'rejected';
|
||||||
|
estimatedEffort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Issue {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
context?: string;
|
||||||
|
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'completed';
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
solutions?: IssueSolution[];
|
||||||
|
labels?: string[];
|
||||||
|
assignee?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueQueue {
|
||||||
|
tasks: string[];
|
||||||
|
solutions: string[];
|
||||||
|
conflicts: string[];
|
||||||
|
execution_groups: string[];
|
||||||
|
grouped_items: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuesResponse {
|
||||||
|
issues: Issue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all issues
|
||||||
|
*/
|
||||||
|
export async function fetchIssues(projectPath?: string): Promise<IssuesResponse> {
|
||||||
|
const url = projectPath
|
||||||
|
? `/api/issues?path=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/issues';
|
||||||
|
const data = await fetchApi<{ issues?: Issue[] }>(url);
|
||||||
|
return {
|
||||||
|
issues: data.issues ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch issue history
|
||||||
|
*/
|
||||||
|
export async function fetchIssueHistory(projectPath?: string): Promise<IssuesResponse> {
|
||||||
|
const url = projectPath
|
||||||
|
? `/api/issues/history?path=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/issues/history';
|
||||||
|
const data = await fetchApi<{ issues?: Issue[] }>(url);
|
||||||
|
return {
|
||||||
|
issues: data.issues ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch issue queue
|
||||||
|
*/
|
||||||
|
export async function fetchIssueQueue(projectPath?: string): Promise<IssueQueue> {
|
||||||
|
const url = projectPath
|
||||||
|
? `/api/queue?path=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/queue';
|
||||||
|
return fetchApi<IssueQueue>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new issue
|
||||||
|
*/
|
||||||
|
export async function createIssue(input: {
|
||||||
|
title: string;
|
||||||
|
context?: string;
|
||||||
|
priority?: Issue['priority'];
|
||||||
|
}): Promise<Issue> {
|
||||||
|
return fetchApi<Issue>('/api/issues', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an issue
|
||||||
|
*/
|
||||||
|
export async function updateIssue(
|
||||||
|
issueId: string,
|
||||||
|
input: Partial<Issue>
|
||||||
|
): Promise<Issue> {
|
||||||
|
return fetchApi<Issue>(`/api/issues/${encodeURIComponent(issueId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an issue
|
||||||
|
*/
|
||||||
|
export async function deleteIssue(issueId: string): Promise<void> {
|
||||||
|
return fetchApi<void>(`/api/issues/${encodeURIComponent(issueId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Skills API ==========
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
triggers: string[];
|
||||||
|
category?: string;
|
||||||
|
source?: 'builtin' | 'custom' | 'community';
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillsResponse {
|
||||||
|
skills: Skill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all skills
|
||||||
|
*/
|
||||||
|
export async function fetchSkills(): Promise<SkillsResponse> {
|
||||||
|
const data = await fetchApi<{ skills?: Skill[] }>('/api/skills');
|
||||||
|
return {
|
||||||
|
skills: data.skills ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle skill enabled status
|
||||||
|
*/
|
||||||
|
export async function toggleSkill(skillName: string, enabled: boolean): Promise<Skill> {
|
||||||
|
return fetchApi<Skill>(`/api/skills/${encodeURIComponent(skillName)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Commands API ==========
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
usage?: string;
|
||||||
|
examples?: string[];
|
||||||
|
category?: string;
|
||||||
|
aliases?: string[];
|
||||||
|
source?: 'builtin' | 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandsResponse {
|
||||||
|
commands: Command[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all commands
|
||||||
|
*/
|
||||||
|
export async function fetchCommands(): Promise<CommandsResponse> {
|
||||||
|
const data = await fetchApi<{ commands?: Command[] }>('/api/commands');
|
||||||
|
return {
|
||||||
|
commands: data.commands ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Memory API ==========
|
||||||
|
|
||||||
|
export interface CoreMemory {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
source?: string;
|
||||||
|
tags?: string[];
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryResponse {
|
||||||
|
memories: CoreMemory[];
|
||||||
|
totalSize: number;
|
||||||
|
claudeMdCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all memories
|
||||||
|
*/
|
||||||
|
export async function fetchMemories(): Promise<MemoryResponse> {
|
||||||
|
const data = await fetchApi<{
|
||||||
|
memories?: CoreMemory[];
|
||||||
|
totalSize?: number;
|
||||||
|
claudeMdCount?: number;
|
||||||
|
}>('/api/memory');
|
||||||
|
return {
|
||||||
|
memories: data.memories ?? [],
|
||||||
|
totalSize: data.totalSize ?? 0,
|
||||||
|
claudeMdCount: data.claudeMdCount ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new memory entry
|
||||||
|
*/
|
||||||
|
export async function createMemory(input: {
|
||||||
|
content: string;
|
||||||
|
tags?: string[];
|
||||||
|
}): Promise<CoreMemory> {
|
||||||
|
return fetchApi<CoreMemory>('/api/memory', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a memory entry
|
||||||
|
*/
|
||||||
|
export async function updateMemory(
|
||||||
|
memoryId: string,
|
||||||
|
input: Partial<CoreMemory>
|
||||||
|
): Promise<CoreMemory> {
|
||||||
|
return fetchApi<CoreMemory>(`/api/memory/${encodeURIComponent(memoryId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a memory entry
|
||||||
|
*/
|
||||||
|
export async function deleteMemory(memoryId: string): Promise<void> {
|
||||||
|
return fetchApi<void>(`/api/memory/${encodeURIComponent(memoryId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CLI Tools Config API ==========
|
||||||
|
|
||||||
|
export interface CliToolsConfigResponse {
|
||||||
|
version: string;
|
||||||
|
tools: Record<string, {
|
||||||
|
enabled: boolean;
|
||||||
|
primaryModel: string;
|
||||||
|
secondaryModel: string;
|
||||||
|
tags: string[];
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch CLI tools configuration
|
||||||
|
*/
|
||||||
|
export async function fetchCliToolsConfig(): Promise<CliToolsConfigResponse> {
|
||||||
|
return fetchApi<CliToolsConfigResponse>('/api/cli/tools-config');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update CLI tools configuration
|
||||||
|
*/
|
||||||
|
export async function updateCliToolsConfig(
|
||||||
|
config: Partial<CliToolsConfigResponse>
|
||||||
|
): Promise<CliToolsConfigResponse> {
|
||||||
|
return fetchApi<CliToolsConfigResponse>('/api/cli/tools-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
17
ccw/frontend/src/lib/utils.ts
Normal file
17
ccw/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges class names using clsx and tailwind-merge.
|
||||||
|
* This utility combines Tailwind CSS classes intelligently,
|
||||||
|
* handling conflicts and deduplication.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* cn("px-2 py-1", "px-4") // => "py-1 px-4"
|
||||||
|
* cn("bg-primary", condition && "bg-secondary") // conditional classes
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ClassValue };
|
||||||
10
ccw/frontend/src/main.tsx
Normal file
10
ccw/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
351
ccw/frontend/src/pages/CommandsManagerPage.tsx
Normal file
351
ccw/frontend/src/pages/CommandsManagerPage.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// ========================================
|
||||||
|
// Commands Manager Page
|
||||||
|
// ========================================
|
||||||
|
// Manage custom slash commands with search/filter
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Play,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Code,
|
||||||
|
BookOpen,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||||
|
import { useCommands } from '@/hooks';
|
||||||
|
import type { Command } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== Command Card Component ==========
|
||||||
|
|
||||||
|
interface CommandCardProps {
|
||||||
|
command: Command;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Terminal className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-sm font-mono font-medium text-foreground">
|
||||||
|
/{command.name}
|
||||||
|
</code>
|
||||||
|
{command.source && (
|
||||||
|
<Badge variant={command.source === 'builtin' ? 'default' : 'secondary'} className="text-xs">
|
||||||
|
{command.source}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{command.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopy(`/${command.name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category and Aliases */}
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{command.category && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Tag className="w-3 h-3 mr-1" />
|
||||||
|
{command.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{command.aliases?.map((alias) => (
|
||||||
|
<Badge key={alias} variant="secondary" className="text-xs font-mono">
|
||||||
|
/{alias}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||||
|
{/* Usage */}
|
||||||
|
{command.usage && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
Usage
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-background rounded-md font-mono text-sm overflow-x-auto">
|
||||||
|
<code>{command.usage}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Examples */}
|
||||||
|
{command.examples && command.examples.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Examples
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{command.examples.map((example, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-3 bg-background rounded-md font-mono text-sm flex items-center justify-between gap-2 group"
|
||||||
|
>
|
||||||
|
<code className="overflow-x-auto flex-1">{example}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => onCopy(example)}
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function CommandsManagerPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||||
|
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
commands,
|
||||||
|
categories,
|
||||||
|
commandsByCategory,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useCommands({
|
||||||
|
filter: {
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||||
|
source: sourceFilter !== 'all' ? sourceFilter as Command['source'] : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleExpand = (commandName: string) => {
|
||||||
|
setExpandedCommands((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(commandName)) {
|
||||||
|
next.delete(commandName);
|
||||||
|
} else {
|
||||||
|
next.add(commandName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAll = () => {
|
||||||
|
setExpandedCommands(new Set(commands.map((c) => c.name)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAll = () => {
|
||||||
|
setExpandedCommands(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
// TODO: Show toast notification
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count by source
|
||||||
|
const builtinCount = useMemo(
|
||||||
|
() => commands.filter((c) => c.source === 'builtin').length,
|
||||||
|
[commands]
|
||||||
|
);
|
||||||
|
const customCount = useMemo(
|
||||||
|
() => commands.filter((c) => c.source === 'custom').length,
|
||||||
|
[commands]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Terminal className="w-6 h-6 text-primary" />
|
||||||
|
Commands Manager
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage custom slash commands for Claude Code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Command
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-2xl font-bold">{totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Total Commands</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code className="w-5 h-5 text-info" />
|
||||||
|
<span className="text-2xl font-bold">{builtinCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Built-in</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5 text-success" />
|
||||||
|
<span className="text-2xl font-bold">{customCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Custom</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-5 h-5 text-warning" />
|
||||||
|
<span className="text-2xl font-bold">{categories.length}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Categories</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search commands by name, description, or alias..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Sources</SelectItem>
|
||||||
|
<SelectItem value="builtin">Built-in</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand/Collapse All */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={expandAll}>
|
||||||
|
Expand All
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={collapseAll}>
|
||||||
|
Collapse All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commands List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : commands.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-foreground">No commands found</h3>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Try adjusting your search or filters.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{commands.map((command) => (
|
||||||
|
<CommandCard
|
||||||
|
key={command.name}
|
||||||
|
command={command}
|
||||||
|
isExpanded={expandedCommands.has(command.name)}
|
||||||
|
onToggleExpand={() => toggleExpand(command.name)}
|
||||||
|
onCopy={copyToClipboard}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandsManagerPage;
|
||||||
207
ccw/frontend/src/pages/HelpPage.tsx
Normal file
207
ccw/frontend/src/pages/HelpPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// ========================================
|
||||||
|
// Help Page
|
||||||
|
// ========================================
|
||||||
|
// Help documentation and guides
|
||||||
|
|
||||||
|
import {
|
||||||
|
HelpCircle,
|
||||||
|
Book,
|
||||||
|
Video,
|
||||||
|
MessageCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Workflow,
|
||||||
|
FolderKanban,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
interface HelpSection {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
link?: string;
|
||||||
|
isExternal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpSections: HelpSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Getting Started',
|
||||||
|
description: 'Learn the basics of CCW Dashboard and workflow management',
|
||||||
|
icon: Book,
|
||||||
|
link: '#getting-started',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Orchestrator Guide',
|
||||||
|
description: 'Master the visual workflow editor with drag-drop flows',
|
||||||
|
icon: Workflow,
|
||||||
|
link: '/orchestrator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sessions Management',
|
||||||
|
description: 'Understanding workflow sessions and task tracking',
|
||||||
|
icon: FolderKanban,
|
||||||
|
link: '/sessions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'CLI Integration',
|
||||||
|
description: 'Using CCW commands and CLI tool integration',
|
||||||
|
icon: Terminal,
|
||||||
|
link: '#cli-integration',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HelpPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<HelpCircle className="w-6 h-6 text-primary" />
|
||||||
|
Help & Documentation
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Learn how to use CCW Dashboard and get the most out of your workflows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{helpSections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
const content = (
|
||||||
|
<Card className="p-4 h-full hover:shadow-md hover:border-primary/50 transition-all cursor-pointer group">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{section.isExternal && (
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (section.link?.startsWith('/')) {
|
||||||
|
return (
|
||||||
|
<Link key={section.title} to={section.link}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a key={section.title} href={section.link}>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Getting Started Section */}
|
||||||
|
<Card className="p-6" id="getting-started">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
Getting Started with CCW
|
||||||
|
</h2>
|
||||||
|
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
CCW (Claude Code Workflow) Dashboard is your central hub for managing
|
||||||
|
AI-powered development workflows. Here are the key concepts:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Sessions</strong> - Track the
|
||||||
|
progress of multi-step development tasks
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Orchestrator</strong> - Visual
|
||||||
|
workflow builder for creating automation flows
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Loops</strong> - Monitor
|
||||||
|
iterative development cycles in real-time
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Skills</strong> - Extend Claude
|
||||||
|
Code with custom capabilities
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-foreground">Memory</strong> - Store context
|
||||||
|
and knowledge for better AI assistance
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CLI Integration Section */}
|
||||||
|
<Card className="p-6" id="cli-integration">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
|
CLI Integration
|
||||||
|
</h2>
|
||||||
|
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
CCW integrates with multiple CLI tools for AI-assisted development:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
|
ccw cli -p "prompt" --tool gemini
|
||||||
|
</code>
|
||||||
|
- Execute with Gemini
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
|
ccw cli -p "prompt" --tool qwen
|
||||||
|
</code>
|
||||||
|
- Execute with Qwen
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
|
ccw cli -p "prompt" --tool codex
|
||||||
|
</code>
|
||||||
|
- Execute with Codex
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Support Section */}
|
||||||
|
<Card className="p-6 bg-primary/5 border-primary/20">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-3 rounded-lg bg-primary/10">
|
||||||
|
<MessageCircle className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Need more help?
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 mb-4">
|
||||||
|
Check the project documentation or reach out for support.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Book className="w-4 h-4 mr-2" />
|
||||||
|
Documentation
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Tutorials
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HelpPage;
|
||||||
226
ccw/frontend/src/pages/HomePage.tsx
Normal file
226
ccw/frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// ========================================
|
||||||
|
// HomePage Component
|
||||||
|
// ========================================
|
||||||
|
// Dashboard home page with stat cards and recent sessions
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
ListChecks,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
Activity,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||||
|
import { useSessions } from '@/hooks/useSessions';
|
||||||
|
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||||
|
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Stat card configuration
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
key: 'activeSessions',
|
||||||
|
title: 'Active Sessions',
|
||||||
|
icon: FolderKanban,
|
||||||
|
variant: 'primary' as const,
|
||||||
|
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalTasks',
|
||||||
|
title: 'Total Tasks',
|
||||||
|
icon: ListChecks,
|
||||||
|
variant: 'info' as const,
|
||||||
|
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completedTasks',
|
||||||
|
title: 'Completed',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
variant: 'success' as const,
|
||||||
|
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pendingTasks',
|
||||||
|
title: 'Pending',
|
||||||
|
icon: Clock,
|
||||||
|
variant: 'warning' as const,
|
||||||
|
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'failedTasks',
|
||||||
|
title: 'Failed',
|
||||||
|
icon: XCircle,
|
||||||
|
variant: 'danger' as const,
|
||||||
|
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'todayActivity',
|
||||||
|
title: "Today's Activity",
|
||||||
|
icon: Activity,
|
||||||
|
variant: 'default' as const,
|
||||||
|
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomePage component - Dashboard overview with statistics and recent sessions
|
||||||
|
*/
|
||||||
|
export function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Fetch dashboard stats
|
||||||
|
const {
|
||||||
|
stats,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
isFetching: statsFetching,
|
||||||
|
error: statsError,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useDashboardStats({
|
||||||
|
refetchInterval: 60000, // Refetch every minute
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch recent sessions (active only, limited)
|
||||||
|
const {
|
||||||
|
activeSessions,
|
||||||
|
isLoading: sessionsLoading,
|
||||||
|
isFetching: sessionsFetching,
|
||||||
|
error: sessionsError,
|
||||||
|
refetch: refetchSessions,
|
||||||
|
} = useSessions({
|
||||||
|
filter: { location: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get recent sessions (max 6)
|
||||||
|
const recentSessions = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...activeSessions]
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
.slice(0, 6),
|
||||||
|
[activeSessions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await Promise.all([refetchStats(), refetchSessions()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionClick = (sessionId: string) => {
|
||||||
|
navigate(`/sessions/${sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewAllSessions = () => {
|
||||||
|
navigate('/sessions');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = statsLoading || sessionsLoading;
|
||||||
|
const isFetching = statsFetching || sessionsFetching;
|
||||||
|
const hasError = statsError || sessionsError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Overview of your workflow sessions and tasks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error alert */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">Failed to load dashboard data</p>
|
||||||
|
<p className="text-xs mt-0.5">
|
||||||
|
{(statsError || sessionsError)?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-medium text-foreground mb-4">Statistics</h2>
|
||||||
|
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{isLoading
|
||||||
|
? // Loading skeletons
|
||||||
|
Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||||
|
: // Actual stat cards
|
||||||
|
statCards.map((card) => (
|
||||||
|
<StatCard
|
||||||
|
key={card.key}
|
||||||
|
title={card.title}
|
||||||
|
value={stats ? card.getValue(stats as any) : 0}
|
||||||
|
icon={card.icon}
|
||||||
|
variant={card.variant}
|
||||||
|
isLoading={isFetching && !stats}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent Sessions */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Recent Sessions</h2>
|
||||||
|
<Button variant="link" size="sm" onClick={handleViewAllSessions}>
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessionsLoading ? (
|
||||||
|
// Loading skeletons
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<SessionCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : recentSessions.length === 0 ? (
|
||||||
|
// Empty state
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 border border-dashed border-border rounded-lg">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-1">No sessions yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||||
|
Start a new workflow session to track your development tasks and progress.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Session cards grid
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{recentSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.session_id}
|
||||||
|
session={session}
|
||||||
|
onClick={handleSessionClick}
|
||||||
|
onView={handleSessionClick}
|
||||||
|
showActions={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
401
ccw/frontend/src/pages/IssueManagerPage.tsx
Normal file
401
ccw/frontend/src/pages/IssueManagerPage.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
// ========================================
|
||||||
|
// Issue Manager Page
|
||||||
|
// ========================================
|
||||||
|
// Track and manage project issues with drag-drop queue
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Github,
|
||||||
|
ListFilter,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||||
|
import { IssueCard } from '@/components/shared/IssueCard';
|
||||||
|
import { useIssues, useIssueMutations } from '@/hooks';
|
||||||
|
import type { Issue } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
type ViewMode = 'issues' | 'queue';
|
||||||
|
type StatusFilter = 'all' | Issue['status'];
|
||||||
|
type PriorityFilter = 'all' | Issue['priority'];
|
||||||
|
|
||||||
|
// ========== New Issue Dialog ==========
|
||||||
|
|
||||||
|
interface NewIssueDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [context, setContext] = useState('');
|
||||||
|
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (title.trim()) {
|
||||||
|
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||||
|
setTitle('');
|
||||||
|
setContext('');
|
||||||
|
setPriority('medium');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Issue</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Title</label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Issue title..."
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Context (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={context}
|
||||||
|
onChange={(e) => setContext(e.target.value)}
|
||||||
|
placeholder="Describe the issue..."
|
||||||
|
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Priority</label>
|
||||||
|
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="critical">Critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Issue
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Issue List Component ==========
|
||||||
|
|
||||||
|
interface IssueListProps {
|
||||||
|
issues: Issue[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onIssueClick: (issue: Issue) => void;
|
||||||
|
onIssueEdit: (issue: Issue) => void;
|
||||||
|
onIssueDelete: (issue: Issue) => void;
|
||||||
|
onStatusChange: (issue: Issue, status: Issue['status']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueList({
|
||||||
|
issues,
|
||||||
|
isLoading,
|
||||||
|
onIssueClick,
|
||||||
|
onIssueEdit,
|
||||||
|
onIssueDelete,
|
||||||
|
onStatusChange,
|
||||||
|
}: IssueListProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<AlertCircle className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-foreground">No issues found</h3>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Create a new issue or adjust your filters.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<IssueCard
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
onClick={onIssueClick}
|
||||||
|
onEdit={onIssueEdit}
|
||||||
|
onDelete={onIssueDelete}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function IssueManagerPage() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('issues');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
|
||||||
|
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
issues,
|
||||||
|
issuesByStatus,
|
||||||
|
issuesByPriority,
|
||||||
|
openCount,
|
||||||
|
criticalCount,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useIssues({
|
||||||
|
filter: {
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
status: statusFilter !== 'all' ? [statusFilter] : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? [priorityFilter] : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createIssue, updateIssue, deleteIssue, isCreating, isUpdating } = useIssueMutations();
|
||||||
|
|
||||||
|
// Filter counts
|
||||||
|
const statusCounts = useMemo(() => ({
|
||||||
|
all: issues.length,
|
||||||
|
open: issuesByStatus.open?.length || 0,
|
||||||
|
in_progress: issuesByStatus.in_progress?.length || 0,
|
||||||
|
resolved: issuesByStatus.resolved?.length || 0,
|
||||||
|
closed: issuesByStatus.closed?.length || 0,
|
||||||
|
completed: issuesByStatus.completed?.length || 0,
|
||||||
|
}), [issues, issuesByStatus]);
|
||||||
|
|
||||||
|
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||||
|
await createIssue(data);
|
||||||
|
setIsNewIssueOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditIssue = (issue: Issue) => {
|
||||||
|
setSelectedIssue(issue);
|
||||||
|
// TODO: Open edit dialog
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteIssue = async (issue: Issue) => {
|
||||||
|
if (confirm(`Delete issue "${issue.title}"?`)) {
|
||||||
|
await deleteIssue(issue.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (issue: Issue, status: Issue['status']) => {
|
||||||
|
await updateIssue(issue.id, { status });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-6 h-6 text-primary" />
|
||||||
|
Issue Manager
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Track and manage project issues and bugs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Github className="w-4 h-4 mr-2" />
|
||||||
|
Pull from GitHub
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-info" />
|
||||||
|
<span className="text-2xl font-bold">{openCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Open Issues</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-warning" />
|
||||||
|
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">In Progress</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||||
|
<span className="text-2xl font-bold">{criticalCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Critical</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success" />
|
||||||
|
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Resolved</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search issues..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Priority</SelectItem>
|
||||||
|
<SelectItem value="critical">Critical</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
>
|
||||||
|
All ({statusCounts.all})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'open' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('open')}
|
||||||
|
>
|
||||||
|
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === 'in_progress' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter('in_progress')}
|
||||||
|
>
|
||||||
|
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
|
||||||
|
In Progress
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={priorityFilter === 'critical' ? 'destructive' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical');
|
||||||
|
setStatusFilter('all');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
|
||||||
|
Critical
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue List */}
|
||||||
|
<IssueList
|
||||||
|
issues={issues}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onIssueClick={setSelectedIssue}
|
||||||
|
onIssueEdit={handleEditIssue}
|
||||||
|
onIssueDelete={handleDeleteIssue}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New Issue Dialog */}
|
||||||
|
<NewIssueDialog
|
||||||
|
open={isNewIssueOpen}
|
||||||
|
onOpenChange={setIsNewIssueOpen}
|
||||||
|
onSubmit={handleCreateIssue}
|
||||||
|
isCreating={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IssueManagerPage;
|
||||||
438
ccw/frontend/src/pages/LoopMonitorPage.tsx
Normal file
438
ccw/frontend/src/pages/LoopMonitorPage.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// ========================================
|
||||||
|
// Loop Monitor Page
|
||||||
|
// ========================================
|
||||||
|
// Monitor running development loops with Kanban board
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
StopCircle,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { DropResult, DraggableProvided } from '@hello-pangea/dnd';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/Dialog';
|
||||||
|
import { KanbanBoard, useLoopKanbanColumns, type LoopKanbanItem } from '@/components/shared/KanbanBoard';
|
||||||
|
import { useLoops, useLoopMutations } from '@/hooks';
|
||||||
|
import type { Loop } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== Loop Card Component ==========
|
||||||
|
|
||||||
|
interface LoopCardProps {
|
||||||
|
loop: Loop;
|
||||||
|
provided: DraggableProvided;
|
||||||
|
onPause?: (loop: Loop) => void;
|
||||||
|
onResume?: (loop: Loop) => void;
|
||||||
|
onStop?: (loop: Loop) => void;
|
||||||
|
onClick?: (loop: Loop) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoopCard({ loop, provided, onPause, onResume, onStop, onClick }: LoopCardProps) {
|
||||||
|
const statusIcons: Record<Loop['status'], React.ReactNode> = {
|
||||||
|
created: <Clock className="w-4 h-4 text-muted-foreground" />,
|
||||||
|
running: <Loader2 className="w-4 h-4 text-primary animate-spin" />,
|
||||||
|
paused: <Pause className="w-4 h-4 text-warning" />,
|
||||||
|
completed: <CheckCircle className="w-4 h-4 text-success" />,
|
||||||
|
failed: <XCircle className="w-4 h-4 text-destructive" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = loop.totalSteps > 0
|
||||||
|
? Math.round((loop.currentStep / loop.totalSteps) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
onClick={() => onClick?.(loop)}
|
||||||
|
className={cn(
|
||||||
|
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||||
|
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{statusIcons[loop.status]}
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
|
{loop.name || loop.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loop.tool && (
|
||||||
|
<Badge variant="outline" className="text-xs flex-shrink-0">
|
||||||
|
{loop.tool}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Preview */}
|
||||||
|
{loop.prompt && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||||
|
{loop.prompt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{loop.status === 'running' && loop.totalSteps > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||||
|
<span>Step {loop.currentStep}/{loop.totalSteps}</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{(loop.status === 'running' || loop.status === 'paused') && (
|
||||||
|
<div className="flex items-center gap-1 mt-3 pt-2 border-t border-border">
|
||||||
|
{loop.status === 'running' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPause?.(loop); }}
|
||||||
|
>
|
||||||
|
<Pause className="w-3 h-3 mr-1" />
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onResume?.(loop); }}
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onStop?.(loop); }}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3 h-3 mr-1" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{loop.status === 'failed' && loop.error && (
|
||||||
|
<div className="mt-2 p-2 bg-destructive/10 rounded text-xs text-destructive">
|
||||||
|
{loop.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== New Loop Dialog ==========
|
||||||
|
|
||||||
|
interface NewLoopDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: { prompt: string; tool?: string; mode?: string }) => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewLoopDialog({ open, onOpenChange, onSubmit, isCreating }: NewLoopDialogProps) {
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [tool, setTool] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (prompt.trim()) {
|
||||||
|
onSubmit({ prompt: prompt.trim(), tool: tool || undefined });
|
||||||
|
setPrompt('');
|
||||||
|
setTool('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Start New Loop</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="Enter your development loop prompt..."
|
||||||
|
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">CLI Tool (optional)</label>
|
||||||
|
<Input
|
||||||
|
value={tool}
|
||||||
|
onChange={(e) => setTool(e.target.value)}
|
||||||
|
placeholder="e.g., gemini, qwen, codex"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !prompt.trim()}>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Start Loop
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function LoopMonitorPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isNewLoopOpen, setIsNewLoopOpen] = useState(false);
|
||||||
|
const [selectedLoop, setSelectedLoop] = useState<Loop | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loops,
|
||||||
|
loopsByStatus,
|
||||||
|
runningCount,
|
||||||
|
completedCount,
|
||||||
|
failedCount,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useLoops({
|
||||||
|
filter: searchQuery ? { search: searchQuery } : undefined,
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds for real-time updates
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createLoop, updateStatus, isCreating, isUpdating } = useLoopMutations();
|
||||||
|
|
||||||
|
// Kanban columns
|
||||||
|
const columns = useLoopKanbanColumns(loopsByStatus as unknown as Record<string, LoopKanbanItem[]>);
|
||||||
|
|
||||||
|
// Handle drag and drop status change
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
async (result: DropResult, _source: string, destination: string) => {
|
||||||
|
const loopId = result.draggableId;
|
||||||
|
const newStatus = destination as Loop['status'];
|
||||||
|
|
||||||
|
// Only allow certain transitions
|
||||||
|
const allowedTransitions: Record<Loop['status'], Loop['status'][]> = {
|
||||||
|
created: ['running'],
|
||||||
|
running: ['paused', 'completed', 'failed'],
|
||||||
|
paused: ['running', 'completed'],
|
||||||
|
completed: [],
|
||||||
|
failed: ['created'], // Retry
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = loops.find((l) => l.id === loopId);
|
||||||
|
if (!loop) return;
|
||||||
|
|
||||||
|
if (!allowedTransitions[loop.status]?.includes(newStatus)) {
|
||||||
|
return; // Invalid transition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map status to action
|
||||||
|
const actionMap: Record<Loop['status'], 'pause' | 'resume' | 'stop' | null> = {
|
||||||
|
paused: 'pause',
|
||||||
|
running: 'resume',
|
||||||
|
completed: 'stop',
|
||||||
|
failed: 'stop',
|
||||||
|
created: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action = actionMap[newStatus];
|
||||||
|
if (action) {
|
||||||
|
await updateStatus(loopId, action);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loops, updateStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePause = async (loop: Loop) => {
|
||||||
|
await updateStatus(loop.id, 'pause');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async (loop: Loop) => {
|
||||||
|
await updateStatus(loop.id, 'resume');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async (loop: Loop) => {
|
||||||
|
await updateStatus(loop.id, 'stop');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLoop = async (data: { prompt: string; tool?: string; mode?: string }) => {
|
||||||
|
await createLoop(data);
|
||||||
|
setIsNewLoopOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom item renderer for loops
|
||||||
|
const renderLoopItem = useCallback(
|
||||||
|
(item: LoopKanbanItem, provided: DraggableProvided) => (
|
||||||
|
<LoopCard
|
||||||
|
loop={item as unknown as Loop}
|
||||||
|
provided={provided}
|
||||||
|
onPause={handlePause}
|
||||||
|
onResume={handleResume}
|
||||||
|
onStop={handleStop}
|
||||||
|
onClick={setSelectedLoop}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-6 h-6 text-primary" />
|
||||||
|
Loop Monitor
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Monitor and control running development loops
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsNewLoopOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Loop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Overview */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-2xl font-bold">{runningCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Running</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pause className="w-5 h-5 text-warning" />
|
||||||
|
<span className="text-2xl font-bold">{loopsByStatus.paused?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Paused</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-success" />
|
||||||
|
<span className="text-2xl font-bold">{completedCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Completed</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="w-5 h-5 text-destructive" />
|
||||||
|
<span className="text-2xl font-bold">{failedCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Failed</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search loops..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Board */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Card key={i} className="p-4">
|
||||||
|
<div className="h-6 w-20 bg-muted animate-pulse rounded mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2].map((j) => (
|
||||||
|
<div key={j} className="h-24 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : loops.length === 0 && !searchQuery ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<RefreshCw className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||||
|
No active loops
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Start a new development loop to begin monitoring progress.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" onClick={() => setIsNewLoopOpen(true)}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Start New Loop
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<KanbanBoard
|
||||||
|
columns={columns}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
renderItem={renderLoopItem}
|
||||||
|
emptyColumnMessage="No loops"
|
||||||
|
className="min-h-[400px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Loop Dialog */}
|
||||||
|
<NewLoopDialog
|
||||||
|
open={isNewLoopOpen}
|
||||||
|
onOpenChange={setIsNewLoopOpen}
|
||||||
|
onSubmit={handleCreateLoop}
|
||||||
|
isCreating={isCreating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoopMonitorPage;
|
||||||
480
ccw/frontend/src/pages/MemoryPage.tsx
Normal file
480
ccw/frontend/src/pages/MemoryPage.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
// ========================================
|
||||||
|
// Memory Page
|
||||||
|
// ========================================
|
||||||
|
// View and manage core memory and context with CRUD operations
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Tag,
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||||
|
import { useMemory, useMemoryMutations } from '@/hooks';
|
||||||
|
import type { CoreMemory } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== Memory Card Component ==========
|
||||||
|
|
||||||
|
interface MemoryCardProps {
|
||||||
|
memory: CoreMemory;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onEdit: (memory: CoreMemory) => void;
|
||||||
|
onDelete: (memory: CoreMemory) => void;
|
||||||
|
onCopy: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy }: MemoryCardProps) {
|
||||||
|
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
|
||||||
|
const formattedSize = memory.size
|
||||||
|
? memory.size < 1024
|
||||||
|
? `${memory.size} B`
|
||||||
|
: `${(memory.size / 1024).toFixed(1)} KB`
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Brain className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{memory.id}
|
||||||
|
</span>
|
||||||
|
{memory.source && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{memory.source}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formattedDate} - {formattedSize}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCopy(memory.content);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(memory);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(memory);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{!isExpanded && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||||
|
{memory.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{memory.tags && memory.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{memory.tags.slice(0, 5).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
<Tag className="w-3 h-3 mr-1" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{memory.tags.length > 5 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{memory.tags.length - 5}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border p-4 bg-muted/30">
|
||||||
|
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono bg-background p-4 rounded-lg overflow-x-auto max-h-96">
|
||||||
|
{memory.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== New Memory Dialog ==========
|
||||||
|
|
||||||
|
interface NewMemoryDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: { content: string; tags?: string[] }) => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
editingMemory?: CoreMemory | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewMemoryDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isCreating,
|
||||||
|
editingMemory,
|
||||||
|
}: NewMemoryDialogProps) {
|
||||||
|
const [content, setContent] = useState(editingMemory?.content || '');
|
||||||
|
const [tagsInput, setTagsInput] = useState(editingMemory?.tags?.join(', ') || '');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (content.trim()) {
|
||||||
|
const tags = tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
onSubmit({ content: content.trim(), tags: tags.length > 0 ? tags : undefined });
|
||||||
|
setContent('');
|
||||||
|
setTagsInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingMemory ? 'Edit Memory' : 'Add New Memory'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Content</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="Enter memory content..."
|
||||||
|
className="mt-1 w-full min-h-[200px] p-3 bg-background border border-input rounded-md text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Tags (comma-separated)</label>
|
||||||
|
<Input
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="e.g., project, config, api"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !content.trim()}>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{editingMemory ? 'Updating...' : 'Creating...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{editingMemory ? 'Update Memory' : 'Add Memory'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function MemoryPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
|
||||||
|
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
|
||||||
|
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const {
|
||||||
|
memories,
|
||||||
|
totalSize,
|
||||||
|
claudeMdCount,
|
||||||
|
allTags,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useMemory({
|
||||||
|
filter: {
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createMemory, updateMemory, deleteMemory, isCreating, isUpdating, isDeleting } =
|
||||||
|
useMemoryMutations();
|
||||||
|
|
||||||
|
const toggleExpand = (memoryId: string) => {
|
||||||
|
setExpandedMemories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(memoryId)) {
|
||||||
|
next.delete(memoryId);
|
||||||
|
} else {
|
||||||
|
next.add(memoryId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateMemory = async (data: { content: string; tags?: string[] }) => {
|
||||||
|
if (editingMemory) {
|
||||||
|
await updateMemory(editingMemory.id, data);
|
||||||
|
setEditingMemory(null);
|
||||||
|
} else {
|
||||||
|
await createMemory(data);
|
||||||
|
}
|
||||||
|
setIsNewMemoryOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (memory: CoreMemory) => {
|
||||||
|
setEditingMemory(memory);
|
||||||
|
setIsNewMemoryOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (memory: CoreMemory) => {
|
||||||
|
if (confirm(`Delete memory "${memory.id}"?`)) {
|
||||||
|
await deleteMemory(memory.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (content: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
// TODO: Show toast notification
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedTotalSize = totalSize < 1024
|
||||||
|
? `${totalSize} B`
|
||||||
|
: totalSize < 1024 * 1024
|
||||||
|
? `${(totalSize / 1024).toFixed(1)} KB`
|
||||||
|
: `${(totalSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Brain className="w-6 h-6 text-primary" />
|
||||||
|
Memory
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage core memory, context, and knowledge base
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Memory
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Database className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-foreground">{memories.length}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Core Memories</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-info/10">
|
||||||
|
<FileText className="w-5 h-5 text-info" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-foreground">{claudeMdCount}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">CLAUDE.md Files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-success/10">
|
||||||
|
<Brain className="w-5 h-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-foreground">{formattedTotalSize}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search memories..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Filter */}
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground py-1">Tags:</span>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<Button
|
||||||
|
key={tag}
|
||||||
|
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
>
|
||||||
|
<Tag className="w-3 h-3 mr-1" />
|
||||||
|
{tag}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
onClick={() => setSelectedTags([])}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : memories.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<Brain className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||||
|
No memories stored
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Add context and knowledge to help Claude understand your project better.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add First Memory
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{memories.map((memory) => (
|
||||||
|
<MemoryCard
|
||||||
|
key={memory.id}
|
||||||
|
memory={memory}
|
||||||
|
isExpanded={expandedMemories.has(memory.id)}
|
||||||
|
onToggleExpand={() => toggleExpand(memory.id)}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onCopy={copyToClipboard}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New/Edit Memory Dialog */}
|
||||||
|
<NewMemoryDialog
|
||||||
|
open={isNewMemoryOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsNewMemoryOpen(open);
|
||||||
|
if (!open) setEditingMemory(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCreateMemory}
|
||||||
|
isCreating={isCreating || isUpdating}
|
||||||
|
editingMemory={editingMemory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MemoryPage;
|
||||||
450
ccw/frontend/src/pages/SessionsPage.tsx
Normal file
450
ccw/frontend/src/pages/SessionsPage.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
// ========================================
|
||||||
|
// SessionsPage Component
|
||||||
|
// ========================================
|
||||||
|
// Sessions list page with CRUD operations
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
AlertCircle,
|
||||||
|
FolderKanban,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useSessions,
|
||||||
|
useCreateSession,
|
||||||
|
useArchiveSession,
|
||||||
|
useDeleteSession,
|
||||||
|
type SessionsFilter,
|
||||||
|
} from '@/hooks/useSessions';
|
||||||
|
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/Dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from '@/components/ui/Dropdown';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SessionMetadata } from '@/types/store';
|
||||||
|
|
||||||
|
type LocationFilter = 'all' | 'active' | 'archived';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionsPage component - Sessions list with CRUD operations
|
||||||
|
*/
|
||||||
|
export function SessionsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [locationFilter, setLocationFilter] = React.useState<LocationFilter>('active');
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||||
|
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create session form state
|
||||||
|
const [newSessionId, setNewSessionId] = React.useState('');
|
||||||
|
const [newSessionTitle, setNewSessionTitle] = React.useState('');
|
||||||
|
const [newSessionDescription, setNewSessionDescription] = React.useState('');
|
||||||
|
|
||||||
|
// Build filter object
|
||||||
|
const filter: SessionsFilter = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
location: locationFilter,
|
||||||
|
search: searchQuery,
|
||||||
|
status: statusFilter.length > 0 ? statusFilter : undefined,
|
||||||
|
}),
|
||||||
|
[locationFilter, searchQuery, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch sessions with filter
|
||||||
|
const {
|
||||||
|
filteredSessions,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useSessions({ filter });
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const { createSession, isCreating } = useCreateSession();
|
||||||
|
const { archiveSession, isArchiving } = useArchiveSession();
|
||||||
|
const { deleteSession, isDeleting } = useDeleteSession();
|
||||||
|
|
||||||
|
const isMutating = isCreating || isArchiving || isDeleting;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleSessionClick = (sessionId: string) => {
|
||||||
|
navigate(`/sessions/${sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSession = async () => {
|
||||||
|
if (!newSessionId.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSession({
|
||||||
|
session_id: newSessionId.trim(),
|
||||||
|
title: newSessionTitle.trim() || undefined,
|
||||||
|
description: newSessionDescription.trim() || undefined,
|
||||||
|
});
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create session:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCreateForm = () => {
|
||||||
|
setNewSessionId('');
|
||||||
|
setNewSessionTitle('');
|
||||||
|
setNewSessionDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
await archiveSession(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to archive session:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (sessionId: string) => {
|
||||||
|
setSessionToDelete(sessionId);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!sessionToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSession(sessionToDelete);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSessionToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete session:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatusFilter = (status: SessionMetadata['status']) => {
|
||||||
|
setStatusFilter((prev) =>
|
||||||
|
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter([]);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = statusFilter.length > 0 || searchQuery.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Sessions</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Manage your workflow sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Session
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">Failed to load sessions</p>
|
||||||
|
<p className="text-xs mt-0.5">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
{/* Location tabs */}
|
||||||
|
<Tabs value={locationFilter} onValueChange={(v) => setLocationFilter(v as LocationFilter)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
|
<TabsTrigger value="archived">Archived</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex-1 max-w-sm relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search sessions..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
{statusFilter.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||||
|
{statusFilter.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(['planning', 'in_progress', 'completed', 'paused'] as const).map((status) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
|
<span className="capitalize">{status.replace('_', ' ')}</span>
|
||||||
|
{statusFilter.includes(status) && (
|
||||||
|
<span className="text-primary">✓</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={clearFilters} className="text-destructive">
|
||||||
|
Clear filters
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Filters:</span>
|
||||||
|
{statusFilter.map((status) => (
|
||||||
|
<Badge
|
||||||
|
key={status}
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
<X className="ml-1 h-3 w-3" />
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{searchQuery && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
Search: {searchQuery}
|
||||||
|
<X className="ml-1 h-3 w-3" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<SessionCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredSessions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-1">
|
||||||
|
{hasActiveFilters ? 'No sessions match your filters' : 'No sessions found'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
||||||
|
{hasActiveFilters
|
||||||
|
? 'Try adjusting your filters or search query.'
|
||||||
|
: 'Create a new session to get started with your workflow.'}
|
||||||
|
</p>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<Button variant="outline" onClick={clearFilters}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Session
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredSessions.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.session_id}
|
||||||
|
session={session}
|
||||||
|
onClick={handleSessionClick}
|
||||||
|
onView={handleSessionClick}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onDelete={handleDeleteClick}
|
||||||
|
actionsDisabled={isMutating}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Session Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Session</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new workflow session to track your development tasks.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="sessionId" className="text-sm font-medium">
|
||||||
|
Session ID <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="sessionId"
|
||||||
|
placeholder="e.g., WFS-feature-auth"
|
||||||
|
value={newSessionId}
|
||||||
|
onChange={(e) => setNewSessionId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="sessionTitle" className="text-sm font-medium">
|
||||||
|
Title (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="sessionTitle"
|
||||||
|
placeholder="e.g., Authentication System"
|
||||||
|
value={newSessionTitle}
|
||||||
|
onChange={(e) => setNewSessionTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="sessionDescription" className="text-sm font-medium">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="sessionDescription"
|
||||||
|
placeholder="Brief description of the session"
|
||||||
|
value={newSessionDescription}
|
||||||
|
onChange={(e) => setNewSessionDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateSession}
|
||||||
|
disabled={!newSessionId.trim() || isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? 'Creating...' : 'Create Session'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Session</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this session? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setSessionToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionsPage;
|
||||||
440
ccw/frontend/src/pages/SettingsPage.tsx
Normal file
440
ccw/frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// ========================================
|
||||||
|
// Settings Page
|
||||||
|
// ========================================
|
||||||
|
// Application settings and configuration with CLI tools management
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Globe,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
Cpu,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { useTheme, useConfig } from '@/hooks';
|
||||||
|
import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPreferences } from '@/stores/configStore';
|
||||||
|
import type { CliToolConfig, UserPreferences } from '@/types/store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== CLI Tool Card Component ==========
|
||||||
|
|
||||||
|
interface CliToolCardProps {
|
||||||
|
toolId: string;
|
||||||
|
config: CliToolConfig;
|
||||||
|
isDefault: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
onSetDefault: () => void;
|
||||||
|
onUpdateModel: (field: 'primaryModel' | 'secondaryModel', value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CliToolCard({
|
||||||
|
toolId,
|
||||||
|
config,
|
||||||
|
isDefault,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onToggleEnabled,
|
||||||
|
onSetDefault,
|
||||||
|
onUpdateModel,
|
||||||
|
}: CliToolCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={cn('overflow-hidden', !config.enabled && 'opacity-60')}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
'p-2 rounded-lg',
|
||||||
|
config.enabled ? 'bg-primary/10' : 'bg-muted'
|
||||||
|
)}>
|
||||||
|
<Cpu className={cn(
|
||||||
|
'w-5 h-5',
|
||||||
|
config.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground capitalize">
|
||||||
|
{toolId}
|
||||||
|
</span>
|
||||||
|
{isDefault && (
|
||||||
|
<Badge variant="default" className="text-xs">Default</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">{config.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{config.primaryModel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={config.enabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleEnabled();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.enabled ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
Enabled
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Disabled
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{config.tags && config.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{config.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Primary Model</label>
|
||||||
|
<Input
|
||||||
|
value={config.primaryModel}
|
||||||
|
onChange={(e) => onUpdateModel('primaryModel', e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">Secondary Model</label>
|
||||||
|
<Input
|
||||||
|
value={config.secondaryModel}
|
||||||
|
onChange={(e) => onUpdateModel('secondaryModel', e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isDefault && config.enabled && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onSetDefault}>
|
||||||
|
Set as Default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const cliTools = useConfigStore(selectCliTools);
|
||||||
|
const defaultCliTool = useConfigStore(selectDefaultCliTool);
|
||||||
|
const userPreferences = useConfigStore(selectUserPreferences);
|
||||||
|
const { updateCliTool, setDefaultCliTool, setUserPreferences, resetUserPreferences } = useConfigStore();
|
||||||
|
|
||||||
|
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const toggleToolExpand = (toolId: string) => {
|
||||||
|
setExpandedTools((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(toolId)) {
|
||||||
|
next.delete(toolId);
|
||||||
|
} else {
|
||||||
|
next.add(toolId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleToolEnabled = (toolId: string) => {
|
||||||
|
updateCliTool(toolId, { enabled: !cliTools[toolId].enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefaultTool = (toolId: string) => {
|
||||||
|
setDefaultCliTool(toolId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateModel = (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => {
|
||||||
|
updateCliTool(toolId, { [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreferenceChange = (key: keyof UserPreferences, value: unknown) => {
|
||||||
|
setUserPreferences({ [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Settings className="w-6 h-6 text-primary" />
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Configure your dashboard preferences and CLI tools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
Appearance
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Theme</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose your preferred color theme
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={theme === 'light' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
>
|
||||||
|
<Sun className="w-4 h-4 mr-2" />
|
||||||
|
Light
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
>
|
||||||
|
<Moon className="w-4 h-4 mr-2" />
|
||||||
|
Dark
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={theme === 'system' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTheme('system')}
|
||||||
|
>
|
||||||
|
System
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Compact View</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Use a more compact layout for lists
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={userPreferences.compactView ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('compactView', !userPreferences.compactView)}
|
||||||
|
>
|
||||||
|
{userPreferences.compactView ? 'On' : 'Off'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CLI Tools Configuration */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<Cpu className="w-5 h-5" />
|
||||||
|
CLI Tools
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Configure available CLI tools and their models. Default tool: <strong className="text-foreground">{defaultCliTool}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(cliTools).map(([toolId, config]) => (
|
||||||
|
<CliToolCard
|
||||||
|
key={toolId}
|
||||||
|
toolId={toolId}
|
||||||
|
config={config}
|
||||||
|
isDefault={toolId === defaultCliTool}
|
||||||
|
isExpanded={expandedTools.has(toolId)}
|
||||||
|
onToggleExpand={() => toggleToolExpand(toolId)}
|
||||||
|
onToggleEnabled={() => handleToggleToolEnabled(toolId)}
|
||||||
|
onSetDefault={() => handleSetDefaultTool(toolId)}
|
||||||
|
onUpdateModel={(field, value) => handleUpdateModel(toolId, field, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data Refresh Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Data Refresh
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Auto Refresh</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically refresh data periodically
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={userPreferences.autoRefresh ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('autoRefresh', !userPreferences.autoRefresh)}
|
||||||
|
>
|
||||||
|
{userPreferences.autoRefresh ? 'Enabled' : 'Disabled'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userPreferences.autoRefresh && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Refresh Interval</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How often to refresh data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[15000, 30000, 60000, 120000].map((interval) => (
|
||||||
|
<Button
|
||||||
|
key={interval}
|
||||||
|
variant={userPreferences.refreshInterval === interval ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('refreshInterval', interval)}
|
||||||
|
>
|
||||||
|
{interval / 1000}s
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Enable Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Show notifications for workflow events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={userPreferences.notificationsEnabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('notificationsEnabled', !userPreferences.notificationsEnabled)}
|
||||||
|
>
|
||||||
|
{userPreferences.notificationsEnabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Sound Effects</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Play sound for notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={userPreferences.soundEnabled ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('soundEnabled', !userPreferences.soundEnabled)}
|
||||||
|
>
|
||||||
|
{userPreferences.soundEnabled ? 'On' : 'Off'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
Display Settings
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Show Completed Tasks</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Display completed tasks in task lists
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={userPreferences.showCompletedTasks ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreferenceChange('showCompletedTasks', !userPreferences.showCompletedTasks)}
|
||||||
|
>
|
||||||
|
{userPreferences.showCompletedTasks ? 'Show' : 'Hide'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reset Settings */}
|
||||||
|
<Card className="p-6 border-destructive/50">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||||
|
<RotateCcw className="w-5 h-5" />
|
||||||
|
Reset Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Reset all user preferences to their default values. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Reset all settings to defaults?')) {
|
||||||
|
resetUserPreferences();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
279
ccw/frontend/src/pages/SkillsManagerPage.tsx
Normal file
279
ccw/frontend/src/pages/SkillsManagerPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// ========================================
|
||||||
|
// Skills Manager Page
|
||||||
|
// ========================================
|
||||||
|
// Browse and manage skills library with search/filter
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Filter,
|
||||||
|
RefreshCw,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Tag,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||||
|
import { SkillCard } from '@/components/shared/SkillCard';
|
||||||
|
import { useSkills, useSkillMutations } from '@/hooks';
|
||||||
|
import type { Skill } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ========== Skill Grid Component ==========
|
||||||
|
|
||||||
|
interface SkillGridProps {
|
||||||
|
skills: Skill[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onToggle: (skill: Skill, enabled: boolean) => void;
|
||||||
|
onClick: (skill: Skill) => void;
|
||||||
|
isToggling: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillGrid({ skills, isLoading, onToggle, onClick, isToggling, compact }: SkillGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'grid gap-4',
|
||||||
|
compact ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'
|
||||||
|
)}>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<div key={i} className="h-48 bg-muted animate-pulse rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-foreground">No skills found</h3>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Try adjusting your search or filters.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'grid gap-4',
|
||||||
|
compact ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'
|
||||||
|
)}>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<SkillCard
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClick={onClick}
|
||||||
|
isToggling={isToggling}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Page Component ==========
|
||||||
|
|
||||||
|
export function SkillsManagerPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||||
|
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'compact'>('grid');
|
||||||
|
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
skills,
|
||||||
|
enabledSkills,
|
||||||
|
categories,
|
||||||
|
skillsByCategory,
|
||||||
|
totalCount,
|
||||||
|
enabledCount,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useSkills({
|
||||||
|
filter: {
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||||
|
source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined,
|
||||||
|
enabledOnly: enabledFilter === 'enabled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toggleSkill, isToggling } = useSkillMutations();
|
||||||
|
|
||||||
|
// Filter skills based on enabled filter
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
if (enabledFilter === 'disabled') {
|
||||||
|
return skills.filter((s) => !s.enabled);
|
||||||
|
}
|
||||||
|
return skills;
|
||||||
|
}, [skills, enabledFilter]);
|
||||||
|
|
||||||
|
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
||||||
|
await toggleSkill(skill.name, enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Sparkles className="w-6 h-6 text-primary" />
|
||||||
|
Skills Manager
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Browse, install, and manage Claude Code skills
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Install Skill
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-primary" />
|
||||||
|
<span className="text-2xl font-bold">{totalCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Total Skills</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Power className="w-5 h-5 text-success" />
|
||||||
|
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Enabled</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PowerOff className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<span className="text-2xl font-bold">{totalCount - enabledCount}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Disabled</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-5 h-5 text-info" />
|
||||||
|
<span className="text-2xl font-bold">{categories.length}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Categories</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Search */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search skills by name, description, or trigger..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Sources</SelectItem>
|
||||||
|
<SelectItem value="builtin">Built-in</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
<SelectItem value="community">Community</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={enabledFilter} onValueChange={(v) => setEnabledFilter(v as 'all' | 'enabled' | 'disabled')}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="enabled">Enabled Only</SelectItem>
|
||||||
|
<SelectItem value="disabled">Disabled Only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={enabledFilter === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEnabledFilter('all')}
|
||||||
|
>
|
||||||
|
All ({totalCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={enabledFilter === 'enabled' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEnabledFilter('enabled')}
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4 mr-1" />
|
||||||
|
Enabled ({enabledCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={enabledFilter === 'disabled' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEnabledFilter('disabled')}
|
||||||
|
>
|
||||||
|
<PowerOff className="w-4 h-4 mr-1" />
|
||||||
|
Disabled ({totalCount - enabledCount})
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||||
|
>
|
||||||
|
{viewMode === 'grid' ? 'Compact View' : 'Grid View'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Grid */}
|
||||||
|
<SkillGrid
|
||||||
|
skills={filteredSkills}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onClick={setSelectedSkill}
|
||||||
|
isToggling={isToggling}
|
||||||
|
compact={viewMode === 'compact'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SkillsManagerPage;
|
||||||
15
ccw/frontend/src/pages/index.ts
Normal file
15
ccw/frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// ========================================
|
||||||
|
// Pages Barrel Export
|
||||||
|
// ========================================
|
||||||
|
// Re-export all page components for convenient imports
|
||||||
|
|
||||||
|
export { HomePage } from './HomePage';
|
||||||
|
export { SessionsPage } from './SessionsPage';
|
||||||
|
export { OrchestratorPage } from './orchestrator';
|
||||||
|
export { LoopMonitorPage } from './LoopMonitorPage';
|
||||||
|
export { IssueManagerPage } from './IssueManagerPage';
|
||||||
|
export { SkillsManagerPage } from './SkillsManagerPage';
|
||||||
|
export { CommandsManagerPage } from './CommandsManagerPage';
|
||||||
|
export { MemoryPage } from './MemoryPage';
|
||||||
|
export { SettingsPage } from './SettingsPage';
|
||||||
|
export { HelpPage } from './HelpPage';
|
||||||
462
ccw/frontend/src/pages/orchestrator/ExecutionMonitor.tsx
Normal file
462
ccw/frontend/src/pages/orchestrator/ExecutionMonitor.tsx
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
// ========================================
|
||||||
|
// Execution Monitor
|
||||||
|
// ========================================
|
||||||
|
// Real-time execution monitoring panel with logs and controls
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Square,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
ArrowDownToLine,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore';
|
||||||
|
import {
|
||||||
|
useExecuteFlow,
|
||||||
|
usePauseExecution,
|
||||||
|
useResumeExecution,
|
||||||
|
useStopExecution,
|
||||||
|
} from '@/hooks/useFlows';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import type { ExecutionStatus, LogLevel } from '@/types/execution';
|
||||||
|
|
||||||
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
|
function formatElapsedTime(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeVariant(status: ExecutionStatus): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'default';
|
||||||
|
case 'paused':
|
||||||
|
return 'warning';
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(status: ExecutionStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-3 w-3" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="h-3 w-3" />;
|
||||||
|
case 'failed':
|
||||||
|
return <AlertCircle className="h-3 w-3" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-3 w-3" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogLevelColor(level: LogLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-500';
|
||||||
|
case 'warn':
|
||||||
|
return 'text-yellow-500';
|
||||||
|
case 'info':
|
||||||
|
return 'text-blue-500';
|
||||||
|
case 'debug':
|
||||||
|
return 'text-gray-400';
|
||||||
|
default:
|
||||||
|
return 'text-foreground';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Component ==========
|
||||||
|
|
||||||
|
interface ExecutionMonitorProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||||
|
|
||||||
|
// Execution store state
|
||||||
|
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||||
|
const logs = useExecutionStore((state) => state.logs);
|
||||||
|
const nodeStates = useExecutionStore((state) => state.nodeStates);
|
||||||
|
const isMonitorExpanded = useExecutionStore((state) => state.isMonitorExpanded);
|
||||||
|
const autoScrollLogs = useExecutionStore((state) => state.autoScrollLogs);
|
||||||
|
const setMonitorExpanded = useExecutionStore((state) => state.setMonitorExpanded);
|
||||||
|
const setAutoScrollLogs = useExecutionStore((state) => state.setAutoScrollLogs);
|
||||||
|
const startExecution = useExecutionStore((state) => state.startExecution);
|
||||||
|
|
||||||
|
// Local state for elapsed time (calculated from startedAt)
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
|
||||||
|
// Flow store state
|
||||||
|
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||||
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const executeFlow = useExecuteFlow();
|
||||||
|
const pauseExecution = usePauseExecution();
|
||||||
|
const resumeExecution = useResumeExecution();
|
||||||
|
const stopExecution = useStopExecution();
|
||||||
|
|
||||||
|
// Update elapsed time every second while running (calculated from startedAt)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentExecution?.status === 'running' && currentExecution.startedAt) {
|
||||||
|
const calculateElapsed = () => {
|
||||||
|
const startTime = new Date(currentExecution.startedAt).getTime();
|
||||||
|
setElapsedMs(Date.now() - startTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate immediately
|
||||||
|
calculateElapsed();
|
||||||
|
|
||||||
|
// Update every second
|
||||||
|
const interval = setInterval(calculateElapsed, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
} else if (currentExecution?.completedAt) {
|
||||||
|
// Use final elapsed time from store when completed
|
||||||
|
setElapsedMs(currentExecution.elapsedMs);
|
||||||
|
} else if (!currentExecution) {
|
||||||
|
setElapsedMs(0);
|
||||||
|
}
|
||||||
|
}, [currentExecution?.status, currentExecution?.startedAt, currentExecution?.completedAt, currentExecution?.elapsedMs]);
|
||||||
|
|
||||||
|
// Auto-scroll logs
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScrollLogs && !isUserScrolling && logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs, autoScrollLogs, isUserScrolling]);
|
||||||
|
|
||||||
|
// Handle scroll to detect user scrolling
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!logsContainerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||||
|
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||||
|
|
||||||
|
setIsUserScrolling(!isAtBottom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom handler
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
setIsUserScrolling(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle execute
|
||||||
|
const handleExecute = useCallback(async () => {
|
||||||
|
if (!currentFlow) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeFlow.mutateAsync(currentFlow.id);
|
||||||
|
startExecution(result.execId, currentFlow.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to execute flow:', error);
|
||||||
|
}
|
||||||
|
}, [currentFlow, executeFlow, startExecution]);
|
||||||
|
|
||||||
|
// Handle pause
|
||||||
|
const handlePause = useCallback(async () => {
|
||||||
|
if (!currentExecution) return;
|
||||||
|
try {
|
||||||
|
await pauseExecution.mutateAsync(currentExecution.execId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to pause execution:', error);
|
||||||
|
}
|
||||||
|
}, [currentExecution, pauseExecution]);
|
||||||
|
|
||||||
|
// Handle resume
|
||||||
|
const handleResume = useCallback(async () => {
|
||||||
|
if (!currentExecution) return;
|
||||||
|
try {
|
||||||
|
await resumeExecution.mutateAsync(currentExecution.execId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resume execution:', error);
|
||||||
|
}
|
||||||
|
}, [currentExecution, resumeExecution]);
|
||||||
|
|
||||||
|
// Handle stop
|
||||||
|
const handleStop = useCallback(async () => {
|
||||||
|
if (!currentExecution) return;
|
||||||
|
try {
|
||||||
|
await stopExecution.mutateAsync(currentExecution.execId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop execution:', error);
|
||||||
|
}
|
||||||
|
}, [currentExecution, stopExecution]);
|
||||||
|
|
||||||
|
// Calculate node progress
|
||||||
|
const completedNodes = Object.values(nodeStates).filter(
|
||||||
|
(state) => state.status === 'completed'
|
||||||
|
).length;
|
||||||
|
const totalNodes = nodes.length;
|
||||||
|
const progressPercent = totalNodes > 0 ? (completedNodes / totalNodes) * 100 : 0;
|
||||||
|
|
||||||
|
const isExecuting = currentExecution?.status === 'running';
|
||||||
|
const isPaused = currentExecution?.status === 'paused';
|
||||||
|
const canExecute = currentFlow && !isExecuting && !isPaused;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-t border-border bg-card transition-all duration-300',
|
||||||
|
isMonitorExpanded ? 'h-64' : 'h-12',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 h-12 border-b border-border cursor-pointer"
|
||||||
|
onClick={() => setMonitorExpanded(!isMonitorExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Execution Monitor</span>
|
||||||
|
|
||||||
|
{currentExecution && (
|
||||||
|
<>
|
||||||
|
<Badge variant={getStatusBadgeVariant(currentExecution.status)}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{getStatusIcon(currentExecution.status)}
|
||||||
|
{currentExecution.status}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatElapsedTime(elapsedMs)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{totalNodes > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{completedNodes}/{totalNodes} nodes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Control buttons */}
|
||||||
|
{canExecute && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExecute();
|
||||||
|
}}
|
||||||
|
disabled={executeFlow.isPending}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
Execute
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExecuting && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePause();
|
||||||
|
}}
|
||||||
|
disabled={pauseExecution.isPending}
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStop();
|
||||||
|
}}
|
||||||
|
disabled={stopExecution.isPending}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPaused && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleResume();
|
||||||
|
}}
|
||||||
|
disabled={resumeExecution.isPending}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStop();
|
||||||
|
}}
|
||||||
|
disabled={stopExecution.isPending}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expand/collapse button */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMonitorExpanded(!isMonitorExpanded);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMonitorExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isMonitorExpanded && (
|
||||||
|
<div className="flex h-[calc(100%-3rem)]">
|
||||||
|
{/* Progress bar */}
|
||||||
|
{currentExecution && (
|
||||||
|
<div className="absolute top-12 left-0 right-0 h-1 bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs panel */}
|
||||||
|
<div className="flex-1 flex flex-col relative">
|
||||||
|
{/* Logs container */}
|
||||||
|
<div
|
||||||
|
ref={logsContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
{currentExecution
|
||||||
|
? 'Waiting for logs...'
|
||||||
|
: 'Select a flow and click Execute to start'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
{new Date(log.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'uppercase w-12 shrink-0',
|
||||||
|
getLogLevelColor(log.level)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
[{log.level}]
|
||||||
|
</span>
|
||||||
|
{log.nodeId && (
|
||||||
|
<span className="text-purple-500 shrink-0">
|
||||||
|
[{log.nodeId}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-foreground break-all">
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to bottom button */}
|
||||||
|
{isUserScrolling && logs.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="absolute bottom-3 right-3"
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||||
|
Scroll to bottom
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node states panel (collapsed by default) */}
|
||||||
|
{currentExecution && Object.keys(nodeStates).length > 0 && (
|
||||||
|
<div className="w-48 border-l border-border p-2 overflow-y-auto">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Node Status
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(nodeStates).map(([nodeId, state]) => (
|
||||||
|
<div
|
||||||
|
key={nodeId}
|
||||||
|
className="flex items-center gap-2 text-xs p-1 rounded hover:bg-muted"
|
||||||
|
>
|
||||||
|
{state.status === 'running' && (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
||||||
|
)}
|
||||||
|
{state.status === 'completed' && (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||||
|
)}
|
||||||
|
{state.status === 'failed' && (
|
||||||
|
<AlertCircle className="h-3 w-3 text-red-500" />
|
||||||
|
)}
|
||||||
|
{state.status === 'pending' && (
|
||||||
|
<Clock className="h-3 w-3 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="truncate" title={nodeId}>
|
||||||
|
{nodeId.slice(0, 20)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExecutionMonitor;
|
||||||
199
ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx
Normal file
199
ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// ========================================
|
||||||
|
// Flow Canvas Component
|
||||||
|
// ========================================
|
||||||
|
// React Flow canvas with minimap, controls, and background
|
||||||
|
|
||||||
|
import { useCallback, useRef, DragEvent } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
MiniMap,
|
||||||
|
Controls,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Connection,
|
||||||
|
NodeChange,
|
||||||
|
EdgeChange,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import type { FlowNodeType, FlowNode, FlowEdge } from '@/types/flow';
|
||||||
|
import { NODE_TYPE_CONFIGS } from '@/types/flow';
|
||||||
|
|
||||||
|
// Custom node types (enhanced with execution status in IMPL-A8)
|
||||||
|
import { nodeTypes } from './nodes';
|
||||||
|
|
||||||
|
interface FlowCanvasProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||||
|
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||||
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
|
|
||||||
|
// Get state and actions from store
|
||||||
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
|
const edges = useFlowStore((state) => state.edges);
|
||||||
|
const setNodes = useFlowStore((state) => state.setNodes);
|
||||||
|
const setEdges = useFlowStore((state) => state.setEdges);
|
||||||
|
const addNode = useFlowStore((state) => state.addNode);
|
||||||
|
const setSelectedNodeId = useFlowStore((state) => state.setSelectedNodeId);
|
||||||
|
const setSelectedEdgeId = useFlowStore((state) => state.setSelectedEdgeId);
|
||||||
|
const markModified = useFlowStore((state) => state.markModified);
|
||||||
|
|
||||||
|
// Handle node changes (position, selection, etc.)
|
||||||
|
const onNodesChange = useCallback(
|
||||||
|
(changes: NodeChange[]) => {
|
||||||
|
const updatedNodes = applyNodeChanges(changes, nodes as Node[]);
|
||||||
|
setNodes(updatedNodes as FlowNode[]);
|
||||||
|
},
|
||||||
|
[nodes, setNodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle edge changes
|
||||||
|
const onEdgesChange = useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
const updatedEdges = applyEdgeChanges(changes, edges as Edge[]);
|
||||||
|
setEdges(updatedEdges as FlowEdge[]);
|
||||||
|
},
|
||||||
|
[edges, setEdges]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle new edge connections
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(connection: Connection) => {
|
||||||
|
if (connection.source && connection.target) {
|
||||||
|
const newEdge: FlowEdge = {
|
||||||
|
id: `edge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
source: connection.source,
|
||||||
|
target: connection.target,
|
||||||
|
sourceHandle: connection.sourceHandle ?? undefined,
|
||||||
|
targetHandle: connection.targetHandle ?? undefined,
|
||||||
|
};
|
||||||
|
setEdges([...edges, newEdge]);
|
||||||
|
markModified();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[edges, setEdges, markModified]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle node selection
|
||||||
|
const onNodeClick = useCallback(
|
||||||
|
(_event: React.MouseEvent, node: Node) => {
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
},
|
||||||
|
[setSelectedNodeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle edge selection
|
||||||
|
const onEdgeClick = useCallback(
|
||||||
|
(_event: React.MouseEvent, edge: Edge) => {
|
||||||
|
setSelectedEdgeId(edge.id);
|
||||||
|
},
|
||||||
|
[setSelectedEdgeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle canvas click (deselect)
|
||||||
|
const onPaneClick = useCallback(() => {
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
}, [setSelectedNodeId, setSelectedEdgeId]);
|
||||||
|
|
||||||
|
// Handle drag over for node palette drop
|
||||||
|
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle drop from node palette
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const nodeType = event.dataTransfer.getData('application/reactflow-node-type') as FlowNodeType;
|
||||||
|
if (!nodeType || !NODE_TYPE_CONFIGS[nodeType]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get drop position in flow coordinates
|
||||||
|
const position = screenToFlowPosition({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add node at drop position
|
||||||
|
addNode(nodeType, position);
|
||||||
|
},
|
||||||
|
[screenToFlowPosition, addNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={reactFlowWrapper} className={`w-full h-full ${className || ''}`}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes as Node[]}
|
||||||
|
edges={edges as Edge[]}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
onEdgeClick={onEdgeClick}
|
||||||
|
onPaneClick={onPaneClick}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={[15, 15]}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
className="bg-background"
|
||||||
|
>
|
||||||
|
<Controls
|
||||||
|
className="bg-card border border-border rounded-md shadow-sm"
|
||||||
|
showZoom={true}
|
||||||
|
showFitView={true}
|
||||||
|
showInteractive={true}
|
||||||
|
/>
|
||||||
|
<MiniMap
|
||||||
|
className="bg-card border border-border rounded-md shadow-sm"
|
||||||
|
nodeColor={(node) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'slash-command':
|
||||||
|
return '#3b82f6'; // blue-500
|
||||||
|
case 'file-operation':
|
||||||
|
return '#22c55e'; // green-500
|
||||||
|
case 'conditional':
|
||||||
|
return '#f59e0b'; // amber-500
|
||||||
|
case 'parallel':
|
||||||
|
return '#a855f7'; // purple-500
|
||||||
|
default:
|
||||||
|
return '#6b7280'; // gray-500
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
className="bg-muted/20"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowCanvas(props: FlowCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<FlowCanvasInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlowCanvas;
|
||||||
308
ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx
Normal file
308
ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
// ========================================
|
||||||
|
// Flow Toolbar Component
|
||||||
|
// ========================================
|
||||||
|
// Toolbar for flow operations: New, Save, Load, Export
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
FolderOpen,
|
||||||
|
Download,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Workflow,
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
Library,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { useFlowStore, toast } from '@/stores';
|
||||||
|
import type { Flow } from '@/types/flow';
|
||||||
|
|
||||||
|
interface FlowToolbarProps {
|
||||||
|
className?: string;
|
||||||
|
onOpenTemplateLibrary?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
||||||
|
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
||||||
|
const [flowName, setFlowName] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Flow store
|
||||||
|
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||||
|
const isModified = useFlowStore((state) => state.isModified);
|
||||||
|
const flows = useFlowStore((state) => state.flows);
|
||||||
|
const isLoadingFlows = useFlowStore((state) => state.isLoadingFlows);
|
||||||
|
const createFlow = useFlowStore((state) => state.createFlow);
|
||||||
|
const saveFlow = useFlowStore((state) => state.saveFlow);
|
||||||
|
const loadFlow = useFlowStore((state) => state.loadFlow);
|
||||||
|
const deleteFlow = useFlowStore((state) => state.deleteFlow);
|
||||||
|
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
||||||
|
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||||
|
|
||||||
|
// Load flows on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFlows();
|
||||||
|
}, [fetchFlows]);
|
||||||
|
|
||||||
|
// Sync flow name with current flow
|
||||||
|
useEffect(() => {
|
||||||
|
setFlowName(currentFlow?.name || '');
|
||||||
|
}, [currentFlow?.name]);
|
||||||
|
|
||||||
|
// Handle new flow
|
||||||
|
const handleNew = useCallback(() => {
|
||||||
|
const newFlow = createFlow('Untitled Flow', 'A new workflow');
|
||||||
|
setFlowName(newFlow.name);
|
||||||
|
toast.success('Flow Created', 'New flow created successfully');
|
||||||
|
}, [createFlow]);
|
||||||
|
|
||||||
|
// Handle save
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!currentFlow) {
|
||||||
|
toast.error('No Flow', 'Create a flow first before saving');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// Update flow name if changed
|
||||||
|
if (flowName && flowName !== currentFlow.name) {
|
||||||
|
useFlowStore.setState((state) => ({
|
||||||
|
currentFlow: state.currentFlow
|
||||||
|
? { ...state.currentFlow, name: flowName }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveFlow();
|
||||||
|
if (saved) {
|
||||||
|
toast.success('Flow Saved', `"${flowName || currentFlow.name}" saved successfully`);
|
||||||
|
} else {
|
||||||
|
toast.error('Save Failed', 'Could not save the flow');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Save Error', 'An error occurred while saving');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [currentFlow, flowName, saveFlow]);
|
||||||
|
|
||||||
|
// Handle load
|
||||||
|
const handleLoad = useCallback(
|
||||||
|
async (flow: Flow) => {
|
||||||
|
const loaded = await loadFlow(flow.id);
|
||||||
|
if (loaded) {
|
||||||
|
setIsFlowListOpen(false);
|
||||||
|
toast.success('Flow Loaded', `"${flow.name}" loaded successfully`);
|
||||||
|
} else {
|
||||||
|
toast.error('Load Failed', 'Could not load the flow');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadFlow]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (flow: Flow, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm(`Delete "${flow.name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
const deleted = await deleteFlow(flow.id);
|
||||||
|
if (deleted) {
|
||||||
|
toast.success('Flow Deleted', `"${flow.name}" deleted successfully`);
|
||||||
|
} else {
|
||||||
|
toast.error('Delete Failed', 'Could not delete the flow');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteFlow]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle duplicate
|
||||||
|
const handleDuplicate = useCallback(
|
||||||
|
async (flow: Flow, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const duplicated = await duplicateFlow(flow.id);
|
||||||
|
if (duplicated) {
|
||||||
|
toast.success('Flow Duplicated', `"${duplicated.name}" created`);
|
||||||
|
} else {
|
||||||
|
toast.error('Duplicate Failed', 'Could not duplicate the flow');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[duplicateFlow]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle export
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
if (!currentFlow) {
|
||||||
|
toast.error('No Flow', 'Create or load a flow first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const edges = useFlowStore.getState().edges;
|
||||||
|
const exportData = {
|
||||||
|
...currentFlow,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${currentFlow.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success('Flow Exported', 'Flow exported as JSON file');
|
||||||
|
}, [currentFlow]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
||||||
|
{/* Flow Icon and Name */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<Workflow className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
|
<Input
|
||||||
|
value={flowName}
|
||||||
|
onChange={(e) => setFlowName(e.target.value)}
|
||||||
|
placeholder="Flow name"
|
||||||
|
className="max-w-[200px] h-8 text-sm"
|
||||||
|
/>
|
||||||
|
{isModified && (
|
||||||
|
<span className="text-xs text-amber-500 flex-shrink-0">Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleNew}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !currentFlow}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Flow List Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsFlowListOpen(!isFlowListOpen)}
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4 mr-1" />
|
||||||
|
Load
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isFlowListOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsFlowListOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
<div className="absolute top-full right-0 mt-1 w-72 bg-card border border-border rounded-lg shadow-lg z-50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-border bg-muted/50">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Saved Flows ({flows.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{isLoadingFlows ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : flows.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
|
No saved flows
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flows.map((flow) => (
|
||||||
|
<div
|
||||||
|
key={flow.id}
|
||||||
|
onClick={() => handleLoad(flow)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors',
|
||||||
|
currentFlow?.id === flow.id && 'bg-primary/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
|
{flow.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(flow.updated_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => handleDuplicate(flow, e)}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => handleDelete(flow, e)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport} disabled={!currentFlow}>
|
||||||
|
<Download className="w-4 h-4 mr-1" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={onOpenTemplateLibrary}>
|
||||||
|
<Library className="w-4 h-4 mr-1" />
|
||||||
|
Templates
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlowToolbar;
|
||||||
154
ccw/frontend/src/pages/orchestrator/NodePalette.tsx
Normal file
154
ccw/frontend/src/pages/orchestrator/NodePalette.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// ========================================
|
||||||
|
// Node Palette Component
|
||||||
|
// ========================================
|
||||||
|
// Draggable node palette for creating new nodes
|
||||||
|
|
||||||
|
import { DragEvent, useState } from 'react';
|
||||||
|
import { Terminal, FileText, GitBranch, GitMerge, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import type { FlowNodeType } from '@/types/flow';
|
||||||
|
import { NODE_TYPE_CONFIGS } from '@/types/flow';
|
||||||
|
|
||||||
|
// Icon mapping for node types
|
||||||
|
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||||
|
'slash-command': Terminal,
|
||||||
|
'file-operation': FileText,
|
||||||
|
conditional: GitBranch,
|
||||||
|
parallel: GitMerge,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color mapping for node types
|
||||||
|
const nodeColors: Record<FlowNodeType, string> = {
|
||||||
|
'slash-command': 'bg-blue-500 hover:bg-blue-600',
|
||||||
|
'file-operation': 'bg-green-500 hover:bg-green-600',
|
||||||
|
conditional: 'bg-amber-500 hover:bg-amber-600',
|
||||||
|
parallel: 'bg-purple-500 hover:bg-purple-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeBorderColors: Record<FlowNodeType, string> = {
|
||||||
|
'slash-command': 'border-blue-500',
|
||||||
|
'file-operation': 'border-green-500',
|
||||||
|
conditional: 'border-amber-500',
|
||||||
|
parallel: 'border-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NodePaletteProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeTypeCardProps {
|
||||||
|
type: FlowNodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeTypeCard({ type }: NodeTypeCardProps) {
|
||||||
|
const config = NODE_TYPE_CONFIGS[type];
|
||||||
|
const Icon = nodeIcons[type];
|
||||||
|
|
||||||
|
// Handle drag start
|
||||||
|
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.dataTransfer.setData('application/reactflow-node-type', type);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 p-3 rounded-lg border-2 bg-card cursor-grab transition-all',
|
||||||
|
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
|
||||||
|
nodeBorderColors[type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('p-2 rounded-md text-white', nodeColors[type])}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">{config.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{config.description}</div>
|
||||||
|
</div>
|
||||||
|
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodePalette({ className }: NodePaletteProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||||
|
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||||
|
|
||||||
|
if (!isPaletteOpen) {
|
||||||
|
return (
|
||||||
|
<div className={cn('w-10 bg-card border-r border-border flex flex-col items-center py-4', className)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsPaletteOpen(true)}
|
||||||
|
title="Open node palette"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h3 className="font-semibold text-foreground">Node Palette</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setIsPaletteOpen(false)}
|
||||||
|
title="Collapse palette"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="px-4 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
|
||||||
|
Drag nodes onto the canvas to add them to your workflow
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Type Categories */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Execution Nodes */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Node Types
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(Object.keys(NODE_TYPE_CONFIGS) as FlowNodeType[]).map((type) => (
|
||||||
|
<NodeTypeCard key={type} type={type} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-3 border-t border-border bg-muted/30">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Tip:</span> Connect nodes by dragging from output to input handles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NodePalette;
|
||||||
70
ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx
Normal file
70
ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// ========================================
|
||||||
|
// Orchestrator Page
|
||||||
|
// ========================================
|
||||||
|
// Visual workflow editor with React Flow, drag-drop node palette, and property panel
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import { FlowCanvas } from './FlowCanvas';
|
||||||
|
import { NodePalette } from './NodePalette';
|
||||||
|
import { PropertyPanel } from './PropertyPanel';
|
||||||
|
import { FlowToolbar } from './FlowToolbar';
|
||||||
|
import { ExecutionMonitor } from './ExecutionMonitor';
|
||||||
|
import { TemplateLibrary } from './TemplateLibrary';
|
||||||
|
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||||
|
|
||||||
|
export function OrchestratorPage() {
|
||||||
|
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||||
|
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||||
|
|
||||||
|
// Initialize WebSocket connection for real-time updates
|
||||||
|
const { isConnected, reconnect } = useWebSocket({
|
||||||
|
enabled: true,
|
||||||
|
onMessage: (message) => {
|
||||||
|
// Additional message handling can be added here if needed
|
||||||
|
console.log('[Orchestrator] WebSocket message:', message.type);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load flows on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFlows();
|
||||||
|
}, [fetchFlows]);
|
||||||
|
|
||||||
|
// Handle open template library
|
||||||
|
const handleOpenTemplateLibrary = useCallback(() => {
|
||||||
|
setIsTemplateLibraryOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<FlowToolbar onOpenTemplateLibrary={handleOpenTemplateLibrary} />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Node Palette (Left) */}
|
||||||
|
<NodePalette />
|
||||||
|
|
||||||
|
{/* Flow Canvas (Center) */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<FlowCanvas className="absolute inset-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property Panel (Right) */}
|
||||||
|
<PropertyPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution Monitor (Bottom) */}
|
||||||
|
<ExecutionMonitor />
|
||||||
|
|
||||||
|
{/* Template Library Dialog */}
|
||||||
|
<TemplateLibrary
|
||||||
|
open={isTemplateLibraryOpen}
|
||||||
|
onOpenChange={setIsTemplateLibraryOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrchestratorPage;
|
||||||
472
ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx
Normal file
472
ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
// ========================================
|
||||||
|
// Property Panel Component
|
||||||
|
// ========================================
|
||||||
|
// Dynamic property editor for selected nodes
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import type {
|
||||||
|
FlowNodeType,
|
||||||
|
SlashCommandNodeData,
|
||||||
|
FileOperationNodeData,
|
||||||
|
ConditionalNodeData,
|
||||||
|
ParallelNodeData,
|
||||||
|
NodeData,
|
||||||
|
} from '@/types/flow';
|
||||||
|
|
||||||
|
interface PropertyPanelProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon mapping for node types
|
||||||
|
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||||
|
'slash-command': Terminal,
|
||||||
|
'file-operation': FileText,
|
||||||
|
conditional: GitBranch,
|
||||||
|
parallel: GitMerge,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slash Command Property Editor
|
||||||
|
function SlashCommandProperties({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: SlashCommandNodeData;
|
||||||
|
onChange: (updates: Partial<SlashCommandNodeData>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.label || ''}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
placeholder="Node label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Command</label>
|
||||||
|
<Input
|
||||||
|
value={data.command || ''}
|
||||||
|
onChange={(e) => onChange({ command: e.target.value })}
|
||||||
|
placeholder="/command-name"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Arguments</label>
|
||||||
|
<Input
|
||||||
|
value={data.args || ''}
|
||||||
|
onChange={(e) => onChange({ args: e.target.value })}
|
||||||
|
placeholder="Command arguments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Execution Mode</label>
|
||||||
|
<select
|
||||||
|
value={data.execution?.mode || 'analysis'}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
execution: { ...data.execution, mode: e.target.value as 'analysis' | 'write' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="analysis">Analysis (Read-only)</option>
|
||||||
|
<option value="write">Write (Modify files)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">On Error</label>
|
||||||
|
<select
|
||||||
|
value={data.onError || 'stop'}
|
||||||
|
onChange={(e) => onChange({ onError: e.target.value as 'continue' | 'stop' | 'retry' })}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="stop">Stop execution</option>
|
||||||
|
<option value="continue">Continue</option>
|
||||||
|
<option value="retry">Retry</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={data.execution?.timeout || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
execution: {
|
||||||
|
...data.execution,
|
||||||
|
mode: data.execution?.mode || 'analysis',
|
||||||
|
timeout: e.target.value ? parseInt(e.target.value) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="60000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Operation Property Editor
|
||||||
|
function FileOperationProperties({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: FileOperationNodeData;
|
||||||
|
onChange: (updates: Partial<FileOperationNodeData>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.label || ''}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
placeholder="Node label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Operation</label>
|
||||||
|
<select
|
||||||
|
value={data.operation || 'read'}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
operation: e.target.value as FileOperationNodeData['operation'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="write">Write</option>
|
||||||
|
<option value="append">Append</option>
|
||||||
|
<option value="delete">Delete</option>
|
||||||
|
<option value="copy">Copy</option>
|
||||||
|
<option value="move">Move</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Path</label>
|
||||||
|
<Input
|
||||||
|
value={data.path || ''}
|
||||||
|
onChange={(e) => onChange({ path: e.target.value })}
|
||||||
|
placeholder="/path/to/file"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(data.operation === 'write' || data.operation === 'append') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Content</label>
|
||||||
|
<textarea
|
||||||
|
value={data.content || ''}
|
||||||
|
onChange={(e) => onChange({ content: e.target.value })}
|
||||||
|
placeholder="File content..."
|
||||||
|
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(data.operation === 'copy' || data.operation === 'move') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Destination Path</label>
|
||||||
|
<Input
|
||||||
|
value={data.destinationPath || ''}
|
||||||
|
onChange={(e) => onChange({ destinationPath: e.target.value })}
|
||||||
|
placeholder="/path/to/destination"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Output Variable</label>
|
||||||
|
<Input
|
||||||
|
value={data.outputVariable || ''}
|
||||||
|
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||||
|
placeholder="variableName"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="addToContext"
|
||||||
|
checked={data.addToContext || false}
|
||||||
|
onChange={(e) => onChange({ addToContext: e.target.checked })}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<label htmlFor="addToContext" className="text-sm text-foreground">
|
||||||
|
Add to context
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional Property Editor
|
||||||
|
function ConditionalProperties({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: ConditionalNodeData;
|
||||||
|
onChange: (updates: Partial<ConditionalNodeData>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.label || ''}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
placeholder="Node label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Condition</label>
|
||||||
|
<textarea
|
||||||
|
value={data.condition || ''}
|
||||||
|
onChange={(e) => onChange({ condition: e.target.value })}
|
||||||
|
placeholder="e.g., result.success === true"
|
||||||
|
className="w-full h-20 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">True Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.trueLabel || ''}
|
||||||
|
onChange={(e) => onChange({ trueLabel: e.target.value })}
|
||||||
|
placeholder="True"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">False Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.falseLabel || ''}
|
||||||
|
onChange={(e) => onChange({ falseLabel: e.target.value })}
|
||||||
|
placeholder="False"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel Property Editor
|
||||||
|
function ParallelProperties({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: ParallelNodeData;
|
||||||
|
onChange: (updates: Partial<ParallelNodeData>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||||
|
<Input
|
||||||
|
value={data.label || ''}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
placeholder="Node label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Join Mode</label>
|
||||||
|
<select
|
||||||
|
value={data.joinMode || 'all'}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ joinMode: e.target.value as ParallelNodeData['joinMode'] })
|
||||||
|
}
|
||||||
|
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Wait for all branches</option>
|
||||||
|
<option value="any">Complete when any branch finishes</option>
|
||||||
|
<option value="none">No synchronization</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={data.timeout || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ timeout: e.target.value ? parseInt(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
placeholder="30000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="failFast"
|
||||||
|
checked={data.failFast || false}
|
||||||
|
onChange={(e) => onChange({ failFast: e.target.checked })}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<label htmlFor="failFast" className="text-sm text-foreground">
|
||||||
|
Fail fast (stop all branches on first error)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||||
|
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
|
||||||
|
const nodes = useFlowStore((state) => state.nodes);
|
||||||
|
const updateNode = useFlowStore((state) => state.updateNode);
|
||||||
|
const removeNode = useFlowStore((state) => state.removeNode);
|
||||||
|
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
|
||||||
|
const setIsPropertyPanelOpen = useFlowStore((state) => state.setIsPropertyPanelOpen);
|
||||||
|
|
||||||
|
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(updates: Partial<NodeData>) => {
|
||||||
|
if (selectedNodeId) {
|
||||||
|
updateNode(selectedNodeId, updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedNodeId, updateNode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (selectedNodeId) {
|
||||||
|
removeNode(selectedNodeId);
|
||||||
|
}
|
||||||
|
}, [selectedNodeId, removeNode]);
|
||||||
|
|
||||||
|
if (!isPropertyPanelOpen) {
|
||||||
|
return (
|
||||||
|
<div className={cn('w-10 bg-card border-l border-border flex flex-col items-center py-4', className)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsPropertyPanelOpen(true)}
|
||||||
|
title="Open properties panel"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedNode) {
|
||||||
|
return (
|
||||||
|
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h3 className="font-semibold text-foreground">Properties</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setIsPropertyPanelOpen(false)}
|
||||||
|
title="Close panel"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Settings className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">Select a node to edit its properties</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeType = selectedNode.type as FlowNodeType;
|
||||||
|
const Icon = nodeIcons[nodeType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="w-4 h-4 text-primary" />}
|
||||||
|
<h3 className="font-semibold text-foreground">Properties</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setIsPropertyPanelOpen(false)}
|
||||||
|
title="Close panel"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Type Badge */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-muted/30">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{nodeType.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Properties Form */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{nodeType === 'slash-command' && (
|
||||||
|
<SlashCommandProperties
|
||||||
|
data={selectedNode.data as SlashCommandNodeData}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{nodeType === 'file-operation' && (
|
||||||
|
<FileOperationProperties
|
||||||
|
data={selectedNode.data as FileOperationNodeData}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{nodeType === 'conditional' && (
|
||||||
|
<ConditionalProperties
|
||||||
|
data={selectedNode.data as ConditionalNodeData}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{nodeType === 'parallel' && (
|
||||||
|
<ParallelProperties
|
||||||
|
data={selectedNode.data as ParallelNodeData}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className="px-4 py-3 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Node
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PropertyPanel;
|
||||||
567
ccw/frontend/src/pages/orchestrator/TemplateLibrary.tsx
Normal file
567
ccw/frontend/src/pages/orchestrator/TemplateLibrary.tsx
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
// ========================================
|
||||||
|
// Template Library
|
||||||
|
// ========================================
|
||||||
|
// Template browser with import/export functionality
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Library,
|
||||||
|
Search,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
Tag,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
GitBranch,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/Dialog';
|
||||||
|
import { useTemplates, useInstallTemplate, useExportTemplate, useDeleteTemplate } from '@/hooks/useTemplates';
|
||||||
|
import { useFlowStore } from '@/stores';
|
||||||
|
import type { FlowTemplate } from '@/types/execution';
|
||||||
|
|
||||||
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Template Card Component ==========
|
||||||
|
|
||||||
|
interface TemplateCardProps {
|
||||||
|
template: FlowTemplate;
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
onInstall: (template: FlowTemplate) => void;
|
||||||
|
onDelete: (template: FlowTemplate) => void;
|
||||||
|
isInstalling: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCard({
|
||||||
|
template,
|
||||||
|
viewMode,
|
||||||
|
onInstall,
|
||||||
|
onDelete,
|
||||||
|
isInstalling,
|
||||||
|
isDeleting,
|
||||||
|
}: TemplateCardProps) {
|
||||||
|
const isGrid = viewMode === 'grid';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'hover:border-primary/50 transition-colors',
|
||||||
|
isGrid ? '' : 'flex items-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGrid ? (
|
||||||
|
<>
|
||||||
|
{/* Grid view */}
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardTitle className="text-base truncate" title={template.name}>
|
||||||
|
{template.name}
|
||||||
|
</CardTitle>
|
||||||
|
{template.category && (
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
{template.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{template.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{template.nodeCount} nodes
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDate(template.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{template.tags && template.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{template.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">
|
||||||
|
<Tag className="h-2 w-2 mr-1" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{template.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{template.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onInstall(template)}
|
||||||
|
disabled={isInstalling}
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onDelete(template)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* List view */}
|
||||||
|
<div className="flex-1 flex items-center gap-4 p-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium truncate">{template.name}</span>
|
||||||
|
{template.category && (
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
{template.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground shrink-0">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{template.nodeCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDate(template.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => onInstall(template)}
|
||||||
|
disabled={isInstalling}
|
||||||
|
>
|
||||||
|
{isInstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onDelete(template)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Export Dialog Component ==========
|
||||||
|
|
||||||
|
interface ExportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onExport: (name: string, description: string, category: string, tags: string[]) => void;
|
||||||
|
isExporting: boolean;
|
||||||
|
flowName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onExport,
|
||||||
|
isExporting,
|
||||||
|
flowName,
|
||||||
|
}: ExportDialogProps) {
|
||||||
|
const [name, setName] = useState(flowName);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
|
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
const tags = tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
onExport(name, description, category, tags);
|
||||||
|
}, [name, description, category, tagsInput, onExport]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export as Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Save this flow as a reusable template in your library.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Name</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Template name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Description</label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this template"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Category</label>
|
||||||
|
<Input
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="e.g., Development, Testing, Deployment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Tags (comma-separated)</label>
|
||||||
|
<Input
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="e.g., react, testing, ci/cd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={!name.trim() || isExporting}>
|
||||||
|
{isExporting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Main Component ==========
|
||||||
|
|
||||||
|
interface TemplateLibraryProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
|
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||||
|
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Flow store
|
||||||
|
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||||
|
const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow);
|
||||||
|
|
||||||
|
// Query hooks
|
||||||
|
const { data, isLoading, error } = useTemplates(selectedCategory ?? undefined);
|
||||||
|
|
||||||
|
// Mutation hooks
|
||||||
|
const installTemplate = useInstallTemplate();
|
||||||
|
const exportTemplate = useExportTemplate();
|
||||||
|
const deleteTemplate = useDeleteTemplate();
|
||||||
|
|
||||||
|
// Filter templates by search query
|
||||||
|
const filteredTemplates = useMemo(() => {
|
||||||
|
if (!data?.templates) return [];
|
||||||
|
if (!searchQuery.trim()) return data.templates;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return data.templates.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(query) ||
|
||||||
|
t.description?.toLowerCase().includes(query) ||
|
||||||
|
t.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}, [data?.templates, searchQuery]);
|
||||||
|
|
||||||
|
// Handle install
|
||||||
|
const handleInstall = useCallback(
|
||||||
|
async (template: FlowTemplate) => {
|
||||||
|
setInstallingId(template.id);
|
||||||
|
try {
|
||||||
|
const result = await installTemplate.mutateAsync({
|
||||||
|
templateId: template.id,
|
||||||
|
});
|
||||||
|
// Set the installed flow as current
|
||||||
|
setCurrentFlow(result.flow);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to install template:', error);
|
||||||
|
} finally {
|
||||||
|
setInstallingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[installTemplate, setCurrentFlow, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle export
|
||||||
|
const handleExport = useCallback(
|
||||||
|
async (name: string, description: string, category: string, tags: string[]) => {
|
||||||
|
if (!currentFlow) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportTemplate.mutateAsync({
|
||||||
|
flowId: currentFlow.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
setExportDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export template:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentFlow, exportTemplate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (template: FlowTemplate) => {
|
||||||
|
setDeletingId(template.id);
|
||||||
|
try {
|
||||||
|
await deleteTemplate.mutateAsync(template.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete template:', error);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteTemplate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Library className="h-5 w-5" />
|
||||||
|
Template Library
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Browse and import workflow templates, or export your current flow as a template.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-4 py-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search templates..."
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
{data?.categories && data.categories.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={selectedCategory === null ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Button>
|
||||||
|
{data.categories.slice(0, 4).map((cat) => (
|
||||||
|
<Button
|
||||||
|
key={cat}
|
||||||
|
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex items-center border border-border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-r-none"
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-l-none"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export button */}
|
||||||
|
{currentFlow && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExportDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
|
Export Current
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mb-2" />
|
||||||
|
<p>Failed to load templates</p>
|
||||||
|
<p className="text-sm">{(error as Error).message}</p>
|
||||||
|
</div>
|
||||||
|
) : filteredTemplates.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||||
|
<Library className="h-12 w-12 mb-2" />
|
||||||
|
<p>No templates found</p>
|
||||||
|
{searchQuery && (
|
||||||
|
<p className="text-sm">Try a different search query</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||||
|
: 'space-y-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filteredTemplates.map((template) => (
|
||||||
|
<TemplateCard
|
||||||
|
key={template.id}
|
||||||
|
template={template}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onInstall={handleInstall}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isInstalling={installingId === template.id}
|
||||||
|
isDeleting={deletingId === template.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<DialogFooter className="border-t border-border pt-4">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Export Dialog */}
|
||||||
|
{currentFlow && (
|
||||||
|
<ExportDialog
|
||||||
|
open={exportDialogOpen}
|
||||||
|
onOpenChange={setExportDialogOpen}
|
||||||
|
onExport={handleExport}
|
||||||
|
isExporting={exportTemplate.isPending}
|
||||||
|
flowName={currentFlow.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemplateLibrary;
|
||||||
15
ccw/frontend/src/pages/orchestrator/index.ts
Normal file
15
ccw/frontend/src/pages/orchestrator/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// ========================================
|
||||||
|
// Orchestrator Page Barrel Export
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export { OrchestratorPage } from './OrchestratorPage';
|
||||||
|
export { FlowCanvas } from './FlowCanvas';
|
||||||
|
export { NodePalette } from './NodePalette';
|
||||||
|
export { PropertyPanel } from './PropertyPanel';
|
||||||
|
export { FlowToolbar } from './FlowToolbar';
|
||||||
|
|
||||||
|
// Node components
|
||||||
|
export { SlashCommandNode } from './nodes/SlashCommandNode';
|
||||||
|
export { FileOperationNode } from './nodes/FileOperationNode';
|
||||||
|
export { ConditionalNode } from './nodes/ConditionalNode';
|
||||||
|
export { ParallelNode } from './nodes/ParallelNode';
|
||||||
118
ccw/frontend/src/pages/orchestrator/nodes/ConditionalNode.tsx
Normal file
118
ccw/frontend/src/pages/orchestrator/nodes/ConditionalNode.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// ========================================
|
||||||
|
// Conditional Node Component
|
||||||
|
// ========================================
|
||||||
|
// Custom node for conditional branching with true/false outputs
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import { GitBranch, Check, X } from 'lucide-react';
|
||||||
|
import type { ConditionalNodeData } from '@/types/flow';
|
||||||
|
import { NodeWrapper } from './NodeWrapper';
|
||||||
|
|
||||||
|
interface ConditionalNodeProps {
|
||||||
|
data: ConditionalNodeData;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConditionalNode = memo(({ data, selected }: ConditionalNodeProps) => {
|
||||||
|
// Truncate condition for display
|
||||||
|
const displayCondition = data.condition
|
||||||
|
? data.condition.length > 30
|
||||||
|
? data.condition.slice(0, 27) + '...'
|
||||||
|
: data.condition
|
||||||
|
: 'No condition';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeWrapper
|
||||||
|
status={data.executionStatus}
|
||||||
|
selected={selected}
|
||||||
|
accentColor="amber"
|
||||||
|
>
|
||||||
|
{/* Input Handle */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500 text-white rounded-t-md">
|
||||||
|
<GitBranch className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{data.label || 'Condition'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Content */}
|
||||||
|
<div className="px-3 py-2 space-y-2">
|
||||||
|
{/* Condition expression */}
|
||||||
|
<div
|
||||||
|
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
||||||
|
title={data.condition}
|
||||||
|
>
|
||||||
|
{displayCondition}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch labels */}
|
||||||
|
<div className="flex justify-between items-center pt-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Check className="w-3 h-3 text-green-500" />
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||||
|
{data.trueLabel || 'True'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<X className="w-3 h-3 text-red-500" />
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
|
||||||
|
{data.falseLabel || 'False'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution result indicator */}
|
||||||
|
{data.executionStatus === 'completed' && data.executionResult !== undefined && (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-center">
|
||||||
|
Result:{' '}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
data.executionResult
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.executionResult ? 'true' : 'false'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution error message */}
|
||||||
|
{data.executionStatus === 'failed' && data.executionError && (
|
||||||
|
<div
|
||||||
|
className="text-[10px] text-destructive truncate"
|
||||||
|
title={data.executionError}
|
||||||
|
>
|
||||||
|
{data.executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Handles (True and False) */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="true"
|
||||||
|
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||||
|
style={{ left: '30%' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="false"
|
||||||
|
className="!w-3 !h-3 !bg-red-500 !border-2 !border-background"
|
||||||
|
style={{ left: '70%' }}
|
||||||
|
/>
|
||||||
|
</NodeWrapper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ConditionalNode.displayName = 'ConditionalNode';
|
||||||
145
ccw/frontend/src/pages/orchestrator/nodes/FileOperationNode.tsx
Normal file
145
ccw/frontend/src/pages/orchestrator/nodes/FileOperationNode.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// ========================================
|
||||||
|
// File Operation Node Component
|
||||||
|
// ========================================
|
||||||
|
// Custom node for file read/write operations
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
FileInput,
|
||||||
|
FileOutput,
|
||||||
|
FilePlus,
|
||||||
|
FileX,
|
||||||
|
Copy,
|
||||||
|
Move,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { FileOperationNodeData } from '@/types/flow';
|
||||||
|
import { NodeWrapper } from './NodeWrapper';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FileOperationNodeProps {
|
||||||
|
data: FileOperationNodeData;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation icons and colors
|
||||||
|
const OPERATION_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ icon: React.ElementType; label: string; color: string }
|
||||||
|
> = {
|
||||||
|
read: { icon: FileInput, label: 'Read', color: 'text-blue-500' },
|
||||||
|
write: { icon: FileOutput, label: 'Write', color: 'text-amber-500' },
|
||||||
|
append: { icon: FilePlus, label: 'Append', color: 'text-green-500' },
|
||||||
|
delete: { icon: FileX, label: 'Delete', color: 'text-red-500' },
|
||||||
|
copy: { icon: Copy, label: 'Copy', color: 'text-purple-500' },
|
||||||
|
move: { icon: Move, label: 'Move', color: 'text-indigo-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileOperationNode = memo(({ data, selected }: FileOperationNodeProps) => {
|
||||||
|
const operation = data.operation || 'read';
|
||||||
|
const config = OPERATION_CONFIG[operation] || OPERATION_CONFIG.read;
|
||||||
|
const IconComponent = config.icon;
|
||||||
|
|
||||||
|
// Truncate path for display
|
||||||
|
const displayPath = data.path
|
||||||
|
? data.path.length > 25
|
||||||
|
? '...' + data.path.slice(-22)
|
||||||
|
: data.path
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeWrapper
|
||||||
|
status={data.executionStatus}
|
||||||
|
selected={selected}
|
||||||
|
accentColor="green"
|
||||||
|
>
|
||||||
|
{/* Input Handle */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-green-500 text-white rounded-t-md">
|
||||||
|
<FileText className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{data.label || 'File Operation'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Content */}
|
||||||
|
<div className="px-3 py-2 space-y-1.5">
|
||||||
|
{/* Operation type with icon */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<IconComponent className={cn('w-3.5 h-3.5', config.color)} />
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File path */}
|
||||||
|
{data.path && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground font-mono truncate max-w-[160px]"
|
||||||
|
title={data.path}
|
||||||
|
>
|
||||||
|
{displayPath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badges row */}
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{/* Add to context badge */}
|
||||||
|
{data.addToContext && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
+ context
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Output variable badge */}
|
||||||
|
{data.outputVariable && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
|
||||||
|
title={data.outputVariable}
|
||||||
|
>
|
||||||
|
${data.outputVariable}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination path for copy/move */}
|
||||||
|
{(operation === 'copy' || operation === 'move') && data.destinationPath && (
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
To:{' '}
|
||||||
|
<span className="font-mono text-foreground/70" title={data.destinationPath}>
|
||||||
|
{data.destinationPath.length > 20
|
||||||
|
? '...' + data.destinationPath.slice(-17)
|
||||||
|
: data.destinationPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution error message */}
|
||||||
|
{data.executionStatus === 'failed' && data.executionError && (
|
||||||
|
<div
|
||||||
|
className="text-[10px] text-destructive truncate max-w-[160px]"
|
||||||
|
title={data.executionError}
|
||||||
|
>
|
||||||
|
{data.executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Handle */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
</NodeWrapper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileOperationNode.displayName = 'FileOperationNode';
|
||||||
81
ccw/frontend/src/pages/orchestrator/nodes/NodeWrapper.tsx
Normal file
81
ccw/frontend/src/pages/orchestrator/nodes/NodeWrapper.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// ========================================
|
||||||
|
// Node Wrapper Component
|
||||||
|
// ========================================
|
||||||
|
// Shared wrapper for all custom nodes with execution status styling
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Circle,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { ExecutionStatus } from '@/types/flow';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface NodeWrapperProps {
|
||||||
|
children: ReactNode;
|
||||||
|
status?: ExecutionStatus;
|
||||||
|
selected?: boolean;
|
||||||
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status styling configuration
|
||||||
|
const STATUS_STYLES: Record<ExecutionStatus, string> = {
|
||||||
|
pending: 'border-muted bg-card',
|
||||||
|
running: 'border-primary bg-primary/10 animate-pulse',
|
||||||
|
completed: 'border-green-500 bg-green-500/10',
|
||||||
|
failed: 'border-destructive bg-destructive/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection ring styles per accent color
|
||||||
|
const SELECTION_STYLES: Record<string, string> = {
|
||||||
|
blue: 'ring-2 ring-blue-500/20 border-blue-500',
|
||||||
|
green: 'ring-2 ring-green-500/20 border-green-500',
|
||||||
|
amber: 'ring-2 ring-amber-500/20 border-amber-500',
|
||||||
|
purple: 'ring-2 ring-purple-500/20 border-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status icons
|
||||||
|
function StatusIcon({ status }: { status: ExecutionStatus }) {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Circle className="w-3 h-3 text-muted-foreground" />;
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="w-3 h-3 text-primary animate-spin" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-3 h-3 text-destructive" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeWrapper({
|
||||||
|
children,
|
||||||
|
status = 'pending',
|
||||||
|
selected = false,
|
||||||
|
accentColor,
|
||||||
|
className,
|
||||||
|
}: NodeWrapperProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative min-w-[180px] rounded-lg border-2 shadow-md transition-all',
|
||||||
|
STATUS_STYLES[status],
|
||||||
|
selected && SELECTION_STYLES[accentColor],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="absolute -top-2 -right-2 z-10 bg-background rounded-full p-0.5 shadow-sm border border-border">
|
||||||
|
<StatusIcon status={status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node content (includes handles, header, body) */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeWrapper.displayName = 'NodeWrapper';
|
||||||
129
ccw/frontend/src/pages/orchestrator/nodes/ParallelNode.tsx
Normal file
129
ccw/frontend/src/pages/orchestrator/nodes/ParallelNode.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// ========================================
|
||||||
|
// Parallel Node Component
|
||||||
|
// ========================================
|
||||||
|
// Custom node for parallel execution with multiple branch outputs
|
||||||
|
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import { GitMerge, Layers, Timer, AlertTriangle } from 'lucide-react';
|
||||||
|
import type { ParallelNodeData } from '@/types/flow';
|
||||||
|
import { NodeWrapper } from './NodeWrapper';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ParallelNodeProps {
|
||||||
|
data: ParallelNodeData;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join mode configuration
|
||||||
|
const JOIN_MODE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
all: { label: 'Wait All', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||||
|
any: { label: 'Wait Any', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||||
|
none: { label: 'Fire & Forget', color: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ParallelNode = memo(({ data, selected }: ParallelNodeProps) => {
|
||||||
|
const joinMode = data.joinMode || 'all';
|
||||||
|
const branchCount = Math.max(2, Math.min(data.branchCount || 2, 5)); // Clamp between 2-5
|
||||||
|
const joinConfig = JOIN_MODE_CONFIG[joinMode] || JOIN_MODE_CONFIG.all;
|
||||||
|
|
||||||
|
// Calculate branch handle positions
|
||||||
|
const branchPositions = useMemo(() => {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const step = 100 / (branchCount + 1);
|
||||||
|
for (let i = 1; i <= branchCount; i++) {
|
||||||
|
positions.push(step * i);
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}, [branchCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeWrapper
|
||||||
|
status={data.executionStatus}
|
||||||
|
selected={selected}
|
||||||
|
accentColor="purple"
|
||||||
|
>
|
||||||
|
{/* Input Handle */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500 text-white rounded-t-md">
|
||||||
|
<GitMerge className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{data.label || 'Parallel'}
|
||||||
|
</span>
|
||||||
|
{/* Branch count indicator */}
|
||||||
|
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded">
|
||||||
|
{branchCount}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Content */}
|
||||||
|
<div className="px-3 py-2 space-y-2">
|
||||||
|
{/* Join mode badge */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', joinConfig.color)}>
|
||||||
|
{joinConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional settings row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* Timeout indicator */}
|
||||||
|
{data.timeout && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<Timer className="w-3 h-3" />
|
||||||
|
<span>{data.timeout}ms</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fail fast indicator */}
|
||||||
|
{data.failFast && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
<span>Fail Fast</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch labels */}
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground pt-1">
|
||||||
|
{branchPositions.map((_, index) => (
|
||||||
|
<span key={index} className="text-purple-600 dark:text-purple-400">
|
||||||
|
B{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution error message */}
|
||||||
|
{data.executionStatus === 'failed' && data.executionError && (
|
||||||
|
<div
|
||||||
|
className="text-[10px] text-destructive truncate"
|
||||||
|
title={data.executionError}
|
||||||
|
>
|
||||||
|
{data.executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Branch Output Handles */}
|
||||||
|
{branchPositions.map((position, index) => (
|
||||||
|
<Handle
|
||||||
|
key={`branch-${index + 1}`}
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={`branch-${index + 1}`}
|
||||||
|
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||||
|
style={{ left: `${position}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</NodeWrapper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ParallelNode.displayName = 'ParallelNode';
|
||||||
100
ccw/frontend/src/pages/orchestrator/nodes/SlashCommandNode.tsx
Normal file
100
ccw/frontend/src/pages/orchestrator/nodes/SlashCommandNode.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// ========================================
|
||||||
|
// Slash Command Node Component
|
||||||
|
// ========================================
|
||||||
|
// Custom node for executing CCW slash commands
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import { Terminal } from 'lucide-react';
|
||||||
|
import type { SlashCommandNodeData } from '@/types/flow';
|
||||||
|
import { NodeWrapper } from './NodeWrapper';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SlashCommandNodeProps {
|
||||||
|
data: SlashCommandNodeData;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode badge styling
|
||||||
|
const MODE_STYLES = {
|
||||||
|
analysis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||||
|
write: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlashCommandNode = memo(({ data, selected }: SlashCommandNodeProps) => {
|
||||||
|
const executionMode = data.execution?.mode || 'analysis';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeWrapper
|
||||||
|
status={data.executionStatus}
|
||||||
|
selected={selected}
|
||||||
|
accentColor="blue"
|
||||||
|
>
|
||||||
|
{/* Input Handle */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-blue-500 text-white rounded-t-md">
|
||||||
|
<Terminal className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{data.label || 'Command'}
|
||||||
|
</span>
|
||||||
|
{/* Execution mode badge */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
||||||
|
MODE_STYLES[executionMode]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{executionMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Content */}
|
||||||
|
<div className="px-3 py-2 space-y-1.5">
|
||||||
|
{/* Command name */}
|
||||||
|
{data.command && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||||
|
/{data.command}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arguments (truncated) */}
|
||||||
|
{data.args && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate max-w-[160px]">
|
||||||
|
<span className="text-foreground/70 font-mono">{data.args}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error handling indicator */}
|
||||||
|
{data.onError && data.onError !== 'stop' && (
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
On error: <span className="text-foreground">{data.onError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execution error message */}
|
||||||
|
{data.executionStatus === 'failed' && data.executionError && (
|
||||||
|
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
|
||||||
|
{data.executionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Handle */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||||
|
/>
|
||||||
|
</NodeWrapper>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SlashCommandNode.displayName = 'SlashCommandNode';
|
||||||
26
ccw/frontend/src/pages/orchestrator/nodes/index.ts
Normal file
26
ccw/frontend/src/pages/orchestrator/nodes/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ========================================
|
||||||
|
// Node Components Barrel Export
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Shared wrapper component
|
||||||
|
export { NodeWrapper } from './NodeWrapper';
|
||||||
|
|
||||||
|
// Custom node components
|
||||||
|
export { SlashCommandNode } from './SlashCommandNode';
|
||||||
|
export { FileOperationNode } from './FileOperationNode';
|
||||||
|
export { ConditionalNode } from './ConditionalNode';
|
||||||
|
export { ParallelNode } from './ParallelNode';
|
||||||
|
|
||||||
|
// Node types map for React Flow registration
|
||||||
|
import { SlashCommandNode } from './SlashCommandNode';
|
||||||
|
import { FileOperationNode } from './FileOperationNode';
|
||||||
|
import { ConditionalNode } from './ConditionalNode';
|
||||||
|
import { ParallelNode } from './ParallelNode';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const nodeTypes: Record<string, any> = {
|
||||||
|
'slash-command': SlashCommandNode,
|
||||||
|
'file-operation': FileOperationNode,
|
||||||
|
conditional: ConditionalNode,
|
||||||
|
parallel: ParallelNode,
|
||||||
|
};
|
||||||
95
ccw/frontend/src/router.tsx
Normal file
95
ccw/frontend/src/router.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// ========================================
|
||||||
|
// Router Configuration
|
||||||
|
// ========================================
|
||||||
|
// React Router v6 configuration with all dashboard routes
|
||||||
|
|
||||||
|
import { createBrowserRouter, RouteObject } from 'react-router-dom';
|
||||||
|
import { AppShell } from '@/components/layout';
|
||||||
|
import {
|
||||||
|
HomePage,
|
||||||
|
SessionsPage,
|
||||||
|
OrchestratorPage,
|
||||||
|
LoopMonitorPage,
|
||||||
|
IssueManagerPage,
|
||||||
|
SkillsManagerPage,
|
||||||
|
CommandsManagerPage,
|
||||||
|
MemoryPage,
|
||||||
|
SettingsPage,
|
||||||
|
HelpPage,
|
||||||
|
} from '@/pages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route configuration for the dashboard
|
||||||
|
* All routes are wrapped in AppShell layout
|
||||||
|
*/
|
||||||
|
const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <AppShell />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <HomePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions',
|
||||||
|
element: <SessionsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'orchestrator',
|
||||||
|
element: <OrchestratorPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'loops',
|
||||||
|
element: <LoopMonitorPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'issues',
|
||||||
|
element: <IssueManagerPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'skills',
|
||||||
|
element: <SkillsManagerPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'commands',
|
||||||
|
element: <CommandsManagerPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'memory',
|
||||||
|
element: <MemoryPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
element: <SettingsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'help',
|
||||||
|
element: <HelpPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the browser router instance
|
||||||
|
*/
|
||||||
|
export const router = createBrowserRouter(routes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export route paths for type-safe navigation
|
||||||
|
*/
|
||||||
|
export const ROUTES = {
|
||||||
|
HOME: '/',
|
||||||
|
SESSIONS: '/sessions',
|
||||||
|
ORCHESTRATOR: '/orchestrator',
|
||||||
|
LOOPS: '/loops',
|
||||||
|
ISSUES: '/issues',
|
||||||
|
SKILLS: '/skills',
|
||||||
|
COMMANDS: '/commands',
|
||||||
|
MEMORY: '/memory',
|
||||||
|
SETTINGS: '/settings',
|
||||||
|
HELP: '/help',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
164
ccw/frontend/src/stores/appStore.ts
Normal file
164
ccw/frontend/src/stores/appStore.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// ========================================
|
||||||
|
// App Store
|
||||||
|
// ========================================
|
||||||
|
// Manages UI state: theme, sidebar, view, loading, error
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, devtools } from 'zustand/middleware';
|
||||||
|
import type { AppStore, Theme, ViewMode, SessionFilter, LiteTaskType } from '../types/store';
|
||||||
|
|
||||||
|
// Helper to resolve system theme
|
||||||
|
const getSystemTheme = (): 'light' | 'dark' => {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to resolve theme based on preference
|
||||||
|
const resolveTheme = (theme: Theme): 'light' | 'dark' => {
|
||||||
|
if (theme === 'system') {
|
||||||
|
return getSystemTheme();
|
||||||
|
}
|
||||||
|
return theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState = {
|
||||||
|
// Theme
|
||||||
|
theme: 'system' as Theme,
|
||||||
|
resolvedTheme: 'light' as 'light' | 'dark',
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
sidebarOpen: true,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
|
// View state
|
||||||
|
currentView: 'sessions' as ViewMode,
|
||||||
|
currentFilter: 'all' as SessionFilter,
|
||||||
|
currentLiteType: null as LiteTaskType,
|
||||||
|
currentSessionDetailKey: null as string | null,
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
isLoading: false,
|
||||||
|
loadingMessage: null as string | null,
|
||||||
|
error: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppStore = create<AppStore>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Theme Actions ==========
|
||||||
|
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
const resolved = resolveTheme(theme);
|
||||||
|
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const { theme } = get();
|
||||||
|
const newTheme: Theme = theme === 'dark' ? 'light' : theme === 'light' ? 'dark' : 'dark';
|
||||||
|
get().setTheme(newTheme);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Sidebar Actions ==========
|
||||||
|
|
||||||
|
setSidebarOpen: (open: boolean) => {
|
||||||
|
set({ sidebarOpen: open }, false, 'setSidebarOpen');
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSidebar: () => {
|
||||||
|
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar');
|
||||||
|
},
|
||||||
|
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => {
|
||||||
|
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== View Actions ==========
|
||||||
|
|
||||||
|
setCurrentView: (view: ViewMode) => {
|
||||||
|
set({ currentView: view }, false, 'setCurrentView');
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentFilter: (filter: SessionFilter) => {
|
||||||
|
set({ currentFilter: filter }, false, 'setCurrentFilter');
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentLiteType: (type: LiteTaskType) => {
|
||||||
|
set({ currentLiteType: type }, false, 'setCurrentLiteType');
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentSessionDetailKey: (key: string | null) => {
|
||||||
|
set({ currentSessionDetailKey: key }, false, 'setCurrentSessionDetailKey');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Loading/Error Actions ==========
|
||||||
|
|
||||||
|
setLoading: (loading: boolean, message: string | null = null) => {
|
||||||
|
set({ isLoading: loading, loadingMessage: message }, false, 'setLoading');
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
set({ error }, false, 'setError');
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null }, false, 'clearError');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ccw-app-store',
|
||||||
|
// Only persist theme preference
|
||||||
|
partialize: (state) => ({
|
||||||
|
theme: state.theme,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Apply theme on rehydration
|
||||||
|
if (state) {
|
||||||
|
const resolved = resolveTheme(state.theme);
|
||||||
|
state.resolvedTheme = resolved;
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ name: 'AppStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup system theme listener
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
mediaQuery.addEventListener('change', () => {
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
if (state.theme === 'system') {
|
||||||
|
const resolved = getSystemTheme();
|
||||||
|
useAppStore.setState({ resolvedTheme: resolved });
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolved);
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectTheme = (state: AppStore) => state.theme;
|
||||||
|
export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme;
|
||||||
|
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
|
||||||
|
export const selectCurrentView = (state: AppStore) => state.currentView;
|
||||||
|
export const selectIsLoading = (state: AppStore) => state.isLoading;
|
||||||
|
export const selectError = (state: AppStore) => state.error;
|
||||||
223
ccw/frontend/src/stores/configStore.ts
Normal file
223
ccw/frontend/src/stores/configStore.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// ========================================
|
||||||
|
// Config Store
|
||||||
|
// ========================================
|
||||||
|
// Manages CLI tools, API endpoints, and user preferences with persistence
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
ConfigStore,
|
||||||
|
ConfigState,
|
||||||
|
CliToolConfig,
|
||||||
|
ApiEndpoints,
|
||||||
|
UserPreferences,
|
||||||
|
} from '../types/store';
|
||||||
|
|
||||||
|
// Default CLI tools configuration
|
||||||
|
const defaultCliTools: Record<string, CliToolConfig> = {
|
||||||
|
gemini: {
|
||||||
|
enabled: true,
|
||||||
|
primaryModel: 'gemini-2.5-pro',
|
||||||
|
secondaryModel: 'gemini-2.5-flash',
|
||||||
|
tags: ['analysis', 'debug'],
|
||||||
|
type: 'builtin',
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
enabled: true,
|
||||||
|
primaryModel: 'coder-model',
|
||||||
|
secondaryModel: 'coder-model',
|
||||||
|
tags: [],
|
||||||
|
type: 'builtin',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
enabled: true,
|
||||||
|
primaryModel: 'gpt-5.2',
|
||||||
|
secondaryModel: 'gpt-5.2',
|
||||||
|
tags: [],
|
||||||
|
type: 'builtin',
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
enabled: true,
|
||||||
|
primaryModel: 'sonnet',
|
||||||
|
secondaryModel: 'haiku',
|
||||||
|
tags: [],
|
||||||
|
type: 'builtin',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default API endpoints
|
||||||
|
const defaultApiEndpoints: ApiEndpoints = {
|
||||||
|
base: '/api',
|
||||||
|
sessions: '/api/sessions',
|
||||||
|
tasks: '/api/tasks',
|
||||||
|
loops: '/api/loops',
|
||||||
|
issues: '/api/issues',
|
||||||
|
orchestrator: '/api/orchestrator',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default user preferences
|
||||||
|
const defaultUserPreferences: UserPreferences = {
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000, // 30 seconds
|
||||||
|
notificationsEnabled: true,
|
||||||
|
soundEnabled: false,
|
||||||
|
compactView: false,
|
||||||
|
showCompletedTasks: true,
|
||||||
|
defaultSessionFilter: 'all',
|
||||||
|
defaultSortField: 'created_at',
|
||||||
|
defaultSortDirection: 'desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: ConfigState = {
|
||||||
|
cliTools: defaultCliTools,
|
||||||
|
defaultCliTool: 'gemini',
|
||||||
|
apiEndpoints: defaultApiEndpoints,
|
||||||
|
userPreferences: defaultUserPreferences,
|
||||||
|
featureFlags: {
|
||||||
|
orchestratorEnabled: true,
|
||||||
|
darkModeEnabled: true,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
experimentalFeatures: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useConfigStore = create<ConfigStore>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== CLI Tools Actions ==========
|
||||||
|
|
||||||
|
setCliTools: (tools: Record<string, CliToolConfig>) => {
|
||||||
|
set({ cliTools: tools }, false, 'setCliTools');
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
cliTools: {
|
||||||
|
...state.cliTools,
|
||||||
|
[toolId]: {
|
||||||
|
...state.cliTools[toolId],
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'updateCliTool'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefaultCliTool: (toolId: string) => {
|
||||||
|
const { cliTools } = get();
|
||||||
|
if (cliTools[toolId]?.enabled) {
|
||||||
|
set({ defaultCliTool: toolId }, false, 'setDefaultCliTool');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== API Endpoints Actions ==========
|
||||||
|
|
||||||
|
setApiEndpoints: (endpoints: Partial<ApiEndpoints>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
apiEndpoints: {
|
||||||
|
...state.apiEndpoints,
|
||||||
|
...endpoints,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setApiEndpoints'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== User Preferences Actions ==========
|
||||||
|
|
||||||
|
setUserPreferences: (prefs: Partial<UserPreferences>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
userPreferences: {
|
||||||
|
...state.userPreferences,
|
||||||
|
...prefs,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setUserPreferences'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetUserPreferences: () => {
|
||||||
|
set({ userPreferences: defaultUserPreferences }, false, 'resetUserPreferences');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Feature Flags Actions ==========
|
||||||
|
|
||||||
|
setFeatureFlag: (flag: string, enabled: boolean) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
featureFlags: {
|
||||||
|
...state.featureFlags,
|
||||||
|
[flag]: enabled,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setFeatureFlag'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Bulk Config Actions ==========
|
||||||
|
|
||||||
|
loadConfig: (config: Partial<ConfigState>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
...state,
|
||||||
|
...config,
|
||||||
|
// Deep merge nested objects
|
||||||
|
cliTools: config.cliTools || state.cliTools,
|
||||||
|
apiEndpoints: {
|
||||||
|
...state.apiEndpoints,
|
||||||
|
...(config.apiEndpoints || {}),
|
||||||
|
},
|
||||||
|
userPreferences: {
|
||||||
|
...state.userPreferences,
|
||||||
|
...(config.userPreferences || {}),
|
||||||
|
},
|
||||||
|
featureFlags: {
|
||||||
|
...state.featureFlags,
|
||||||
|
...(config.featureFlags || {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'loadConfig'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ccw-config-store',
|
||||||
|
// Persist all config state
|
||||||
|
partialize: (state) => ({
|
||||||
|
cliTools: state.cliTools,
|
||||||
|
defaultCliTool: state.defaultCliTool,
|
||||||
|
userPreferences: state.userPreferences,
|
||||||
|
featureFlags: state.featureFlags,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ name: 'ConfigStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectCliTools = (state: ConfigStore) => state.cliTools;
|
||||||
|
export const selectDefaultCliTool = (state: ConfigStore) => state.defaultCliTool;
|
||||||
|
export const selectApiEndpoints = (state: ConfigStore) => state.apiEndpoints;
|
||||||
|
export const selectUserPreferences = (state: ConfigStore) => state.userPreferences;
|
||||||
|
export const selectFeatureFlags = (state: ConfigStore) => state.featureFlags;
|
||||||
|
|
||||||
|
// Helper to get first enabled CLI tool
|
||||||
|
export const getFirstEnabledCliTool = (cliTools: Record<string, CliToolConfig>): string => {
|
||||||
|
const entries = Object.entries(cliTools);
|
||||||
|
const enabled = entries.find(([, config]) => config.enabled);
|
||||||
|
return enabled ? enabled[0] : 'gemini';
|
||||||
|
};
|
||||||
227
ccw/frontend/src/stores/executionStore.ts
Normal file
227
ccw/frontend/src/stores/executionStore.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// ========================================
|
||||||
|
// Execution Store
|
||||||
|
// ========================================
|
||||||
|
// Zustand store for Orchestrator execution state management
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
ExecutionStore,
|
||||||
|
ExecutionState,
|
||||||
|
ExecutionStatus,
|
||||||
|
NodeExecutionState,
|
||||||
|
ExecutionLog,
|
||||||
|
} from '../types/execution';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const MAX_LOGS = 500;
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState = {
|
||||||
|
// Current execution
|
||||||
|
currentExecution: null as ExecutionState | null,
|
||||||
|
|
||||||
|
// Node execution states
|
||||||
|
nodeStates: {} as Record<string, NodeExecutionState>,
|
||||||
|
|
||||||
|
// Execution logs
|
||||||
|
logs: [] as ExecutionLog[],
|
||||||
|
maxLogs: MAX_LOGS,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isMonitorExpanded: true,
|
||||||
|
autoScrollLogs: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useExecutionStore = create<ExecutionStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Execution Lifecycle ==========
|
||||||
|
|
||||||
|
startExecution: (execId: string, flowId: string) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentExecution: {
|
||||||
|
execId,
|
||||||
|
flowId,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now,
|
||||||
|
elapsedMs: 0,
|
||||||
|
},
|
||||||
|
nodeStates: {},
|
||||||
|
logs: [],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'startExecution'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setExecutionStatus: (status: ExecutionStatus, currentNodeId?: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
if (!state.currentExecution) return state;
|
||||||
|
return {
|
||||||
|
currentExecution: {
|
||||||
|
...state.currentExecution,
|
||||||
|
status,
|
||||||
|
currentNodeId: currentNodeId ?? state.currentExecution.currentNodeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'setExecutionStatus'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
completeExecution: (status: 'completed' | 'failed') => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
if (!state.currentExecution) return state;
|
||||||
|
const startTime = new Date(state.currentExecution.startedAt).getTime();
|
||||||
|
const elapsedMs = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
currentExecution: {
|
||||||
|
...state.currentExecution,
|
||||||
|
status,
|
||||||
|
completedAt: now,
|
||||||
|
elapsedMs,
|
||||||
|
currentNodeId: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'completeExecution'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearExecution: () => {
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentExecution: null,
|
||||||
|
nodeStates: {},
|
||||||
|
logs: [],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'clearExecution'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Node State Updates ==========
|
||||||
|
|
||||||
|
setNodeStarted: (nodeId: string) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodeStates: {
|
||||||
|
...state.nodeStates,
|
||||||
|
[nodeId]: {
|
||||||
|
nodeId,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setNodeStarted'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setNodeCompleted: (nodeId: string, result?: unknown) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodeStates: {
|
||||||
|
...state.nodeStates,
|
||||||
|
[nodeId]: {
|
||||||
|
...state.nodeStates[nodeId],
|
||||||
|
nodeId,
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: now,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setNodeCompleted'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setNodeFailed: (nodeId: string, error: string) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodeStates: {
|
||||||
|
...state.nodeStates,
|
||||||
|
[nodeId]: {
|
||||||
|
...state.nodeStates[nodeId],
|
||||||
|
nodeId,
|
||||||
|
status: 'failed',
|
||||||
|
completedAt: now,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setNodeFailed'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNodeStates: () => {
|
||||||
|
set({ nodeStates: {} }, false, 'clearNodeStates');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Logs ==========
|
||||||
|
|
||||||
|
addLog: (log: ExecutionLog) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const newLogs = [...state.logs, log];
|
||||||
|
// Trim logs if exceeding max
|
||||||
|
if (newLogs.length > state.maxLogs) {
|
||||||
|
return { logs: newLogs.slice(-state.maxLogs) };
|
||||||
|
}
|
||||||
|
return { logs: newLogs };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'addLog'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLogs: () => {
|
||||||
|
set({ logs: [] }, false, 'clearLogs');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== UI State ==========
|
||||||
|
|
||||||
|
setMonitorExpanded: (expanded: boolean) => {
|
||||||
|
set({ isMonitorExpanded: expanded }, false, 'setMonitorExpanded');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAutoScrollLogs: (autoScroll: boolean) => {
|
||||||
|
set({ autoScrollLogs: autoScroll }, false, 'setAutoScrollLogs');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'ExecutionStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
|
||||||
|
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
|
||||||
|
export const selectLogs = (state: ExecutionStore) => state.logs;
|
||||||
|
export const selectIsMonitorExpanded = (state: ExecutionStore) => state.isMonitorExpanded;
|
||||||
|
export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollLogs;
|
||||||
|
|
||||||
|
// Helper to check if execution is active
|
||||||
|
export const selectIsExecuting = (state: ExecutionStore) => {
|
||||||
|
return state.currentExecution?.status === 'running';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get node status
|
||||||
|
export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
|
||||||
|
return state.nodeStates[nodeId]?.status ?? 'pending';
|
||||||
|
};
|
||||||
435
ccw/frontend/src/stores/flowStore.ts
Normal file
435
ccw/frontend/src/stores/flowStore.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// ========================================
|
||||||
|
// Flow Store
|
||||||
|
// ========================================
|
||||||
|
// Zustand store for Orchestrator flow editor state management
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
FlowStore,
|
||||||
|
Flow,
|
||||||
|
FlowNode,
|
||||||
|
FlowEdge,
|
||||||
|
FlowNodeType,
|
||||||
|
NodeData,
|
||||||
|
FlowEdgeData,
|
||||||
|
} from '../types/flow';
|
||||||
|
import { NODE_TYPE_CONFIGS as nodeConfigs } from '../types/flow';
|
||||||
|
|
||||||
|
// Helper to generate unique IDs
|
||||||
|
const generateId = (prefix: string): string => {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// API base URL
|
||||||
|
const API_BASE = '/api/orchestrator';
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState = {
|
||||||
|
// Current flow
|
||||||
|
currentFlow: null as Flow | null,
|
||||||
|
isModified: false,
|
||||||
|
|
||||||
|
// Nodes and edges
|
||||||
|
nodes: [] as FlowNode[],
|
||||||
|
edges: [] as FlowEdge[],
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
selectedNodeId: null as string | null,
|
||||||
|
selectedEdgeId: null as string | null,
|
||||||
|
|
||||||
|
// Flow list
|
||||||
|
flows: [] as Flow[],
|
||||||
|
isLoadingFlows: false,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isPaletteOpen: true,
|
||||||
|
isPropertyPanelOpen: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFlowStore = create<FlowStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Flow CRUD ==========
|
||||||
|
|
||||||
|
setCurrentFlow: (flow: Flow | null) => {
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentFlow: flow,
|
||||||
|
nodes: flow?.nodes ?? [],
|
||||||
|
edges: flow?.edges ?? [],
|
||||||
|
isModified: false,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedEdgeId: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'setCurrentFlow'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
createFlow: (name: string, description?: string): Flow => {
|
||||||
|
const flow: Flow = {
|
||||||
|
id: generateId('flow'),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
version: 1,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
variables: {},
|
||||||
|
metadata: { source: 'custom' },
|
||||||
|
};
|
||||||
|
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentFlow: flow,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
isModified: true,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedEdgeId: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'createFlow'
|
||||||
|
);
|
||||||
|
|
||||||
|
return flow;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveFlow: async (): Promise<boolean> => {
|
||||||
|
const { currentFlow, nodes, edges } = get();
|
||||||
|
if (!currentFlow) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const flowToSave: Flow = {
|
||||||
|
...currentFlow,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNew = !get().flows.some((f) => f.id === currentFlow.id);
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
const url = isNew
|
||||||
|
? `${API_BASE}/flows`
|
||||||
|
: `${API_BASE}/flows/${currentFlow.id}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(flowToSave),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedFlow = await response.json();
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
currentFlow: savedFlow,
|
||||||
|
isModified: false,
|
||||||
|
flows: isNew
|
||||||
|
? [...state.flows, savedFlow]
|
||||||
|
: state.flows.map((f) => (f.id === savedFlow.id ? savedFlow : f)),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'saveFlow'
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving flow:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadFlow: async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow: Flow = await response.json();
|
||||||
|
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentFlow: flow,
|
||||||
|
nodes: flow.nodes,
|
||||||
|
edges: flow.edges,
|
||||||
|
isModified: false,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedEdgeId: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'loadFlow'
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading flow:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFlow: async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
flows: state.flows.filter((f) => f.id !== id),
|
||||||
|
currentFlow: state.currentFlow?.id === id ? null : state.currentFlow,
|
||||||
|
nodes: state.currentFlow?.id === id ? [] : state.nodes,
|
||||||
|
edges: state.currentFlow?.id === id ? [] : state.edges,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'deleteFlow'
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting flow:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
duplicateFlow: async (id: string): Promise<Flow | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/flows/${id}/duplicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to duplicate flow: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatedFlow: Flow = await response.json();
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
flows: [...state.flows, duplicatedFlow],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'duplicateFlow'
|
||||||
|
);
|
||||||
|
|
||||||
|
return duplicatedFlow;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error duplicating flow:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Node Operations ==========
|
||||||
|
|
||||||
|
addNode: (type: FlowNodeType, position: { x: number; y: number }): string => {
|
||||||
|
const config = nodeConfigs[type];
|
||||||
|
const id = generateId('node');
|
||||||
|
|
||||||
|
const newNode: FlowNode = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: { ...config.defaultData },
|
||||||
|
};
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodes: [...state.nodes, newNode],
|
||||||
|
isModified: true,
|
||||||
|
selectedNodeId: id,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'addNode'
|
||||||
|
);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNode: (id: string, data: Partial<NodeData>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodes: state.nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? { ...node, data: { ...node.data, ...data } as NodeData }
|
||||||
|
: node
|
||||||
|
),
|
||||||
|
isModified: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'updateNode'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNode: (id: string) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
nodes: state.nodes.filter((node) => node.id !== id),
|
||||||
|
edges: state.edges.filter(
|
||||||
|
(edge) => edge.source !== id && edge.target !== id
|
||||||
|
),
|
||||||
|
isModified: true,
|
||||||
|
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'removeNode'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setNodes: (nodes: FlowNode[]) => {
|
||||||
|
set({ nodes, isModified: true }, false, 'setNodes');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Edge Operations ==========
|
||||||
|
|
||||||
|
addEdge: (
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
sourceHandle?: string,
|
||||||
|
targetHandle?: string
|
||||||
|
): string => {
|
||||||
|
const id = generateId('edge');
|
||||||
|
|
||||||
|
const newEdge: FlowEdge = {
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceHandle,
|
||||||
|
targetHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
edges: [...state.edges, newEdge],
|
||||||
|
isModified: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'addEdge'
|
||||||
|
);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEdge: (id: string, data: Partial<FlowEdgeData>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
edges: state.edges.map((edge) =>
|
||||||
|
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
|
||||||
|
),
|
||||||
|
isModified: true,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'updateEdge'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEdge: (id: string) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
edges: state.edges.filter((edge) => edge.id !== id),
|
||||||
|
isModified: true,
|
||||||
|
selectedEdgeId: state.selectedEdgeId === id ? null : state.selectedEdgeId,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'removeEdge'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setEdges: (edges: FlowEdge[]) => {
|
||||||
|
set({ edges, isModified: true }, false, 'setEdges');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Selection ==========
|
||||||
|
|
||||||
|
setSelectedNodeId: (id: string | null) => {
|
||||||
|
set({ selectedNodeId: id, selectedEdgeId: null }, false, 'setSelectedNodeId');
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedEdgeId: (id: string | null) => {
|
||||||
|
set({ selectedEdgeId: id, selectedNodeId: null }, false, 'setSelectedEdgeId');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Flow List ==========
|
||||||
|
|
||||||
|
fetchFlows: async (): Promise<void> => {
|
||||||
|
set({ isLoadingFlows: true }, false, 'fetchFlows/start');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/flows`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch flows: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const flows: Flow[] = data.flows || [];
|
||||||
|
|
||||||
|
set({ flows, isLoadingFlows: false }, false, 'fetchFlows/success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching flows:', error);
|
||||||
|
set({ isLoadingFlows: false }, false, 'fetchFlows/error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== UI State ==========
|
||||||
|
|
||||||
|
setIsPaletteOpen: (open: boolean) => {
|
||||||
|
set({ isPaletteOpen: open }, false, 'setIsPaletteOpen');
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsPropertyPanelOpen: (open: boolean) => {
|
||||||
|
set({ isPropertyPanelOpen: open }, false, 'setIsPropertyPanelOpen');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Utility ==========
|
||||||
|
|
||||||
|
resetFlow: () => {
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
currentFlow: null,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
isModified: false,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedEdgeId: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'resetFlow'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedNode: (): FlowNode | undefined => {
|
||||||
|
const { nodes, selectedNodeId } = get();
|
||||||
|
return nodes.find((node) => node.id === selectedNodeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
markModified: () => {
|
||||||
|
set({ isModified: true }, false, 'markModified');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'FlowStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectCurrentFlow = (state: FlowStore) => state.currentFlow;
|
||||||
|
export const selectNodes = (state: FlowStore) => state.nodes;
|
||||||
|
export const selectEdges = (state: FlowStore) => state.edges;
|
||||||
|
export const selectSelectedNodeId = (state: FlowStore) => state.selectedNodeId;
|
||||||
|
export const selectSelectedEdgeId = (state: FlowStore) => state.selectedEdgeId;
|
||||||
|
export const selectFlows = (state: FlowStore) => state.flows;
|
||||||
|
export const selectIsModified = (state: FlowStore) => state.isModified;
|
||||||
|
export const selectIsLoadingFlows = (state: FlowStore) => state.isLoadingFlows;
|
||||||
|
export const selectIsPaletteOpen = (state: FlowStore) => state.isPaletteOpen;
|
||||||
|
export const selectIsPropertyPanelOpen = (state: FlowStore) => state.isPropertyPanelOpen;
|
||||||
154
ccw/frontend/src/stores/index.ts
Normal file
154
ccw/frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// ========================================
|
||||||
|
// Stores Barrel Export
|
||||||
|
// ========================================
|
||||||
|
// Re-export all stores for convenient imports
|
||||||
|
|
||||||
|
// App Store
|
||||||
|
export {
|
||||||
|
useAppStore,
|
||||||
|
selectTheme,
|
||||||
|
selectResolvedTheme,
|
||||||
|
selectSidebarOpen,
|
||||||
|
selectCurrentView,
|
||||||
|
selectIsLoading,
|
||||||
|
selectError,
|
||||||
|
} from './appStore';
|
||||||
|
|
||||||
|
// Workflow Store
|
||||||
|
export {
|
||||||
|
useWorkflowStore,
|
||||||
|
selectWorkflowData,
|
||||||
|
selectActiveSessions,
|
||||||
|
selectArchivedSessions,
|
||||||
|
selectActiveSessionId,
|
||||||
|
selectProjectPath,
|
||||||
|
selectFilters,
|
||||||
|
selectSorting,
|
||||||
|
} from './workflowStore';
|
||||||
|
|
||||||
|
// Config Store
|
||||||
|
export {
|
||||||
|
useConfigStore,
|
||||||
|
selectCliTools,
|
||||||
|
selectDefaultCliTool,
|
||||||
|
selectApiEndpoints,
|
||||||
|
selectUserPreferences,
|
||||||
|
selectFeatureFlags,
|
||||||
|
getFirstEnabledCliTool,
|
||||||
|
} from './configStore';
|
||||||
|
|
||||||
|
// Notification Store
|
||||||
|
export {
|
||||||
|
useNotificationStore,
|
||||||
|
selectToasts,
|
||||||
|
selectWsStatus,
|
||||||
|
selectWsLastMessage,
|
||||||
|
selectIsPanelVisible,
|
||||||
|
selectPersistentNotifications,
|
||||||
|
toast,
|
||||||
|
} from './notificationStore';
|
||||||
|
|
||||||
|
// Flow Store
|
||||||
|
export {
|
||||||
|
useFlowStore,
|
||||||
|
selectCurrentFlow,
|
||||||
|
selectNodes,
|
||||||
|
selectEdges,
|
||||||
|
selectSelectedNodeId,
|
||||||
|
selectSelectedEdgeId,
|
||||||
|
selectFlows,
|
||||||
|
selectIsModified,
|
||||||
|
selectIsLoadingFlows,
|
||||||
|
selectIsPaletteOpen,
|
||||||
|
selectIsPropertyPanelOpen,
|
||||||
|
} from './flowStore';
|
||||||
|
|
||||||
|
// Execution Store
|
||||||
|
export {
|
||||||
|
useExecutionStore,
|
||||||
|
selectCurrentExecution,
|
||||||
|
selectNodeStates,
|
||||||
|
selectLogs,
|
||||||
|
selectIsMonitorExpanded,
|
||||||
|
selectAutoScrollLogs,
|
||||||
|
selectIsExecuting,
|
||||||
|
selectNodeStatus,
|
||||||
|
} from './executionStore';
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type {
|
||||||
|
// App Store Types
|
||||||
|
AppStore,
|
||||||
|
AppState,
|
||||||
|
AppActions,
|
||||||
|
Theme,
|
||||||
|
ViewMode,
|
||||||
|
SessionFilter,
|
||||||
|
LiteTaskType,
|
||||||
|
|
||||||
|
// Workflow Store Types
|
||||||
|
WorkflowStore,
|
||||||
|
WorkflowState,
|
||||||
|
WorkflowActions,
|
||||||
|
WorkflowData,
|
||||||
|
WorkflowFilters,
|
||||||
|
WorkflowSorting,
|
||||||
|
SessionMetadata,
|
||||||
|
TaskData,
|
||||||
|
LiteTaskSession,
|
||||||
|
|
||||||
|
// Config Store Types
|
||||||
|
ConfigStore,
|
||||||
|
ConfigState,
|
||||||
|
ConfigActions,
|
||||||
|
CliToolConfig,
|
||||||
|
ApiEndpoints,
|
||||||
|
UserPreferences,
|
||||||
|
|
||||||
|
// Notification Store Types
|
||||||
|
NotificationStore,
|
||||||
|
NotificationState,
|
||||||
|
NotificationActions,
|
||||||
|
Toast,
|
||||||
|
ToastType,
|
||||||
|
WebSocketStatus,
|
||||||
|
WebSocketMessage,
|
||||||
|
} from '../types/store';
|
||||||
|
|
||||||
|
// Execution Types
|
||||||
|
export type {
|
||||||
|
ExecutionStatus,
|
||||||
|
NodeExecutionStatus,
|
||||||
|
LogLevel,
|
||||||
|
ExecutionLog,
|
||||||
|
NodeExecutionState,
|
||||||
|
ExecutionState,
|
||||||
|
OrchestratorWebSocketMessage,
|
||||||
|
ExecutionStore,
|
||||||
|
ExecutionStoreState,
|
||||||
|
ExecutionStoreActions,
|
||||||
|
FlowTemplate,
|
||||||
|
TemplateInstallRequest,
|
||||||
|
TemplateExportRequest,
|
||||||
|
} from '../types/execution';
|
||||||
|
|
||||||
|
// Flow Types
|
||||||
|
export type {
|
||||||
|
FlowNodeType,
|
||||||
|
SlashCommandNodeData,
|
||||||
|
FileOperationNodeData,
|
||||||
|
ConditionalNodeData,
|
||||||
|
ParallelNodeData,
|
||||||
|
NodeData,
|
||||||
|
FlowNode,
|
||||||
|
FlowEdge,
|
||||||
|
FlowEdgeData,
|
||||||
|
Flow,
|
||||||
|
FlowMetadata,
|
||||||
|
FlowState,
|
||||||
|
FlowActions,
|
||||||
|
FlowStore,
|
||||||
|
NodeTypeConfig,
|
||||||
|
} from '../types/flow';
|
||||||
|
|
||||||
|
export { NODE_TYPE_CONFIGS } from '../types/flow';
|
||||||
257
ccw/frontend/src/stores/notificationStore.ts
Normal file
257
ccw/frontend/src/stores/notificationStore.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
// ========================================
|
||||||
|
// Notification Store
|
||||||
|
// ========================================
|
||||||
|
// Manages toasts, WebSocket connection status, and persistent notifications
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
NotificationStore,
|
||||||
|
NotificationState,
|
||||||
|
Toast,
|
||||||
|
WebSocketStatus,
|
||||||
|
WebSocketMessage,
|
||||||
|
} from '../types/store';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const NOTIFICATION_STORAGE_KEY = 'ccw_notifications';
|
||||||
|
const NOTIFICATION_MAX_STORED = 100;
|
||||||
|
const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
// Helper to generate unique ID
|
||||||
|
const generateId = (): string => {
|
||||||
|
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to load notifications from localStorage
|
||||||
|
const loadFromStorage = (): Toast[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed: Toast[] = JSON.parse(stored);
|
||||||
|
// Filter out notifications older than max age
|
||||||
|
const cutoffTime = Date.now() - NOTIFICATION_MAX_AGE_MS;
|
||||||
|
return parsed.filter((n) => new Date(n.timestamp).getTime() > cutoffTime);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NotificationStore] Failed to load from storage:', e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to save notifications to localStorage
|
||||||
|
const saveToStorage = (notifications: Toast[]): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Keep only the last N notifications
|
||||||
|
const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED);
|
||||||
|
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(toSave));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NotificationStore] Failed to save to storage:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: NotificationState = {
|
||||||
|
// Toast queue (ephemeral, UI-only)
|
||||||
|
toasts: [],
|
||||||
|
maxToasts: 5,
|
||||||
|
|
||||||
|
// WebSocket status
|
||||||
|
wsStatus: 'disconnected',
|
||||||
|
wsLastMessage: null,
|
||||||
|
wsReconnectAttempts: 0,
|
||||||
|
|
||||||
|
// Notification panel
|
||||||
|
isPanelVisible: false,
|
||||||
|
|
||||||
|
// Persistent notifications (stored in localStorage)
|
||||||
|
persistentNotifications: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNotificationStore = create<NotificationStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Toast Actions ==========
|
||||||
|
|
||||||
|
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>): string => {
|
||||||
|
const id = generateId();
|
||||||
|
const newToast: Toast = {
|
||||||
|
...toast,
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
dismissible: toast.dismissible ?? true,
|
||||||
|
duration: toast.duration ?? 5000, // Default 5 seconds
|
||||||
|
};
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const { maxToasts } = state;
|
||||||
|
// Add new toast at the end, remove oldest if over limit
|
||||||
|
let newToasts = [...state.toasts, newToast];
|
||||||
|
if (newToasts.length > maxToasts) {
|
||||||
|
newToasts = newToasts.slice(-maxToasts);
|
||||||
|
}
|
||||||
|
return { toasts: newToasts };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'addToast'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-remove after duration (if not persistent)
|
||||||
|
if (newToast.duration && newToast.duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().removeToast(id);
|
||||||
|
}, newToast.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id: string) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'removeToast'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllToasts: () => {
|
||||||
|
set({ toasts: [] }, false, 'clearAllToasts');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== WebSocket Status Actions ==========
|
||||||
|
|
||||||
|
setWsStatus: (status: WebSocketStatus) => {
|
||||||
|
set({ wsStatus: status }, false, 'setWsStatus');
|
||||||
|
},
|
||||||
|
|
||||||
|
setWsLastMessage: (message: WebSocketMessage | null) => {
|
||||||
|
set({ wsLastMessage: message }, false, 'setWsLastMessage');
|
||||||
|
},
|
||||||
|
|
||||||
|
incrementReconnectAttempts: () => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
wsReconnectAttempts: state.wsReconnectAttempts + 1,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'incrementReconnectAttempts'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetReconnectAttempts: () => {
|
||||||
|
set({ wsReconnectAttempts: 0 }, false, 'resetReconnectAttempts');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Notification Panel Actions ==========
|
||||||
|
|
||||||
|
togglePanel: () => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
isPanelVisible: !state.isPanelVisible,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'togglePanel'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPanelVisible: (visible: boolean) => {
|
||||||
|
set({ isPanelVisible: visible }, false, 'setPanelVisible');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Persistent Notification Actions ==========
|
||||||
|
|
||||||
|
addPersistentNotification: (notification: Omit<Toast, 'id' | 'timestamp'>) => {
|
||||||
|
const id = generateId();
|
||||||
|
const newNotification: Toast = {
|
||||||
|
...notification,
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
dismissible: notification.dismissible ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
persistentNotifications: [newNotification, ...state.persistentNotifications],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'addPersistentNotification'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also save to localStorage
|
||||||
|
const state = get();
|
||||||
|
saveToStorage(state.persistentNotifications);
|
||||||
|
},
|
||||||
|
|
||||||
|
removePersistentNotification: (id: string) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
persistentNotifications: state.persistentNotifications.filter((n) => n.id !== id),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'removePersistentNotification'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also save to localStorage
|
||||||
|
const state = get();
|
||||||
|
saveToStorage(state.persistentNotifications);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPersistentNotifications: () => {
|
||||||
|
set({ persistentNotifications: [] }, false, 'clearPersistentNotifications');
|
||||||
|
|
||||||
|
// Also clear localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(NOTIFICATION_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPersistentNotifications: () => {
|
||||||
|
const loaded = loadFromStorage();
|
||||||
|
set({ persistentNotifications: loaded }, false, 'loadPersistentNotifications');
|
||||||
|
},
|
||||||
|
|
||||||
|
savePersistentNotifications: () => {
|
||||||
|
const state = get();
|
||||||
|
saveToStorage(state.persistentNotifications);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'NotificationStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize persistent notifications on store creation
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const loaded = loadFromStorage();
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
useNotificationStore.setState({ persistentNotifications: loaded });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectToasts = (state: NotificationStore) => state.toasts;
|
||||||
|
export const selectWsStatus = (state: NotificationStore) => state.wsStatus;
|
||||||
|
export const selectWsLastMessage = (state: NotificationStore) => state.wsLastMessage;
|
||||||
|
export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelVisible;
|
||||||
|
export const selectPersistentNotifications = (state: NotificationStore) =>
|
||||||
|
state.persistentNotifications;
|
||||||
|
|
||||||
|
// Helper to create toast shortcuts
|
||||||
|
export const toast = {
|
||||||
|
info: (title: string, message?: string) =>
|
||||||
|
useNotificationStore.getState().addToast({ type: 'info', title, message }),
|
||||||
|
success: (title: string, message?: string) =>
|
||||||
|
useNotificationStore.getState().addToast({ type: 'success', title, message }),
|
||||||
|
warning: (title: string, message?: string) =>
|
||||||
|
useNotificationStore.getState().addToast({ type: 'warning', title, message }),
|
||||||
|
error: (title: string, message?: string) =>
|
||||||
|
useNotificationStore.getState().addToast({ type: 'error', title, message, duration: 0 }),
|
||||||
|
};
|
||||||
477
ccw/frontend/src/stores/workflowStore.ts
Normal file
477
ccw/frontend/src/stores/workflowStore.ts
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
// ========================================
|
||||||
|
// Workflow Store
|
||||||
|
// ========================================
|
||||||
|
// Manages workflow sessions, tasks, and related data
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type {
|
||||||
|
WorkflowStore,
|
||||||
|
WorkflowState,
|
||||||
|
SessionMetadata,
|
||||||
|
TaskData,
|
||||||
|
LiteTaskSession,
|
||||||
|
WorkflowFilters,
|
||||||
|
WorkflowSorting,
|
||||||
|
} from '../types/store';
|
||||||
|
|
||||||
|
// Helper to generate session key from ID
|
||||||
|
const sessionKey = (sessionId: string): string => {
|
||||||
|
return `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default filters
|
||||||
|
const defaultFilters: WorkflowFilters = {
|
||||||
|
status: null,
|
||||||
|
search: '',
|
||||||
|
dateRange: { start: null, end: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default sorting
|
||||||
|
const defaultSorting: WorkflowSorting = {
|
||||||
|
field: 'created_at',
|
||||||
|
direction: 'desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: WorkflowState = {
|
||||||
|
// Core data
|
||||||
|
workflowData: {
|
||||||
|
activeSessions: [],
|
||||||
|
archivedSessions: [],
|
||||||
|
},
|
||||||
|
projectPath: '',
|
||||||
|
recentPaths: [],
|
||||||
|
serverPlatform: 'win32',
|
||||||
|
|
||||||
|
// Data stores
|
||||||
|
sessionDataStore: {},
|
||||||
|
liteTaskDataStore: {},
|
||||||
|
taskJsonStore: {},
|
||||||
|
|
||||||
|
// Active session
|
||||||
|
activeSessionId: null,
|
||||||
|
|
||||||
|
// Filters and sorting
|
||||||
|
filters: defaultFilters,
|
||||||
|
sorting: defaultSorting,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWorkflowStore = create<WorkflowStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Session Actions ==========
|
||||||
|
|
||||||
|
setSessions: (active: SessionMetadata[], archived: SessionMetadata[]) => {
|
||||||
|
const sessionDataStore: Record<string, SessionMetadata> = {};
|
||||||
|
|
||||||
|
// Build sessionDataStore from both arrays
|
||||||
|
[...active, ...archived].forEach((session) => {
|
||||||
|
const key = sessionKey(session.session_id);
|
||||||
|
sessionDataStore[key] = session;
|
||||||
|
});
|
||||||
|
|
||||||
|
set(
|
||||||
|
{
|
||||||
|
workflowData: {
|
||||||
|
activeSessions: active,
|
||||||
|
archivedSessions: archived,
|
||||||
|
},
|
||||||
|
sessionDataStore,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'setSessions'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
addSession: (session: SessionMetadata) => {
|
||||||
|
const key = sessionKey(session.session_id);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
workflowData: {
|
||||||
|
...state.workflowData,
|
||||||
|
activeSessions: [...state.workflowData.activeSessions, session],
|
||||||
|
},
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: session,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'addSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const session = state.sessionDataStore[key];
|
||||||
|
if (!session) return state;
|
||||||
|
|
||||||
|
const updatedSession = { ...session, ...updates, updated_at: new Date().toISOString() };
|
||||||
|
|
||||||
|
// Update in the appropriate array
|
||||||
|
const isActive = session.location === 'active';
|
||||||
|
const targetArray = isActive ? 'activeSessions' : 'archivedSessions';
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: updatedSession,
|
||||||
|
},
|
||||||
|
workflowData: {
|
||||||
|
...state.workflowData,
|
||||||
|
[targetArray]: state.workflowData[targetArray].map((s) =>
|
||||||
|
s.session_id === sessionId ? updatedSession : s
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'updateSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSession: (sessionId: string) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const { [key]: removed, ...remainingStore } = state.sessionDataStore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: remainingStore,
|
||||||
|
workflowData: {
|
||||||
|
activeSessions: state.workflowData.activeSessions.filter(
|
||||||
|
(s) => s.session_id !== sessionId
|
||||||
|
),
|
||||||
|
archivedSessions: state.workflowData.archivedSessions.filter(
|
||||||
|
(s) => s.session_id !== sessionId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'removeSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
archiveSession: (sessionId: string) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const session = state.sessionDataStore[key];
|
||||||
|
if (!session || session.location === 'archived') return state;
|
||||||
|
|
||||||
|
const archivedSession: SessionMetadata = {
|
||||||
|
...session,
|
||||||
|
location: 'archived',
|
||||||
|
status: 'archived',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: archivedSession,
|
||||||
|
},
|
||||||
|
workflowData: {
|
||||||
|
activeSessions: state.workflowData.activeSessions.filter(
|
||||||
|
(s) => s.session_id !== sessionId
|
||||||
|
),
|
||||||
|
archivedSessions: [...state.workflowData.archivedSessions, archivedSession],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'archiveSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Task Actions ==========
|
||||||
|
|
||||||
|
addTask: (sessionId: string, task: TaskData) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const session = state.sessionDataStore[key];
|
||||||
|
if (!session) return state;
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existingTask = session.tasks?.find((t) => t.task_id === task.task_id);
|
||||||
|
if (existingTask) return state;
|
||||||
|
|
||||||
|
const updatedSession: SessionMetadata = {
|
||||||
|
...session,
|
||||||
|
tasks: [...(session.tasks || []), task],
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: updatedSession,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'addTask'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const session = state.sessionDataStore[key];
|
||||||
|
if (!session?.tasks) return state;
|
||||||
|
|
||||||
|
const updatedTasks = session.tasks.map((task) =>
|
||||||
|
task.task_id === taskId
|
||||||
|
? { ...task, ...updates, updated_at: new Date().toISOString() }
|
||||||
|
: task
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedSession: SessionMetadata = {
|
||||||
|
...session,
|
||||||
|
tasks: updatedTasks,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: updatedSession,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'updateTask'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTask: (sessionId: string, taskId: string) => {
|
||||||
|
const key = sessionKey(sessionId);
|
||||||
|
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const session = state.sessionDataStore[key];
|
||||||
|
if (!session?.tasks) return state;
|
||||||
|
|
||||||
|
const updatedSession: SessionMetadata = {
|
||||||
|
...session,
|
||||||
|
tasks: session.tasks.filter((t) => t.task_id !== taskId),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionDataStore: {
|
||||||
|
...state.sessionDataStore,
|
||||||
|
[key]: updatedSession,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'removeTask'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Lite Task Actions ==========
|
||||||
|
|
||||||
|
setLiteTaskSession: (key: string, session: LiteTaskSession) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
liteTaskDataStore: {
|
||||||
|
...state.liteTaskDataStore,
|
||||||
|
[key]: session,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setLiteTaskSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLiteTaskSession: (key: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const { [key]: removed, ...remaining } = state.liteTaskDataStore;
|
||||||
|
return { liteTaskDataStore: remaining };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'removeLiteTaskSession'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Task JSON Store ==========
|
||||||
|
|
||||||
|
setTaskJson: (key: string, data: unknown) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
taskJsonStore: {
|
||||||
|
...state.taskJsonStore,
|
||||||
|
[key]: data,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setTaskJson'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTaskJson: (key: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const { [key]: removed, ...remaining } = state.taskJsonStore;
|
||||||
|
return { taskJsonStore: remaining };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'removeTaskJson'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Active Session ==========
|
||||||
|
|
||||||
|
setActiveSessionId: (sessionId: string | null) => {
|
||||||
|
set({ activeSessionId: sessionId }, false, 'setActiveSessionId');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Project Path ==========
|
||||||
|
|
||||||
|
setProjectPath: (path: string) => {
|
||||||
|
set({ projectPath: path }, false, 'setProjectPath');
|
||||||
|
},
|
||||||
|
|
||||||
|
addRecentPath: (path: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
// Remove if exists, add to front
|
||||||
|
const filtered = state.recentPaths.filter((p) => p !== path);
|
||||||
|
const updated = [path, ...filtered].slice(0, 10); // Keep max 10
|
||||||
|
return { recentPaths: updated };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'addRecentPath'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => {
|
||||||
|
set({ serverPlatform: platform }, false, 'setServerPlatform');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Filters and Sorting ==========
|
||||||
|
|
||||||
|
setFilters: (filters: Partial<WorkflowFilters>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
filters: { ...state.filters, ...filters },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setFilters'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setSorting: (sorting: Partial<WorkflowSorting>) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
sorting: { ...state.sorting, ...sorting },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'setSorting'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFilters: () => {
|
||||||
|
set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== Computed Selectors ==========
|
||||||
|
|
||||||
|
getActiveSession: () => {
|
||||||
|
const { activeSessionId, sessionDataStore } = get();
|
||||||
|
if (!activeSessionId) return null;
|
||||||
|
const key = sessionKey(activeSessionId);
|
||||||
|
return sessionDataStore[key] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilteredSessions: () => {
|
||||||
|
const { workflowData, filters, sorting } = get();
|
||||||
|
|
||||||
|
// Combine active and archived based on filter
|
||||||
|
let sessions = [...workflowData.activeSessions, ...workflowData.archivedSessions];
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (filters.status && filters.status.length > 0) {
|
||||||
|
sessions = sessions.filter((s) => filters.status!.includes(s.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
sessions = sessions.filter(
|
||||||
|
(s) =>
|
||||||
|
s.session_id.toLowerCase().includes(searchLower) ||
|
||||||
|
s.title?.toLowerCase().includes(searchLower) ||
|
||||||
|
s.description?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply date range filter
|
||||||
|
if (filters.dateRange.start || filters.dateRange.end) {
|
||||||
|
sessions = sessions.filter((s) => {
|
||||||
|
const createdAt = new Date(s.created_at);
|
||||||
|
if (filters.dateRange.start && createdAt < filters.dateRange.start) return false;
|
||||||
|
if (filters.dateRange.end && createdAt > filters.dateRange.end) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sessions.sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
switch (sorting.field) {
|
||||||
|
case 'created_at':
|
||||||
|
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||||
|
break;
|
||||||
|
case 'updated_at':
|
||||||
|
comparison =
|
||||||
|
new Date(a.updated_at || a.created_at).getTime() -
|
||||||
|
new Date(b.updated_at || b.created_at).getTime();
|
||||||
|
break;
|
||||||
|
case 'title':
|
||||||
|
comparison = (a.title || a.session_id).localeCompare(b.title || b.session_id);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
comparison = a.status.localeCompare(b.status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorting.direction === 'desc' ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessions;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSessionByKey: (key: string) => {
|
||||||
|
return get().sessionDataStore[key];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'WorkflowStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selectors for common access patterns
|
||||||
|
export const selectWorkflowData = (state: WorkflowStore) => state.workflowData;
|
||||||
|
export const selectActiveSessions = (state: WorkflowStore) => state.workflowData.activeSessions;
|
||||||
|
export const selectArchivedSessions = (state: WorkflowStore) => state.workflowData.archivedSessions;
|
||||||
|
export const selectActiveSessionId = (state: WorkflowStore) => state.activeSessionId;
|
||||||
|
export const selectProjectPath = (state: WorkflowStore) => state.projectPath;
|
||||||
|
export const selectFilters = (state: WorkflowStore) => state.filters;
|
||||||
|
export const selectSorting = (state: WorkflowStore) => state.sorting;
|
||||||
214
ccw/frontend/src/types/execution.ts
Normal file
214
ccw/frontend/src/types/execution.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// ========================================
|
||||||
|
// Execution Types
|
||||||
|
// ========================================
|
||||||
|
// TypeScript interfaces for Orchestrator execution monitoring
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ========== Execution Status ==========
|
||||||
|
|
||||||
|
export type ExecutionStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
// ========== Log Types ==========
|
||||||
|
|
||||||
|
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
|
||||||
|
export interface ExecutionLog {
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
nodeId?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Node Execution State ==========
|
||||||
|
|
||||||
|
export interface NodeExecutionState {
|
||||||
|
nodeId: string;
|
||||||
|
status: NodeExecutionStatus;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Execution State ==========
|
||||||
|
|
||||||
|
export interface ExecutionState {
|
||||||
|
execId: string;
|
||||||
|
flowId: string;
|
||||||
|
status: ExecutionStatus;
|
||||||
|
currentNodeId?: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WebSocket Message Types ==========
|
||||||
|
|
||||||
|
// Server-side message type definitions (matching websocket.ts)
|
||||||
|
export interface OrchestratorStateUpdateMessage {
|
||||||
|
type: 'ORCHESTRATOR_STATE_UPDATE';
|
||||||
|
execId: string;
|
||||||
|
status: ExecutionStatus;
|
||||||
|
currentNodeId?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrchestratorNodeStartedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_STARTED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrchestratorNodeCompletedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_COMPLETED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
result?: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrchestratorNodeFailedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_FAILED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
error: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrchestratorLogMessage {
|
||||||
|
type: 'ORCHESTRATOR_LOG';
|
||||||
|
execId: string;
|
||||||
|
log: ExecutionLog;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for all orchestrator WebSocket messages
|
||||||
|
export type OrchestratorWebSocketMessage =
|
||||||
|
| OrchestratorStateUpdateMessage
|
||||||
|
| OrchestratorNodeStartedMessage
|
||||||
|
| OrchestratorNodeCompletedMessage
|
||||||
|
| OrchestratorNodeFailedMessage
|
||||||
|
| OrchestratorLogMessage;
|
||||||
|
|
||||||
|
// ========== Zod Schemas for WebSocket Validation ==========
|
||||||
|
|
||||||
|
const ExecutionStatusSchema = z.enum(['pending', 'running', 'paused', 'completed', 'failed']);
|
||||||
|
|
||||||
|
const ExecutionLogSchema = z.object({
|
||||||
|
timestamp: z.string(),
|
||||||
|
level: z.enum(['info', 'warn', 'error', 'debug']),
|
||||||
|
nodeId: z.string().optional(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OrchestratorMessageSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ORCHESTRATOR_STATE_UPDATE'),
|
||||||
|
execId: z.string(),
|
||||||
|
status: ExecutionStatusSchema,
|
||||||
|
currentNodeId: z.string().optional(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ORCHESTRATOR_NODE_STARTED'),
|
||||||
|
execId: z.string(),
|
||||||
|
nodeId: z.string(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ORCHESTRATOR_NODE_COMPLETED'),
|
||||||
|
execId: z.string(),
|
||||||
|
nodeId: z.string(),
|
||||||
|
result: z.unknown().optional(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ORCHESTRATOR_NODE_FAILED'),
|
||||||
|
execId: z.string(),
|
||||||
|
nodeId: z.string(),
|
||||||
|
error: z.string(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ORCHESTRATOR_LOG'),
|
||||||
|
execId: z.string(),
|
||||||
|
log: ExecutionLogSchema,
|
||||||
|
timestamp: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ========== Execution Store Types ==========
|
||||||
|
|
||||||
|
export interface ExecutionStoreState {
|
||||||
|
// Current execution
|
||||||
|
currentExecution: ExecutionState | null;
|
||||||
|
|
||||||
|
// Node execution states
|
||||||
|
nodeStates: Record<string, NodeExecutionState>;
|
||||||
|
|
||||||
|
// Execution logs
|
||||||
|
logs: ExecutionLog[];
|
||||||
|
maxLogs: number;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isMonitorExpanded: boolean;
|
||||||
|
autoScrollLogs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionStoreActions {
|
||||||
|
// Execution lifecycle
|
||||||
|
startExecution: (execId: string, flowId: string) => void;
|
||||||
|
setExecutionStatus: (status: ExecutionStatus, currentNodeId?: string) => void;
|
||||||
|
completeExecution: (status: 'completed' | 'failed') => void;
|
||||||
|
clearExecution: () => void;
|
||||||
|
|
||||||
|
// Node state updates
|
||||||
|
setNodeStarted: (nodeId: string) => void;
|
||||||
|
setNodeCompleted: (nodeId: string, result?: unknown) => void;
|
||||||
|
setNodeFailed: (nodeId: string, error: string) => void;
|
||||||
|
clearNodeStates: () => void;
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
addLog: (log: ExecutionLog) => void;
|
||||||
|
clearLogs: () => void;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
setMonitorExpanded: (expanded: boolean) => void;
|
||||||
|
setAutoScrollLogs: (autoScroll: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecutionStore = ExecutionStoreState & ExecutionStoreActions;
|
||||||
|
|
||||||
|
// ========== Template Types ==========
|
||||||
|
|
||||||
|
export interface FlowTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
preview?: string; // Base64 preview image or ASCII art
|
||||||
|
author?: string;
|
||||||
|
version: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
nodeCount: number;
|
||||||
|
edgeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateInstallRequest {
|
||||||
|
templateId: string;
|
||||||
|
name?: string; // Optional custom name for the installed flow
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateExportRequest {
|
||||||
|
flowId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
240
ccw/frontend/src/types/flow.ts
Normal file
240
ccw/frontend/src/types/flow.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
// ========================================
|
||||||
|
// Flow Types
|
||||||
|
// ========================================
|
||||||
|
// TypeScript interfaces for Orchestrator flow editor
|
||||||
|
|
||||||
|
import type { Node, Edge } from '@xyflow/react';
|
||||||
|
|
||||||
|
// ========== Node Types ==========
|
||||||
|
|
||||||
|
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel';
|
||||||
|
|
||||||
|
// Execution status for nodes during workflow execution
|
||||||
|
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
// Base interface for all node data - must have index signature for React Flow compatibility
|
||||||
|
interface BaseNodeData {
|
||||||
|
label: string;
|
||||||
|
executionStatus?: ExecutionStatus;
|
||||||
|
executionError?: string;
|
||||||
|
executionResult?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash Command Node Data
|
||||||
|
export interface SlashCommandNodeData extends BaseNodeData {
|
||||||
|
command: string;
|
||||||
|
args?: string;
|
||||||
|
execution: {
|
||||||
|
mode: 'analysis' | 'write';
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
contextHint?: string;
|
||||||
|
onError?: 'continue' | 'stop' | 'retry';
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Operation Node Data
|
||||||
|
export interface FileOperationNodeData extends BaseNodeData {
|
||||||
|
operation: 'read' | 'write' | 'append' | 'delete' | 'copy' | 'move';
|
||||||
|
path: string;
|
||||||
|
content?: string;
|
||||||
|
destinationPath?: string;
|
||||||
|
encoding?: 'utf8' | 'ascii' | 'base64';
|
||||||
|
outputVariable?: string;
|
||||||
|
addToContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional Node Data
|
||||||
|
export interface ConditionalNodeData extends BaseNodeData {
|
||||||
|
condition: string;
|
||||||
|
trueLabel?: string;
|
||||||
|
falseLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel Node Data
|
||||||
|
export interface ParallelNodeData extends BaseNodeData {
|
||||||
|
joinMode: 'all' | 'any' | 'none';
|
||||||
|
branchCount?: number; // Number of parallel branches (default: 2)
|
||||||
|
timeout?: number;
|
||||||
|
failFast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for all node data
|
||||||
|
export type NodeData =
|
||||||
|
| SlashCommandNodeData
|
||||||
|
| FileOperationNodeData
|
||||||
|
| ConditionalNodeData
|
||||||
|
| ParallelNodeData;
|
||||||
|
|
||||||
|
// Extended Node type for React Flow
|
||||||
|
export type FlowNode = Node<NodeData, FlowNodeType>;
|
||||||
|
|
||||||
|
// ========== Edge Types ==========
|
||||||
|
|
||||||
|
export interface FlowEdgeData {
|
||||||
|
label?: string;
|
||||||
|
condition?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowEdge = Edge<FlowEdgeData>;
|
||||||
|
|
||||||
|
// ========== Flow Definition ==========
|
||||||
|
|
||||||
|
export interface FlowMetadata {
|
||||||
|
source?: 'template' | 'custom' | 'imported';
|
||||||
|
templateId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Flow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
version: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
variables: Record<string, unknown>;
|
||||||
|
metadata: FlowMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Flow Store Types ==========
|
||||||
|
|
||||||
|
export interface FlowState {
|
||||||
|
// Current flow
|
||||||
|
currentFlow: Flow | null;
|
||||||
|
isModified: boolean;
|
||||||
|
|
||||||
|
// Nodes and edges (React Flow state)
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
selectedEdgeId: string | null;
|
||||||
|
|
||||||
|
// Flow list
|
||||||
|
flows: Flow[];
|
||||||
|
isLoadingFlows: boolean;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isPaletteOpen: boolean;
|
||||||
|
isPropertyPanelOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowActions {
|
||||||
|
// Flow CRUD
|
||||||
|
setCurrentFlow: (flow: Flow | null) => void;
|
||||||
|
createFlow: (name: string, description?: string) => Flow;
|
||||||
|
saveFlow: () => Promise<boolean>;
|
||||||
|
loadFlow: (id: string) => Promise<boolean>;
|
||||||
|
deleteFlow: (id: string) => Promise<boolean>;
|
||||||
|
duplicateFlow: (id: string) => Promise<Flow | null>;
|
||||||
|
|
||||||
|
// Node operations
|
||||||
|
addNode: (type: FlowNodeType, position: { x: number; y: number }) => string;
|
||||||
|
updateNode: (id: string, data: Partial<NodeData>) => void;
|
||||||
|
removeNode: (id: string) => void;
|
||||||
|
setNodes: (nodes: FlowNode[]) => void;
|
||||||
|
|
||||||
|
// Edge operations
|
||||||
|
addEdge: (source: string, target: string, sourceHandle?: string, targetHandle?: string) => string;
|
||||||
|
updateEdge: (id: string, data: Partial<FlowEdgeData>) => void;
|
||||||
|
removeEdge: (id: string) => void;
|
||||||
|
setEdges: (edges: FlowEdge[]) => void;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
setSelectedNodeId: (id: string | null) => void;
|
||||||
|
setSelectedEdgeId: (id: string | null) => void;
|
||||||
|
|
||||||
|
// Flow list
|
||||||
|
fetchFlows: () => Promise<void>;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
setIsPaletteOpen: (open: boolean) => void;
|
||||||
|
setIsPropertyPanelOpen: (open: boolean) => void;
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
resetFlow: () => void;
|
||||||
|
getSelectedNode: () => FlowNode | undefined;
|
||||||
|
markModified: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowStore = FlowState & FlowActions;
|
||||||
|
|
||||||
|
// ========== Node Type Configuration ==========
|
||||||
|
|
||||||
|
export interface NodeTypeConfig {
|
||||||
|
type: FlowNodeType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
defaultData: NodeData;
|
||||||
|
handles: {
|
||||||
|
inputs: number;
|
||||||
|
outputs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_TYPE_CONFIGS: Record<FlowNodeType, NodeTypeConfig> = {
|
||||||
|
'slash-command': {
|
||||||
|
type: 'slash-command',
|
||||||
|
label: 'Slash Command',
|
||||||
|
description: 'Execute CCW slash commands',
|
||||||
|
icon: 'Terminal',
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
defaultData: {
|
||||||
|
label: 'New Command',
|
||||||
|
command: '',
|
||||||
|
args: '',
|
||||||
|
execution: { mode: 'analysis' },
|
||||||
|
onError: 'stop',
|
||||||
|
} as SlashCommandNodeData,
|
||||||
|
handles: { inputs: 1, outputs: 1 },
|
||||||
|
},
|
||||||
|
'file-operation': {
|
||||||
|
type: 'file-operation',
|
||||||
|
label: 'File Operation',
|
||||||
|
description: 'Read/write/delete files',
|
||||||
|
icon: 'FileText',
|
||||||
|
color: 'bg-green-500',
|
||||||
|
defaultData: {
|
||||||
|
label: 'File Operation',
|
||||||
|
operation: 'read',
|
||||||
|
path: '',
|
||||||
|
addToContext: false,
|
||||||
|
} as FileOperationNodeData,
|
||||||
|
handles: { inputs: 1, outputs: 1 },
|
||||||
|
},
|
||||||
|
conditional: {
|
||||||
|
type: 'conditional',
|
||||||
|
label: 'Conditional',
|
||||||
|
description: 'Branch based on condition',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
color: 'bg-amber-500',
|
||||||
|
defaultData: {
|
||||||
|
label: 'Condition',
|
||||||
|
condition: '',
|
||||||
|
trueLabel: 'True',
|
||||||
|
falseLabel: 'False',
|
||||||
|
} as ConditionalNodeData,
|
||||||
|
handles: { inputs: 1, outputs: 2 },
|
||||||
|
},
|
||||||
|
parallel: {
|
||||||
|
type: 'parallel',
|
||||||
|
label: 'Parallel',
|
||||||
|
description: 'Execute branches in parallel',
|
||||||
|
icon: 'GitMerge',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
defaultData: {
|
||||||
|
label: 'Parallel',
|
||||||
|
joinMode: 'all',
|
||||||
|
failFast: false,
|
||||||
|
} as ParallelNodeData,
|
||||||
|
handles: { inputs: 1, outputs: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
316
ccw/frontend/src/types/store.ts
Normal file
316
ccw/frontend/src/types/store.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// ========================================
|
||||||
|
// Store Types
|
||||||
|
// ========================================
|
||||||
|
// TypeScript interfaces for all Zustand stores
|
||||||
|
|
||||||
|
// ========== App Store Types ==========
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator';
|
||||||
|
export type SessionFilter = 'all' | 'active' | 'archived';
|
||||||
|
export type LiteTaskType = 'lite-plan' | 'lite-fix' | null;
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
// Theme
|
||||||
|
theme: Theme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
|
||||||
|
// View state
|
||||||
|
currentView: ViewMode;
|
||||||
|
currentFilter: SessionFilter;
|
||||||
|
currentLiteType: LiteTaskType;
|
||||||
|
currentSessionDetailKey: string | null;
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
isLoading: boolean;
|
||||||
|
loadingMessage: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppActions {
|
||||||
|
// Theme actions
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
|
||||||
|
// Sidebar actions
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
|
||||||
|
// View actions
|
||||||
|
setCurrentView: (view: ViewMode) => void;
|
||||||
|
setCurrentFilter: (filter: SessionFilter) => void;
|
||||||
|
setCurrentLiteType: (type: LiteTaskType) => void;
|
||||||
|
setCurrentSessionDetailKey: (key: string | null) => void;
|
||||||
|
|
||||||
|
// Loading/error actions
|
||||||
|
setLoading: (loading: boolean, message?: string | null) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppStore = AppState & AppActions;
|
||||||
|
|
||||||
|
// ========== Workflow Store Types ==========
|
||||||
|
|
||||||
|
export interface SessionMetadata {
|
||||||
|
session_id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'planning' | 'in_progress' | 'completed' | 'archived' | 'paused';
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
location: 'active' | 'archived';
|
||||||
|
has_plan?: boolean;
|
||||||
|
plan_updated_at?: string;
|
||||||
|
has_review?: boolean;
|
||||||
|
review?: {
|
||||||
|
dimensions: string[];
|
||||||
|
iterations: string[];
|
||||||
|
fixes: string[];
|
||||||
|
};
|
||||||
|
summaries?: Array<{ task_id: string; content: unknown }>;
|
||||||
|
tasks?: TaskData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskData {
|
||||||
|
task_id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
has_summary?: boolean;
|
||||||
|
depends_on?: string[];
|
||||||
|
estimated_complexity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiteTaskSession {
|
||||||
|
session_id: string;
|
||||||
|
type: LiteTaskType;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
tasks?: TaskData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowData {
|
||||||
|
activeSessions: SessionMetadata[];
|
||||||
|
archivedSessions: SessionMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowFilters {
|
||||||
|
status: SessionMetadata['status'][] | null;
|
||||||
|
search: string;
|
||||||
|
dateRange: { start: Date | null; end: Date | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowSorting {
|
||||||
|
field: 'created_at' | 'updated_at' | 'title' | 'status';
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowState {
|
||||||
|
// Core data
|
||||||
|
workflowData: WorkflowData;
|
||||||
|
projectPath: string;
|
||||||
|
recentPaths: string[];
|
||||||
|
serverPlatform: 'win32' | 'darwin' | 'linux';
|
||||||
|
|
||||||
|
// Data stores (maps)
|
||||||
|
sessionDataStore: Record<string, SessionMetadata>;
|
||||||
|
liteTaskDataStore: Record<string, LiteTaskSession>;
|
||||||
|
taskJsonStore: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Active session
|
||||||
|
activeSessionId: string | null;
|
||||||
|
|
||||||
|
// Filters and sorting
|
||||||
|
filters: WorkflowFilters;
|
||||||
|
sorting: WorkflowSorting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowActions {
|
||||||
|
// Session actions
|
||||||
|
setSessions: (active: SessionMetadata[], archived: SessionMetadata[]) => void;
|
||||||
|
addSession: (session: SessionMetadata) => void;
|
||||||
|
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => void;
|
||||||
|
removeSession: (sessionId: string) => void;
|
||||||
|
archiveSession: (sessionId: string) => void;
|
||||||
|
|
||||||
|
// Task actions
|
||||||
|
addTask: (sessionId: string, task: TaskData) => void;
|
||||||
|
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => void;
|
||||||
|
removeTask: (sessionId: string, taskId: string) => void;
|
||||||
|
|
||||||
|
// Lite task actions
|
||||||
|
setLiteTaskSession: (key: string, session: LiteTaskSession) => void;
|
||||||
|
removeLiteTaskSession: (key: string) => void;
|
||||||
|
|
||||||
|
// Task JSON store
|
||||||
|
setTaskJson: (key: string, data: unknown) => void;
|
||||||
|
removeTaskJson: (key: string) => void;
|
||||||
|
|
||||||
|
// Active session
|
||||||
|
setActiveSessionId: (sessionId: string | null) => void;
|
||||||
|
|
||||||
|
// Project path
|
||||||
|
setProjectPath: (path: string) => void;
|
||||||
|
addRecentPath: (path: string) => void;
|
||||||
|
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => void;
|
||||||
|
|
||||||
|
// Filters and sorting
|
||||||
|
setFilters: (filters: Partial<WorkflowFilters>) => void;
|
||||||
|
setSorting: (sorting: Partial<WorkflowSorting>) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
|
||||||
|
// Computed selectors
|
||||||
|
getActiveSession: () => SessionMetadata | null;
|
||||||
|
getFilteredSessions: () => SessionMetadata[];
|
||||||
|
getSessionByKey: (key: string) => SessionMetadata | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowStore = WorkflowState & WorkflowActions;
|
||||||
|
|
||||||
|
// ========== Config Store Types ==========
|
||||||
|
|
||||||
|
export interface CliToolConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
primaryModel: string;
|
||||||
|
secondaryModel: string;
|
||||||
|
tags: string[];
|
||||||
|
type: 'builtin' | 'cli-wrapper' | 'api-endpoint';
|
||||||
|
settingsFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEndpoints {
|
||||||
|
base: string;
|
||||||
|
sessions: string;
|
||||||
|
tasks: string;
|
||||||
|
loops: string;
|
||||||
|
issues: string;
|
||||||
|
orchestrator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
autoRefresh: boolean;
|
||||||
|
refreshInterval: number; // milliseconds
|
||||||
|
notificationsEnabled: boolean;
|
||||||
|
soundEnabled: boolean;
|
||||||
|
compactView: boolean;
|
||||||
|
showCompletedTasks: boolean;
|
||||||
|
defaultSessionFilter: SessionFilter;
|
||||||
|
defaultSortField: WorkflowSorting['field'];
|
||||||
|
defaultSortDirection: WorkflowSorting['direction'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigState {
|
||||||
|
// CLI tools configuration
|
||||||
|
cliTools: Record<string, CliToolConfig>;
|
||||||
|
defaultCliTool: string;
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
apiEndpoints: ApiEndpoints;
|
||||||
|
|
||||||
|
// User preferences
|
||||||
|
userPreferences: UserPreferences;
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
featureFlags: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigActions {
|
||||||
|
// CLI tools
|
||||||
|
setCliTools: (tools: Record<string, CliToolConfig>) => void;
|
||||||
|
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => void;
|
||||||
|
setDefaultCliTool: (toolId: string) => void;
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
setApiEndpoints: (endpoints: Partial<ApiEndpoints>) => void;
|
||||||
|
|
||||||
|
// User preferences
|
||||||
|
setUserPreferences: (prefs: Partial<UserPreferences>) => void;
|
||||||
|
resetUserPreferences: () => void;
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
setFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||||
|
|
||||||
|
// Bulk config
|
||||||
|
loadConfig: (config: Partial<ConfigState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigStore = ConfigState & ConfigActions;
|
||||||
|
|
||||||
|
// ========== Notification Store Types ==========
|
||||||
|
|
||||||
|
export type ToastType = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
export type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number; // milliseconds, 0 = persistent
|
||||||
|
timestamp: string;
|
||||||
|
dismissible?: boolean;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketMessage {
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
sessionId?: string;
|
||||||
|
entityId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationState {
|
||||||
|
// Toast queue
|
||||||
|
toasts: Toast[];
|
||||||
|
maxToasts: number;
|
||||||
|
|
||||||
|
// WebSocket status
|
||||||
|
wsStatus: WebSocketStatus;
|
||||||
|
wsLastMessage: WebSocketMessage | null;
|
||||||
|
wsReconnectAttempts: number;
|
||||||
|
|
||||||
|
// Notification panel
|
||||||
|
isPanelVisible: boolean;
|
||||||
|
|
||||||
|
// Persistent notifications (stored in localStorage)
|
||||||
|
persistentNotifications: Toast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationActions {
|
||||||
|
// Toast actions
|
||||||
|
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>) => string;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
clearAllToasts: () => void;
|
||||||
|
|
||||||
|
// WebSocket status
|
||||||
|
setWsStatus: (status: WebSocketStatus) => void;
|
||||||
|
setWsLastMessage: (message: WebSocketMessage | null) => void;
|
||||||
|
incrementReconnectAttempts: () => void;
|
||||||
|
resetReconnectAttempts: () => void;
|
||||||
|
|
||||||
|
// Notification panel
|
||||||
|
togglePanel: () => void;
|
||||||
|
setPanelVisible: (visible: boolean) => void;
|
||||||
|
|
||||||
|
// Persistent notifications
|
||||||
|
addPersistentNotification: (notification: Omit<Toast, 'id' | 'timestamp'>) => void;
|
||||||
|
removePersistentNotification: (id: string) => void;
|
||||||
|
clearPersistentNotifications: () => void;
|
||||||
|
loadPersistentNotifications: () => void;
|
||||||
|
savePersistentNotifications: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationStore = NotificationState & NotificationActions;
|
||||||
113
ccw/frontend/tailwind.config.js
Normal file
113
ccw/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ['class', '[data-theme="dark"]'],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Base colors
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
|
||||||
|
// Interactive colors
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
light: "hsl(var(--primary-light))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Semantic colors
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sidebar colors
|
||||||
|
sidebar: {
|
||||||
|
background: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
},
|
||||||
|
|
||||||
|
// State colors
|
||||||
|
hover: "hsl(var(--hover))",
|
||||||
|
success: {
|
||||||
|
DEFAULT: "hsl(var(--success))",
|
||||||
|
light: "hsl(var(--success-light))",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: "hsl(var(--warning))",
|
||||||
|
light: "hsl(var(--warning-light))",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: "hsl(var(--info))",
|
||||||
|
light: "hsl(var(--info-light))",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
DEFAULT: "hsl(var(--indigo))",
|
||||||
|
light: "hsl(var(--indigo-light))",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
DEFAULT: "hsl(var(--orange))",
|
||||||
|
light: "hsl(var(--orange-light))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
|
||||||
|
mono: ["Consolas", "Monaco", "Courier New", "monospace"],
|
||||||
|
},
|
||||||
|
|
||||||
|
boxShadow: {
|
||||||
|
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||||
|
DEFAULT: "0 2px 8px rgb(0 0 0 / 0.08)",
|
||||||
|
md: "0 4px 12px rgb(0 0 0 / 0.1)",
|
||||||
|
lg: "0 8px 24px rgb(0 0 0 / 0.12)",
|
||||||
|
},
|
||||||
|
|
||||||
|
borderRadius: {
|
||||||
|
lg: "0.5rem",
|
||||||
|
md: "0.375rem",
|
||||||
|
sm: "0.25rem",
|
||||||
|
},
|
||||||
|
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
31
ccw/frontend/tsconfig.json
Normal file
31
ccw/frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
27
ccw/frontend/tsconfig.node.json
Normal file
27
ccw/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
|
||||||
|
/* Project references */
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
30
ccw/frontend/vite.config.ts
Normal file
30
ccw/frontend/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3456',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:3456',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
103
ccw/src/core/routes/dashboard-routes.ts
Normal file
103
ccw/src/core/routes/dashboard-routes.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Routes Module
|
||||||
|
* Provides API endpoints for dashboard initialization and configuration
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/dashboard/init - Returns initial dashboard data (projectPath, recentPaths, platform, initialData)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouteContext } from './types.js';
|
||||||
|
import { getRecentPaths, normalizePathForDisplay } from '../../utils/path-resolver.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard initialization response structure
|
||||||
|
*/
|
||||||
|
interface DashboardInitResponse {
|
||||||
|
projectPath: string;
|
||||||
|
recentPaths: string[];
|
||||||
|
platform: string;
|
||||||
|
initialData: {
|
||||||
|
generatedAt: string;
|
||||||
|
activeSessions: unknown[];
|
||||||
|
archivedSessions: unknown[];
|
||||||
|
liteTasks: {
|
||||||
|
litePlan: unknown[];
|
||||||
|
liteFix: unknown[];
|
||||||
|
multiCliPlan: unknown[];
|
||||||
|
};
|
||||||
|
reviewData: {
|
||||||
|
dimensions: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
projectOverview: null;
|
||||||
|
statistics: {
|
||||||
|
totalSessions: number;
|
||||||
|
activeSessions: number;
|
||||||
|
totalTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
reviewFindings: number;
|
||||||
|
litePlanCount: number;
|
||||||
|
liteFixCount: number;
|
||||||
|
multiCliPlanCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dashboard routes
|
||||||
|
* @returns true if route was handled, false otherwise
|
||||||
|
*/
|
||||||
|
export async function handleDashboardRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
const { pathname, req, res, initialPath } = ctx;
|
||||||
|
|
||||||
|
// GET /api/dashboard/init - Return initial dashboard data
|
||||||
|
if (pathname === '/api/dashboard/init' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const response: DashboardInitResponse = {
|
||||||
|
projectPath: normalizePathForDisplay(initialPath),
|
||||||
|
recentPaths: getRecentPaths(),
|
||||||
|
platform: process.platform,
|
||||||
|
initialData: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
activeSessions: [],
|
||||||
|
archivedSessions: [],
|
||||||
|
liteTasks: {
|
||||||
|
litePlan: [],
|
||||||
|
liteFix: [],
|
||||||
|
multiCliPlan: []
|
||||||
|
},
|
||||||
|
reviewData: {
|
||||||
|
dimensions: {}
|
||||||
|
},
|
||||||
|
projectOverview: null,
|
||||||
|
statistics: {
|
||||||
|
totalSessions: 0,
|
||||||
|
activeSessions: 0,
|
||||||
|
totalTasks: 0,
|
||||||
|
completedTasks: 0,
|
||||||
|
reviewFindings: 0,
|
||||||
|
litePlanCount: 0,
|
||||||
|
liteFixCount: 0,
|
||||||
|
multiCliPlanCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
1688
ccw/src/core/routes/orchestrator-routes.ts
Normal file
1688
ccw/src/core/routes/orchestrator-routes.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,8 @@ import { handleLoopRoutes } from './routes/loop-routes.js';
|
|||||||
import { handleLoopV2Routes, initializeCliToolsCache } from './routes/loop-v2-routes.js';
|
import { handleLoopV2Routes, initializeCliToolsCache } from './routes/loop-v2-routes.js';
|
||||||
import { handleTestLoopRoutes } from './routes/test-loop-routes.js';
|
import { handleTestLoopRoutes } from './routes/test-loop-routes.js';
|
||||||
import { handleTaskRoutes } from './routes/task-routes.js';
|
import { handleTaskRoutes } from './routes/task-routes.js';
|
||||||
|
import { handleDashboardRoutes } from './routes/dashboard-routes.js';
|
||||||
|
import { handleOrchestratorRoutes } from './routes/orchestrator-routes.js';
|
||||||
|
|
||||||
// Import WebSocket handling
|
// Import WebSocket handling
|
||||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||||
@@ -514,6 +516,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleNavStatusRoutes(routeContext)) return;
|
if (await handleNavStatusRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard routes (/api/dashboard/*) - Dashboard initialization
|
||||||
|
if (pathname.startsWith('/api/dashboard/')) {
|
||||||
|
if (await handleDashboardRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
// CLI routes (/api/cli/*)
|
// CLI routes (/api/cli/*)
|
||||||
if (pathname.startsWith('/api/cli/')) {
|
if (pathname.startsWith('/api/cli/')) {
|
||||||
// CLI Settings routes first (more specific path /api/cli/settings/*)
|
// CLI Settings routes first (more specific path /api/cli/settings/*)
|
||||||
@@ -577,6 +584,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleCcwRoutes(routeContext)) return;
|
if (await handleCcwRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Orchestrator routes (/api/orchestrator/*)
|
||||||
|
if (pathname.startsWith('/api/orchestrator/')) {
|
||||||
|
if (await handleOrchestratorRoutes(routeContext)) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Loop V2 routes (/api/loops/v2/*) - must be checked before v1
|
// Loop V2 routes (/api/loops/v2/*) - must be checked before v1
|
||||||
if (pathname.startsWith('/api/loops/v2')) {
|
if (pathname.startsWith('/api/loops/v2')) {
|
||||||
if (await handleLoopV2Routes(routeContext)) return;
|
if (await handleLoopV2Routes(routeContext)) return;
|
||||||
|
|||||||
1154
ccw/src/core/services/flow-executor.ts
Normal file
1154
ccw/src/core/services/flow-executor.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,79 @@ export interface LoopLogEntryMessage {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator WebSocket message types
|
||||||
|
*/
|
||||||
|
export type OrchestratorMessageType =
|
||||||
|
| 'ORCHESTRATOR_STATE_UPDATE'
|
||||||
|
| 'ORCHESTRATOR_NODE_STARTED'
|
||||||
|
| 'ORCHESTRATOR_NODE_COMPLETED'
|
||||||
|
| 'ORCHESTRATOR_NODE_FAILED'
|
||||||
|
| 'ORCHESTRATOR_LOG';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execution log entry for Orchestrator
|
||||||
|
*/
|
||||||
|
export interface ExecutionLog {
|
||||||
|
timestamp: string;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
nodeId?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator State Update - fired when execution status changes
|
||||||
|
*/
|
||||||
|
export interface OrchestratorStateUpdateMessage {
|
||||||
|
type: 'ORCHESTRATOR_STATE_UPDATE';
|
||||||
|
execId: string;
|
||||||
|
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed';
|
||||||
|
currentNodeId?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator Node Started - fired when a node begins execution
|
||||||
|
*/
|
||||||
|
export interface OrchestratorNodeStartedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_STARTED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator Node Completed - fired when a node finishes successfully
|
||||||
|
*/
|
||||||
|
export interface OrchestratorNodeCompletedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_COMPLETED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
result?: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator Node Failed - fired when a node encounters an error
|
||||||
|
*/
|
||||||
|
export interface OrchestratorNodeFailedMessage {
|
||||||
|
type: 'ORCHESTRATOR_NODE_FAILED';
|
||||||
|
execId: string;
|
||||||
|
nodeId: string;
|
||||||
|
error: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator Log - fired for execution log entries
|
||||||
|
*/
|
||||||
|
export interface OrchestratorLogMessage {
|
||||||
|
type: 'ORCHESTRATOR_LOG';
|
||||||
|
execId: string;
|
||||||
|
log: ExecutionLog;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _head: Buffer): void {
|
export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _head: Buffer): void {
|
||||||
const header = req.headers['sec-websocket-key'];
|
const header = req.headers['sec-websocket-key'];
|
||||||
const key = Array.isArray(header) ? header[0] : header;
|
const key = Array.isArray(header) ? header[0] : header;
|
||||||
@@ -300,3 +373,59 @@ export function broadcastLoopLog(loop_id: string, step_id: string, line: string)
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type for Orchestrator messages (without timestamp - added automatically)
|
||||||
|
*/
|
||||||
|
export type OrchestratorMessage =
|
||||||
|
| Omit<OrchestratorStateUpdateMessage, 'timestamp'>
|
||||||
|
| Omit<OrchestratorNodeStartedMessage, 'timestamp'>
|
||||||
|
| Omit<OrchestratorNodeCompletedMessage, 'timestamp'>
|
||||||
|
| Omit<OrchestratorNodeFailedMessage, 'timestamp'>
|
||||||
|
| Omit<OrchestratorLogMessage, 'timestamp'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrator-specific broadcast with throttling
|
||||||
|
* Throttles ORCHESTRATOR_STATE_UPDATE messages to avoid flooding clients
|
||||||
|
*/
|
||||||
|
let lastOrchestratorBroadcast = 0;
|
||||||
|
const ORCHESTRATOR_BROADCAST_THROTTLE = 1000; // 1 second
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast orchestrator update with throttling
|
||||||
|
* STATE_UPDATE messages are throttled to 1 per second
|
||||||
|
* Other message types are sent immediately
|
||||||
|
*/
|
||||||
|
export function broadcastOrchestratorUpdate(message: OrchestratorMessage): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Throttle ORCHESTRATOR_STATE_UPDATE to reduce WebSocket traffic
|
||||||
|
if (message.type === 'ORCHESTRATOR_STATE_UPDATE' && now - lastOrchestratorBroadcast < ORCHESTRATOR_BROADCAST_THROTTLE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'ORCHESTRATOR_STATE_UPDATE') {
|
||||||
|
lastOrchestratorBroadcast = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToClients({
|
||||||
|
...message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast orchestrator log entry (no throttling)
|
||||||
|
* Used for streaming real-time execution logs to Dashboard
|
||||||
|
*/
|
||||||
|
export function broadcastOrchestratorLog(execId: string, log: Omit<ExecutionLog, 'timestamp'>): void {
|
||||||
|
broadcastToClients({
|
||||||
|
type: 'ORCHESTRATOR_LOG',
|
||||||
|
execId,
|
||||||
|
log: {
|
||||||
|
...log,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user