feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution

This commit is contained in:
catlog22
2026-01-30 16:59:18 +08:00
parent 0a7c1454d9
commit a5c3dff8d3
92 changed files with 23875 additions and 542 deletions

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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

View 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
View 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

File diff suppressed because it is too large Load Diff

42
ccw/frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

17
ccw/frontend/src/App.tsx Normal file
View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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,
};

View 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 };

View 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,
};

View 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";

View 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';

View 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;
}

View 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,
};
}

View 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,
});
};
}

View 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,
});
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
});
};
}

View 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,
};
}

View 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,
};
});
},
});
}

View 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,
};
}

View 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
View 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
View 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),
});
}

View 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
View 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>,
)

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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">&#10003;</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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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';

View 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';

View 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';

View 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';

View 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';

View 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,
};

View 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];

View 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;

View 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';
};

View 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';
};

View 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;

View 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';

View 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 }),
};

View 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;

View 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[];
}

View 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 },
},
};

View 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;

View 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")],
}

View 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" }]
}

View 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"]
}

View 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,
},
})

View 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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()
});
}