mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +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)
|
||||
|
||||
### 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)
|
||||
|
||||
Task Configuration:
|
||||
@@ -154,9 +154,29 @@ Task Configuration:
|
||||
- flow_control: Test generation strategy from TEST_ANALYSIS_RESULTS.md
|
||||
- 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):
|
||||
- meta.type: "test-fix"
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
## 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
|
||||
- Test-specific metadata: type, agent, test_framework, coverage_target
|
||||
- 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)
|
||||
- Artifact references from test-context-package.json
|
||||
- Absolute paths in context.files_to_test
|
||||
@@ -211,7 +238,7 @@ PRIMARY requirements source - extract and map to task JSONs:
|
||||
|
||||
## QUALITY STANDARDS
|
||||
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
|
||||
- Test framework matches existing project framework
|
||||
- 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)
|
||||
|
||||
### Output
|
||||
- Test task JSON files in `.task/` directory (minimum 2)
|
||||
- IMPL_PLAN.md with test strategy and fix cycle specification
|
||||
- Test task JSON files in `.task/` directory (minimum 4):
|
||||
- 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
|
||||
- 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 { handleTestLoopRoutes } from './routes/test-loop-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 { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||
@@ -514,6 +516,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
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/*)
|
||||
if (pathname.startsWith('/api/cli/')) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (pathname.startsWith('/api/loops/v2')) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const header = req.headers['sec-websocket-key'];
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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