mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-06 01:54:11 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dca69fbec | ||
|
|
ac626e5895 | ||
|
|
cb78758839 | ||
|
|
844a2412b2 | ||
|
|
650d877430 | ||
|
|
f459061ad5 | ||
|
|
a6f9701679 | ||
|
|
26a325efff | ||
|
|
0a96ee16a8 | ||
|
|
43c962b48b | ||
|
|
724545ebd6 | ||
|
|
a9a2004d4a | ||
|
|
5b14c8a832 | ||
|
|
e2c5a514cb | ||
|
|
296761a34e | ||
|
|
1d3436d51b | ||
|
|
60bb11c315 | ||
|
|
72fe6195af | ||
|
|
04fb3b7ee3 | ||
|
|
942fca7ad8 | ||
|
|
39df995e37 | ||
|
|
efaa8b6620 | ||
|
|
35bd0aa8f6 | ||
|
|
0f9adc59f9 |
@@ -16,11 +16,9 @@ description: |
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are a pure execution agent specialized in creating actionable implementation plans. You receive requirements and control flags from the command layer and execute planning tasks without complex decision-making logic.
|
||||
|
||||
## Overview
|
||||
|
||||
**Agent Role**: Transform user requirements and brainstorming artifacts into structured, executable implementation plans with quantified deliverables and measurable acceptance criteria.
|
||||
**Agent Role**: Pure execution agent that transforms user requirements and brainstorming artifacts into structured, executable implementation plans with quantified deliverables and measurable acceptance criteria. Receives requirements and control flags from the command layer and executes planning tasks without complex decision-making logic.
|
||||
|
||||
**Core Capabilities**:
|
||||
- Load and synthesize context from multiple sources (session metadata, context packages, brainstorming artifacts)
|
||||
@@ -33,7 +31,7 @@ You are a pure execution agent specialized in creating actionable implementation
|
||||
|
||||
---
|
||||
|
||||
## 1. Execution Process
|
||||
## 1. Input & Execution
|
||||
|
||||
### 1.1 Input Processing
|
||||
|
||||
@@ -50,7 +48,7 @@ You are a pure execution agent specialized in creating actionable implementation
|
||||
- **Control flags**: DEEP_ANALYSIS_REQUIRED, etc.
|
||||
- **Task requirements**: Direct task description
|
||||
|
||||
### 1.2 Two-Phase Execution Flow
|
||||
### 1.2 Execution Flow
|
||||
|
||||
#### Phase 1: Context Loading & Assembly
|
||||
|
||||
@@ -88,6 +86,27 @@ You are a pure execution agent specialized in creating actionable implementation
|
||||
6. Assess task complexity (simple/medium/complex)
|
||||
```
|
||||
|
||||
**MCP Integration** (when `mcp_capabilities` available):
|
||||
|
||||
```javascript
|
||||
// Exa Code Context (mcp_capabilities.exa_code = true)
|
||||
mcp__exa__get_code_context_exa(
|
||||
query="TypeScript OAuth2 JWT authentication patterns",
|
||||
tokensNum="dynamic"
|
||||
)
|
||||
|
||||
// Integration in flow_control.pre_analysis
|
||||
{
|
||||
"step": "local_codebase_exploration",
|
||||
"action": "Explore codebase structure",
|
||||
"commands": [
|
||||
"bash(rg '^(function|class|interface).*[task_keyword]' --type ts -n --max-count 15)",
|
||||
"bash(find . -name '*[task_keyword]*' -type f | grep -v node_modules | head -10)"
|
||||
],
|
||||
"output_to": "codebase_structure"
|
||||
}
|
||||
```
|
||||
|
||||
**Context Package Structure** (fields defined by context-search-agent):
|
||||
|
||||
**Always Present**:
|
||||
@@ -169,30 +188,6 @@ if (contextPackage.brainstorm_artifacts?.role_analyses?.length > 0) {
|
||||
5. Update session state for execution readiness
|
||||
```
|
||||
|
||||
### 1.3 MCP Integration Guidelines
|
||||
|
||||
**Exa Code Context** (`mcp_capabilities.exa_code = true`):
|
||||
```javascript
|
||||
// Get best practices and examples
|
||||
mcp__exa__get_code_context_exa(
|
||||
query="TypeScript OAuth2 JWT authentication patterns",
|
||||
tokensNum="dynamic"
|
||||
)
|
||||
```
|
||||
|
||||
**Integration in flow_control.pre_analysis**:
|
||||
```json
|
||||
{
|
||||
"step": "local_codebase_exploration",
|
||||
"action": "Explore codebase structure",
|
||||
"commands": [
|
||||
"bash(rg '^(function|class|interface).*[task_keyword]' --type ts -n --max-count 15)",
|
||||
"bash(find . -name '*[task_keyword]*' -type f | grep -v node_modules | head -10)"
|
||||
],
|
||||
"output_to": "codebase_structure"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Output Specifications
|
||||
@@ -213,7 +208,11 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
|
||||
```
|
||||
|
||||
**Field Descriptions**:
|
||||
- `id`: Task identifier (format: `IMPL-N`)
|
||||
- `id`: Task identifier
|
||||
- Single module format: `IMPL-N` (e.g., IMPL-001, IMPL-002)
|
||||
- Multi-module format: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1, IMPL-C1)
|
||||
- Prefix: A, B, C... (assigned by module detection order)
|
||||
- Sequence: 1, 2, 3... (per-module increment)
|
||||
- `title`: Descriptive task name summarizing the work
|
||||
- `status`: Task state - `pending` (not started), `active` (in progress), `completed` (done), `blocked` (waiting on dependencies)
|
||||
- `context_package_path`: Path to smart context package containing project structure, dependencies, and brainstorming artifacts catalog
|
||||
@@ -225,7 +224,8 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
|
||||
"meta": {
|
||||
"type": "feature|bugfix|refactor|test-gen|test-fix|docs",
|
||||
"agent": "@code-developer|@action-planning-agent|@test-fix-agent|@universal-executor",
|
||||
"execution_group": "parallel-abc123|null"
|
||||
"execution_group": "parallel-abc123|null",
|
||||
"module": "frontend|backend|shared|null"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -234,6 +234,7 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
|
||||
- `type`: Task category - `feature` (new functionality), `bugfix` (fix defects), `refactor` (restructure code), `test-gen` (generate tests), `test-fix` (fix failing tests), `docs` (documentation)
|
||||
- `agent`: Assigned agent for execution
|
||||
- `execution_group`: Parallelization group ID (tasks with same ID can run concurrently) or `null` for sequential tasks
|
||||
- `module`: Module identifier for multi-module projects (e.g., `frontend`, `backend`, `shared`) or `null` for single-module
|
||||
|
||||
**Test Task Extensions** (for type="test-gen" or type="test-fix"):
|
||||
|
||||
@@ -604,10 +605,42 @@ Agent determines CLI tool usage per-step based on user semantics and task nature
|
||||
- Analysis results (technical approach, architecture decisions)
|
||||
- Brainstorming artifacts (role analyses, guidance specifications)
|
||||
|
||||
**Multi-Module Format** (when modules detected):
|
||||
|
||||
When multiple modules are detected (frontend/backend, etc.), organize IMPL_PLAN.md by module:
|
||||
|
||||
```markdown
|
||||
# Implementation Plan
|
||||
|
||||
## Module A: Frontend (N tasks)
|
||||
### IMPL-A1: [Task Title]
|
||||
[Task details...]
|
||||
|
||||
### IMPL-A2: [Task Title]
|
||||
[Task details...]
|
||||
|
||||
## Module B: Backend (N tasks)
|
||||
### IMPL-B1: [Task Title]
|
||||
[Task details...]
|
||||
|
||||
### IMPL-B2: [Task Title]
|
||||
[Task details...]
|
||||
|
||||
## Cross-Module Dependencies
|
||||
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
|
||||
- IMPL-A2 → IMPL-B2 (UI state depends on Backend service)
|
||||
```
|
||||
|
||||
**Cross-Module Dependency Notation**:
|
||||
- During parallel planning, use `CROSS::{module}::{pattern}` format
|
||||
- Example: `depends_on: ["CROSS::B::api-endpoint"]`
|
||||
- Integration phase resolves to actual task IDs: `CROSS::B::api → IMPL-B1`
|
||||
|
||||
### 2.3 TODO_LIST.md Structure
|
||||
|
||||
Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
|
||||
|
||||
**Single Module Format**:
|
||||
```markdown
|
||||
# Tasks: {Session Topic}
|
||||
|
||||
@@ -621,30 +654,54 @@ Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
|
||||
- `- [x]` = Completed task
|
||||
```
|
||||
|
||||
**Multi-Module Format** (hierarchical by module):
|
||||
```markdown
|
||||
# Tasks: {Session Topic}
|
||||
|
||||
## Module A (Frontend)
|
||||
- [ ] **IMPL-A1**: [Task Title] → [📋](./.task/IMPL-A1.json)
|
||||
- [ ] **IMPL-A2**: [Task Title] → [📋](./.task/IMPL-A2.json)
|
||||
|
||||
## Module B (Backend)
|
||||
- [ ] **IMPL-B1**: [Task Title] → [📋](./.task/IMPL-B1.json)
|
||||
- [ ] **IMPL-B2**: [Task Title] → [📋](./.task/IMPL-B2.json)
|
||||
|
||||
## Cross-Module Dependencies
|
||||
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
|
||||
|
||||
## Status Legend
|
||||
- `- [ ]` = Pending task
|
||||
- `- [x]` = Completed task
|
||||
```
|
||||
|
||||
**Linking Rules**:
|
||||
- Todo items → task JSON: `[📋](./.task/IMPL-XXX.json)`
|
||||
- Completed tasks → summaries: `[✅](./.summaries/IMPL-XXX-summary.md)`
|
||||
- Consistent ID schemes: IMPL-XXX
|
||||
- Consistent ID schemes: `IMPL-N` (single) or `IMPL-{prefix}{seq}` (multi-module)
|
||||
|
||||
### 2.4 Complexity-Based Structure Selection
|
||||
### 2.4 Complexity & Structure Selection
|
||||
|
||||
Use `analysis_results.complexity` or task count to determine structure:
|
||||
|
||||
**Simple Tasks** (≤5 tasks):
|
||||
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
|
||||
- All tasks at same level
|
||||
**Single Module Mode**:
|
||||
- **Simple Tasks** (≤5 tasks): Flat structure
|
||||
- **Medium Tasks** (6-12 tasks): Flat structure
|
||||
- **Complex Tasks** (>12 tasks): Re-scope required (maximum 12 tasks hard limit)
|
||||
|
||||
**Medium Tasks** (6-12 tasks):
|
||||
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
|
||||
- All tasks at same level
|
||||
**Multi-Module Mode** (N+1 parallel planning):
|
||||
- **Per-module limit**: ≤9 tasks per module
|
||||
- **Total limit**: Sum of all module tasks ≤27 (3 modules × 9 tasks)
|
||||
- **Task ID format**: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
|
||||
- **Structure**: Hierarchical by module in IMPL_PLAN.md and TODO_LIST.md
|
||||
|
||||
**Complex Tasks** (>12 tasks):
|
||||
- **Re-scope required**: Maximum 12 tasks hard limit
|
||||
- If analysis_results contains >12 tasks, consolidate or request re-scoping
|
||||
**Multi-Module Detection Triggers**:
|
||||
- Explicit frontend/backend separation (`src/frontend`, `src/backend`)
|
||||
- Monorepo structure (`packages/*`, `apps/*`)
|
||||
- Context-package dependency clustering (2+ distinct module groups)
|
||||
|
||||
---
|
||||
|
||||
## 3. Quality & Standards
|
||||
## 3. Quality Standards
|
||||
|
||||
### 3.1 Quantification Requirements (MANDATORY)
|
||||
|
||||
@@ -670,47 +727,46 @@ Use `analysis_results.complexity` or task count to determine structure:
|
||||
- [ ] Each implementation step has its own acceptance criteria
|
||||
|
||||
**Examples**:
|
||||
- ✅ GOOD: `"Implement 5 commands: [cmd1, cmd2, cmd3, cmd4, cmd5]"`
|
||||
- ❌ BAD: `"Implement new commands"`
|
||||
- ✅ GOOD: `"5 files created: verify by ls .claude/commands/*.md | wc -l = 5"`
|
||||
- ❌ BAD: `"All commands implemented successfully"`
|
||||
- GOOD: `"Implement 5 commands: [cmd1, cmd2, cmd3, cmd4, cmd5]"`
|
||||
- BAD: `"Implement new commands"`
|
||||
- GOOD: `"5 files created: verify by ls .claude/commands/*.md | wc -l = 5"`
|
||||
- BAD: `"All commands implemented successfully"`
|
||||
|
||||
### 3.2 Planning Principles
|
||||
### 3.2 Planning & Organization Standards
|
||||
|
||||
**Planning Principles**:
|
||||
- Each stage produces working, testable code
|
||||
- Clear success criteria for each deliverable
|
||||
- Dependencies clearly identified between stages
|
||||
- Incremental progress over big bangs
|
||||
|
||||
### 3.3 File Organization
|
||||
|
||||
**File Organization**:
|
||||
- Session naming: `WFS-[topic-slug]`
|
||||
- Task IDs: IMPL-XXX (flat structure only)
|
||||
- Directory structure: flat task organization
|
||||
|
||||
### 3.4 Document Standards
|
||||
- Task IDs:
|
||||
- Single module: `IMPL-N` (e.g., IMPL-001, IMPL-002)
|
||||
- Multi-module: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
|
||||
- Directory structure: flat task organization (all tasks in `.task/`)
|
||||
|
||||
**Document Standards**:
|
||||
- Proper linking between documents
|
||||
- Consistent navigation and references
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Reminders
|
||||
### 3.3 Guidelines Checklist
|
||||
|
||||
**ALWAYS:**
|
||||
- **Apply Quantification Requirements**: All requirements, acceptance criteria, and modification points MUST include explicit counts and enumerations
|
||||
- **Load IMPL_PLAN template**: Read(~/.claude/workflows/cli-templates/prompts/workflow/impl-plan-template.txt) before generating IMPL_PLAN.md
|
||||
- **Use provided context package**: Extract all information from structured context
|
||||
- **Respect memory-first rule**: Use provided content (already loaded from memory/file)
|
||||
- **Follow 6-field schema**: All task JSONs must have id, title, status, context_package_path, meta, context, flow_control
|
||||
- **Map artifacts**: Use artifacts_inventory to populate task.context.artifacts array
|
||||
- **Add MCP integration**: Include MCP tool steps in flow_control.pre_analysis when capabilities available
|
||||
- **Validate task count**: Maximum 12 tasks hard limit, request re-scope if exceeded
|
||||
- **Use session paths**: Construct all paths using provided session_id
|
||||
- **Link documents properly**: Use correct linking format (📋 for JSON, ✅ for summaries)
|
||||
- **Run validation checklist**: Verify all quantification requirements before finalizing task JSONs
|
||||
- **Apply 举一反三 principle**: Adapt pre-analysis patterns to task-specific needs dynamically
|
||||
- **Follow template validation**: Complete IMPL_PLAN.md template validation checklist before finalization
|
||||
- Apply Quantification Requirements to all requirements, acceptance criteria, and modification points
|
||||
- Load IMPL_PLAN template: `Read(~/.claude/workflows/cli-templates/prompts/workflow/impl-plan-template.txt)` before generating IMPL_PLAN.md
|
||||
- Use provided context package: Extract all information from structured context
|
||||
- Respect memory-first rule: Use provided content (already loaded from memory/file)
|
||||
- Follow 6-field schema: All task JSONs must have id, title, status, context_package_path, meta, context, flow_control
|
||||
- Map artifacts: Use artifacts_inventory to populate task.context.artifacts array
|
||||
- Add MCP integration: Include MCP tool steps in flow_control.pre_analysis when capabilities available
|
||||
- Validate task count: Maximum 12 tasks hard limit, request re-scope if exceeded
|
||||
- Use session paths: Construct all paths using provided session_id
|
||||
- Link documents properly: Use correct linking format (📋 for JSON, ✅ for summaries)
|
||||
- Run validation checklist: Verify all quantification requirements before finalizing task JSONs
|
||||
- Apply 举一反三 principle: Adapt pre-analysis patterns to task-specific needs dynamically
|
||||
- Follow template validation: Complete IMPL_PLAN.md template validation checklist before finalization
|
||||
|
||||
**NEVER:**
|
||||
- Load files directly (use provided context package instead)
|
||||
|
||||
@@ -64,12 +64,17 @@ Lightweight planner that analyzes project structure, decomposes documentation wo
|
||||
```bash
|
||||
# Get target path, project name, and root
|
||||
bash(pwd && basename "$(pwd)" && git rev-parse --show-toplevel 2>/dev/null || pwd && date +%Y%m%d-%H%M%S)
|
||||
```
|
||||
|
||||
# Create session directories (replace timestamp)
|
||||
bash(mkdir -p .workflow/active/WFS-docs-{timestamp}/.{task,process,summaries})
|
||||
```javascript
|
||||
// Create docs session (type: docs)
|
||||
SlashCommand(command="/workflow:session:start --type docs --new \"{project_name}-docs-{timestamp}\"")
|
||||
// Parse output to get sessionId
|
||||
```
|
||||
|
||||
# Create workflow-session.json (replace values)
|
||||
bash(echo '{"session_id":"WFS-docs-{timestamp}","project":"{project} documentation","status":"planning","timestamp":"2024-01-20T14:30:22+08:00","path":".","target_path":"{target_path}","project_root":"{project_root}","project_name":"{project_name}","mode":"full","tool":"gemini","cli_execute":false}' | jq '.' > .workflow/active/WFS-docs-{timestamp}/workflow-session.json)
|
||||
```bash
|
||||
# Update workflow-session.json with docs-specific fields
|
||||
bash(jq '. + {"target_path":"{target_path}","project_root":"{project_root}","project_name":"{project_name}","mode":"full","tool":"gemini","cli_execute":false}' .workflow/active/{sessionId}/workflow-session.json > tmp.json && mv tmp.json .workflow/active/{sessionId}/workflow-session.json)
|
||||
```
|
||||
|
||||
### Phase 2: Analyze Structure
|
||||
|
||||
@@ -46,8 +46,7 @@ Automated fix orchestrator with **two-phase architecture**: AI-powered planning
|
||||
1. **Intelligent Planning**: AI-powered analysis identifies optimal grouping and execution strategy
|
||||
2. **Multi-stage Coordination**: Supports complex parallel + serial execution with dependency management
|
||||
3. **Conservative Safety**: Mandatory test verification with automatic rollback on failure
|
||||
4. **Real-time Visibility**: Dashboard shows planning progress, stage timeline, and active agents
|
||||
5. **Resume Support**: Checkpoint-based recovery for interrupted sessions
|
||||
4. **Resume Support**: Checkpoint-based recovery for interrupted sessions
|
||||
|
||||
### Orchestrator Boundary (CRITICAL)
|
||||
- **ONLY command** for automated review finding fixes
|
||||
@@ -59,14 +58,14 @@ Automated fix orchestrator with **two-phase architecture**: AI-powered planning
|
||||
|
||||
```
|
||||
Phase 1: Discovery & Initialization
|
||||
└─ Validate export file, create fix session structure, initialize state files → Generate fix-dashboard.html
|
||||
└─ Validate export file, create fix session structure, initialize state files
|
||||
|
||||
Phase 2: Planning Coordination (@cli-planning-agent)
|
||||
├─ Analyze findings for patterns and dependencies
|
||||
├─ Group by file + dimension + root cause similarity
|
||||
├─ Determine execution strategy (parallel/serial/hybrid)
|
||||
├─ Generate fix timeline with stages
|
||||
└─ Output: fix-plan.json (dashboard auto-polls for status)
|
||||
└─ Output: fix-plan.json
|
||||
|
||||
Phase 3: Execution Orchestration (Stage-based)
|
||||
For each timeline stage:
|
||||
@@ -198,12 +197,10 @@ if (result.passRate < 100%) {
|
||||
- Session creation: Generate fix-session-id (`fix-{timestamp}`)
|
||||
- Directory structure: Create `{review-dir}/fixes/{fix-session-id}/` with subdirectories
|
||||
- State files: Initialize active-fix-session.json (session marker)
|
||||
- Dashboard generation: Create fix-dashboard.html from template (see Dashboard Generation below)
|
||||
- TodoWrite initialization: Set up 4-phase tracking
|
||||
|
||||
**Phase 2: Planning Coordination**
|
||||
- Launch @cli-planning-agent with findings data and project context
|
||||
- Monitor planning progress (dashboard shows "Planning fixes..." indicator)
|
||||
- Validate fix-plan.json output (schema conformance, includes metadata with session status)
|
||||
- Load plan into memory for execution phase
|
||||
- TodoWrite update: Mark planning complete, start execution
|
||||
@@ -216,7 +213,6 @@ if (result.passRate < 100%) {
|
||||
- Assign agent IDs (agents update their fix-progress-{N}.json)
|
||||
- Handle agent failures gracefully (mark group as failed, continue)
|
||||
- Advance to next stage only when current stage complete
|
||||
- Dashboard polls and aggregates fix-progress-{N}.json files for display
|
||||
|
||||
**Phase 4: Completion & Aggregation**
|
||||
- Collect final status from all fix-progress-{N}.json files
|
||||
@@ -224,7 +220,7 @@ if (result.passRate < 100%) {
|
||||
- Update fix-history.json with new session entry
|
||||
- Remove active-fix-session.json
|
||||
- TodoWrite completion: Mark all phases done
|
||||
- Output summary to user with dashboard link
|
||||
- Output summary to user
|
||||
|
||||
**Phase 5: Session Completion (Optional)**
|
||||
- If all findings fixed successfully (no failures):
|
||||
@@ -234,53 +230,12 @@ if (result.passRate < 100%) {
|
||||
- Output: "Some findings failed. Review fix-summary.md before completing session."
|
||||
- Do NOT auto-complete session
|
||||
|
||||
### Dashboard Generation
|
||||
|
||||
**MANDATORY**: Dashboard MUST be generated from template during Phase 1 initialization
|
||||
|
||||
**Template Location**: `~/.claude/templates/fix-dashboard.html`
|
||||
|
||||
**⚠️ POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify fix-dashboard.html after creation
|
||||
|
||||
**Generation Steps**:
|
||||
|
||||
```bash
|
||||
# 1. Copy template to fix session directory
|
||||
cp ~/.claude/templates/fix-dashboard.html ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
|
||||
|
||||
# 2. Replace SESSION_ID placeholder
|
||||
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
|
||||
|
||||
# 3. Replace REVIEW_DIR placeholder
|
||||
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
|
||||
|
||||
# 4. Start local server and output dashboard URL
|
||||
cd ${sessionDir}/fixes/${fixSessionId} && python -m http.server 8766 --bind 127.0.0.1 &
|
||||
echo "🔧 Fix Dashboard: http://127.0.0.1:8766/fix-dashboard.html"
|
||||
echo " (Press Ctrl+C to stop server when done)"
|
||||
```
|
||||
|
||||
**Dashboard Features**:
|
||||
- Real-time progress tracking via JSON polling (3-second interval)
|
||||
- Stage timeline visualization with parallel/serial execution modes
|
||||
- Active groups and agents monitoring
|
||||
- Flow control steps tracking for each agent
|
||||
- Fix history drawer with session summaries
|
||||
- Consumes new JSON structure (fix-plan.json with metadata + fix-progress-{N}.json)
|
||||
|
||||
**JSON Consumption**:
|
||||
- `fix-plan.json`: Reads metadata field for session info, timeline stages, groups configuration
|
||||
- `fix-progress-{N}.json`: Polls all progress files to aggregate real-time status
|
||||
- `active-fix-session.json`: Detects active session on load
|
||||
- `fix-history.json`: Loads historical fix sessions
|
||||
|
||||
### Output File Structure
|
||||
|
||||
```
|
||||
.workflow/active/WFS-{session-id}/.review/
|
||||
├── fix-export-{timestamp}.json # Exported findings (input)
|
||||
└── fixes/{fix-session-id}/
|
||||
├── fix-dashboard.html # Interactive dashboard (generated once, auto-polls JSON)
|
||||
├── fix-plan.json # Planning agent output (execution plan with metadata)
|
||||
├── fix-progress-1.json # Group 1 progress (planning agent init → agent updates)
|
||||
├── fix-progress-2.json # Group 2 progress (planning agent init → agent updates)
|
||||
@@ -291,10 +246,8 @@ echo " (Press Ctrl+C to stop server when done)"
|
||||
```
|
||||
|
||||
**File Producers**:
|
||||
- **Orchestrator**: `fix-dashboard.html` (generated once from template during Phase 1)
|
||||
- **Planning Agent**: `fix-plan.json` (with metadata), all `fix-progress-*.json` (initial state)
|
||||
- **Execution Agents**: Update assigned `fix-progress-{N}.json` in real-time
|
||||
- **Dashboard (Browser)**: Reads `fix-plan.json` + all `fix-progress-*.json`, aggregates in-memory every 3 seconds via JavaScript polling
|
||||
|
||||
|
||||
### Agent Invocation Template
|
||||
@@ -347,7 +300,7 @@ For each group (G1, G2, G3, ...), generate fix-progress-{N}.json following templ
|
||||
- Flow control: Empty implementation_approach array
|
||||
- Errors: Empty array
|
||||
|
||||
**CRITICAL**: Ensure complete template structure for Dashboard consumption - all fields must be present.
|
||||
**CRITICAL**: Ensure complete template structure - all fields must be present.
|
||||
|
||||
## Analysis Requirements
|
||||
|
||||
@@ -419,7 +372,7 @@ Task({
|
||||
description: `Fix ${group.findings.length} issues: ${group.group_name}`,
|
||||
prompt: `
|
||||
## Task Objective
|
||||
Execute fixes for code review findings in group ${group.group_id}. Update progress file in real-time with flow control tracking for dashboard visibility.
|
||||
Execute fixes for code review findings in group ${group.group_id}. Update progress file in real-time with flow control tracking.
|
||||
|
||||
## Assignment
|
||||
- Group ID: ${group.group_id}
|
||||
@@ -549,7 +502,6 @@ When all findings processed:
|
||||
|
||||
### Progress File Updates
|
||||
- **MUST update after every significant action** (before/after each step)
|
||||
- **Dashboard polls every 3 seconds** - ensure writes are atomic
|
||||
- **Always maintain complete structure** - never write partial updates
|
||||
- **Use ISO 8601 timestamps** - e.g., "2025-01-25T14:36:00Z"
|
||||
|
||||
@@ -638,9 +590,17 @@ TodoWrite({
|
||||
1. **Trust AI Planning**: Planning agent's grouping and execution strategy are based on dependency analysis
|
||||
2. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests
|
||||
3. **Parallel Efficiency**: Default 3 concurrent agents balances speed and resource usage
|
||||
4. **Monitor Dashboard**: Real-time stage timeline and agent status provide execution visibility
|
||||
5. **Resume Support**: Fix sessions can resume from checkpoints after interruption
|
||||
6. **Manual Review**: Always review failed fixes manually - may require architectural changes
|
||||
7. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes
|
||||
4. **Resume Support**: Fix sessions can resume from checkpoints after interruption
|
||||
5. **Manual Review**: Always review failed fixes manually - may require architectural changes
|
||||
6. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes
|
||||
|
||||
## Related Commands
|
||||
|
||||
### View Fix Progress
|
||||
Use `ccw view` to open the workflow dashboard in browser:
|
||||
|
||||
```bash
|
||||
ccw view
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -51,14 +51,12 @@ Independent multi-dimensional code review orchestrator with **hybrid parallel-it
|
||||
2. **Session-Integrated**: Review results tracked within workflow session for unified management
|
||||
3. **Comprehensive Coverage**: Same 7 specialized dimensions as session review
|
||||
4. **Intelligent Prioritization**: Automatic identification of critical issues and cross-cutting concerns
|
||||
5. **Real-time Visibility**: JSON-based progress tracking with interactive HTML dashboard
|
||||
6. **Unified Archive**: Review results archived with session for historical reference
|
||||
5. **Unified Archive**: Review results archived with session for historical reference
|
||||
|
||||
### Orchestrator Boundary (CRITICAL)
|
||||
- **ONLY command** for independent multi-dimensional module review
|
||||
- Manages: dimension coordination, aggregation, iteration control, progress tracking
|
||||
- Delegates: Code exploration and analysis to @cli-explore-agent, dimension-specific reviews via Deep Scan mode
|
||||
- **⚠️ DASHBOARD CONSTRAINT**: Dashboard is generated ONCE during Phase 1 initialization. After initialization, orchestrator and agents MUST NOT read, write, or modify dashboard.html - it remains static for user interaction only.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -66,7 +64,7 @@ Independent multi-dimensional code review orchestrator with **hybrid parallel-it
|
||||
|
||||
```
|
||||
Phase 1: Discovery & Initialization
|
||||
└─ Resolve file patterns, validate paths, initialize state, create output structure → Generate dashboard.html
|
||||
└─ Resolve file patterns, validate paths, initialize state, create output structure
|
||||
|
||||
Phase 2: Parallel Reviews (for each dimension)
|
||||
├─ Launch 7 review agents simultaneously
|
||||
@@ -90,7 +88,7 @@ Phase 4: Iterative Deep-Dive (optional)
|
||||
└─ Loop until no critical findings OR max iterations
|
||||
|
||||
Phase 5: Completion
|
||||
└─ Finalize review-progress.json → Output dashboard path
|
||||
└─ Finalize review-progress.json
|
||||
```
|
||||
|
||||
### Agent Roles
|
||||
@@ -188,8 +186,8 @@ const CATEGORIES = {
|
||||
|
||||
**Step 1: Session Creation**
|
||||
```javascript
|
||||
// Create workflow session for this review
|
||||
SlashCommand(command="/workflow:session:start \"Code review for [target_pattern]\"")
|
||||
// Create workflow session for this review (type: review)
|
||||
SlashCommand(command="/workflow:session:start --type review \"Code review for [target_pattern]\"")
|
||||
|
||||
// Parse output
|
||||
const sessionId = output.match(/SESSION_ID: (WFS-[^\s]+)/)[1];
|
||||
@@ -219,37 +217,9 @@ done
|
||||
|
||||
**Step 4: Initialize Review State**
|
||||
- State initialization: Create `review-state.json` with metadata, dimensions, max_iterations, resolved_files (merged metadata + state)
|
||||
- Progress tracking: Create `review-progress.json` for dashboard polling
|
||||
- Progress tracking: Create `review-progress.json` for progress tracking
|
||||
|
||||
**Step 5: Dashboard Generation**
|
||||
|
||||
**Constraints**:
|
||||
- **MANDATORY**: Dashboard MUST be generated from template: `~/.claude/templates/review-cycle-dashboard.html`
|
||||
- **PROHIBITED**: Direct creation or custom generation without template
|
||||
- **POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify dashboard.html after creation
|
||||
|
||||
**Generation Commands** (3 independent steps):
|
||||
```bash
|
||||
# Step 1: Copy template to output location
|
||||
cp ~/.claude/templates/review-cycle-dashboard.html ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 2: Replace SESSION_ID placeholder
|
||||
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 3: Replace REVIEW_TYPE placeholder
|
||||
sed -i "s|{{REVIEW_TYPE}}|module|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 4: Replace REVIEW_DIR placeholder
|
||||
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Output: Start local server and output dashboard URL
|
||||
# Use Python HTTP server (available on most systems)
|
||||
cd ${sessionDir}/.review && python -m http.server 8765 --bind 127.0.0.1 &
|
||||
echo "📊 Dashboard: http://127.0.0.1:8765/dashboard.html"
|
||||
echo " (Press Ctrl+C to stop server when done)"
|
||||
```
|
||||
|
||||
**Step 6: TodoWrite Initialization**
|
||||
**Step 5: TodoWrite Initialization**
|
||||
- Set up progress tracking with hierarchical structure
|
||||
- Mark Phase 1 completed, Phase 2 in_progress
|
||||
|
||||
@@ -280,7 +250,6 @@ echo " (Press Ctrl+C to stop server when done)"
|
||||
- Finalize review-progress.json with completion statistics
|
||||
- Update review-state.json with completion_time and phase=complete
|
||||
- TodoWrite completion: Mark all tasks done
|
||||
- Output: Dashboard path to user
|
||||
|
||||
|
||||
|
||||
@@ -301,12 +270,11 @@ echo " (Press Ctrl+C to stop server when done)"
|
||||
├── iterations/ # Deep-dive results
|
||||
│ ├── iteration-1-finding-{uuid}.json
|
||||
│ └── iteration-2-finding-{uuid}.json
|
||||
├── reports/ # Human-readable reports
|
||||
│ ├── security-analysis.md
|
||||
│ ├── security-cli-output.txt
|
||||
│ ├── deep-dive-1-{uuid}.md
|
||||
│ └── ...
|
||||
└── dashboard.html # Interactive dashboard (primary output)
|
||||
└── reports/ # Human-readable reports
|
||||
├── security-analysis.md
|
||||
├── security-cli-output.txt
|
||||
├── deep-dive-1-{uuid}.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Session Context**:
|
||||
@@ -772,23 +740,25 @@ TodoWrite({
|
||||
3. **Use Glob Wisely**: `src/auth/**` is more efficient than `src/**` with lots of irrelevant files
|
||||
4. **Trust Aggregation Logic**: Auto-selection based on proven heuristics
|
||||
5. **Monitor Logs**: Check reports/ directory for CLI analysis insights
|
||||
6. **Dashboard Polling**: Refresh every 5 seconds for real-time updates
|
||||
7. **Export Results**: Use dashboard export for external tracking tools
|
||||
|
||||
## Related Commands
|
||||
|
||||
### View Review Progress
|
||||
Use `ccw view` to open the review dashboard in browser:
|
||||
|
||||
```bash
|
||||
ccw view
|
||||
```
|
||||
|
||||
### Automated Fix Workflow
|
||||
After completing a module review, use the dashboard to select findings and export them for automated fixing:
|
||||
After completing a module review, use the generated findings JSON for automated fixing:
|
||||
|
||||
```bash
|
||||
# Step 1: Complete review (this command)
|
||||
/workflow:review-module-cycle src/auth/**
|
||||
|
||||
# Step 2: Open dashboard, select findings, and export
|
||||
# Dashboard generates: fix-export-{timestamp}.json
|
||||
|
||||
# Step 3: Run automated fixes
|
||||
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/fix-export-{timestamp}.json
|
||||
# Step 2: Run automated fixes using dimension findings
|
||||
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/
|
||||
```
|
||||
|
||||
See `/workflow:review-fix` for automated fixing with smart grouping, parallel execution, and test verification.
|
||||
|
||||
@@ -45,13 +45,11 @@ Session-based multi-dimensional code review orchestrator with **hybrid parallel-
|
||||
1. **Comprehensive Coverage**: 7 specialized dimensions analyze all quality aspects simultaneously
|
||||
2. **Intelligent Prioritization**: Automatic identification of critical issues and cross-cutting concerns
|
||||
3. **Actionable Insights**: Deep-dive iterations provide step-by-step remediation plans
|
||||
4. **Real-time Visibility**: JSON-based progress tracking with interactive HTML dashboard
|
||||
|
||||
### Orchestrator Boundary (CRITICAL)
|
||||
- **ONLY command** for comprehensive multi-dimensional review
|
||||
- Manages: dimension coordination, aggregation, iteration control, progress tracking
|
||||
- Delegates: Code exploration and analysis to @cli-explore-agent, dimension-specific reviews via Deep Scan mode
|
||||
- **⚠️ DASHBOARD CONSTRAINT**: Dashboard is generated ONCE during Phase 1 initialization. After initialization, orchestrator and agents MUST NOT read, write, or modify dashboard.html - it remains static for user interaction only.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -59,7 +57,7 @@ Session-based multi-dimensional code review orchestrator with **hybrid parallel-
|
||||
|
||||
```
|
||||
Phase 1: Discovery & Initialization
|
||||
└─ Validate session, initialize state, create output structure → Generate dashboard.html
|
||||
└─ Validate session, initialize state, create output structure
|
||||
|
||||
Phase 2: Parallel Reviews (for each dimension)
|
||||
├─ Launch 7 review agents simultaneously
|
||||
@@ -83,7 +81,7 @@ Phase 4: Iterative Deep-Dive (optional)
|
||||
└─ Loop until no critical findings OR max iterations
|
||||
|
||||
Phase 5: Completion
|
||||
└─ Finalize review-progress.json → Output dashboard path
|
||||
└─ Finalize review-progress.json
|
||||
```
|
||||
|
||||
### Agent Roles
|
||||
@@ -199,36 +197,9 @@ git log --since="${sessionCreatedAt}" --name-only --pretty=format: | sort -u
|
||||
|
||||
**Step 5: Initialize Review State**
|
||||
- State initialization: Create `review-state.json` with metadata, dimensions, max_iterations (merged metadata + state)
|
||||
- Progress tracking: Create `review-progress.json` for dashboard polling
|
||||
- Progress tracking: Create `review-progress.json` for progress tracking
|
||||
|
||||
**Step 6: Dashboard Generation**
|
||||
|
||||
**Constraints**:
|
||||
- **MANDATORY**: Dashboard MUST be generated from template: `~/.claude/templates/review-cycle-dashboard.html`
|
||||
- **PROHIBITED**: Direct creation or custom generation without template
|
||||
- **POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify dashboard.html after creation
|
||||
|
||||
**Generation Commands** (3 independent steps):
|
||||
```bash
|
||||
# Step 1: Copy template to output location
|
||||
cp ~/.claude/templates/review-cycle-dashboard.html ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 2: Replace SESSION_ID placeholder
|
||||
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 3: Replace REVIEW_TYPE placeholder
|
||||
sed -i "s|{{REVIEW_TYPE}}|session|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Step 4: Replace REVIEW_DIR placeholder
|
||||
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/.review/dashboard.html
|
||||
|
||||
# Output: Start local server and output dashboard URL
|
||||
cd ${sessionDir}/.review && python -m http.server 8765 --bind 127.0.0.1 &
|
||||
echo "📊 Dashboard: http://127.0.0.1:8765/dashboard.html"
|
||||
echo " (Press Ctrl+C to stop server when done)"
|
||||
```
|
||||
|
||||
**Step 7: TodoWrite Initialization**
|
||||
**Step 6: TodoWrite Initialization**
|
||||
- Set up progress tracking with hierarchical structure
|
||||
- Mark Phase 1 completed, Phase 2 in_progress
|
||||
|
||||
@@ -259,7 +230,6 @@ echo " (Press Ctrl+C to stop server when done)"
|
||||
- Finalize review-progress.json with completion statistics
|
||||
- Update review-state.json with completion_time and phase=complete
|
||||
- TodoWrite completion: Mark all tasks done
|
||||
- Output: Dashboard path to user
|
||||
|
||||
|
||||
|
||||
@@ -280,12 +250,11 @@ echo " (Press Ctrl+C to stop server when done)"
|
||||
├── iterations/ # Deep-dive results
|
||||
│ ├── iteration-1-finding-{uuid}.json
|
||||
│ └── iteration-2-finding-{uuid}.json
|
||||
├── reports/ # Human-readable reports
|
||||
│ ├── security-analysis.md
|
||||
│ ├── security-cli-output.txt
|
||||
│ ├── deep-dive-1-{uuid}.md
|
||||
│ └── ...
|
||||
└── dashboard.html # Interactive dashboard (primary output)
|
||||
└── reports/ # Human-readable reports
|
||||
├── security-analysis.md
|
||||
├── security-cli-output.txt
|
||||
├── deep-dive-1-{uuid}.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Session Context**:
|
||||
@@ -782,23 +751,25 @@ TodoWrite({
|
||||
2. **Parallel Execution**: ~60 minutes for full initial review (7 dimensions)
|
||||
3. **Trust Aggregation Logic**: Auto-selection based on proven heuristics
|
||||
4. **Monitor Logs**: Check reports/ directory for CLI analysis insights
|
||||
5. **Dashboard Polling**: Refresh every 5 seconds for real-time updates
|
||||
6. **Export Results**: Use dashboard export for external tracking tools
|
||||
|
||||
## Related Commands
|
||||
|
||||
### View Review Progress
|
||||
Use `ccw view` to open the review dashboard in browser:
|
||||
|
||||
```bash
|
||||
ccw view
|
||||
```
|
||||
|
||||
### Automated Fix Workflow
|
||||
After completing a review, use the dashboard to select findings and export them for automated fixing:
|
||||
After completing a review, use the generated findings JSON for automated fixing:
|
||||
|
||||
```bash
|
||||
# Step 1: Complete review (this command)
|
||||
/workflow:review-session-cycle
|
||||
|
||||
# Step 2: Open dashboard, select findings, and export
|
||||
# Dashboard generates: fix-export-{timestamp}.json
|
||||
|
||||
# Step 3: Run automated fixes
|
||||
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/fix-export-{timestamp}.json
|
||||
# Step 2: Run automated fixes using dimension findings
|
||||
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/
|
||||
```
|
||||
|
||||
See `/workflow:review-fix` for automated fixing with smart grouping, parallel execution, and test verification.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: start
|
||||
description: Discover existing sessions or start new workflow session with intelligent session management and conflict detection
|
||||
argument-hint: [--auto|--new] [optional: task description for new session]
|
||||
argument-hint: [--type <workflow|review|tdd|test|docs>] [--auto|--new] [optional: task description for new session]
|
||||
examples:
|
||||
- /workflow:session:start
|
||||
- /workflow:session:start --auto "implement OAuth2 authentication"
|
||||
- /workflow:session:start --new "fix login bug"
|
||||
- /workflow:session:start --type review "Code review for auth module"
|
||||
- /workflow:session:start --type tdd --auto "implement user authentication"
|
||||
- /workflow:session:start --type test --new "test payment flow"
|
||||
---
|
||||
|
||||
# Start Workflow Session (/workflow:session:start)
|
||||
@@ -17,6 +19,23 @@ Manages workflow sessions with three operation modes: discovery (manual), auto (
|
||||
1. **Project-level initialization** (first-time only): Creates `.workflow/project.json` for feature registry
|
||||
2. **Session-level initialization** (always): Creates session directory structure
|
||||
|
||||
## Session Types
|
||||
|
||||
The `--type` parameter classifies sessions for CCW dashboard organization:
|
||||
|
||||
| Type | Description | Default For |
|
||||
|------|-------------|-------------|
|
||||
| `workflow` | Standard implementation (default) | `/workflow:plan` |
|
||||
| `review` | Code review sessions | `/workflow:review-module-cycle` |
|
||||
| `tdd` | TDD-based development | `/workflow:tdd-plan` |
|
||||
| `test` | Test generation/fix sessions | `/workflow:test-fix-gen` |
|
||||
| `docs` | Documentation sessions | `/memory:docs` |
|
||||
|
||||
**Validation**: If `--type` is provided with invalid value, return error:
|
||||
```
|
||||
ERROR: Invalid session type. Valid types: workflow, review, tdd, test, docs
|
||||
```
|
||||
|
||||
## Step 0: Initialize Project State (First-time Only)
|
||||
|
||||
**Executed before all modes** - Ensures project-level state file exists by calling `/workflow:init`.
|
||||
@@ -86,8 +105,8 @@ bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.process)
|
||||
bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.task)
|
||||
bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.summaries)
|
||||
|
||||
# Create metadata
|
||||
bash(echo '{"session_id":"WFS-implement-oauth2-auth","project":"implement OAuth2 auth","status":"planning"}' > .workflow/active/WFS-implement-oauth2-auth/workflow-session.json)
|
||||
# Create metadata (include type field, default to "workflow" if not specified)
|
||||
bash(echo '{"session_id":"WFS-implement-oauth2-auth","project":"implement OAuth2 auth","status":"planning","type":"workflow","created_at":"2024-12-04T08:00:00Z"}' > .workflow/active/WFS-implement-oauth2-auth/workflow-session.json)
|
||||
```
|
||||
|
||||
**Output**: `SESSION_ID: WFS-implement-oauth2-auth`
|
||||
@@ -143,7 +162,8 @@ bash(mkdir -p .workflow/active/WFS-fix-login-bug/.summaries)
|
||||
|
||||
### Step 3: Create Metadata
|
||||
```bash
|
||||
bash(echo '{"session_id":"WFS-fix-login-bug","project":"fix login bug","status":"planning"}' > .workflow/active/WFS-fix-login-bug/workflow-session.json)
|
||||
# Include type field from --type parameter (default: "workflow")
|
||||
bash(echo '{"session_id":"WFS-fix-login-bug","project":"fix login bug","status":"planning","type":"workflow","created_at":"2024-12-04T08:00:00Z"}' > .workflow/active/WFS-fix-login-bug/workflow-session.json)
|
||||
```
|
||||
|
||||
**Output**: `SESSION_ID: WFS-fix-login-bug`
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
---
|
||||
name: workflow:status
|
||||
description: Generate on-demand views for project overview and workflow tasks with optional task-id filtering for detailed view
|
||||
argument-hint: "[optional: --project|task-id|--validate|--dashboard]"
|
||||
---
|
||||
|
||||
# Workflow Status Command (/workflow:status)
|
||||
|
||||
## Overview
|
||||
Generates on-demand views from project and session data. Supports multiple modes:
|
||||
1. **Project Overview** (`--project`): Shows completed features and project statistics
|
||||
2. **Workflow Tasks** (default): Shows current session task progress
|
||||
3. **HTML Dashboard** (`--dashboard`): Generates interactive HTML task board with active and archived sessions
|
||||
|
||||
No synchronization needed - all views are calculated from current JSON state.
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
/workflow:status # Show current workflow session overview
|
||||
/workflow:status --project # Show project-level feature registry
|
||||
/workflow:status impl-1 # Show specific task details
|
||||
/workflow:status --validate # Validate workflow integrity
|
||||
/workflow:status --dashboard # Generate HTML dashboard board
|
||||
```
|
||||
|
||||
## Execution Process
|
||||
|
||||
```
|
||||
Input Parsing:
|
||||
└─ Decision (mode detection):
|
||||
├─ --project flag → Project Overview Mode
|
||||
├─ --dashboard flag → Dashboard Mode
|
||||
├─ task-id argument → Task Details Mode
|
||||
└─ No flags → Workflow Session Mode (default)
|
||||
|
||||
Project Overview Mode:
|
||||
├─ Check project.json exists
|
||||
├─ Read project data
|
||||
├─ Parse and display overview + features
|
||||
└─ Show recent archived sessions
|
||||
|
||||
Workflow Session Mode (default):
|
||||
├─ Find active session
|
||||
├─ Load session data
|
||||
├─ Scan task files
|
||||
└─ Display task progress
|
||||
|
||||
Dashboard Mode:
|
||||
├─ Collect active sessions
|
||||
├─ Collect archived sessions
|
||||
├─ Generate HTML from template
|
||||
└─ Write dashboard.html
|
||||
```
|
||||
|
||||
## Implementation Flow
|
||||
|
||||
### Mode Selection
|
||||
|
||||
**Check for --project flag**:
|
||||
- If `--project` flag present → Execute **Project Overview Mode**
|
||||
- Otherwise → Execute **Workflow Session Mode** (default)
|
||||
|
||||
## Project Overview Mode
|
||||
|
||||
### Step 1: Check Project State
|
||||
```bash
|
||||
bash(test -f .workflow/project.json && echo "EXISTS" || echo "NOT_FOUND")
|
||||
```
|
||||
|
||||
**If NOT_FOUND**:
|
||||
```
|
||||
No project state found.
|
||||
Run /workflow:session:start to initialize project.
|
||||
```
|
||||
|
||||
### Step 2: Read Project Data
|
||||
```bash
|
||||
bash(cat .workflow/project.json)
|
||||
```
|
||||
|
||||
### Step 3: Parse and Display
|
||||
|
||||
**Data Processing**:
|
||||
```javascript
|
||||
const projectData = JSON.parse(Read('.workflow/project.json'));
|
||||
const features = projectData.features || [];
|
||||
const stats = projectData.statistics || {};
|
||||
const overview = projectData.overview || null;
|
||||
|
||||
// Sort features by implementation date (newest first)
|
||||
const sortedFeatures = features.sort((a, b) =>
|
||||
new Date(b.implemented_at) - new Date(a.implemented_at)
|
||||
);
|
||||
```
|
||||
|
||||
**Output Format** (with extended overview):
|
||||
```
|
||||
## Project: ${projectData.project_name}
|
||||
Initialized: ${projectData.initialized_at}
|
||||
|
||||
${overview ? `
|
||||
### Overview
|
||||
${overview.description}
|
||||
|
||||
**Technology Stack**:
|
||||
${overview.technology_stack.languages.map(l => `- ${l.name}${l.primary ? ' (primary)' : ''}: ${l.file_count} files`).join('\n')}
|
||||
Frameworks: ${overview.technology_stack.frameworks.join(', ')}
|
||||
|
||||
**Architecture**:
|
||||
Style: ${overview.architecture.style}
|
||||
Patterns: ${overview.architecture.patterns.join(', ')}
|
||||
|
||||
**Key Components** (${overview.key_components.length}):
|
||||
${overview.key_components.map(c => `- ${c.name} (${c.path})\n ${c.description}`).join('\n')}
|
||||
|
||||
---
|
||||
` : ''}
|
||||
|
||||
### Completed Features (${stats.total_features})
|
||||
|
||||
${sortedFeatures.map(f => `
|
||||
- ${f.title} (${f.timeline?.implemented_at || f.implemented_at})
|
||||
${f.description}
|
||||
Tags: ${f.tags?.join(', ') || 'none'}
|
||||
Session: ${f.traceability?.session_id || f.session_id}
|
||||
Archive: ${f.traceability?.archive_path || 'unknown'}
|
||||
${f.traceability?.commit_hash ? `Commit: ${f.traceability.commit_hash}` : ''}
|
||||
`).join('\n')}
|
||||
|
||||
### Project Statistics
|
||||
- Total Features: ${stats.total_features}
|
||||
- Total Sessions: ${stats.total_sessions}
|
||||
- Last Updated: ${stats.last_updated}
|
||||
|
||||
### Quick Access
|
||||
- View session details: /workflow:status
|
||||
- Archive query: jq '.archives[] | select(.session_id == "SESSION_ID")' .workflow/archives/manifest.json
|
||||
- Documentation: .workflow/docs/${projectData.project_name}/
|
||||
|
||||
### Query Commands
|
||||
# Find by tag
|
||||
cat .workflow/project.json | jq '.features[] | select(.tags[] == "auth")'
|
||||
|
||||
# View archive
|
||||
cat ${feature.traceability.archive_path}/IMPL_PLAN.md
|
||||
|
||||
# List all tags
|
||||
cat .workflow/project.json | jq -r '.features[].tags[]' | sort -u
|
||||
```
|
||||
|
||||
**Empty State**:
|
||||
```
|
||||
## Project: ${projectData.project_name}
|
||||
Initialized: ${projectData.initialized_at}
|
||||
|
||||
No features completed yet.
|
||||
|
||||
Complete your first workflow session to add features:
|
||||
1. /workflow:plan "feature description"
|
||||
2. /workflow:execute
|
||||
3. /workflow:session:complete
|
||||
```
|
||||
|
||||
### Step 4: Show Recent Sessions (Optional)
|
||||
|
||||
```bash
|
||||
# List 5 most recent archived sessions
|
||||
bash(ls -1t .workflow/archives/WFS-* 2>/dev/null | head -5 | xargs -I {} basename {})
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```
|
||||
### Recent Sessions
|
||||
- WFS-auth-system (archived)
|
||||
- WFS-payment-flow (archived)
|
||||
- WFS-user-dashboard (archived)
|
||||
|
||||
Use /workflow:session:complete to archive current session.
|
||||
```
|
||||
|
||||
## Workflow Session Mode (Default)
|
||||
|
||||
### Step 1: Find Active Session
|
||||
```bash
|
||||
find .workflow/active/ -name "WFS-*" -type d 2>/dev/null | head -1
|
||||
```
|
||||
|
||||
### Step 2: Load Session Data
|
||||
```bash
|
||||
cat .workflow/active/WFS-session/workflow-session.json
|
||||
```
|
||||
|
||||
### Step 3: Scan Task Files
|
||||
```bash
|
||||
find .workflow/active/WFS-session/.task/ -name "*.json" -type f 2>/dev/null
|
||||
```
|
||||
|
||||
### Step 4: Generate Task Status
|
||||
```bash
|
||||
cat .workflow/active/WFS-session/.task/impl-1.json | jq -r '.status'
|
||||
```
|
||||
|
||||
### Step 5: Count Task Progress
|
||||
```bash
|
||||
find .workflow/active/WFS-session/.task/ -name "*.json" -type f | wc -l
|
||||
find .workflow/active/WFS-session/.summaries/ -name "*.md" -type f 2>/dev/null | wc -l
|
||||
```
|
||||
|
||||
### Step 6: Display Overview
|
||||
```markdown
|
||||
# Workflow Overview
|
||||
**Session**: WFS-session-name
|
||||
**Progress**: 3/8 tasks completed
|
||||
|
||||
## Active Tasks
|
||||
- [IN PROGRESS] impl-1: Current task in progress
|
||||
- [ ] impl-2: Next pending task
|
||||
|
||||
## Completed Tasks
|
||||
- [COMPLETED] impl-0: Setup completed
|
||||
```
|
||||
|
||||
## Dashboard Mode (HTML Board)
|
||||
|
||||
### Step 1: Check for --dashboard flag
|
||||
```bash
|
||||
# If --dashboard flag present → Execute Dashboard Mode
|
||||
```
|
||||
|
||||
### Step 2: Collect Workflow Data
|
||||
|
||||
**Collect Active Sessions**:
|
||||
```bash
|
||||
# Find all active sessions
|
||||
find .workflow/active/ -name "WFS-*" -type d 2>/dev/null
|
||||
|
||||
# For each active session, read metadata and tasks
|
||||
for session in $(find .workflow/active/ -name "WFS-*" -type d 2>/dev/null); do
|
||||
cat "$session/workflow-session.json"
|
||||
find "$session/.task/" -name "*.json" -type f 2>/dev/null
|
||||
done
|
||||
```
|
||||
|
||||
**Collect Archived Sessions**:
|
||||
```bash
|
||||
# Find all archived sessions
|
||||
find .workflow/archives/ -name "WFS-*" -type d 2>/dev/null
|
||||
|
||||
# Read manifest if exists
|
||||
cat .workflow/archives/manifest.json 2>/dev/null
|
||||
|
||||
# For each archived session, read metadata
|
||||
for archive in $(find .workflow/archives/ -name "WFS-*" -type d 2>/dev/null); do
|
||||
cat "$archive/workflow-session.json" 2>/dev/null
|
||||
# Count completed tasks
|
||||
find "$archive/.task/" -name "*.json" -type f 2>/dev/null | wc -l
|
||||
done
|
||||
```
|
||||
|
||||
### Step 3: Process and Structure Data
|
||||
|
||||
**Build data structure for dashboard**:
|
||||
```javascript
|
||||
const dashboardData = {
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Process active sessions
|
||||
for each active_session in active_sessions:
|
||||
const sessionData = JSON.parse(Read(active_session/workflow-session.json));
|
||||
const tasks = [];
|
||||
|
||||
// Load all tasks for this session
|
||||
for each task_file in find(active_session/.task/*.json):
|
||||
const taskData = JSON.parse(Read(task_file));
|
||||
tasks.push({
|
||||
task_id: taskData.task_id,
|
||||
title: taskData.title,
|
||||
status: taskData.status,
|
||||
type: taskData.type
|
||||
});
|
||||
|
||||
dashboardData.activeSessions.push({
|
||||
session_id: sessionData.session_id,
|
||||
project: sessionData.project,
|
||||
status: sessionData.status,
|
||||
created_at: sessionData.created_at || sessionData.initialized_at,
|
||||
tasks: tasks
|
||||
});
|
||||
|
||||
// Process archived sessions
|
||||
for each archived_session in archived_sessions:
|
||||
const sessionData = JSON.parse(Read(archived_session/workflow-session.json));
|
||||
const taskCount = bash(find archived_session/.task/*.json | wc -l);
|
||||
|
||||
dashboardData.archivedSessions.push({
|
||||
session_id: sessionData.session_id,
|
||||
project: sessionData.project,
|
||||
archived_at: sessionData.completed_at || sessionData.archived_at,
|
||||
taskCount: parseInt(taskCount),
|
||||
archive_path: archived_session
|
||||
});
|
||||
```
|
||||
|
||||
### Step 4: Generate HTML from Template
|
||||
|
||||
**Load template and inject data**:
|
||||
```javascript
|
||||
// Read the HTML template
|
||||
const template = Read("~/.claude/templates/workflow-dashboard.html");
|
||||
|
||||
// Prepare data for injection
|
||||
const dataJson = JSON.stringify(dashboardData, null, 2);
|
||||
|
||||
// Replace placeholder with actual data
|
||||
const htmlContent = template.replace('{{WORKFLOW_DATA}}', dataJson);
|
||||
|
||||
// Ensure .workflow directory exists
|
||||
bash(mkdir -p .workflow);
|
||||
```
|
||||
|
||||
### Step 5: Write HTML File
|
||||
|
||||
```bash
|
||||
# Write the generated HTML to .workflow/dashboard.html
|
||||
Write({
|
||||
file_path: ".workflow/dashboard.html",
|
||||
content: htmlContent
|
||||
})
|
||||
```
|
||||
|
||||
### Step 6: Display Success Message
|
||||
|
||||
```markdown
|
||||
Dashboard generated successfully!
|
||||
|
||||
Location: .workflow/dashboard.html
|
||||
|
||||
Open in browser:
|
||||
file://$(pwd)/.workflow/dashboard.html
|
||||
|
||||
Features:
|
||||
- 📊 Active sessions overview
|
||||
- 📦 Archived sessions history
|
||||
- 🔍 Search and filter
|
||||
- 📈 Progress tracking
|
||||
- 🎨 Dark/light theme
|
||||
|
||||
Refresh data: Re-run /workflow:status --dashboard
|
||||
```
|
||||
@@ -44,7 +44,7 @@ allowed-tools: SlashCommand(*), TodoWrite(*), Read(*), Bash(*)
|
||||
**Step 1.1: Dispatch** - Session discovery and initialization
|
||||
|
||||
```javascript
|
||||
SlashCommand(command="/workflow:session:start --auto \"TDD: [structured-description]\"")
|
||||
SlashCommand(command="/workflow:session:start --type tdd --auto \"TDD: [structured-description]\"")
|
||||
```
|
||||
|
||||
**TDD Structured Format**:
|
||||
|
||||
@@ -159,19 +159,19 @@ Read(".workflow/active/[sourceSessionId]/.process/context-package.json")
|
||||
|
||||
```javascript
|
||||
// Session Mode - Include original task description to enable semantic CLI selection
|
||||
SlashCommand(command="/workflow:session:start --new \"Test validation for [sourceSessionId]: [originalTaskDescription]\"")
|
||||
SlashCommand(command="/workflow:session:start --type test --new \"Test validation for [sourceSessionId]: [originalTaskDescription]\"")
|
||||
|
||||
// Prompt Mode - User's description already contains their intent
|
||||
SlashCommand(command="/workflow:session:start --new \"Test generation for: [description]\"")
|
||||
SlashCommand(command="/workflow:session:start --type test --new \"Test generation for: [description]\"")
|
||||
```
|
||||
|
||||
**Input**: User argument (session ID, description, or file path)
|
||||
|
||||
**Expected Behavior**:
|
||||
- Creates new session: `WFS-test-[slug]`
|
||||
- Writes `workflow-session.json` metadata:
|
||||
- **Session Mode**: Includes `workflow_type: "test_session"`, `source_session_id: "[sourceId]"`, description with original user intent
|
||||
- **Prompt Mode**: Includes `workflow_type: "test_session"` only (user's description already contains intent)
|
||||
- Writes `workflow-session.json` metadata with `type: "test"`
|
||||
- **Session Mode**: Additionally includes `source_session_id: "[sourceId]"`, description with original user intent
|
||||
- **Prompt Mode**: Uses user's description (already contains intent)
|
||||
- Returns new session ID
|
||||
|
||||
**Parse Output**:
|
||||
@@ -579,11 +579,11 @@ WFS-test-[session]/
|
||||
**File**: `workflow-session.json`
|
||||
|
||||
**Session Mode** includes:
|
||||
- `workflow_type: "test_session"`
|
||||
- `type: "test"` (set by session:start --type test)
|
||||
- `source_session_id: "[sourceSessionId]"` (enables automatic cross-session context)
|
||||
|
||||
**Prompt Mode** includes:
|
||||
- `workflow_type: "test_session"`
|
||||
- `type: "test"` (set by session:start --type test)
|
||||
- No `source_session_id` field
|
||||
|
||||
### Execution Flow Diagram
|
||||
|
||||
@@ -14,8 +14,8 @@ Generate implementation planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.
|
||||
## Core Philosophy
|
||||
- **Planning Only**: Generate planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.md) - does NOT implement code
|
||||
- **Agent-Driven Document Generation**: Delegate plan generation to action-planning-agent
|
||||
- **N+1 Parallel Planning**: Auto-detect multi-module projects, enable parallel planning (2+1 or 3+1 mode)
|
||||
- **Progressive Loading**: Load context incrementally (Core → Selective → On-Demand) due to analysis.md file size
|
||||
- **Two-Phase Flow**: Discovery (context gathering) → Output (planning document generation)
|
||||
- **Memory-First**: Reuse loaded documents from conversation memory
|
||||
- **Smart Selection**: Load synthesis_output OR guidance + relevant role analyses, NOT all role analyses
|
||||
- **MCP-Enhanced**: Use MCP tools for advanced code analysis and research
|
||||
@@ -28,22 +28,38 @@ Input Parsing:
|
||||
├─ Parse flags: --session
|
||||
└─ Validation: session_id REQUIRED
|
||||
|
||||
Phase 1: Context Preparation (Command)
|
||||
Phase 1: Context Preparation & Module Detection (Command)
|
||||
├─ Assemble session paths (metadata, context package, output dirs)
|
||||
└─ Provide metadata (session_id, execution_mode, mcp_capabilities)
|
||||
├─ Provide metadata (session_id, execution_mode, mcp_capabilities)
|
||||
├─ Auto-detect modules from context-package + directory structure
|
||||
└─ Decision:
|
||||
├─ modules.length == 1 → Single Agent Mode (Phase 2A)
|
||||
└─ modules.length >= 2 → Parallel Mode (Phase 2B + Phase 3)
|
||||
|
||||
Phase 2: Planning Document Generation (Agent)
|
||||
Phase 2A: Single Agent Planning (Original Flow)
|
||||
├─ Load context package (progressive loading strategy)
|
||||
├─ Generate Task JSON Files (.task/IMPL-*.json)
|
||||
├─ Create IMPL_PLAN.md
|
||||
└─ Generate TODO_LIST.md
|
||||
|
||||
Phase 2B: N Parallel Planning (Multi-Module)
|
||||
├─ Launch N action-planning-agents simultaneously (one per module)
|
||||
├─ Each agent generates module-scoped tasks (IMPL-{prefix}{seq}.json)
|
||||
├─ Task ID format: IMPL-A1, IMPL-A2... / IMPL-B1, IMPL-B2...
|
||||
└─ Each module limited to ≤9 tasks
|
||||
|
||||
Phase 3: Integration (+1 Coordinator, Multi-Module Only)
|
||||
├─ Collect all module task JSONs
|
||||
├─ Resolve cross-module dependencies (CROSS::{module}::{pattern} → actual ID)
|
||||
├─ Generate unified IMPL_PLAN.md (grouped by module)
|
||||
└─ Generate TODO_LIST.md (hierarchical: module → tasks)
|
||||
```
|
||||
|
||||
## Document Generation Lifecycle
|
||||
|
||||
### Phase 1: Context Preparation (Command Responsibility)
|
||||
### Phase 1: Context Preparation & Module Detection (Command Responsibility)
|
||||
|
||||
**Command prepares session paths and metadata for planning document generation.**
|
||||
**Command prepares session paths, metadata, and detects module structure.**
|
||||
|
||||
**Session Path Structure**:
|
||||
```
|
||||
@@ -52,8 +68,12 @@ Phase 2: Planning Document Generation (Agent)
|
||||
├── .process/
|
||||
│ └── context-package.json # Context package with artifact catalog
|
||||
├── .task/ # Output: Task JSON files
|
||||
├── IMPL_PLAN.md # Output: Implementation plan
|
||||
└── TODO_LIST.md # Output: TODO list
|
||||
│ ├── IMPL-A1.json # Multi-module: prefixed by module
|
||||
│ ├── IMPL-A2.json
|
||||
│ ├── IMPL-B1.json
|
||||
│ └── ...
|
||||
├── IMPL_PLAN.md # Output: Implementation plan (grouped by module)
|
||||
└── TODO_LIST.md # Output: TODO list (hierarchical)
|
||||
```
|
||||
|
||||
**Command Preparation**:
|
||||
@@ -66,9 +86,40 @@ Phase 2: Planning Document Generation (Agent)
|
||||
- `session_id`
|
||||
- `mcp_capabilities` (available MCP tools)
|
||||
|
||||
3. **Auto Module Detection** (determines single vs parallel mode):
|
||||
```javascript
|
||||
function autoDetectModules(contextPackage, projectRoot) {
|
||||
// Priority 1: Explicit frontend/backend separation
|
||||
if (exists('src/frontend') && exists('src/backend')) {
|
||||
return [
|
||||
{ name: 'frontend', prefix: 'A', paths: ['src/frontend'] },
|
||||
{ name: 'backend', prefix: 'B', paths: ['src/backend'] }
|
||||
];
|
||||
}
|
||||
|
||||
// Priority 2: Monorepo structure
|
||||
if (exists('packages/*') || exists('apps/*')) {
|
||||
return detectMonorepoModules(); // Returns 2-3 main packages
|
||||
}
|
||||
|
||||
// Priority 3: Context-package dependency clustering
|
||||
const modules = clusterByDependencies(contextPackage.dependencies?.internal);
|
||||
if (modules.length >= 2) return modules.slice(0, 3);
|
||||
|
||||
// Default: Single module (original flow)
|
||||
return [{ name: 'main', prefix: '', paths: ['.'] }];
|
||||
}
|
||||
```
|
||||
|
||||
**Decision Logic**:
|
||||
- `modules.length == 1` → Phase 2A (Single Agent, original flow)
|
||||
- `modules.length >= 2` → Phase 2B + Phase 3 (N+1 Parallel)
|
||||
|
||||
**Note**: CLI tool usage is now determined semantically by action-planning-agent based on user's task description, not by flags.
|
||||
|
||||
### Phase 2: Planning Document Generation (Agent Responsibility)
|
||||
### Phase 2A: Single Agent Planning (Original Flow)
|
||||
|
||||
**Condition**: `modules.length == 1` (no multi-module detected)
|
||||
|
||||
**Purpose**: Generate IMPL_PLAN.md, task JSONs, and TODO_LIST.md - planning documents only, NOT code implementation.
|
||||
|
||||
@@ -148,4 +199,93 @@ Hard Constraints:
|
||||
)
|
||||
```
|
||||
|
||||
、
|
||||
### Phase 2B: N Parallel Planning (Multi-Module)
|
||||
|
||||
**Condition**: `modules.length >= 2` (multi-module detected)
|
||||
|
||||
**Purpose**: Launch N action-planning-agents simultaneously, one per module, for parallel task generation.
|
||||
|
||||
**Parallel Agent Invocation**:
|
||||
```javascript
|
||||
// Launch N agents in parallel (one per module)
|
||||
const planningTasks = modules.map(module =>
|
||||
Task(
|
||||
subagent_type="action-planning-agent",
|
||||
description=`Plan ${module.name} module`,
|
||||
prompt=`
|
||||
## SCOPE
|
||||
- Module: ${module.name} (${module.type})
|
||||
- Focus Paths: ${module.paths.join(', ')}
|
||||
- Task ID Prefix: IMPL-${module.prefix}
|
||||
- Task Limit: ≤9 tasks
|
||||
- Other Modules: ${otherModules.join(', ')}
|
||||
- Cross-module deps format: "CROSS::{module}::{pattern}"
|
||||
|
||||
## SESSION PATHS
|
||||
Input:
|
||||
- Context Package: .workflow/active/{session-id}/.process/context-package.json
|
||||
Output:
|
||||
- Task Dir: .workflow/active/{session-id}/.task/
|
||||
|
||||
## INSTRUCTIONS
|
||||
- Generate tasks ONLY for ${module.name} module
|
||||
- Use task ID format: IMPL-${module.prefix}1, IMPL-${module.prefix}2, ...
|
||||
- For cross-module dependencies, use: depends_on: ["CROSS::B::api-endpoint"]
|
||||
- Maximum 9 tasks per module
|
||||
`
|
||||
)
|
||||
);
|
||||
|
||||
// Execute all in parallel
|
||||
await Promise.all(planningTasks);
|
||||
```
|
||||
|
||||
**Output Structure** (direct to .task/):
|
||||
```
|
||||
.task/
|
||||
├── IMPL-A1.json # Module A (e.g., frontend)
|
||||
├── IMPL-A2.json
|
||||
├── IMPL-B1.json # Module B (e.g., backend)
|
||||
├── IMPL-B2.json
|
||||
└── IMPL-C1.json # Module C (e.g., shared)
|
||||
```
|
||||
|
||||
**Task ID Naming**:
|
||||
- Format: `IMPL-{prefix}{seq}.json`
|
||||
- Prefix: A, B, C... (assigned by detection order)
|
||||
- Sequence: 1, 2, 3... (per-module increment)
|
||||
|
||||
### Phase 3: Integration (+1 Coordinator, Multi-Module Only)
|
||||
|
||||
**Condition**: Only executed when `modules.length >= 2`
|
||||
|
||||
**Purpose**: Collect all module tasks, resolve cross-module dependencies, generate unified documents.
|
||||
|
||||
**Integration Logic**:
|
||||
```javascript
|
||||
// 1. Collect all module task JSONs
|
||||
const allTasks = glob('.task/IMPL-*.json').map(loadJson);
|
||||
|
||||
// 2. Resolve cross-module dependencies
|
||||
for (const task of allTasks) {
|
||||
if (task.depends_on) {
|
||||
task.depends_on = task.depends_on.map(dep => {
|
||||
if (dep.startsWith('CROSS::')) {
|
||||
// CROSS::B::api-endpoint → find matching IMPL-B* task
|
||||
const [, targetModule, pattern] = dep.match(/CROSS::(\w+)::(.+)/);
|
||||
return findTaskByModuleAndPattern(allTasks, targetModule, pattern);
|
||||
}
|
||||
return dep;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate unified IMPL_PLAN.md (grouped by module)
|
||||
generateIMPL_PLAN(allTasks, modules);
|
||||
|
||||
// 4. Generate TODO_LIST.md (hierarchical structure)
|
||||
generateTODO_LIST(allTasks, modules);
|
||||
```
|
||||
|
||||
**Note**: IMPL_PLAN.md and TODO_LIST.md structure definitions are in `action-planning-agent.md`.
|
||||
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Dashboard - Task Board</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #f5f7fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #718096;
|
||||
--border-color: #e2e8f0;
|
||||
--accent-color: #4299e1;
|
||||
--success-color: #48bb78;
|
||||
--warning-color: #ed8936;
|
||||
--danger-color: #f56565;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a202c;
|
||||
--bg-secondary: #2d3748;
|
||||
--bg-card: #2d3748;
|
||||
--text-primary: #f7fafc;
|
||||
--text-secondary: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--bg-card);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-active {
|
||||
background-color: #22543d;
|
||||
color: #c6f6d5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-archived {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
border-left-color: var(--success-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.task-item.in_progress {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.task-item.pending {
|
||||
border-left-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-color);
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-item.completed .task-checkbox {
|
||||
background-color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.task-item.completed .task-checkbox::after {
|
||||
content: '✓';
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.task-item.in_progress .task-checkbox {
|
||||
border-color: var(--warning-color);
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.task-item.in_progress .task-checkbox::after {
|
||||
content: '⟳';
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all 0.3s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sessions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge-count {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🚀 Workflow Dashboard</h1>
|
||||
<p style="color: var(--text-secondary);">Task Board - Active and Archived Sessions</p>
|
||||
|
||||
<div class="header-controls">
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Search tasks or sessions..." />
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="btn active" data-filter="all">All</button>
|
||||
<button class="btn" data-filter="active">Active</button>
|
||||
<button class="btn" data-filter="archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalSessions">0</div>
|
||||
<div class="stat-label">Total Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="activeSessions">0</div>
|
||||
<div class="stat-label">Active Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="totalTasks">0</div>
|
||||
<div class="stat-label">Total Tasks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="completedTasks">0</div>
|
||||
<div class="stat-label">Completed Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="activeSectionContainer">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">📋 Active Sessions</h2>
|
||||
</div>
|
||||
<div class="sessions-grid" id="activeSessions"></div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="archivedSectionContainer">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">📦 Archived Sessions</h2>
|
||||
</div>
|
||||
<div class="sessions-grid" id="archivedSessions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="theme-toggle" id="themeToggle">🌙</button>
|
||||
|
||||
<script>
|
||||
// Workflow data will be injected here
|
||||
const workflowData = {{WORKFLOW_DATA}};
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
// Statistics calculation
|
||||
function updateStatistics() {
|
||||
const stats = {
|
||||
totalSessions: workflowData.activeSessions.length + workflowData.archivedSessions.length,
|
||||
activeSessions: workflowData.activeSessions.length,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0
|
||||
};
|
||||
|
||||
workflowData.activeSessions.forEach(session => {
|
||||
stats.totalTasks += session.tasks.length;
|
||||
stats.completedTasks += session.tasks.filter(t => t.status === 'completed').length;
|
||||
});
|
||||
|
||||
workflowData.archivedSessions.forEach(session => {
|
||||
stats.totalTasks += session.taskCount || 0;
|
||||
stats.completedTasks += session.taskCount || 0;
|
||||
});
|
||||
|
||||
document.getElementById('totalSessions').textContent = stats.totalSessions;
|
||||
document.getElementById('activeSessions').textContent = stats.activeSessions;
|
||||
document.getElementById('totalTasks').textContent = stats.totalTasks;
|
||||
document.getElementById('completedTasks').textContent = stats.completedTasks;
|
||||
}
|
||||
|
||||
// Render session card
|
||||
function createSessionCard(session, isActive) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.dataset.sessionType = isActive ? 'active' : 'archived';
|
||||
|
||||
const completedTasks = isActive
|
||||
? session.tasks.filter(t => t.status === 'completed').length
|
||||
: (session.taskCount || 0);
|
||||
const totalTasks = isActive ? session.tasks.length : (session.taskCount || 0);
|
||||
const progress = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0;
|
||||
|
||||
let tasksHtml = '';
|
||||
if (isActive && session.tasks.length > 0) {
|
||||
tasksHtml = `
|
||||
<div class="tasks-list">
|
||||
${session.tasks.map(task => `
|
||||
<div class="task-item ${task.status}">
|
||||
<div class="task-checkbox"></div>
|
||||
<div class="task-title">${task.title || 'Untitled Task'}</div>
|
||||
<span class="task-id">${task.task_id || ''}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="session-header">
|
||||
<div>
|
||||
<h3 class="session-title">${session.session_id || 'Unknown Session'}</h3>
|
||||
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 5px;">
|
||||
${session.project || ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="session-status ${isActive ? 'status-active' : 'status-archived'}">
|
||||
${isActive ? 'Active' : 'Archived'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="session-meta">
|
||||
<span>📅 ${session.created_at || session.archived_at || 'N/A'}</span>
|
||||
<span>📊 ${completedTasks}/${totalTasks} tasks</span>
|
||||
</div>
|
||||
|
||||
${totalTasks > 0 ? `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<div style="text-align: center; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
${Math.round(progress)}% Complete
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${tasksHtml}
|
||||
|
||||
${!isActive && session.archive_path ? `
|
||||
<div class="session-footer">
|
||||
📁 Archive: ${session.archive_path}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Render all sessions
|
||||
function renderSessions(filter = 'all') {
|
||||
const activeContainer = document.getElementById('activeSessions');
|
||||
const archivedContainer = document.getElementById('archivedSessions');
|
||||
|
||||
activeContainer.innerHTML = '';
|
||||
archivedContainer.innerHTML = '';
|
||||
|
||||
if (filter === 'all' || filter === 'active') {
|
||||
if (workflowData.activeSessions.length === 0) {
|
||||
activeContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<p>No active sessions</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
workflowData.activeSessions.forEach(session => {
|
||||
activeContainer.appendChild(createSessionCard(session, true));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filter === 'all' || filter === 'archived') {
|
||||
if (workflowData.archivedSessions.length === 0) {
|
||||
archivedContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📦</div>
|
||||
<p>No archived sessions</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
workflowData.archivedSessions.forEach(session => {
|
||||
archivedContainer.appendChild(createSessionCard(session, false));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide sections
|
||||
document.getElementById('activeSectionContainer').style.display =
|
||||
(filter === 'all' || filter === 'active') ? 'block' : 'none';
|
||||
document.getElementById('archivedSectionContainer').style.display =
|
||||
(filter === 'all' || filter === 'archived') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function setupSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
const cards = document.querySelectorAll('.session-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const text = card.textContent.toLowerCase();
|
||||
card.style.display = text.includes(query) ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter functionality
|
||||
function setupFilters() {
|
||||
const filterButtons = document.querySelectorAll('[data-filter]');
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderSessions(btn.dataset.filter);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
updateStatistics();
|
||||
renderSessions();
|
||||
setupSearch();
|
||||
setupFilters();
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -582,7 +582,7 @@ prompts/
|
||||
- **Complex** (implementation, migration): 20-60min (1200000-3600000ms)
|
||||
- **Heavy** (large codebase, multi-file): 60-120min (3600000-7200000ms)
|
||||
|
||||
**Codex Multiplier**: 1.5x of allocated time
|
||||
**Codex Multiplier**: 3x of allocated time (minimum 15min / 900000ms)
|
||||
|
||||
**Application**: All bash() wrapped commands including Gemini, Qwen and Codex executions
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# MCP Tool Strategy: Exa Usage
|
||||
|
||||
## ⚡ Exa Triggering Mechanisms
|
||||
|
||||
**Auto-Trigger**:
|
||||
- User mentions "exa-code" or code-related queries → `mcp__exa__get_code_context_exa`
|
||||
- Need current web information → `mcp__exa__web_search_exa`
|
||||
|
||||
**Manual Trigger**:
|
||||
- Complex API research → Exa Code Context
|
||||
- Real-time information needs → Exa Web Search
|
||||
71
.claude/workflows/tool-strategy.md
Normal file
71
.claude/workflows/tool-strategy.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Tool Strategy
|
||||
|
||||
## ⚡ Exa Triggering Mechanisms
|
||||
|
||||
**Auto-Trigger**:
|
||||
- User mentions "exa-code" or code-related queries → `mcp__exa__get_code_context_exa`
|
||||
- Need current web information → `mcp__exa__web_search_exa`
|
||||
|
||||
**Manual Trigger**:
|
||||
- Complex API research → Exa Code Context
|
||||
- Real-time information needs → Exa Web Search
|
||||
|
||||
## ⚡ Bash Text Processing (sed/awk)
|
||||
|
||||
**When to Use**: Edit tool fails 2+ times on same file
|
||||
|
||||
### sed Quick Reference
|
||||
|
||||
```bash
|
||||
# Replace first occurrence per line
|
||||
sed 's/old/new/' file.txt
|
||||
|
||||
# Replace all occurrences (global)
|
||||
sed 's/old/new/g' file.txt
|
||||
|
||||
# In-place edit (modify file directly)
|
||||
sed -i 's/old/new/g' file.txt
|
||||
|
||||
# Delete lines matching pattern
|
||||
sed '/pattern/d' file.txt
|
||||
|
||||
# Insert line before match
|
||||
sed '/pattern/i\new line' file.txt
|
||||
|
||||
# Insert line after match
|
||||
sed '/pattern/a\new line' file.txt
|
||||
|
||||
# Replace on specific line number
|
||||
sed '5s/old/new/' file.txt
|
||||
|
||||
# Multi-line replacement (escape newlines)
|
||||
sed ':a;N;$!ba;s/old\npattern/new\ntext/g' file.txt
|
||||
```
|
||||
|
||||
### awk Quick Reference
|
||||
|
||||
```bash
|
||||
# Print specific column
|
||||
awk '{print $1}' file.txt
|
||||
|
||||
# Print lines matching pattern
|
||||
awk '/pattern/' file.txt
|
||||
|
||||
# Replace field value
|
||||
awk '{$2="new"; print}' file.txt
|
||||
|
||||
# Conditional replacement
|
||||
awk '/pattern/{gsub(/old/,"new")}1' file.txt
|
||||
|
||||
# Insert line after match
|
||||
awk '/pattern/{print; print "new line"; next}1' file.txt
|
||||
|
||||
# Multi-field operations
|
||||
awk -F',' '{print $1, $3}' file.csv
|
||||
```
|
||||
|
||||
### Fallback Strategy
|
||||
|
||||
1. **Edit fails 2+ times** → Try sed for simple replacements
|
||||
2. **sed fails** → Try awk for complex patterns
|
||||
3. **awk fails** → Use Write to recreate file
|
||||
@@ -782,15 +782,11 @@ All workflows use the same file structure definition regardless of complexity. *
|
||||
|
||||
**Examples**:
|
||||
|
||||
*Analysis Commands (read-only):*
|
||||
- `/cli:analyze "security"` (no session) → `.scratchpad/analyze-security-20250105-143022.md`
|
||||
- `/cli:chat "build process"` (unrelated to active session) → `.scratchpad/chat-build-process-20250105-143045.md`
|
||||
- `/cli:mode:plan "feature idea"` (exploratory) → `.scratchpad/plan-feature-idea-20250105-143110.md`
|
||||
- `/cli:mode:code-analysis "trace auth flow"` (no session) → `.scratchpad/code-analysis-auth-flow-20250105-143130.md`
|
||||
*Workflow Commands (lightweight):*
|
||||
- `/workflow:lite-plan "feature idea"` (exploratory) → `.scratchpad/lite-plan-feature-idea-20250105-143110.md`
|
||||
- `/workflow:lite-fix "bug description"` (bug fixing) → `.scratchpad/lite-fix-bug-20250105-143130.md`
|
||||
|
||||
*Implementation Commands (⚠️ modifies code):*
|
||||
- `/cli:execute "implement JWT auth"` (no session) → `.scratchpad/execute-jwt-auth-20250105-143200.md`
|
||||
- `/cli:codex-execute "refactor API layer"` (no session) → `.scratchpad/codex-execute-api-refactor-20250105-143230.md`
|
||||
> **Note**: Direct CLI commands (`/cli:analyze`, `/cli:execute`, etc.) have been replaced by semantic invocation and workflow commands.
|
||||
|
||||
**Maintenance**:
|
||||
- Periodically review and clean up old scratchpad files
|
||||
|
||||
51
.npmignore
Normal file
51
.npmignore
Normal file
@@ -0,0 +1,51 @@
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# History and temp files
|
||||
.history/
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# Development files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.md
|
||||
!README.md
|
||||
!CLAUDE.md
|
||||
|
||||
# Workflow runtime data
|
||||
.workflow/
|
||||
|
||||
# Test files
|
||||
test/
|
||||
tests/
|
||||
*.test.js
|
||||
*.spec.js
|
||||
|
||||
# Build artifacts
|
||||
node_modules/
|
||||
*.tgz
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Documentation (keep only essential)
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
COMMAND_REFERENCE.md
|
||||
COMMAND_SPEC.md
|
||||
FAQ.md
|
||||
GETTING_STARTED.md
|
||||
GETTING_STARTED_CN.md
|
||||
|
||||
# PowerShell installer (not needed for npm)
|
||||
Install-Claude.ps1
|
||||
install-remote.ps1
|
||||
|
||||
# ccw internal files
|
||||
ccw/package.json
|
||||
ccw/node_modules/
|
||||
ccw/*.md
|
||||
@@ -5,7 +5,7 @@
|
||||
This document defines project-specific coding standards and development principles.
|
||||
### CLI Tool Context Protocols
|
||||
For all CLI tool usage, command syntax, and integration guidelines:
|
||||
- **MCP Tool Strategy**: @~/.claude/workflows/mcp-tool-strategy.md
|
||||
- **Tool Strategy**: @~/.claude/workflows/tool-strategy.md
|
||||
- **Intelligent Context Strategy**: @~/.claude/workflows/intelligent-tools-strategy.md
|
||||
- **Context Search Commands**: @~/.claude/workflows/context-search-strategy.md
|
||||
|
||||
@@ -73,6 +73,7 @@ For all CLI tool usage, command syntax, and integration guidelines:
|
||||
- Update plan documentation and progress tracking as you go
|
||||
- Learn from existing implementations
|
||||
- Stop after 3 failed attempts and reassess
|
||||
- **Edit fallback**: When Edit tool fails 2+ times on same file, try Bash sed/awk first, then Write to recreate if still failing
|
||||
|
||||
## Platform-Specific Guidelines
|
||||
|
||||
|
||||
@@ -4,21 +4,13 @@ This document provides a comprehensive reference for all commands available in t
|
||||
|
||||
> **Version 5.9.6 Update**: Enhanced review cycle with dashboards, optimized lite-plan with parallel execution, and added lite-fix workflow for intelligent bug diagnosis.
|
||||
|
||||
## Unified CLI Commands (`/cli:*`)
|
||||
|
||||
These commands provide direct access to AI tools for quick analysis and interaction without initiating a full workflow.
|
||||
## CLI Commands (`/cli:*`)
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/cli:analyze` | Quick codebase analysis using CLI tools (codex/gemini/qwen). |
|
||||
| `/cli:chat` | Simple CLI interaction command for direct codebase analysis. |
|
||||
| `/cli:cli-init`| Initialize CLI tool configurations (Gemini and Qwen) based on workspace analysis. |
|
||||
| `/cli:codex-execute` | Automated task decomposition and execution with Codex using resume mechanism. |
|
||||
| `/cli:discuss-plan` | Orchestrates an iterative, multi-model discussion for planning and analysis without implementation. |
|
||||
| `/cli:execute` | Auto-execution of implementation tasks with YOLO permissions and intelligent context inference. |
|
||||
| `/cli:mode:bug-diagnosis` | Bug analysis and fix suggestions using CLI tools. |
|
||||
| `/cli:mode:code-analysis` | Deep code analysis and debugging using CLI tools with specialized template. |
|
||||
| `/cli:mode:plan` | Project planning and architecture analysis using CLI tools. |
|
||||
|
||||
> **Note**: For analysis, planning, and bug fixing, use workflow commands (`/workflow:lite-plan`, `/workflow:lite-fix`) or semantic invocation through natural language.
|
||||
|
||||
## Workflow Commands (`/workflow:*`)
|
||||
|
||||
|
||||
@@ -191,25 +191,7 @@ Commands for creating, listing, and managing workflow sessions.
|
||||
|
||||
## 5. CLI Commands
|
||||
|
||||
Direct access to AI tools for analysis and code interaction without a full workflow structure.
|
||||
|
||||
### **/cli:analyze**
|
||||
- **Syntax**: `/cli:analyze [--agent] [--tool codex|gemini|qwen] [--enhance] <analysis target>`
|
||||
- **Responsibilities**: Performs read-only codebase analysis. Can operate in standard mode (direct tool call) or agent mode (`@cli-execution-agent`) for automated context discovery.
|
||||
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:analyze "authentication patterns"
|
||||
```
|
||||
|
||||
### **/cli:chat**
|
||||
- **Syntax**: `/cli:chat [--agent] [--tool codex|gemini|qwen] [--enhance] <inquiry>`
|
||||
- **Responsibilities**: Provides a direct Q&A interface with AI tools for codebase questions. Read-only.
|
||||
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:chat "how does the caching layer work?"
|
||||
```
|
||||
CLI tool configuration commands.
|
||||
|
||||
### **/cli:cli-init**
|
||||
- **Syntax**: `/cli:cli-init [--tool gemini|qwen|all] [--output path] [--preview]`
|
||||
@@ -220,59 +202,7 @@ Direct access to AI tools for analysis and code interaction without a full workf
|
||||
/cli:cli-init
|
||||
```
|
||||
|
||||
### **/cli:codex-execute**
|
||||
- **Syntax**: `/cli:codex-execute [--verify-git] <description|task-id>`
|
||||
- **Responsibilities**: Orchestrates automated task decomposition and sequential execution using Codex. It uses the `resume --last` mechanism for context continuity between subtasks.
|
||||
- **Agent Calls**: None directly, but orchestrates `codex` CLI tool.
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:codex-execute "implement user authentication system"
|
||||
```
|
||||
|
||||
### **/cli:discuss-plan**
|
||||
- **Syntax**: `/cli:discuss-plan [--topic '...'] [--task-id '...'] [--rounds N] <input>`
|
||||
- **Responsibilities**: Orchestrates an iterative, multi-model (Gemini, Codex, Claude) discussion to perform deep analysis and planning without modifying code.
|
||||
- **Agent Calls**: None directly, but orchestrates `gemini` and `codex` CLI tools.
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:discuss-plan --topic "Design a new caching layer"
|
||||
```
|
||||
|
||||
### **/cli:execute**
|
||||
- **Syntax**: `/cli:execute [--agent] [--tool codex|gemini|qwen] [--enhance] <description|task-id>`
|
||||
- **Responsibilities**: Executes implementation tasks with auto-approval (`YOLO` mode). **MODIFIES CODE**.
|
||||
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:execute "implement JWT authentication with middleware"
|
||||
```
|
||||
|
||||
### **/cli:mode:bug-diagnosis**
|
||||
- **Syntax**: `/cli:mode:bug-diagnosis [--tool ...] [--enhance] [--cd path] <bug description>`
|
||||
- **Responsibilities**: Performs systematic bug analysis using the `bug-fix.md` template. Read-only.
|
||||
- **Agent Calls**: `@cli-execution-agent` (default).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:mode:bug-diagnosis "null pointer error in login flow"
|
||||
```
|
||||
|
||||
### **/cli:mode:code-analysis**
|
||||
- **Syntax**: `/cli:mode:code-analysis [--agent] [--tool ...] [--enhance] [--cd path] <analysis target>`
|
||||
- **Responsibilities**: Performs deep code analysis and execution path tracing using the `code-analysis.md` template. Read-only.
|
||||
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:mode:code-analysis "trace authentication execution flow"
|
||||
```
|
||||
|
||||
### **/cli:mode:plan**
|
||||
- **Syntax**: `/cli:mode:plan [--agent] [--tool ...] [--enhance] [--cd path] <topic>`
|
||||
- **Responsibilities**: Performs comprehensive planning and architecture analysis using the `plan.md` template. Read-only.
|
||||
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
|
||||
- **Example**:
|
||||
```bash
|
||||
/cli:mode:plan "design user dashboard architecture"
|
||||
```
|
||||
> **Note**: For analysis, planning, and bug fixing, use workflow commands (`/workflow:lite-plan`, `/workflow:lite-fix`) or semantic invocation through natural language. Claude will automatically use appropriate CLI tools (Gemini/Qwen/Codex) with templates as needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -380,13 +310,14 @@ Commands for managing individual tasks within a workflow session.
|
||||
```
|
||||
|
||||
### **/enhance-prompt**
|
||||
- **Syntax**: `/enhance-prompt <user input>`
|
||||
- **Responsibilities**: A system-level skill that enhances a user's prompt by adding context from session memory and codebase analysis. It is typically triggered automatically by other commands that include the `--enhance` flag.
|
||||
- **Skill Invocation**: This is a core skill, invoked when `--enhance` is used.
|
||||
- **Syntax**: `/enhance-prompt <user input>` or use `-e` flag in conversation
|
||||
- **Responsibilities**: A system-level skill that enhances a user's prompt by adding context from session memory and codebase analysis. It is typically triggered by the `-e` flag in natural conversation.
|
||||
- **Skill Invocation**: This is a core skill, invoked when `-e` is used in conversation.
|
||||
- **Agent Calls**: None.
|
||||
- **Example (as part of another command)**:
|
||||
```bash
|
||||
/cli:execute --enhance "fix the login button"
|
||||
- **Example (in natural conversation)**:
|
||||
```
|
||||
User: "fix the login button -e"
|
||||
→ Prompt-enhancer expands and enhances the request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
24
FAQ.md
24
FAQ.md
@@ -248,16 +248,16 @@ CCW-help
|
||||
### What's the difference between `/cli:*` and `/workflow:*` commands?
|
||||
|
||||
**`/cli:*` commands**:
|
||||
- Direct access to external AI tools
|
||||
- No workflow session required
|
||||
- Quick one-off tasks
|
||||
- Examples: `/cli:analyze`, `/cli:chat`
|
||||
- CLI tool configuration
|
||||
- Example: `/cli:cli-init` (initialize Gemini/Qwen configurations)
|
||||
|
||||
**`/workflow:*` commands**:
|
||||
- Multi-phase orchestration
|
||||
- Session-based
|
||||
- Complex development workflows
|
||||
- Examples: `/workflow:plan`, `/workflow:execute`
|
||||
- Examples: `/workflow:plan`, `/workflow:lite-plan`, `/workflow:lite-fix`
|
||||
|
||||
> **Note**: Most CLI commands have been replaced by **semantic invocation**. Simply describe your needs in natural language, and Claude will automatically use the appropriate tools.
|
||||
|
||||
### How do I use command flags?
|
||||
|
||||
@@ -270,9 +270,6 @@ Most commands support flags for customization:
|
||||
# With CLI execution flag
|
||||
/workflow:plan --cli-execute "Feature description"
|
||||
|
||||
# With tool selection
|
||||
/cli:analyze --tool gemini "Code analysis"
|
||||
|
||||
# With multiple flags
|
||||
/workflow:ui-design:explore-auto --prompt "Login page" --style-variants 3 --layout-variants 2
|
||||
```
|
||||
@@ -281,17 +278,12 @@ Most commands support flags for customization:
|
||||
|
||||
**Yes!** Claude understands semantic invocation:
|
||||
|
||||
Instead of:
|
||||
```bash
|
||||
/cli:analyze --tool gemini "Authentication module"
|
||||
```
|
||||
|
||||
You can say:
|
||||
Instead of using specific commands, you can say:
|
||||
```
|
||||
"Use Gemini to analyze the authentication module architecture"
|
||||
```
|
||||
|
||||
Claude will automatically execute the appropriate command.
|
||||
Claude will automatically select and execute the appropriate CLI tools (Gemini/Qwen/Codex) with optimized templates.
|
||||
|
||||
### What does the `-e` or `--enhance` flag do?
|
||||
|
||||
@@ -303,8 +295,6 @@ User: "Analyze authentication module -e"
|
||||
|
||||
Claude will expand and enhance your request for better results.
|
||||
|
||||
**Note**: `--enhance` in CLI commands (e.g., `/cli:analyze --enhance`) is a different feature built into CLI tools.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Sessions & Tasks
|
||||
|
||||
@@ -178,62 +178,36 @@ After planning, validate your implementation plan for consistency and completene
|
||||
Quick bug analysis and fix workflow:
|
||||
|
||||
```bash
|
||||
# Analyze the bug
|
||||
/cli:mode:bug-diagnosis "Incorrect success message with wrong password"
|
||||
# Lightweight bug fix workflow with intelligent diagnosis
|
||||
/workflow:lite-fix "Incorrect success message with wrong password"
|
||||
|
||||
# Claude will analyze and then directly implement the fix based on the analysis
|
||||
# Claude will analyze severity, diagnose root cause, and implement the fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Workflow-Free Usage: Standalone Tools
|
||||
## 🔧 Lightweight Commands
|
||||
|
||||
Beyond the full workflow mode, CCW provides standalone CLI tools and commands suitable for quick analysis, ad-hoc queries, and routine maintenance tasks.
|
||||
Beyond the full workflow mode, CCW provides lightweight commands suitable for quick analysis and routine tasks.
|
||||
|
||||
### Direct CLI Tool Invocation
|
||||
### Workflow Commands for Quick Tasks
|
||||
|
||||
CCW supports direct invocation of external AI tools (Gemini, Qwen, Codex) through a unified CLI interface without creating workflow sessions.
|
||||
|
||||
#### Code Analysis
|
||||
|
||||
Quickly analyze project code structure and architectural patterns:
|
||||
Use workflow commands for integrated planning and bug fixing:
|
||||
|
||||
```bash
|
||||
# Code analysis with Gemini
|
||||
/cli:analyze --tool gemini "Analyze authentication module architecture"
|
||||
# Lightweight planning workflow
|
||||
/workflow:lite-plan "Design a scalable microservices architecture"
|
||||
|
||||
# Code quality analysis with Qwen
|
||||
/cli:analyze --tool qwen "Review database model design for best practices"
|
||||
# Bug fix workflow with intelligent diagnosis
|
||||
/workflow:lite-fix "Analyze potential causes of memory leak"
|
||||
|
||||
# Initialize CLI tool configurations
|
||||
/cli:cli-init
|
||||
```
|
||||
|
||||
#### Interactive Chat
|
||||
### Semantic Tool Invocation (Replaces Direct CLI Commands)
|
||||
|
||||
Direct interactive dialogue with AI tools:
|
||||
|
||||
```bash
|
||||
# Chat with Gemini
|
||||
/cli:chat --tool gemini "Explain React Hook use cases"
|
||||
|
||||
# Discuss implementation with Codex
|
||||
/cli:chat --tool codex "How to optimize this query performance"
|
||||
```
|
||||
|
||||
#### Specialized Analysis Modes
|
||||
|
||||
Use specific analysis modes for in-depth exploration:
|
||||
|
||||
```bash
|
||||
# Architecture planning mode
|
||||
/cli:mode:plan --tool gemini "Design a scalable microservices architecture"
|
||||
|
||||
# Deep code analysis
|
||||
/cli:mode:code-analysis --tool qwen "Analyze utility functions in src/utils/"
|
||||
|
||||
# Bug analysis mode
|
||||
/cli:mode:bug-index --tool gemini "Analyze potential causes of memory leak"
|
||||
```
|
||||
|
||||
### Semantic Tool Invocation
|
||||
> **Important**: Direct CLI commands (`/cli:analyze`, `/cli:chat`, `/cli:execute`, etc.) have been replaced by **semantic invocation**. Simply describe your needs in natural language, and Claude will automatically select and execute the appropriate CLI tools (Gemini/Qwen/Codex) with optimized templates.
|
||||
|
||||
Users can tell Claude to use specific tools through natural language, and Claude will understand the intent and automatically execute the appropriate commands.
|
||||
|
||||
@@ -381,13 +355,7 @@ User: "Analyze authentication module -e"
|
||||
→ AI uses prompt-enhancer skill to expand the request
|
||||
```
|
||||
|
||||
**CLI Command Enhancement** (built-in CLI feature):
|
||||
```bash
|
||||
# The --enhance flag here is a CLI parameter, not a skill trigger
|
||||
/cli:analyze --enhance "check for security issues"
|
||||
```
|
||||
|
||||
**Important Note**: The `-e` flag works in natural conversation, but `--enhance` in CLI commands is a separate enhancement mechanism, not the skill system.
|
||||
**Important Note**: The `-e` flag works in natural conversation to trigger the prompt-enhancer skill.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -188,62 +188,36 @@
|
||||
快速 Bug 分析和修复工作流:
|
||||
|
||||
```bash
|
||||
# 分析 Bug
|
||||
/cli:mode:bug-diagnosis "密码错误时仍显示成功消息"
|
||||
# 轻量级 Bug 修复工作流,带智能诊断
|
||||
/workflow:lite-fix "密码错误时仍显示成功消息"
|
||||
|
||||
# Claude 会分析后直接根据分析结果实现修复
|
||||
# Claude 会分析严重程度,诊断根因,并实现修复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 无工作流协作:独立工具使用
|
||||
## 🔧 轻量级命令
|
||||
|
||||
除了完整的工作流模式,CCW 还提供独立的 CLI 工具和命令,适合快速分析、临时查询和日常维护任务。
|
||||
除了完整的工作流模式,CCW 还提供轻量级命令,适合快速分析和日常任务。
|
||||
|
||||
### CLI 工具直接调用
|
||||
### 快速任务工作流命令
|
||||
|
||||
CCW 支持通过统一的 CLI 接口直接调用外部 AI 工具(Gemini、Qwen、Codex),无需创建工作流会话。
|
||||
|
||||
#### 代码分析
|
||||
|
||||
快速分析项目代码结构和架构模式:
|
||||
使用工作流命令进行集成规划和 Bug 修复:
|
||||
|
||||
```bash
|
||||
# 使用 Gemini 进行代码分析
|
||||
/cli:analyze --tool gemini "分析认证模块的架构设计"
|
||||
# 轻量级规划工作流
|
||||
/workflow:lite-plan "设计一个可扩展的微服务架构"
|
||||
|
||||
# 使用 Qwen 分析代码质量
|
||||
/cli:analyze --tool qwen "检查数据库模型的设计是否合理"
|
||||
# 带智能诊断的 Bug 修复工作流
|
||||
/workflow:lite-fix "分析内存泄漏问题的可能原因"
|
||||
|
||||
# 初始化 CLI 工具配置
|
||||
/cli:cli-init
|
||||
```
|
||||
|
||||
#### 交互式对话
|
||||
### 语义调用(替代直接 CLI 命令)
|
||||
|
||||
与 AI 工具进行直接交互式对话:
|
||||
|
||||
```bash
|
||||
# 与 Gemini 交互
|
||||
/cli:chat --tool gemini "解释一下 React Hook 的使用场景"
|
||||
|
||||
# 与 Codex 交互讨论实现方案
|
||||
/cli:chat --tool codex "如何优化这个查询性能"
|
||||
```
|
||||
|
||||
#### 专业模式分析
|
||||
|
||||
使用特定的分析模式进行深度探索:
|
||||
|
||||
```bash
|
||||
# 架构分析模式
|
||||
/cli:mode:plan --tool gemini "设计一个可扩展的微服务架构"
|
||||
|
||||
# 深度代码分析
|
||||
/cli:mode:code-analysis --tool qwen "分析 src/utils/ 目录下的工具函数"
|
||||
|
||||
# Bug 分析模式
|
||||
/cli:mode:bug-diagnosis --tool gemini "分析内存泄漏问题的可能原因"
|
||||
```
|
||||
|
||||
### 工具语义调用
|
||||
> **重要**: 直接 CLI 命令(`/cli:analyze`、`/cli:chat`、`/cli:execute` 等)已被**语义调用**替代。只需使用自然语言描述您的需求,Claude 会自动选择并执行合适的 CLI 工具(Gemini/Qwen/Codex)和优化的模板。
|
||||
|
||||
用户可以通过自然语言告诉 Claude 使用特定工具完成任务,Claude 会理解意图并自动执行相应的命令。
|
||||
|
||||
@@ -391,13 +365,7 @@ CCW 使用分层的 CLAUDE.md 文档系统维护项目上下文。定期更新
|
||||
→ AI 使用 prompt-enhancer 技能扩展请求
|
||||
```
|
||||
|
||||
**CLI 命令增强** (CLI 内置功能):
|
||||
```bash
|
||||
# 这里的 --enhance 标识符是 CLI 参数,不是技能触发器
|
||||
/cli:analyze --enhance "检查安全问题"
|
||||
```
|
||||
|
||||
**重要说明**:`-e` 标识符仅在自然对话中有效,而 CLI 命令中的 `--enhance` 是独立的增强机制,与技能系统无关。
|
||||
**重要说明**:`-e` 标识符用于在自然对话中触发 prompt-enhancer 技能。
|
||||
|
||||
---
|
||||
|
||||
|
||||
86
README.md
86
README.md
@@ -2,7 +2,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
[](https://www.npmjs.com/package/claude-code-workflow)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
@@ -12,16 +13,15 @@
|
||||
|
||||
---
|
||||
|
||||
**Claude Code Workflow (CCW)** transforms AI development from simple prompt chaining into a powerful, context-first orchestration system. It solves execution uncertainty and error accumulation through structured planning, deterministic execution, and intelligent multi-model orchestration.
|
||||
**Claude Code Workflow (CCW)** is a JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution. It transforms AI development from simple prompt chaining into a powerful orchestration system.
|
||||
|
||||
> **🎉 Version 5.9.6: Review Cycle Enhancement & Dashboard Automation**
|
||||
> **🎉 Version 6.0.0: npm Package & Simplified Installation**
|
||||
>
|
||||
> **Core Improvements**:
|
||||
> - ✨ **Enhanced Review Dashboard**: The `review-cycle` dashboard now supports real-time progress tracking and advanced filtering for better visibility into code reviews.
|
||||
> - 🎯 **New Fix-Tracking Dashboard**: Introduced a new, independent `fix-dashboard.html` to monitor the progress of bug fixes with rich data integration.
|
||||
> - 🚀 **`lite-fix` Workflow**: Added a new `lite-fix` command for intelligent, streamlined bug diagnosis and resolution.
|
||||
> - 🛠️ **`lite-plan` Optimization**: Significantly optimized the `lite-plan` workflow with cost-aware parallel execution, better complexity analysis, and robust context protection.
|
||||
> - 🧠 **Intelligent Test Cycles**: Improved the `test-cycle-execute` command with smart iteration strategies and a universal `@test-fix-agent` for more effective testing.
|
||||
> - 📦 **npm Package**: Now available as `claude-code-workflow` on npm for simplified global installation
|
||||
> - 🖥️ **CCW CLI Tool**: New `ccw` command with dashboard viewer, installation management, and workflow visualization
|
||||
> - 🎯 **Simplified Install Flow**: Unified installation via npm with local-only operation (no GitHub API dependency)
|
||||
> - ✨ **Enhanced Dashboard**: MCP manager, review session improvements, and UI enhancements
|
||||
>
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for complete details.
|
||||
|
||||
@@ -44,9 +44,26 @@ CCW is built on a set of core principles that distinguish it from traditional AI
|
||||
|
||||
## ⚙️ Installation
|
||||
|
||||
For detailed installation instructions, refer to the [**INSTALL.md**](INSTALL.md) guide.
|
||||
### **📦 npm Install (Recommended)**
|
||||
|
||||
### **🚀 One-Click Quick Install**
|
||||
Install globally via npm:
|
||||
```bash
|
||||
npm install -g claude-code-workflow
|
||||
```
|
||||
|
||||
Then install workflow files to your system:
|
||||
```bash
|
||||
# Interactive installation
|
||||
ccw install
|
||||
|
||||
# Global installation (to ~/.claude)
|
||||
ccw install -m Global
|
||||
|
||||
# Project-specific installation
|
||||
ccw install -m Path -p /path/to/project
|
||||
```
|
||||
|
||||
### **🚀 Alternative: One-Click Script Install**
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
@@ -67,6 +84,55 @@ If slash commands (e.g., `/workflow:*`) are recognized, the installation was suc
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CCW CLI Tool
|
||||
|
||||
The `ccw` command provides a powerful CLI for managing your Claude Code Workflow installation:
|
||||
|
||||
### **Commands**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ccw install` | Install workflow files to Global (~/.claude) or specific Path |
|
||||
| `ccw upgrade` | Upgrade existing installations to current package version |
|
||||
| `ccw uninstall` | Remove workflow files from an installation |
|
||||
| `ccw view` | Open the workflow dashboard in browser |
|
||||
| `ccw serve` | Start dashboard server without opening browser |
|
||||
| `ccw list` | List all managed installations |
|
||||
|
||||
### **Usage Examples**
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
ccw install -m Global
|
||||
|
||||
# Install to specific project
|
||||
ccw install -m Path -p ./my-project
|
||||
|
||||
# Open dashboard
|
||||
ccw view
|
||||
|
||||
# Start dashboard server on custom port
|
||||
ccw serve -p 8080
|
||||
|
||||
# Upgrade all installations
|
||||
ccw upgrade -a
|
||||
|
||||
# List installations
|
||||
ccw list
|
||||
```
|
||||
|
||||
### **Dashboard Features**
|
||||
|
||||
The CCW Dashboard (`ccw view`) provides:
|
||||
- 📊 **Session Overview**: View all workflow sessions with status and progress
|
||||
- 📋 **Task Management**: Track task execution and completion
|
||||
- 🔍 **Review Sessions**: Manage code review cycles
|
||||
- ⚙️ **MCP Manager**: Configure and monitor MCP servers
|
||||
- 🪝 **Hook Manager**: Manage Claude Code hooks
|
||||
- 📁 **Project Explorer**: Navigate project structure and artifacts
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Command Reference
|
||||
|
||||
CCW provides a rich set of commands for managing workflows, tasks, and interactions with AI tools. For a complete list and detailed descriptions of all available commands, please refer to the [**COMMAND_REFERENCE.md**](COMMAND_REFERENCE.md) file.
|
||||
|
||||
@@ -16,7 +16,7 @@ flowchart TD
|
||||
BugFix --> BugSeverity{了解问题根因?}
|
||||
BugSeverity -->|清楚| LiteFix[/ /workflow:lite-fix<br>标准Bug修复 /]
|
||||
BugSeverity -->|生产事故| HotFix[/ /workflow:lite-fix --hotfix<br>热修复模式 /]
|
||||
BugSeverity -->|不清楚| BugDiag[/ /cli:mode:bug-diagnosis<br>先诊断根因 /]
|
||||
BugSeverity -->|不清楚| BugDiag[/ /workflow:lite-fix<br>自动诊断根因 /]
|
||||
|
||||
BugDiag --> LiteFix
|
||||
LiteFix --> BugComplete[Bug修复完成]
|
||||
@@ -142,7 +142,7 @@ flowchart TD
|
||||
|------|------|------|
|
||||
| 🐛 **标准Bug修复** | `/workflow:lite-fix "bug描述"` | 自适应严重性评估,完整诊断→影响评估→修复→验证 |
|
||||
| 🔥 **生产热修复** | `/workflow:lite-fix --hotfix "bug描述"` | 最小化诊断,快速修复,自动生成跟进任务 |
|
||||
| ❓ **根因不清楚** | `/cli:mode:bug-diagnosis` → `/workflow:lite-fix` | 先深度诊断,再执行修复 |
|
||||
| ❓ **根因不清楚** | `/workflow:lite-fix` | 自动进行深度诊断并执行修复 |
|
||||
| ✅ **功能开发** | 继续后续流程 | 不是Bug修复,按正常开发流程 |
|
||||
|
||||
**Lite-Fix 工作流特性**:
|
||||
@@ -171,16 +171,16 @@ flowchart TD
|
||||
→ 最小化诊断 → 假设 Critical → 手术式修复 → 烟雾测试
|
||||
→ 自动生成: 全面修复任务(3天内)+ 事后分析(1周内)
|
||||
|
||||
# 根因不清楚(先诊断)
|
||||
/cli:mode:bug-diagnosis --tool gemini "购物车随机丢失商品"
|
||||
→ 深度诊断报告 → /workflow:lite-fix "修复购物车状态同步问题"
|
||||
# 根因不清楚(lite-fix 自动诊断)
|
||||
/workflow:lite-fix "购物车随机丢失商品"
|
||||
→ 自动深度诊断 → 识别根因 → 实现修复
|
||||
```
|
||||
|
||||
**何时使用 lite-fix**:
|
||||
- ✅ 任何有明确症状的Bug(自动适应严重性)
|
||||
- ✅ 本地化修复(1-5个文件)
|
||||
- ✅ 生产事故(使用 `--hotfix` 模式)
|
||||
- ❌ 根因完全不明 → 先用 `/cli:mode:bug-diagnosis`
|
||||
- ✅ 根因不清楚(自动进行深度诊断)
|
||||
- ❌ 需要架构变更 → 用 `/workflow:plan --mode bugfix`
|
||||
|
||||
---
|
||||
@@ -416,33 +416,24 @@ Phase 1: Gemini 分析 ──┐
|
||||
→ Claude Code 自动生成:codex -C src/auth --full-auto exec "实现注册"
|
||||
```
|
||||
|
||||
**方式二:直接命令调用**
|
||||
|
||||
```bash
|
||||
# 通过 Slash 命令精准调用
|
||||
/cli:chat --tool gemini "解释这个算法"
|
||||
/cli:analyze --tool qwen "分析性能瓶颈"
|
||||
/cli:execute --tool codex "优化查询性能"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 🔗 CLI 结果作为上下文(Memory)
|
||||
#### 🔗 语义调用与结果上下文(Memory)
|
||||
|
||||
CLI 工具的分析结果可以被保存并作为后续操作的上下文(memory),实现智能化的工作流程:
|
||||
通过自然语言描述,Claude 会自动选择并执行适当的 CLI 工具(Gemini/Qwen/Codex),分析结果作为后续操作的上下文。
|
||||
|
||||
**1. 结果持久化**
|
||||
**1. 语义调用示例**
|
||||
|
||||
```bash
|
||||
# CLI 执行结果自动保存到会话目录
|
||||
/cli:chat --tool gemini "分析认证模块架构"
|
||||
→ 保存到:.workflow/active/WFS-xxx/.chat/chat-[timestamp].md
|
||||
# 用自然语言描述需求,Claude 自动选择工具
|
||||
"使用 gemini 分析认证模块架构"
|
||||
→ Claude 自动执行 Gemini CLI 并保存结果
|
||||
|
||||
/cli:analyze --tool qwen "评估性能瓶颈"
|
||||
→ 保存到:.workflow/active/WFS-xxx/.chat/analyze-[timestamp].md
|
||||
"让 qwen 评估性能瓶颈"
|
||||
→ Claude 自动执行 Qwen CLI 并保存结果
|
||||
|
||||
/cli:execute --tool codex "实现功能"
|
||||
→ 保存到:.workflow/active/WFS-xxx/.chat/execute-[timestamp].md
|
||||
"用 codex 实现这个功能"
|
||||
→ Claude 自动执行 Codex CLI
|
||||
```
|
||||
|
||||
**2. 结果作为规划依据**
|
||||
@@ -476,8 +467,8 @@ CLI 工具的分析结果可以被保存并作为后续操作的上下文(memo
|
||||
|
||||
```bash
|
||||
# 引用历史会话的分析结果
|
||||
/cli:execute --tool codex "参考 WFS-2024-001 中的架构分析,实现新的支付模块"
|
||||
→ 系统自动加载指定会话的上下文
|
||||
"参考 WFS-2024-001 中的架构分析,用 codex 实现新的支付模块"
|
||||
→ Claude 自动加载指定会话的上下文
|
||||
→ 基于历史分析进行实现
|
||||
```
|
||||
|
||||
|
||||
@@ -348,33 +348,24 @@ Phase 1: Gemini analysis ──┐
|
||||
→ Claude Code auto-generates: codex -C src/auth --full-auto exec "Implement registration"
|
||||
```
|
||||
|
||||
**Method 2: Direct Command Invocation**
|
||||
|
||||
```bash
|
||||
# Precise invocation via Slash commands
|
||||
/cli:chat --tool gemini "Explain this algorithm"
|
||||
/cli:analyze --tool qwen "Analyze performance bottlenecks"
|
||||
/cli:execute --tool codex "Optimize query performance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 🔗 CLI Results as Context (Memory)
|
||||
#### 🔗 Semantic Invocation & Results Context (Memory)
|
||||
|
||||
CLI tool analysis results can be saved and used as context (memory) for subsequent operations, enabling intelligent workflows:
|
||||
Through natural language, Claude automatically selects and executes appropriate CLI tools (Gemini/Qwen/Codex), with results serving as context for subsequent operations.
|
||||
|
||||
**1. Result Persistence**
|
||||
**1. Semantic Invocation Examples**
|
||||
|
||||
```bash
|
||||
# CLI execution results automatically saved to session directory
|
||||
/cli:chat --tool gemini "Analyze authentication module architecture"
|
||||
→ Saved to: .workflow/active/WFS-xxx/.chat/chat-[timestamp].md
|
||||
# Describe needs in natural language, Claude auto-selects tools
|
||||
"Use gemini to analyze authentication module architecture"
|
||||
→ Claude auto-executes Gemini CLI and saves results
|
||||
|
||||
/cli:analyze --tool qwen "Evaluate performance bottlenecks"
|
||||
→ Saved to: .workflow/active/WFS-xxx/.chat/analyze-[timestamp].md
|
||||
"Have qwen evaluate performance bottlenecks"
|
||||
→ Claude auto-executes Qwen CLI and saves results
|
||||
|
||||
/cli:execute --tool codex "Implement feature"
|
||||
→ Saved to: .workflow/active/WFS-xxx/.chat/execute-[timestamp].md
|
||||
"Use codex to implement this feature"
|
||||
→ Claude auto-executes Codex CLI
|
||||
```
|
||||
|
||||
**2. Results as Planning Basis**
|
||||
@@ -408,8 +399,8 @@ Have codex synthesize above Gemini and Qwen analyses to implement optimal soluti
|
||||
|
||||
```bash
|
||||
# Reference historical session analysis results
|
||||
/cli:execute --tool codex "Refer to architecture analysis in WFS-2024-001, implement new payment module"
|
||||
→ System automatically loads specified session context
|
||||
"Refer to architecture analysis in WFS-2024-001, use codex to implement new payment module"
|
||||
→ Claude automatically loads specified session context
|
||||
→ Implement based on historical analysis
|
||||
```
|
||||
|
||||
@@ -630,16 +621,13 @@ Use gemini to review code quality
|
||||
### Scenario D: Bug Fixing
|
||||
|
||||
```bash
|
||||
# 1. Diagnosis
|
||||
/cli:mode:bug-diagnosis --tool gemini "User login fails with token expired error"
|
||||
# 1. Intelligent bug fix workflow (includes diagnosis)
|
||||
/workflow:lite-fix "User login fails with token expired error"
|
||||
|
||||
# 2. Quick fix
|
||||
/workflow:lite-plan "Fix JWT token expiration validation logic"
|
||||
|
||||
# 3. Test fix
|
||||
# 2. Test fix (if needed)
|
||||
/workflow:test-cycle-execute
|
||||
|
||||
# 4. Complete
|
||||
# 3. Complete
|
||||
```
|
||||
|
||||
---
|
||||
@@ -654,7 +642,7 @@ Use gemini to review code quality
|
||||
| ❓ Know what, don't know how | `/workflow:brainstorm:auto-parallel "Design technical solution"` |
|
||||
| ✅ Know what and how | `/workflow:plan "Specific implementation description"` |
|
||||
| ⚡ Simple, clear small task | `/workflow:lite-plan "Task description"` |
|
||||
| 🐛 Bug fixing | `/cli:mode:bug-diagnosis` + `/workflow:lite-plan` |
|
||||
| 🐛 Bug fixing | `/workflow:lite-fix "bug description"` |
|
||||
|
||||
### Choose by Project Phase
|
||||
|
||||
|
||||
121
ccw/README.md
Normal file
121
ccw/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# CCW - Claude Code Workflow CLI
|
||||
|
||||
A command-line tool for viewing workflow sessions and code review results from the Claude Code Workflow system.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install globally
|
||||
npm install -g ccw
|
||||
|
||||
# Or install from local source
|
||||
cd path/to/ccw
|
||||
npm install
|
||||
npm link
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### View Dashboard
|
||||
|
||||
```bash
|
||||
# Open workflow dashboard in browser
|
||||
ccw view
|
||||
|
||||
# Specify project path
|
||||
ccw view -p /path/to/project
|
||||
|
||||
# Generate dashboard without opening browser
|
||||
ccw view --no-browser
|
||||
|
||||
# Custom output path
|
||||
ccw view -o report.html
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Workflow Dashboard
|
||||
- **Active Sessions**: View all active workflow sessions with task progress
|
||||
- **Archived Sessions**: Browse completed/archived sessions
|
||||
- **Task Tracking**: See individual task status (pending/in_progress/completed)
|
||||
- **Progress Bars**: Visual progress indicators for each session
|
||||
|
||||
### Review Integration
|
||||
- **Code Review Findings**: View results from `review-module-cycle`
|
||||
- **Severity Distribution**: Critical/High/Medium/Low finding counts
|
||||
- **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.)
|
||||
- **Tabbed Interface**: Switch between Workflow and Reviews tabs
|
||||
|
||||
## Dashboard Data Sources
|
||||
|
||||
The CLI reads data from the `.workflow/` directory structure:
|
||||
|
||||
```
|
||||
.workflow/
|
||||
├── active/
|
||||
│ └── WFS-{session-id}/
|
||||
│ ├── workflow-session.json # Session metadata
|
||||
│ ├── .task/
|
||||
│ │ └── IMPL-*.json # Task definitions
|
||||
│ └── .review/
|
||||
│ ├── review-progress.json # Review progress
|
||||
│ └── dimensions/
|
||||
│ └── *.json # Dimension findings
|
||||
└── archives/
|
||||
└── WFS-{session-id}/ # Archived sessions
|
||||
```
|
||||
|
||||
## Bundled Templates
|
||||
|
||||
The CLI includes bundled dashboard templates:
|
||||
- `workflow-dashboard.html` - Workflow session and task visualization
|
||||
- `review-cycle-dashboard.html` - Code review findings display
|
||||
|
||||
No external template installation required - templates are included in the npm package.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 16.0.0
|
||||
- npm or yarn
|
||||
|
||||
## Integration with Claude Code Workflow
|
||||
|
||||
This CLI is a standalone tool that works with the Claude Code Workflow system:
|
||||
|
||||
1. **Install CCW CLI** (via npm)
|
||||
- `npm install -g ccw`
|
||||
- Provides `ccw view` command for dashboard viewing
|
||||
- Templates are bundled - no additional installation required
|
||||
|
||||
2. **Optional: Install Claude Code Workflow** (via `Install-Claude.ps1`)
|
||||
- Provides workflow commands, agents, and automation
|
||||
- CCW will automatically detect and display workflow sessions
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-p, --path <path>` | Path to project directory (default: current directory) |
|
||||
| `--no-browser` | Generate dashboard without opening browser |
|
||||
| `-o, --output <file>` | Custom output path for HTML file |
|
||||
| `-V, --version` | Display version number |
|
||||
| `-h, --help` | Display help information |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Clone and install dependencies
|
||||
git clone <repo-url>
|
||||
cd ccw
|
||||
npm install
|
||||
|
||||
# Link for local testing
|
||||
npm link
|
||||
|
||||
# Test the CLI
|
||||
ccw view -p /path/to/test/project
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
10
ccw/bin/ccw.js
Normal file
10
ccw/bin/ccw.js
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* CCW CLI - Claude Code Workflow Dashboard
|
||||
* Entry point for global CLI installation
|
||||
*/
|
||||
|
||||
import { run } from '../src/cli.js';
|
||||
|
||||
run(process.argv);
|
||||
1914
ccw/package-lock.json
generated
Normal file
1914
ccw/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
ccw/package.json
Normal file
47
ccw/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "ccw",
|
||||
"version": "1.0.0",
|
||||
"description": "Claude Code Workflow CLI - Dashboard viewer for workflow sessions and reviews",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
"ccw": "./bin/ccw.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"workflow",
|
||||
"cli",
|
||||
"dashboard",
|
||||
"code-review"
|
||||
],
|
||||
"author": "Claude Code Workflow",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^11.0.0",
|
||||
"open": "^9.1.0",
|
||||
"chalk": "^5.3.0",
|
||||
"glob": "^10.3.0",
|
||||
"inquirer": "^9.2.0",
|
||||
"ora": "^7.0.0",
|
||||
"figlet": "^1.7.0",
|
||||
"boxen": "^7.1.0",
|
||||
"gradient-string": "^2.0.2"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/claude-code-workflow/ccw"
|
||||
}
|
||||
}
|
||||
100
ccw/src/cli.js
Normal file
100
ccw/src/cli.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Command } from 'commander';
|
||||
import { viewCommand } from './commands/view.js';
|
||||
import { serveCommand } from './commands/serve.js';
|
||||
import { installCommand } from './commands/install.js';
|
||||
import { uninstallCommand } from './commands/uninstall.js';
|
||||
import { upgradeCommand } from './commands/upgrade.js';
|
||||
import { listCommand } from './commands/list.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Load package.json with error handling
|
||||
* @returns {Object} - Package info with version
|
||||
*/
|
||||
function loadPackageInfo() {
|
||||
const pkgPath = join(__dirname, '../package.json');
|
||||
|
||||
try {
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.error('Fatal Error: package.json not found.');
|
||||
console.error(`Expected location: ${pkgPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(pkgPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.error('Fatal Error: package.json contains invalid JSON.');
|
||||
console.error(`Parse error: ${error.message}`);
|
||||
} else {
|
||||
console.error('Fatal Error: Could not read package.json.');
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const pkg = loadPackageInfo();
|
||||
|
||||
export function run(argv) {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('ccw')
|
||||
.description('Claude Code Workflow CLI - Dashboard and workflow tools')
|
||||
.version(pkg.version);
|
||||
|
||||
// View command (server mode with live path switching)
|
||||
program
|
||||
.command('view')
|
||||
.description('Open workflow dashboard server with live path switching')
|
||||
.option('-p, --path <path>', 'Path to project directory', '.')
|
||||
.option('--port <port>', 'Server port', '3456')
|
||||
.option('--no-browser', 'Start server without opening browser')
|
||||
.action(viewCommand);
|
||||
|
||||
// Serve command (alias for view)
|
||||
program
|
||||
.command('serve')
|
||||
.description('Alias for view command')
|
||||
.option('-p, --path <path>', 'Initial project directory')
|
||||
.option('--port <port>', 'Server port', '3456')
|
||||
.option('--no-browser', 'Start server without opening browser')
|
||||
.action(serveCommand);
|
||||
|
||||
// Install command
|
||||
program
|
||||
.command('install')
|
||||
.description('Install Claude Code Workflow to your system')
|
||||
.option('-m, --mode <mode>', 'Installation mode: Global or Path')
|
||||
.option('-p, --path <path>', 'Installation path (for Path mode)')
|
||||
.option('-f, --force', 'Force installation without prompts')
|
||||
.action(installCommand);
|
||||
|
||||
// Uninstall command
|
||||
program
|
||||
.command('uninstall')
|
||||
.description('Uninstall Claude Code Workflow')
|
||||
.action(uninstallCommand);
|
||||
|
||||
// Upgrade command
|
||||
program
|
||||
.command('upgrade')
|
||||
.description('Upgrade Claude Code Workflow installations')
|
||||
.option('-a, --all', 'Upgrade all installations without prompting')
|
||||
.action(upgradeCommand);
|
||||
|
||||
// List command
|
||||
program
|
||||
.command('list')
|
||||
.description('List all installed Claude Code Workflow instances')
|
||||
.action(listCommand);
|
||||
|
||||
program.parse(argv);
|
||||
}
|
||||
324
ccw/src/commands/install.js
Normal file
324
ccw/src/commands/install.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname, basename } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { showHeader, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
|
||||
import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests } from '../core/manifest.js';
|
||||
import { validatePath } from '../utils/path-resolver.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Source directories to install
|
||||
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
|
||||
|
||||
// Get package root directory (ccw/src/commands -> ccw)
|
||||
function getPackageRoot() {
|
||||
return join(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
// Get source installation directory (parent of ccw)
|
||||
function getSourceDir() {
|
||||
return join(getPackageRoot(), '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install command handler
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function installCommand(options) {
|
||||
const version = getVersion();
|
||||
|
||||
// Show beautiful header
|
||||
showHeader(version);
|
||||
|
||||
// Check for existing installations
|
||||
const existingManifests = getAllManifests();
|
||||
if (existingManifests.length > 0 && !options.force) {
|
||||
info('Existing installations detected:');
|
||||
console.log('');
|
||||
existingManifests.forEach((m, i) => {
|
||||
console.log(chalk.gray(` ${i + 1}. ${m.installation_mode} - ${m.installation_path}`));
|
||||
console.log(chalk.gray(` Installed: ${new Date(m.installation_date).toLocaleDateString()}`));
|
||||
});
|
||||
console.log('');
|
||||
|
||||
const { proceed } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'proceed',
|
||||
message: 'Continue with new installation?',
|
||||
default: true
|
||||
}]);
|
||||
|
||||
if (!proceed) {
|
||||
info('Installation cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Local installation from package source
|
||||
const sourceDir = getSourceDir();
|
||||
|
||||
// Interactive mode selection
|
||||
const mode = options.mode || await selectMode();
|
||||
|
||||
let installPath;
|
||||
if (mode === 'Global') {
|
||||
installPath = homedir();
|
||||
info(`Global installation to: ${installPath}`);
|
||||
} else {
|
||||
const inputPath = options.path || await selectPath();
|
||||
|
||||
// Validate the installation path
|
||||
const pathValidation = validatePath(inputPath, { mustExist: true });
|
||||
if (!pathValidation.valid) {
|
||||
error(`Invalid installation path: ${pathValidation.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
installPath = pathValidation.path;
|
||||
info(`Path installation to: ${installPath}`);
|
||||
}
|
||||
|
||||
// Validate source directories exist
|
||||
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
|
||||
|
||||
if (availableDirs.length === 0) {
|
||||
error('No source directories found to install.');
|
||||
error(`Expected directories in: ${sourceDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
|
||||
divider();
|
||||
|
||||
// Check for existing installation at target path
|
||||
const existingManifest = findManifest(installPath, mode);
|
||||
if (existingManifest) {
|
||||
warning('Existing installation found at this location');
|
||||
const { backup } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'backup',
|
||||
message: 'Create backup before reinstalling?',
|
||||
default: true
|
||||
}]);
|
||||
|
||||
if (backup) {
|
||||
await createBackup(installPath, existingManifest);
|
||||
}
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
const manifest = createManifest(mode, installPath);
|
||||
|
||||
// Perform installation
|
||||
console.log('');
|
||||
const spinner = createSpinner('Installing files...').start();
|
||||
|
||||
let totalFiles = 0;
|
||||
let totalDirs = 0;
|
||||
|
||||
try {
|
||||
for (const dir of availableDirs) {
|
||||
const srcPath = join(sourceDir, dir);
|
||||
const destPath = join(installPath, dir);
|
||||
|
||||
spinner.text = `Installing ${dir}...`;
|
||||
|
||||
const { files, directories } = await copyDirectory(srcPath, destPath, manifest);
|
||||
totalFiles += files;
|
||||
totalDirs += directories;
|
||||
}
|
||||
|
||||
// Copy CLAUDE.md to .claude directory
|
||||
const claudeMdSrc = join(sourceDir, 'CLAUDE.md');
|
||||
const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md');
|
||||
if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) {
|
||||
spinner.text = 'Installing CLAUDE.md...';
|
||||
copyFileSync(claudeMdSrc, claudeMdDest);
|
||||
addFileEntry(manifest, claudeMdDest);
|
||||
totalFiles++;
|
||||
}
|
||||
|
||||
// Create version.json
|
||||
const versionPath = join(installPath, '.claude', 'version.json');
|
||||
if (existsSync(dirname(versionPath))) {
|
||||
const versionData = {
|
||||
version: version,
|
||||
installedAt: new Date().toISOString(),
|
||||
mode: mode,
|
||||
installer: 'ccw'
|
||||
};
|
||||
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
|
||||
addFileEntry(manifest, versionPath);
|
||||
totalFiles++;
|
||||
}
|
||||
|
||||
spinner.succeed('Installation complete!');
|
||||
|
||||
} catch (err) {
|
||||
spinner.fail('Installation failed');
|
||||
error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Save manifest
|
||||
const manifestPath = saveManifest(manifest);
|
||||
|
||||
// Show summary
|
||||
console.log('');
|
||||
const summaryLines = [
|
||||
chalk.green.bold('✓ Installation Successful'),
|
||||
'',
|
||||
chalk.white(`Mode: ${chalk.cyan(mode)}`),
|
||||
chalk.white(`Path: ${chalk.cyan(installPath)}`),
|
||||
chalk.white(`Version: ${chalk.cyan(version)}`),
|
||||
'',
|
||||
chalk.gray(`Files installed: ${totalFiles}`),
|
||||
chalk.gray(`Directories created: ${totalDirs}`),
|
||||
'',
|
||||
chalk.gray(`Manifest: ${basename(manifestPath)}`)
|
||||
];
|
||||
|
||||
summaryBox({
|
||||
title: ' Installation Summary ',
|
||||
lines: summaryLines,
|
||||
borderColor: 'green'
|
||||
});
|
||||
|
||||
// Show next steps
|
||||
console.log('');
|
||||
info('Next steps:');
|
||||
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
|
||||
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
|
||||
console.log(chalk.gray(' 3. Run: ccw uninstall - to remove this installation'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive mode selection
|
||||
* @returns {Promise<string>} - Selected mode
|
||||
*/
|
||||
async function selectMode() {
|
||||
const { mode } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'mode',
|
||||
message: 'Select installation mode:',
|
||||
choices: [
|
||||
{
|
||||
name: `${chalk.cyan('Global')} - Install to home directory (recommended)`,
|
||||
value: 'Global'
|
||||
},
|
||||
{
|
||||
name: `${chalk.yellow('Path')} - Install to specific project path`,
|
||||
value: 'Path'
|
||||
}
|
||||
]
|
||||
}]);
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive path selection
|
||||
* @returns {Promise<string>} - Selected path
|
||||
*/
|
||||
async function selectPath() {
|
||||
const { path } = await inquirer.prompt([{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: 'Enter installation path:',
|
||||
default: process.cwd(),
|
||||
validate: (input) => {
|
||||
if (!input) return 'Path is required';
|
||||
if (!existsSync(input)) {
|
||||
return `Path does not exist: ${input}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}]);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup of existing installation
|
||||
* @param {string} installPath - Installation path
|
||||
* @param {Object} manifest - Existing manifest
|
||||
*/
|
||||
async function createBackup(installPath, manifest) {
|
||||
const spinner = createSpinner('Creating backup...').start();
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
|
||||
const backupDir = join(installPath, `.claude-backup-${timestamp}`);
|
||||
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
// Copy existing .claude directory
|
||||
const claudeDir = join(installPath, '.claude');
|
||||
if (existsSync(claudeDir)) {
|
||||
await copyDirectory(claudeDir, join(backupDir, '.claude'));
|
||||
}
|
||||
|
||||
spinner.succeed(`Backup created: ${backupDir}`);
|
||||
} catch (err) {
|
||||
spinner.warn(`Backup failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy directory recursively
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* @param {Object} manifest - Manifest to track files (optional)
|
||||
* @returns {Object} - Count of files and directories
|
||||
*/
|
||||
async function copyDirectory(src, dest, manifest = null) {
|
||||
let files = 0;
|
||||
let directories = 0;
|
||||
|
||||
// Create destination directory
|
||||
if (!existsSync(dest)) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
directories++;
|
||||
if (manifest) addDirectoryEntry(manifest, dest);
|
||||
}
|
||||
|
||||
const entries = readdirSync(src);
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(src, entry);
|
||||
const destPath = join(dest, entry);
|
||||
const stat = statSync(srcPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const result = await copyDirectory(srcPath, destPath, manifest);
|
||||
files += result.files;
|
||||
directories += result.directories;
|
||||
} else {
|
||||
copyFileSync(srcPath, destPath);
|
||||
files++;
|
||||
if (manifest) addFileEntry(manifest, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { files, directories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version
|
||||
* @returns {string} - Version string
|
||||
*/
|
||||
function getVersion() {
|
||||
try {
|
||||
const pkgPath = join(getPackageRoot(), 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
37
ccw/src/commands/list.js
Normal file
37
ccw/src/commands/list.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import chalk from 'chalk';
|
||||
import { showBanner, divider, info } from '../utils/ui.js';
|
||||
import { getAllManifests } from '../core/manifest.js';
|
||||
|
||||
/**
|
||||
* List command handler - shows all installations
|
||||
*/
|
||||
export async function listCommand() {
|
||||
showBanner();
|
||||
console.log(chalk.cyan.bold(' Installed Claude Code Workflow Instances\n'));
|
||||
|
||||
const manifests = getAllManifests();
|
||||
|
||||
if (manifests.length === 0) {
|
||||
info('No installations found.');
|
||||
console.log('');
|
||||
console.log(chalk.gray(' Run: ccw install - to install Claude Code Workflow'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
manifests.forEach((m, i) => {
|
||||
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
|
||||
|
||||
console.log(chalk.white.bold(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
|
||||
console.log(chalk.gray(` Path: ${m.installation_path}`));
|
||||
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
|
||||
console.log(chalk.gray(` Version: ${m.application_version}`));
|
||||
console.log(chalk.gray(` Files: ${m.files_count}`));
|
||||
console.log(chalk.gray(` Dirs: ${m.directories_count}`));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
divider();
|
||||
console.log(chalk.gray(' Run: ccw uninstall - to remove an installation'));
|
||||
console.log('');
|
||||
}
|
||||
67
ccw/src/commands/serve.js
Normal file
67
ccw/src/commands/serve.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { startServer } from '../core/server.js';
|
||||
import { launchBrowser } from '../utils/browser-launcher.js';
|
||||
import { resolvePath, validatePath } from '../utils/path-resolver.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Serve command handler - starts dashboard server with live path switching
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function serveCommand(options) {
|
||||
const port = options.port || 3456;
|
||||
|
||||
// Validate project path
|
||||
let initialPath = process.cwd();
|
||||
if (options.path) {
|
||||
const pathValidation = validatePath(options.path, { mustExist: true });
|
||||
if (!pathValidation.valid) {
|
||||
console.error(chalk.red(`\n Error: ${pathValidation.error}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
initialPath = pathValidation.path;
|
||||
}
|
||||
|
||||
console.log(chalk.blue.bold('\n CCW Dashboard Server\n'));
|
||||
console.log(chalk.gray(` Initial project: ${initialPath}`));
|
||||
console.log(chalk.gray(` Port: ${port}\n`));
|
||||
|
||||
try {
|
||||
// Start server
|
||||
console.log(chalk.cyan(' Starting server...'));
|
||||
const server = await startServer({ port, initialPath });
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(chalk.green(` Server running at ${url}`));
|
||||
|
||||
// Open browser
|
||||
if (options.browser !== false) {
|
||||
console.log(chalk.cyan(' Opening in browser...'));
|
||||
try {
|
||||
await launchBrowser(url);
|
||||
console.log(chalk.green.bold('\n Dashboard opened in browser!'));
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`\n Could not open browser: ${err.message}`));
|
||||
console.log(chalk.gray(` Open manually: ${url}`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.gray('\n Press Ctrl+C to stop the server\n'));
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n Shutting down server...'));
|
||||
server.close(() => {
|
||||
console.log(chalk.green(' Server stopped.\n'));
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(chalk.yellow(` Port ${port} is already in use.`));
|
||||
console.error(chalk.gray(` Try a different port: ccw serve --port ${port + 1}\n`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
238
ccw/src/commands/uninstall.js
Normal file
238
ccw/src/commands/uninstall.js
Normal file
@@ -0,0 +1,238 @@
|
||||
import { existsSync, unlinkSync, rmdirSync, readdirSync, statSync } from 'fs';
|
||||
import { join, dirname, basename } from 'path';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { showBanner, createSpinner, success, info, warning, error, summaryBox, divider } from '../utils/ui.js';
|
||||
import { getAllManifests, deleteManifest } from '../core/manifest.js';
|
||||
|
||||
/**
|
||||
* Uninstall command handler
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function uninstallCommand(options) {
|
||||
showBanner();
|
||||
console.log(chalk.cyan.bold(' Uninstall Claude Code Workflow\n'));
|
||||
|
||||
// Get all manifests
|
||||
const manifests = getAllManifests();
|
||||
|
||||
if (manifests.length === 0) {
|
||||
warning('No installations found.');
|
||||
info('Nothing to uninstall.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Display installations
|
||||
console.log(chalk.white.bold(' Found installations:\n'));
|
||||
|
||||
manifests.forEach((m, i) => {
|
||||
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
|
||||
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
|
||||
console.log(chalk.gray(` Path: ${m.installation_path}`));
|
||||
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
|
||||
console.log(chalk.gray(` Version: ${m.application_version}`));
|
||||
console.log(chalk.gray(` Files: ${m.files_count} | Dirs: ${m.directories_count}`));
|
||||
console.log('');
|
||||
});
|
||||
|
||||
divider();
|
||||
|
||||
// Select installation to uninstall
|
||||
let selectedManifest;
|
||||
|
||||
if (manifests.length === 1) {
|
||||
const { confirm } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Uninstall ${manifests[0].installation_mode} installation at ${manifests[0].installation_path}?`,
|
||||
default: false
|
||||
}]);
|
||||
|
||||
if (!confirm) {
|
||||
info('Uninstall cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedManifest = manifests[0];
|
||||
} else {
|
||||
const choices = manifests.map((m, i) => ({
|
||||
name: `${m.installation_mode} - ${m.installation_path}`,
|
||||
value: i
|
||||
}));
|
||||
|
||||
choices.push({ name: chalk.gray('Cancel'), value: -1 });
|
||||
|
||||
const { selection } = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'selection',
|
||||
message: 'Select installation to uninstall:',
|
||||
choices
|
||||
}]);
|
||||
|
||||
if (selection === -1) {
|
||||
info('Uninstall cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedManifest = manifests[selection];
|
||||
|
||||
// Confirm selection
|
||||
const { confirm } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Are you sure you want to uninstall ${selectedManifest.installation_mode} installation?`,
|
||||
default: false
|
||||
}]);
|
||||
|
||||
if (!confirm) {
|
||||
info('Uninstall cancelled');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// Perform uninstallation
|
||||
const spinner = createSpinner('Removing files...').start();
|
||||
|
||||
let removedFiles = 0;
|
||||
let removedDirs = 0;
|
||||
let failedFiles = [];
|
||||
|
||||
try {
|
||||
// Remove files first (in reverse order to handle nested files)
|
||||
const files = [...(selectedManifest.files || [])].reverse();
|
||||
|
||||
for (const fileEntry of files) {
|
||||
const filePath = fileEntry.path;
|
||||
spinner.text = `Removing: ${basename(filePath)}`;
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
removedFiles++;
|
||||
}
|
||||
} catch (err) {
|
||||
failedFiles.push({ path: filePath, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove directories (in reverse order to remove nested dirs first)
|
||||
const directories = [...(selectedManifest.directories || [])].reverse();
|
||||
|
||||
// Sort by path length (deepest first)
|
||||
directories.sort((a, b) => b.path.length - a.path.length);
|
||||
|
||||
for (const dirEntry of directories) {
|
||||
const dirPath = dirEntry.path;
|
||||
spinner.text = `Removing directory: ${basename(dirPath)}`;
|
||||
|
||||
try {
|
||||
if (existsSync(dirPath)) {
|
||||
// Only remove if empty
|
||||
const contents = readdirSync(dirPath);
|
||||
if (contents.length === 0) {
|
||||
rmdirSync(dirPath);
|
||||
removedDirs++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore directory removal errors (might not be empty)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to clean up parent directories if empty
|
||||
const installPath = selectedManifest.installation_path;
|
||||
for (const dir of ['.claude', '.codex', '.gemini', '.qwen']) {
|
||||
const dirPath = join(installPath, dir);
|
||||
try {
|
||||
if (existsSync(dirPath)) {
|
||||
await removeEmptyDirs(dirPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed('Uninstall complete!');
|
||||
|
||||
} catch (err) {
|
||||
spinner.fail('Uninstall failed');
|
||||
error(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete manifest
|
||||
deleteManifest(selectedManifest.manifest_file);
|
||||
|
||||
// Show summary
|
||||
console.log('');
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
summaryBox({
|
||||
title: ' Uninstall Summary ',
|
||||
lines: [
|
||||
chalk.yellow.bold('⚠ Partially Completed'),
|
||||
'',
|
||||
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
|
||||
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
|
||||
chalk.white(`Failed: ${chalk.red(failedFiles.length)}`),
|
||||
'',
|
||||
chalk.gray('Some files could not be removed.'),
|
||||
chalk.gray('They may be in use or require elevated permissions.'),
|
||||
],
|
||||
borderColor: 'yellow'
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log('');
|
||||
console.log(chalk.gray('Failed files:'));
|
||||
failedFiles.forEach(f => {
|
||||
console.log(chalk.red(` ${f.path}: ${f.error}`));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
summaryBox({
|
||||
title: ' Uninstall Summary ',
|
||||
lines: [
|
||||
chalk.green.bold('✓ Successfully Uninstalled'),
|
||||
'',
|
||||
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
|
||||
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
|
||||
'',
|
||||
chalk.gray('Manifest removed'),
|
||||
],
|
||||
borderColor: 'green'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove empty directories
|
||||
* @param {string} dirPath - Directory path
|
||||
*/
|
||||
async function removeEmptyDirs(dirPath) {
|
||||
if (!existsSync(dirPath)) return;
|
||||
|
||||
const stat = statSync(dirPath);
|
||||
if (!stat.isDirectory()) return;
|
||||
|
||||
let files = readdirSync(dirPath);
|
||||
|
||||
// Recursively check subdirectories
|
||||
for (const file of files) {
|
||||
const filePath = join(dirPath, file);
|
||||
if (statSync(filePath).isDirectory()) {
|
||||
await removeEmptyDirs(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after processing subdirectories
|
||||
files = readdirSync(dirPath);
|
||||
if (files.length === 0) {
|
||||
rmdirSync(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
307
ccw/src/commands/upgrade.js
Normal file
307
ccw/src/commands/upgrade.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import { existsSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, dirname, basename } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import inquirer from 'inquirer';
|
||||
import chalk from 'chalk';
|
||||
import { showBanner, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
|
||||
import { getAllManifests, createManifest, addFileEntry, addDirectoryEntry, saveManifest, deleteManifest } from '../core/manifest.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Source directories to install
|
||||
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
|
||||
|
||||
// Get package root directory (ccw/src/commands -> ccw)
|
||||
function getPackageRoot() {
|
||||
return join(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
// Get source installation directory (parent of ccw)
|
||||
function getSourceDir() {
|
||||
return join(getPackageRoot(), '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version
|
||||
* @returns {string} - Version string
|
||||
*/
|
||||
function getVersion() {
|
||||
try {
|
||||
const pkgPath = join(getPackageRoot(), 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
return pkg.version || '1.0.0';
|
||||
} catch {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade command handler
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function upgradeCommand(options) {
|
||||
showBanner();
|
||||
console.log(chalk.cyan.bold(' Upgrade Claude Code Workflow\n'));
|
||||
|
||||
const currentVersion = getVersion();
|
||||
|
||||
// Get all manifests
|
||||
const manifests = getAllManifests();
|
||||
|
||||
if (manifests.length === 0) {
|
||||
warning('No installations found.');
|
||||
info('Run "ccw install" to install first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Display current installations
|
||||
console.log(chalk.white.bold(' Current installations:\n'));
|
||||
|
||||
const upgradeTargets = [];
|
||||
|
||||
for (let i = 0; i < manifests.length; i++) {
|
||||
const m = manifests[i];
|
||||
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
|
||||
|
||||
// Read installed version
|
||||
const versionFile = join(m.installation_path, '.claude', 'version.json');
|
||||
let installedVersion = 'unknown';
|
||||
|
||||
if (existsSync(versionFile)) {
|
||||
try {
|
||||
const versionData = JSON.parse(readFileSync(versionFile, 'utf8'));
|
||||
installedVersion = versionData.version || 'unknown';
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Check if upgrade needed
|
||||
const needsUpgrade = installedVersion !== currentVersion;
|
||||
|
||||
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
|
||||
console.log(chalk.gray(` Path: ${m.installation_path}`));
|
||||
console.log(chalk.gray(` Installed: ${installedVersion}`));
|
||||
|
||||
if (needsUpgrade) {
|
||||
console.log(chalk.green(` Package: ${currentVersion} `) + chalk.green('← Update available'));
|
||||
upgradeTargets.push({ manifest: m, installedVersion, index: i });
|
||||
} else {
|
||||
console.log(chalk.gray(` Up to date ✓`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
divider();
|
||||
|
||||
if (upgradeTargets.length === 0) {
|
||||
info('All installations are up to date.');
|
||||
console.log('');
|
||||
info('To upgrade ccw itself, run:');
|
||||
console.log(chalk.cyan(' npm update -g ccw'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Select which installations to upgrade
|
||||
let selectedManifests = [];
|
||||
|
||||
if (options.all) {
|
||||
selectedManifests = upgradeTargets.map(t => t.manifest);
|
||||
} else if (upgradeTargets.length === 1) {
|
||||
const target = upgradeTargets[0];
|
||||
const { confirm } = await inquirer.prompt([{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Upgrade ${target.manifest.installation_mode} installation (${target.installedVersion} → ${currentVersion})?`,
|
||||
default: true
|
||||
}]);
|
||||
|
||||
if (!confirm) {
|
||||
info('Upgrade cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedManifests = [target.manifest];
|
||||
} else {
|
||||
const choices = upgradeTargets.map((t, i) => ({
|
||||
name: `${t.manifest.installation_mode} - ${t.manifest.installation_path} (${t.installedVersion} → ${currentVersion})`,
|
||||
value: i,
|
||||
checked: true
|
||||
}));
|
||||
|
||||
const { selections } = await inquirer.prompt([{
|
||||
type: 'checkbox',
|
||||
name: 'selections',
|
||||
message: 'Select installations to upgrade:',
|
||||
choices
|
||||
}]);
|
||||
|
||||
if (selections.length === 0) {
|
||||
info('No installations selected');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedManifests = selections.map(i => upgradeTargets[i].manifest);
|
||||
}
|
||||
|
||||
// Perform upgrades
|
||||
console.log('');
|
||||
const results = [];
|
||||
const sourceDir = getSourceDir();
|
||||
|
||||
for (const manifest of selectedManifests) {
|
||||
const upgradeSpinner = createSpinner(`Upgrading ${manifest.installation_mode} at ${manifest.installation_path}...`).start();
|
||||
|
||||
try {
|
||||
const result = await performUpgrade(manifest, sourceDir, currentVersion);
|
||||
upgradeSpinner.succeed(`Upgraded ${manifest.installation_mode}: ${result.files} files`);
|
||||
results.push({ manifest, success: true, ...result });
|
||||
} catch (err) {
|
||||
upgradeSpinner.fail(`Failed to upgrade ${manifest.installation_mode}`);
|
||||
error(err.message);
|
||||
results.push({ manifest, success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
console.log('');
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
|
||||
const summaryLines = [
|
||||
successCount === results.length
|
||||
? chalk.green.bold('✓ Upgrade Successful')
|
||||
: chalk.yellow.bold('⚠ Upgrade Completed with Issues'),
|
||||
'',
|
||||
chalk.white(`Version: ${chalk.cyan(currentVersion)}`),
|
||||
''
|
||||
];
|
||||
|
||||
if (successCount > 0) {
|
||||
summaryLines.push(chalk.green(`Upgraded: ${successCount} installation(s)`));
|
||||
}
|
||||
if (failCount > 0) {
|
||||
summaryLines.push(chalk.red(`Failed: ${failCount} installation(s)`));
|
||||
}
|
||||
|
||||
summaryBox({
|
||||
title: ' Upgrade Summary ',
|
||||
lines: summaryLines,
|
||||
borderColor: failCount > 0 ? 'yellow' : 'green'
|
||||
});
|
||||
|
||||
// Show next steps
|
||||
console.log('');
|
||||
info('Next steps:');
|
||||
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
|
||||
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform upgrade for a single installation
|
||||
* @param {Object} manifest - Installation manifest
|
||||
* @param {string} sourceDir - Source directory
|
||||
* @param {string} version - Version string
|
||||
* @returns {Promise<Object>} - Upgrade result
|
||||
*/
|
||||
async function performUpgrade(manifest, sourceDir, version) {
|
||||
const installPath = manifest.installation_path;
|
||||
|
||||
// Get available source directories
|
||||
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
|
||||
|
||||
if (availableDirs.length === 0) {
|
||||
throw new Error('No source directories found');
|
||||
}
|
||||
|
||||
// Create new manifest
|
||||
const newManifest = createManifest(manifest.installation_mode, installPath);
|
||||
|
||||
let totalFiles = 0;
|
||||
let totalDirs = 0;
|
||||
|
||||
// Copy each directory
|
||||
for (const dir of availableDirs) {
|
||||
const srcPath = join(sourceDir, dir);
|
||||
const destPath = join(installPath, dir);
|
||||
|
||||
const { files, directories } = await copyDirectory(srcPath, destPath, newManifest);
|
||||
totalFiles += files;
|
||||
totalDirs += directories;
|
||||
}
|
||||
|
||||
// Copy CLAUDE.md to .claude directory
|
||||
const claudeMdSrc = join(sourceDir, 'CLAUDE.md');
|
||||
const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md');
|
||||
if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) {
|
||||
copyFileSync(claudeMdSrc, claudeMdDest);
|
||||
addFileEntry(newManifest, claudeMdDest);
|
||||
totalFiles++;
|
||||
}
|
||||
|
||||
// Update version.json
|
||||
const versionPath = join(installPath, '.claude', 'version.json');
|
||||
if (existsSync(dirname(versionPath))) {
|
||||
const versionData = {
|
||||
version: version,
|
||||
installedAt: new Date().toISOString(),
|
||||
upgradedAt: new Date().toISOString(),
|
||||
mode: manifest.installation_mode,
|
||||
installer: 'ccw'
|
||||
};
|
||||
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
|
||||
addFileEntry(newManifest, versionPath);
|
||||
totalFiles++;
|
||||
}
|
||||
|
||||
// Delete old manifest and save new one
|
||||
if (manifest.manifest_file) {
|
||||
deleteManifest(manifest.manifest_file);
|
||||
}
|
||||
saveManifest(newManifest);
|
||||
|
||||
return { files: totalFiles, directories: totalDirs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy directory recursively
|
||||
* @param {string} src - Source directory
|
||||
* @param {string} dest - Destination directory
|
||||
* @param {Object} manifest - Manifest to track files
|
||||
* @returns {Object} - Count of files and directories
|
||||
*/
|
||||
async function copyDirectory(src, dest, manifest) {
|
||||
let files = 0;
|
||||
let directories = 0;
|
||||
|
||||
// Create destination directory
|
||||
if (!existsSync(dest)) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
directories++;
|
||||
addDirectoryEntry(manifest, dest);
|
||||
}
|
||||
|
||||
const entries = readdirSync(src);
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = join(src, entry);
|
||||
const destPath = join(dest, entry);
|
||||
const stat = statSync(srcPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const result = await copyDirectory(srcPath, destPath, manifest);
|
||||
files += result.files;
|
||||
directories += result.directories;
|
||||
} else {
|
||||
copyFileSync(srcPath, destPath);
|
||||
files++;
|
||||
addFileEntry(manifest, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { files, directories };
|
||||
}
|
||||
14
ccw/src/commands/view.js
Normal file
14
ccw/src/commands/view.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { serveCommand } from './serve.js';
|
||||
|
||||
/**
|
||||
* View command handler - starts dashboard server (unified with serve mode)
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function viewCommand(options) {
|
||||
// Forward to serve command with same options
|
||||
await serveCommand({
|
||||
path: options.path,
|
||||
port: options.port || 3456,
|
||||
browser: options.browser
|
||||
});
|
||||
}
|
||||
29
ccw/src/core/dashboard-generator-patch.js
Normal file
29
ccw/src/core/dashboard-generator-patch.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Add after line 13 (after REVIEW_TEMPLATE constant)
|
||||
|
||||
// Modular dashboard JS files (in dependency order)
|
||||
const MODULE_FILES = [
|
||||
// Base (no dependencies)
|
||||
'dashboard-js/state.js',
|
||||
'dashboard-js/utils.js',
|
||||
'dashboard-js/api.js',
|
||||
// Components (independent)
|
||||
'dashboard-js/components/theme.js',
|
||||
'dashboard-js/components/sidebar.js',
|
||||
'dashboard-js/components/modals.js',
|
||||
'dashboard-js/components/flowchart.js',
|
||||
// Components (dependent)
|
||||
'dashboard-js/components/task-drawer-renderers.js',
|
||||
'dashboard-js/components/task-drawer-core.js',
|
||||
'dashboard-js/components/tabs-context.js',
|
||||
'dashboard-js/components/tabs-other.js',
|
||||
// Views
|
||||
'dashboard-js/views/home.js',
|
||||
'dashboard-js/views/project-overview.js',
|
||||
'dashboard-js/views/review-session.js',
|
||||
'dashboard-js/views/fix-session.js',
|
||||
'dashboard-js/views/lite-tasks.js',
|
||||
'dashboard-js/views/session-detail.js',
|
||||
// Navigation & Main
|
||||
'dashboard-js/components/navigation.js',
|
||||
'dashboard-js/main.js'
|
||||
];
|
||||
667
ccw/src/core/dashboard-generator.js
Normal file
667
ccw/src/core/dashboard-generator.js
Normal file
@@ -0,0 +1,667 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Bundled template paths
|
||||
const UNIFIED_TEMPLATE = join(__dirname, '../templates/dashboard.html');
|
||||
const JS_FILE = join(__dirname, '../templates/dashboard.js');
|
||||
const CSS_FILE = join(__dirname, '../templates/dashboard.css');
|
||||
const WORKFLOW_TEMPLATE = join(__dirname, '../templates/workflow-dashboard.html');
|
||||
const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.html');
|
||||
|
||||
const MODULE_FILES = [
|
||||
'utils.js',
|
||||
'state.js',
|
||||
'api.js',
|
||||
'components/theme.js',
|
||||
'components/modals.js',
|
||||
'components/navigation.js',
|
||||
'components/sidebar.js',
|
||||
'components/tabs-context.js',
|
||||
'components/tabs-other.js',
|
||||
'components/task-drawer-core.js',
|
||||
'components/task-drawer-renderers.js',
|
||||
'components/flowchart.js',
|
||||
'views/home.js',
|
||||
'views/project-overview.js',
|
||||
'views/session-detail.js',
|
||||
'views/review-session.js',
|
||||
'views/lite-tasks.js',
|
||||
'views/fix-session.js',
|
||||
'main.js'
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate dashboard HTML from aggregated data
|
||||
* Uses bundled templates from ccw package
|
||||
* @param {Object} data - Aggregated dashboard data
|
||||
* @returns {Promise<string>} - Generated HTML
|
||||
*/
|
||||
export async function generateDashboard(data) {
|
||||
// Use new unified template (with sidebar layout)
|
||||
if (existsSync(UNIFIED_TEMPLATE)) {
|
||||
return generateFromUnifiedTemplate(data);
|
||||
}
|
||||
|
||||
// Fallback to legacy workflow template
|
||||
if (existsSync(WORKFLOW_TEMPLATE)) {
|
||||
return generateFromBundledTemplate(data, WORKFLOW_TEMPLATE);
|
||||
}
|
||||
|
||||
// Fallback to inline dashboard if templates missing
|
||||
return generateInlineDashboard(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard using unified template (new sidebar layout)
|
||||
* @param {Object} data - Dashboard data
|
||||
* @returns {string} - Generated HTML
|
||||
*/
|
||||
function generateFromUnifiedTemplate(data) {
|
||||
let html = readFileSync(UNIFIED_TEMPLATE, 'utf8');
|
||||
|
||||
// Read CSS file
|
||||
let cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
||||
|
||||
// Read JS content
|
||||
let jsContent = '';
|
||||
const moduleBase = join(__dirname, '../templates/dashboard-js');
|
||||
|
||||
if (existsSync(moduleBase)) {
|
||||
jsContent = MODULE_FILES.map(file => {
|
||||
const filePath = join(moduleBase, file);
|
||||
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
||||
}).join('\n\n');
|
||||
} else if (existsSync(JS_FILE)) {
|
||||
jsContent = readFileSync(JS_FILE, 'utf8');
|
||||
}
|
||||
|
||||
// Prepare complete workflow data
|
||||
const workflowData = {
|
||||
generatedAt: data.generatedAt || new Date().toISOString(),
|
||||
activeSessions: data.activeSessions || [],
|
||||
archivedSessions: data.archivedSessions || [],
|
||||
liteTasks: data.liteTasks || { litePlan: [], liteFix: [] },
|
||||
reviewData: data.reviewData || { dimensions: {} },
|
||||
statistics: data.statistics || {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Get project path and recent paths
|
||||
const projectPath = data.projectPath || process.cwd();
|
||||
const recentPaths = data.recentPaths || [projectPath];
|
||||
|
||||
// Replace JS placeholders with actual data
|
||||
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(recentPaths));
|
||||
|
||||
// Inject JS and CSS into HTML template
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
|
||||
// Also replace any remaining placeholders in HTML
|
||||
html = html.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard using bundled template
|
||||
* @param {Object} data - Dashboard data
|
||||
* @param {string} templatePath - Path to workflow-dashboard.html
|
||||
* @returns {string} - Generated HTML
|
||||
*/
|
||||
function generateFromBundledTemplate(data, templatePath) {
|
||||
let html = readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Prepare workflow data for injection
|
||||
const workflowData = {
|
||||
activeSessions: data.activeSessions,
|
||||
archivedSessions: data.archivedSessions
|
||||
};
|
||||
|
||||
// Inject workflow data
|
||||
html = html.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
|
||||
|
||||
// If we have review data, add a review tab
|
||||
if (data.reviewData && data.reviewData.totalFindings > 0) {
|
||||
html = injectReviewTab(html, data.reviewData);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject review tab into existing dashboard
|
||||
* @param {string} html - Base dashboard HTML
|
||||
* @param {Object} reviewData - Review data to display
|
||||
* @returns {string} - Modified HTML with review tab
|
||||
*/
|
||||
function injectReviewTab(html, reviewData) {
|
||||
// Add review tab button in header controls
|
||||
const tabButtonHtml = `
|
||||
<button class="btn" data-tab="reviews" id="reviewTabBtn">Reviews (${reviewData.totalFindings})</button>
|
||||
`;
|
||||
|
||||
// Insert after filter-group
|
||||
html = html.replace(
|
||||
'</div>\n </div>\n </header>',
|
||||
`</div>
|
||||
<div class="filter-group" style="margin-left: auto;">
|
||||
${tabButtonHtml}
|
||||
</div>
|
||||
</div>
|
||||
</header>`
|
||||
);
|
||||
|
||||
// Add review section before closing container
|
||||
const reviewSectionHtml = generateReviewSection(reviewData);
|
||||
|
||||
html = html.replace(
|
||||
'</div>\n\n <button class="theme-toggle"',
|
||||
`</div>
|
||||
|
||||
${reviewSectionHtml}
|
||||
</div>
|
||||
|
||||
<button class="theme-toggle"`
|
||||
);
|
||||
|
||||
// Add review tab JavaScript
|
||||
const reviewScript = generateReviewScript(reviewData);
|
||||
html = html.replace('</script>', `\n${reviewScript}\n</script>`);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate review section HTML
|
||||
* @param {Object} reviewData - Review data
|
||||
* @returns {string} - HTML for review section
|
||||
*/
|
||||
function generateReviewSection(reviewData) {
|
||||
const severityBars = Object.entries(reviewData.severityDistribution)
|
||||
.map(([severity, count]) => {
|
||||
const colors = {
|
||||
critical: '#c53030',
|
||||
high: '#f56565',
|
||||
medium: '#ed8936',
|
||||
low: '#48bb78'
|
||||
};
|
||||
const percent = reviewData.totalFindings > 0
|
||||
? Math.round((count / reviewData.totalFindings) * 100)
|
||||
: 0;
|
||||
return `
|
||||
<div class="severity-bar-item">
|
||||
<span class="severity-label">${severity}</span>
|
||||
<div class="severity-bar">
|
||||
<div class="severity-fill" style="width: ${percent}%; background-color: ${colors[severity]}"></div>
|
||||
</div>
|
||||
<span class="severity-count">${count}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const dimensionCards = Object.entries(reviewData.dimensionSummary)
|
||||
.map(([name, info]) => `
|
||||
<div class="dimension-card">
|
||||
<div class="dimension-name">${name}</div>
|
||||
<div class="dimension-count">${info.count} findings</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="section" id="reviewSectionContainer" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Code Review Findings</h2>
|
||||
</div>
|
||||
|
||||
<div class="review-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #c53030;">${reviewData.severityDistribution.critical}</div>
|
||||
<div class="stat-label">Critical</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #f56565;">${reviewData.severityDistribution.high}</div>
|
||||
<div class="stat-label">High</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #ed8936;">${reviewData.severityDistribution.medium}</div>
|
||||
<div class="stat-label">Medium</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #48bb78;">${reviewData.severityDistribution.low}</div>
|
||||
<div class="stat-label">Low</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="severity-distribution">
|
||||
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">Severity Distribution</h3>
|
||||
${severityBars}
|
||||
</div>
|
||||
|
||||
<div class="dimensions-grid" style="margin-top: 30px;">
|
||||
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">By Dimension</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
|
||||
${dimensionCards}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.review-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.severity-distribution {
|
||||
background: var(--bg-card);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.severity-bar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.severity-label {
|
||||
width: 80px;
|
||||
text-transform: capitalize;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.severity-bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.severity-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.severity-count {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
.dimension-card {
|
||||
background: var(--bg-card);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.dimension-name {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.dimension-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.review-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JavaScript for review tab functionality
|
||||
* @param {Object} reviewData - Review data
|
||||
* @returns {string} - JavaScript code
|
||||
*/
|
||||
function generateReviewScript(reviewData) {
|
||||
return `
|
||||
// Review tab functionality
|
||||
const reviewTabBtn = document.getElementById('reviewTabBtn');
|
||||
const reviewSection = document.getElementById('reviewSectionContainer');
|
||||
const activeSectionContainer = document.getElementById('activeSectionContainer');
|
||||
const archivedSectionContainer = document.getElementById('archivedSectionContainer');
|
||||
|
||||
if (reviewTabBtn) {
|
||||
reviewTabBtn.addEventListener('click', () => {
|
||||
const isActive = reviewTabBtn.classList.contains('active');
|
||||
|
||||
// Toggle review section
|
||||
if (isActive) {
|
||||
// Hide reviews, show workflow
|
||||
reviewTabBtn.classList.remove('active');
|
||||
reviewSection.style.display = 'none';
|
||||
activeSectionContainer.style.display = 'block';
|
||||
archivedSectionContainer.style.display = 'block';
|
||||
} else {
|
||||
// Show reviews, hide workflow
|
||||
reviewTabBtn.classList.add('active');
|
||||
reviewSection.style.display = 'block';
|
||||
activeSectionContainer.style.display = 'none';
|
||||
archivedSectionContainer.style.display = 'none';
|
||||
|
||||
// Reset filter buttons
|
||||
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('[data-filter="all"]').classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate inline dashboard HTML (fallback if bundled templates missing)
|
||||
* @param {Object} data - Dashboard data
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateInlineDashboard(data) {
|
||||
const stats = data.statistics;
|
||||
const hasReviews = data.reviewData && data.reviewData.totalFindings > 0;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCW Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #f5f7fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1a202c;
|
||||
--text-secondary: #718096;
|
||||
--border-color: #e2e8f0;
|
||||
--accent-color: #4299e1;
|
||||
--success-color: #48bb78;
|
||||
--warning-color: #ed8936;
|
||||
--danger-color: #f56565;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a202c;
|
||||
--bg-secondary: #2d3748;
|
||||
--bg-card: #2d3748;
|
||||
--text-primary: #f7fafc;
|
||||
--text-secondary: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
h1 { font-size: 2rem; color: var(--accent-color); margin-bottom: 10px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat-value { font-size: 2rem; font-weight: bold; color: var(--accent-color); }
|
||||
.stat-label { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.section { margin-bottom: 40px; }
|
||||
.section-title { font-size: 1.5rem; margin-bottom: 20px; }
|
||||
.sessions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.session-card {
|
||||
background: var(--bg-card);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.session-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 10px; }
|
||||
.session-meta { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
|
||||
}
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
}
|
||||
.task-item.completed { border-left-color: var(--success-color); opacity: 0.8; }
|
||||
.task-item.in_progress { border-left-color: var(--warning-color); }
|
||||
.task-title { flex: 1; font-size: 0.9rem; }
|
||||
.task-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
|
||||
.tabs { display: flex; gap: 10px; margin-top: 15px; }
|
||||
.tab-btn {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); }
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>CCW Dashboard</h1>
|
||||
<p style="color: var(--text-secondary);">Workflow Sessions and Reviews</p>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="workflow">Workflow</button>
|
||||
${hasReviews ? '<button class="tab-btn" data-tab="reviews">Reviews</button>' : ''}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="workflowTab">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.totalSessions}</div>
|
||||
<div class="stat-label">Total Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.activeSessions}</div>
|
||||
<div class="stat-label">Active Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.totalTasks}</div>
|
||||
<div class="stat-label">Total Tasks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${stats.completedTasks}</div>
|
||||
<div class="stat-label">Completed Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Active Sessions</h2>
|
||||
<div class="sessions-grid" id="activeSessions">
|
||||
${data.activeSessions.length === 0
|
||||
? '<div class="empty-state">No active sessions</div>'
|
||||
: data.activeSessions.map(s => renderSessionCard(s, true)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Archived Sessions</h2>
|
||||
<div class="sessions-grid" id="archivedSessions">
|
||||
${data.archivedSessions.length === 0
|
||||
? '<div class="empty-state">No archived sessions</div>'
|
||||
: data.archivedSessions.map(s => renderSessionCard(s, false)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasReviews ? renderReviewTab(data.reviewData) : ''}
|
||||
</div>
|
||||
|
||||
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const current = html.getAttribute('data-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
html.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
document.querySelector('.theme-toggle').textContent = next === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
document.querySelector('.theme-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const tab = btn.dataset.tab;
|
||||
document.getElementById('workflowTab').style.display = tab === 'workflow' ? 'block' : 'none';
|
||||
const reviewTab = document.getElementById('reviewsTab');
|
||||
if (reviewTab) reviewTab.style.display = tab === 'reviews' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a session card
|
||||
* @param {Object} session - Session data
|
||||
* @param {boolean} isActive - Whether session is active
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
function renderSessionCard(session, isActive) {
|
||||
const completedTasks = isActive
|
||||
? session.tasks.filter(t => t.status === 'completed').length
|
||||
: session.taskCount;
|
||||
const totalTasks = isActive ? session.tasks.length : session.taskCount;
|
||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||
|
||||
const tasksHtml = isActive && session.tasks.length > 0
|
||||
? session.tasks.map(t => `
|
||||
<div class="task-item ${t.status}">
|
||||
<div class="task-title">${t.title}</div>
|
||||
<span class="task-id">${t.task_id}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="session-card">
|
||||
<div class="session-title">${session.session_id}</div>
|
||||
<div class="session-meta">
|
||||
${session.project ? `<div>${session.project}</div>` : ''}
|
||||
<div>${session.created_at} | ${completedTasks}/${totalTasks} tasks</div>
|
||||
</div>
|
||||
${totalTasks > 0 ? `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${tasksHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render review tab HTML
|
||||
* @param {Object} reviewData - Review data
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
function renderReviewTab(reviewData) {
|
||||
const { severityDistribution, dimensionSummary } = reviewData;
|
||||
|
||||
return `
|
||||
<div id="reviewsTab" style="display: none;">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #c53030;">${severityDistribution.critical}</div>
|
||||
<div class="stat-label">Critical</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #f56565;">${severityDistribution.high}</div>
|
||||
<div class="stat-label">High</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #ed8936;">${severityDistribution.medium}</div>
|
||||
<div class="stat-label">Medium</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #48bb78;">${severityDistribution.low}</div>
|
||||
<div class="stat-label">Low</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Findings by Dimension</h2>
|
||||
<div class="sessions-grid">
|
||||
${Object.entries(dimensionSummary).map(([name, info]) => `
|
||||
<div class="session-card">
|
||||
<div class="session-title" style="text-transform: capitalize;">${name}</div>
|
||||
<div class="session-meta">${info.count} findings</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
409
ccw/src/core/data-aggregator.js
Normal file
409
ccw/src/core/data-aggregator.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { glob } from 'glob';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { scanLiteTasks } from './lite-scanner.js';
|
||||
|
||||
/**
|
||||
* Aggregate all data for dashboard rendering
|
||||
* @param {Object} sessions - Scanned sessions from session-scanner
|
||||
* @param {string} workflowDir - Path to .workflow directory
|
||||
* @returns {Promise<Object>} - Aggregated dashboard data
|
||||
*/
|
||||
export async function aggregateData(sessions, workflowDir) {
|
||||
const data = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: {
|
||||
litePlan: [],
|
||||
liteFix: []
|
||||
},
|
||||
reviewData: null,
|
||||
projectOverview: null,
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Process active sessions
|
||||
for (const session of sessions.active) {
|
||||
const sessionData = await processSession(session, true);
|
||||
data.activeSessions.push(sessionData);
|
||||
data.statistics.totalTasks += sessionData.tasks.length;
|
||||
data.statistics.completedTasks += sessionData.tasks.filter(t => t.status === 'completed').length;
|
||||
}
|
||||
|
||||
// Process archived sessions
|
||||
for (const session of sessions.archived) {
|
||||
const sessionData = await processSession(session, false);
|
||||
data.archivedSessions.push(sessionData);
|
||||
data.statistics.totalTasks += sessionData.taskCount || 0;
|
||||
data.statistics.completedTasks += sessionData.taskCount || 0;
|
||||
}
|
||||
|
||||
// Aggregate review data if present
|
||||
if (sessions.hasReviewData) {
|
||||
data.reviewData = await aggregateReviewData(sessions.active);
|
||||
data.statistics.reviewFindings = data.reviewData.totalFindings;
|
||||
}
|
||||
|
||||
data.statistics.totalSessions = sessions.active.length + sessions.archived.length;
|
||||
data.statistics.activeSessions = sessions.active.length;
|
||||
|
||||
// Scan and include lite tasks
|
||||
try {
|
||||
const liteTasks = await scanLiteTasks(workflowDir);
|
||||
data.liteTasks = liteTasks;
|
||||
data.statistics.litePlanCount = liteTasks.litePlan.length;
|
||||
data.statistics.liteFixCount = liteTasks.liteFix.length;
|
||||
} catch (err) {
|
||||
console.error('Error scanning lite tasks:', err.message);
|
||||
}
|
||||
|
||||
// Load project overview from project.json
|
||||
try {
|
||||
data.projectOverview = loadProjectOverview(workflowDir);
|
||||
} catch (err) {
|
||||
console.error('Error loading project overview:', err.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single session, loading tasks and review info
|
||||
* @param {Object} session - Session object from scanner
|
||||
* @param {boolean} isActive - Whether session is active
|
||||
* @returns {Promise<Object>} - Processed session data
|
||||
*/
|
||||
async function processSession(session, isActive) {
|
||||
const result = {
|
||||
session_id: session.session_id,
|
||||
project: session.project || session.session_id,
|
||||
status: session.status || (isActive ? 'active' : 'archived'),
|
||||
type: session.type || 'workflow', // Session type (workflow, review, test, docs)
|
||||
workflow_type: session.workflow_type || null, // Original workflow_type for reference
|
||||
created_at: session.created_at || null, // Raw ISO string - let frontend format
|
||||
archived_at: session.archived_at || null, // Raw ISO string - let frontend format
|
||||
path: session.path,
|
||||
tasks: [],
|
||||
taskCount: 0,
|
||||
hasReview: false,
|
||||
reviewSummary: null,
|
||||
reviewDimensions: []
|
||||
};
|
||||
|
||||
// Load tasks for active sessions (full details)
|
||||
if (isActive) {
|
||||
const taskDir = join(session.path, '.task');
|
||||
if (existsSync(taskDir)) {
|
||||
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
|
||||
for (const taskFile of taskFiles) {
|
||||
try {
|
||||
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
|
||||
result.tasks.push({
|
||||
task_id: taskData.id || basename(taskFile, '.json'),
|
||||
title: taskData.title || 'Untitled Task',
|
||||
status: taskData.status || 'pending',
|
||||
type: taskData.meta?.type || 'task',
|
||||
meta: taskData.meta || {},
|
||||
context: taskData.context || {},
|
||||
flow_control: taskData.flow_control || {}
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid task files
|
||||
}
|
||||
}
|
||||
// Sort tasks by ID
|
||||
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
|
||||
}
|
||||
result.taskCount = result.tasks.length;
|
||||
|
||||
// Check for review data
|
||||
const reviewDir = join(session.path, '.review');
|
||||
if (existsSync(reviewDir)) {
|
||||
result.hasReview = true;
|
||||
result.reviewSummary = loadReviewSummary(reviewDir);
|
||||
// Load dimension data for review sessions
|
||||
if (session.type === 'review') {
|
||||
result.reviewDimensions = await loadDimensionData(reviewDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For archived, also load tasks (same as active)
|
||||
const taskDir = join(session.path, '.task');
|
||||
if (existsSync(taskDir)) {
|
||||
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
|
||||
for (const taskFile of taskFiles) {
|
||||
try {
|
||||
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
|
||||
result.tasks.push({
|
||||
task_id: taskData.id || basename(taskFile, '.json'),
|
||||
title: taskData.title || 'Untitled Task',
|
||||
status: taskData.status || 'completed', // Archived tasks are usually completed
|
||||
type: taskData.meta?.type || 'task'
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid task files
|
||||
}
|
||||
}
|
||||
// Sort tasks by ID
|
||||
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
|
||||
result.taskCount = result.tasks.length;
|
||||
}
|
||||
|
||||
// Check for review data in archived sessions too
|
||||
const reviewDir = join(session.path, '.review');
|
||||
if (existsSync(reviewDir)) {
|
||||
result.hasReview = true;
|
||||
result.reviewSummary = loadReviewSummary(reviewDir);
|
||||
// Load dimension data for review sessions
|
||||
if (session.type === 'review') {
|
||||
result.reviewDimensions = await loadDimensionData(reviewDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate review data from all active sessions with reviews
|
||||
* @param {Array} activeSessions - Active session objects
|
||||
* @returns {Promise<Object>} - Aggregated review data
|
||||
*/
|
||||
async function aggregateReviewData(activeSessions) {
|
||||
const reviewData = {
|
||||
totalFindings: 0,
|
||||
severityDistribution: { critical: 0, high: 0, medium: 0, low: 0 },
|
||||
dimensionSummary: {},
|
||||
sessions: []
|
||||
};
|
||||
|
||||
for (const session of activeSessions) {
|
||||
const reviewDir = join(session.path, '.review');
|
||||
if (!existsSync(reviewDir)) continue;
|
||||
|
||||
const reviewProgress = loadReviewProgress(reviewDir);
|
||||
const dimensionData = await loadDimensionData(reviewDir);
|
||||
|
||||
if (reviewProgress || dimensionData.length > 0) {
|
||||
const sessionReview = {
|
||||
session_id: session.session_id,
|
||||
progress: reviewProgress,
|
||||
dimensions: dimensionData,
|
||||
findings: []
|
||||
};
|
||||
|
||||
// Collect and count findings
|
||||
for (const dim of dimensionData) {
|
||||
if (dim.findings && Array.isArray(dim.findings)) {
|
||||
for (const finding of dim.findings) {
|
||||
const severity = (finding.severity || 'low').toLowerCase();
|
||||
if (reviewData.severityDistribution.hasOwnProperty(severity)) {
|
||||
reviewData.severityDistribution[severity]++;
|
||||
}
|
||||
reviewData.totalFindings++;
|
||||
sessionReview.findings.push({
|
||||
...finding,
|
||||
dimension: dim.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track dimension summary
|
||||
if (!reviewData.dimensionSummary[dim.name]) {
|
||||
reviewData.dimensionSummary[dim.name] = { count: 0, sessions: [] };
|
||||
}
|
||||
reviewData.dimensionSummary[dim.name].count += dim.findings?.length || 0;
|
||||
reviewData.dimensionSummary[dim.name].sessions.push(session.session_id);
|
||||
}
|
||||
|
||||
reviewData.sessions.push(sessionReview);
|
||||
}
|
||||
}
|
||||
|
||||
return reviewData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load review progress from review-progress.json
|
||||
* @param {string} reviewDir - Path to .review directory
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function loadReviewProgress(reviewDir) {
|
||||
const progressFile = join(reviewDir, 'review-progress.json');
|
||||
if (!existsSync(progressFile)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(progressFile, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load review summary from review-state.json
|
||||
* @param {string} reviewDir - Path to .review directory
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function loadReviewSummary(reviewDir) {
|
||||
const stateFile = join(reviewDir, 'review-state.json');
|
||||
if (!existsSync(stateFile)) return null;
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
||||
return {
|
||||
phase: state.phase || 'unknown',
|
||||
severityDistribution: state.severity_distribution || {},
|
||||
criticalFiles: (state.critical_files || []).slice(0, 3),
|
||||
status: state.status || 'in_progress'
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dimension data from .review/dimensions/
|
||||
* @param {string} reviewDir - Path to .review directory
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async function loadDimensionData(reviewDir) {
|
||||
const dimensionsDir = join(reviewDir, 'dimensions');
|
||||
if (!existsSync(dimensionsDir)) return [];
|
||||
|
||||
const dimensions = [];
|
||||
const dimFiles = await safeGlob('*.json', dimensionsDir);
|
||||
|
||||
for (const file of dimFiles) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
|
||||
// Handle array structure: [ { findings: [...], summary: {...} } ]
|
||||
let findings = [];
|
||||
let summary = null;
|
||||
let status = 'completed';
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const dimData = data[0];
|
||||
findings = dimData.findings || [];
|
||||
summary = dimData.summary || null;
|
||||
status = dimData.status || 'completed';
|
||||
} else if (data.findings) {
|
||||
findings = data.findings;
|
||||
summary = data.summary || null;
|
||||
status = data.status || 'completed';
|
||||
}
|
||||
|
||||
dimensions.push({
|
||||
name: basename(file, '.json'),
|
||||
findings: findings,
|
||||
summary: summary,
|
||||
status: status
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid dimension files
|
||||
}
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe glob wrapper that returns empty array on error
|
||||
* @param {string} pattern - Glob pattern
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async function safeGlob(pattern, cwd) {
|
||||
try {
|
||||
return await glob(pattern, { cwd, absolute: false });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatDate removed - dates are now passed as raw ISO strings
|
||||
// Frontend (dashboard.js) handles all date formatting
|
||||
|
||||
/**
|
||||
* Sort task IDs numerically (IMPL-1, IMPL-2, IMPL-1.1, etc.)
|
||||
* @param {string} a - First task ID
|
||||
* @param {string} b - Second task ID
|
||||
* @returns {number}
|
||||
*/
|
||||
function sortTaskIds(a, b) {
|
||||
const parseId = (id) => {
|
||||
const match = id.match(/IMPL-(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return [0, 0];
|
||||
return [parseInt(match[1]), parseInt(match[2] || 0)];
|
||||
};
|
||||
const [a1, a2] = parseId(a);
|
||||
const [b1, b2] = parseId(b);
|
||||
return a1 - b1 || a2 - b2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project overview from project.json
|
||||
* @param {string} workflowDir - Path to .workflow directory
|
||||
* @returns {Object|null} - Project overview data or null if not found
|
||||
*/
|
||||
function loadProjectOverview(workflowDir) {
|
||||
const projectFile = join(workflowDir, 'project.json');
|
||||
|
||||
if (!existsSync(projectFile)) {
|
||||
console.log(`Project file not found at: ${projectFile}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = readFileSync(projectFile, 'utf8');
|
||||
const projectData = JSON.parse(fileContent);
|
||||
|
||||
console.log(`Successfully loaded project overview: ${projectData.project_name || 'Unknown'}`);
|
||||
|
||||
return {
|
||||
projectName: projectData.project_name || 'Unknown',
|
||||
description: projectData.overview?.description || '',
|
||||
initializedAt: projectData.initialized_at || null,
|
||||
technologyStack: projectData.overview?.technology_stack || {
|
||||
languages: [],
|
||||
frameworks: [],
|
||||
build_tools: [],
|
||||
test_frameworks: []
|
||||
},
|
||||
architecture: projectData.overview?.architecture || {
|
||||
style: 'Unknown',
|
||||
layers: [],
|
||||
patterns: []
|
||||
},
|
||||
keyComponents: projectData.overview?.key_components || [],
|
||||
features: projectData.features || [],
|
||||
developmentIndex: projectData.development_index || {
|
||||
feature: [],
|
||||
enhancement: [],
|
||||
bugfix: [],
|
||||
refactor: [],
|
||||
docs: []
|
||||
},
|
||||
statistics: projectData.statistics || {
|
||||
total_features: 0,
|
||||
total_sessions: 0,
|
||||
last_updated: null
|
||||
},
|
||||
metadata: projectData._metadata || {
|
||||
initialized_by: 'unknown',
|
||||
analysis_timestamp: null,
|
||||
analysis_mode: 'unknown'
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to parse project.json at ${projectFile}:`, err.message);
|
||||
console.error('Error stack:', err.stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
290
ccw/src/core/lite-scanner.js
Normal file
290
ccw/src/core/lite-scanner.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Scan lite-plan and lite-fix directories for task sessions
|
||||
* @param {string} workflowDir - Path to .workflow directory
|
||||
* @returns {Promise<Object>} - Lite tasks data
|
||||
*/
|
||||
export async function scanLiteTasks(workflowDir) {
|
||||
const litePlanDir = join(workflowDir, '.lite-plan');
|
||||
const liteFixDir = join(workflowDir, '.lite-fix');
|
||||
|
||||
return {
|
||||
litePlan: scanLiteDir(litePlanDir, 'lite-plan'),
|
||||
liteFix: scanLiteDir(liteFixDir, 'lite-fix')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a lite task directory
|
||||
* @param {string} dir - Directory path
|
||||
* @param {string} type - Task type ('lite-plan' or 'lite-fix')
|
||||
* @returns {Array} - Array of lite task sessions
|
||||
*/
|
||||
function scanLiteDir(dir, type) {
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
try {
|
||||
const sessions = readdirSync(dir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => {
|
||||
const sessionPath = join(dir, d.name);
|
||||
const session = {
|
||||
id: d.name,
|
||||
type,
|
||||
path: sessionPath,
|
||||
createdAt: getCreatedTime(sessionPath),
|
||||
plan: loadPlanJson(sessionPath),
|
||||
tasks: loadTaskJsons(sessionPath)
|
||||
};
|
||||
|
||||
// Calculate progress
|
||||
session.progress = calculateProgress(session.tasks);
|
||||
|
||||
return session;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
return sessions;
|
||||
} catch (err) {
|
||||
console.error(`Error scanning ${dir}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plan.json from session directory
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Object|null} - Plan data or null
|
||||
*/
|
||||
function loadPlanJson(sessionPath) {
|
||||
const planPath = join(sessionPath, 'plan.json');
|
||||
if (!existsSync(planPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all task JSON files from session directory
|
||||
* Supports multiple task formats:
|
||||
* 1. .task/IMPL-*.json files
|
||||
* 2. tasks array in plan.json
|
||||
* 3. task-*.json files in session root
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Array} - Array of task objects
|
||||
*/
|
||||
function loadTaskJsons(sessionPath) {
|
||||
let tasks = [];
|
||||
|
||||
// Method 1: Check .task/IMPL-*.json files
|
||||
const taskDir = join(sessionPath, '.task');
|
||||
if (existsSync(taskDir)) {
|
||||
try {
|
||||
const implTasks = readdirSync(taskDir)
|
||||
.filter(f => f.endsWith('.json') && (
|
||||
f.startsWith('IMPL-') ||
|
||||
f.startsWith('TASK-') ||
|
||||
f.startsWith('task-') ||
|
||||
/^T\d+\.json$/i.test(f)
|
||||
))
|
||||
.map(f => {
|
||||
const taskPath = join(taskDir, f);
|
||||
try {
|
||||
const content = readFileSync(taskPath, 'utf8');
|
||||
return normalizeTask(JSON.parse(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
tasks = tasks.concat(implTasks);
|
||||
} catch {
|
||||
// Continue to other methods
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Check plan.json for embedded tasks array
|
||||
if (tasks.length === 0) {
|
||||
const planPath = join(sessionPath, 'plan.json');
|
||||
if (existsSync(planPath)) {
|
||||
try {
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf8'));
|
||||
if (Array.isArray(plan.tasks)) {
|
||||
tasks = plan.tasks.map(t => normalizeTask(t));
|
||||
}
|
||||
} catch {
|
||||
// Continue to other methods
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check for task-*.json files in session root
|
||||
if (tasks.length === 0) {
|
||||
try {
|
||||
const rootTasks = readdirSync(sessionPath)
|
||||
.filter(f => f.endsWith('.json') && (
|
||||
f.startsWith('task-') ||
|
||||
f.startsWith('TASK-') ||
|
||||
/^T\d+\.json$/i.test(f)
|
||||
))
|
||||
.map(f => {
|
||||
const taskPath = join(sessionPath, f);
|
||||
try {
|
||||
const content = readFileSync(taskPath, 'utf8');
|
||||
return normalizeTask(JSON.parse(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
tasks = tasks.concat(rootTasks);
|
||||
} catch {
|
||||
// No tasks found
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tasks by ID
|
||||
return tasks.sort((a, b) => {
|
||||
const aNum = parseInt(a.id?.replace(/\D/g, '') || '0');
|
||||
const bNum = parseInt(b.id?.replace(/\D/g, '') || '0');
|
||||
return aNum - bNum;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize task object to consistent structure
|
||||
* @param {Object} task - Raw task object
|
||||
* @returns {Object} - Normalized task
|
||||
*/
|
||||
function normalizeTask(task) {
|
||||
if (!task) return null;
|
||||
|
||||
// Determine status - support various status formats
|
||||
let status = task.status || 'pending';
|
||||
if (typeof status === 'object') {
|
||||
status = status.state || status.value || 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id || task.task_id || 'unknown',
|
||||
title: task.title || task.name || task.summary || 'Untitled Task',
|
||||
status: status.toLowerCase(),
|
||||
// Preserve original fields for flexible rendering
|
||||
meta: task.meta || {
|
||||
type: task.type || task.action || 'task',
|
||||
agent: task.agent || null,
|
||||
scope: task.scope || null,
|
||||
module: task.module || null
|
||||
},
|
||||
context: task.context || {
|
||||
requirements: task.requirements || task.description ? [task.description] : [],
|
||||
focus_paths: task.focus_paths || task.modification_points?.map(m => m.file) || [],
|
||||
acceptance: task.acceptance || [],
|
||||
depends_on: task.depends_on || []
|
||||
},
|
||||
flow_control: task.flow_control || {
|
||||
implementation_approach: task.implementation?.map((step, i) => ({
|
||||
step: `Step ${i + 1}`,
|
||||
action: step
|
||||
})) || []
|
||||
},
|
||||
// Keep all original fields for raw JSON view
|
||||
_raw: task
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory creation time
|
||||
* @param {string} dirPath - Directory path
|
||||
* @returns {string} - ISO date string
|
||||
*/
|
||||
function getCreatedTime(dirPath) {
|
||||
try {
|
||||
const stat = statSync(dirPath);
|
||||
return stat.birthtime.toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress from tasks
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @returns {Object} - Progress info
|
||||
*/
|
||||
function calculateProgress(tasks) {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return { total: 0, completed: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
|
||||
return { total, completed, percentage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed lite task info
|
||||
* @param {string} workflowDir - Workflow directory
|
||||
* @param {string} type - 'lite-plan' or 'lite-fix'
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Object|null} - Detailed task info
|
||||
*/
|
||||
export function getLiteTaskDetail(workflowDir, type, sessionId) {
|
||||
const dir = type === 'lite-plan'
|
||||
? join(workflowDir, '.lite-plan', sessionId)
|
||||
: join(workflowDir, '.lite-fix', sessionId);
|
||||
|
||||
if (!existsSync(dir)) return null;
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
type,
|
||||
path: dir,
|
||||
plan: loadPlanJson(dir),
|
||||
tasks: loadTaskJsons(dir),
|
||||
explorations: loadExplorations(dir),
|
||||
clarifications: loadClarifications(dir)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load exploration results
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Array} - Exploration results
|
||||
*/
|
||||
function loadExplorations(sessionPath) {
|
||||
const explorePath = join(sessionPath, 'explorations.json');
|
||||
if (!existsSync(explorePath)) return [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(explorePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load clarification data
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Object|null} - Clarification data
|
||||
*/
|
||||
function loadClarifications(sessionPath) {
|
||||
const clarifyPath = join(sessionPath, 'clarifications.json');
|
||||
if (!existsSync(clarifyPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(clarifyPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
201
ccw/src/core/manifest.js
Normal file
201
ccw/src/core/manifest.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// Manifest directory location
|
||||
const MANIFEST_DIR = join(homedir(), '.claude-manifests');
|
||||
|
||||
/**
|
||||
* Ensure manifest directory exists
|
||||
*/
|
||||
function ensureManifestDir() {
|
||||
if (!existsSync(MANIFEST_DIR)) {
|
||||
mkdirSync(MANIFEST_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new installation manifest
|
||||
* @param {string} mode - Installation mode (Global/Path)
|
||||
* @param {string} installPath - Installation path
|
||||
* @returns {Object} - New manifest object
|
||||
*/
|
||||
export function createManifest(mode, installPath) {
|
||||
ensureManifestDir();
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
|
||||
const modePrefix = mode === 'Global' ? 'manifest-global' : 'manifest-path';
|
||||
const manifestId = `${modePrefix}-${timestamp}`;
|
||||
|
||||
return {
|
||||
manifest_id: manifestId,
|
||||
version: '1.0',
|
||||
installation_mode: mode,
|
||||
installation_path: installPath,
|
||||
installation_date: new Date().toISOString(),
|
||||
installer_version: '1.0.0',
|
||||
files: [],
|
||||
directories: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file entry to manifest
|
||||
* @param {Object} manifest - Manifest object
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function addFileEntry(manifest, filePath) {
|
||||
manifest.files.push({
|
||||
path: filePath,
|
||||
type: 'File',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add directory entry to manifest
|
||||
* @param {Object} manifest - Manifest object
|
||||
* @param {string} dirPath - Directory path
|
||||
*/
|
||||
export function addDirectoryEntry(manifest, dirPath) {
|
||||
manifest.directories.push({
|
||||
path: dirPath,
|
||||
type: 'Directory',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save manifest to disk
|
||||
* @param {Object} manifest - Manifest object
|
||||
* @returns {string} - Path to saved manifest
|
||||
*/
|
||||
export function saveManifest(manifest) {
|
||||
ensureManifestDir();
|
||||
|
||||
// Remove old manifests for same path and mode
|
||||
removeOldManifests(manifest.installation_path, manifest.installation_mode);
|
||||
|
||||
const manifestPath = join(MANIFEST_DIR, `${manifest.manifest_id}.json`);
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old manifests for the same installation path and mode
|
||||
* @param {string} installPath - Installation path
|
||||
* @param {string} mode - Installation mode
|
||||
*/
|
||||
function removeOldManifests(installPath, mode) {
|
||||
if (!existsSync(MANIFEST_DIR)) return;
|
||||
|
||||
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
|
||||
|
||||
try {
|
||||
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(MANIFEST_DIR, file);
|
||||
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
|
||||
const manifestPath = (content.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
|
||||
const manifestMode = content.installation_mode || 'Global';
|
||||
|
||||
if (manifestPath === normalizedPath && manifestMode === mode) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid manifest files
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all installation manifests
|
||||
* @returns {Array} - Array of manifest objects
|
||||
*/
|
||||
export function getAllManifests() {
|
||||
if (!existsSync(MANIFEST_DIR)) return [];
|
||||
|
||||
const manifests = [];
|
||||
|
||||
try {
|
||||
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(MANIFEST_DIR, file);
|
||||
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
|
||||
// Try to read version.json for application version
|
||||
let appVersion = 'unknown';
|
||||
try {
|
||||
const versionPath = join(content.installation_path, '.claude', 'version.json');
|
||||
if (existsSync(versionPath)) {
|
||||
const versionInfo = JSON.parse(readFileSync(versionPath, 'utf8'));
|
||||
appVersion = versionInfo.version || 'unknown';
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
manifests.push({
|
||||
...content,
|
||||
manifest_file: filePath,
|
||||
application_version: appVersion,
|
||||
files_count: content.files?.length || 0,
|
||||
directories_count: content.directories?.length || 0
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid manifest files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by installation date (newest first)
|
||||
manifests.sort((a, b) => new Date(b.installation_date) - new Date(a.installation_date));
|
||||
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find manifest for a specific path and mode
|
||||
* @param {string} installPath - Installation path
|
||||
* @param {string} mode - Installation mode
|
||||
* @returns {Object|null} - Manifest or null
|
||||
*/
|
||||
export function findManifest(installPath, mode) {
|
||||
const manifests = getAllManifests();
|
||||
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
|
||||
|
||||
return manifests.find(m => {
|
||||
const manifestPath = (m.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
|
||||
return manifestPath === normalizedPath && m.installation_mode === mode;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a manifest file
|
||||
* @param {string} manifestFile - Path to manifest file
|
||||
*/
|
||||
export function deleteManifest(manifestFile) {
|
||||
if (existsSync(manifestFile)) {
|
||||
unlinkSync(manifestFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manifest directory path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getManifestDir() {
|
||||
return MANIFEST_DIR;
|
||||
}
|
||||
1348
ccw/src/core/server.js
Normal file
1348
ccw/src/core/server.js
Normal file
File diff suppressed because it is too large
Load Diff
385
ccw/src/core/server.js.bak
Normal file
385
ccw/src/core/server.js.bak
Normal file
@@ -0,0 +1,385 @@
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { scanSessions } from './session-scanner.js';
|
||||
import { aggregateData } from './data-aggregator.js';
|
||||
import { resolvePath, getRecentPaths, trackRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js';
|
||||
|
||||
const TEMPLATE_PATH = join(import.meta.dirname, '../templates/dashboard.html');
|
||||
const CSS_FILE = join(import.meta.dirname, '../templates/dashboard.css');
|
||||
const JS_FILE = join(import.meta.dirname, '../templates/dashboard.js');
|
||||
|
||||
/**
|
||||
* Create and start the dashboard server
|
||||
* @param {Object} options - Server options
|
||||
* @param {number} options.port - Port to listen on (default: 3456)
|
||||
* @param {string} options.initialPath - Initial project path
|
||||
* @returns {Promise<http.Server>}
|
||||
*/
|
||||
export async function startServer(options = {}) {
|
||||
const port = options.port || 3456;
|
||||
const initialPath = options.initialPath || process.cwd();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS headers for API requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// API: Get workflow data for a path
|
||||
if (pathname === '/api/data') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const data = await getWorkflowData(projectPath);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get recent paths
|
||||
if (pathname === '/api/recent-paths') {
|
||||
const paths = getRecentPaths();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ paths }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get session detail data (context, summaries, impl-plan, review)
|
||||
if (pathname === '/api/session-detail') {
|
||||
const sessionPath = url.searchParams.get('path');
|
||||
const dataType = url.searchParams.get('type') || 'all';
|
||||
|
||||
if (!sessionPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session path is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await getSessionDetailData(sessionPath, dataType);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(detail));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
const html = generateServerDashboard(initialPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Server error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
console.log(`Dashboard server running at http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow data for a project path
|
||||
* @param {string} projectPath
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getWorkflowData(projectPath) {
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const workflowDir = join(resolvedPath, '.workflow');
|
||||
|
||||
// Track this path
|
||||
trackRecentPath(resolvedPath);
|
||||
|
||||
// Check if .workflow exists
|
||||
if (!existsSync(workflowDir)) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
projectOverview: null,
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
},
|
||||
projectPath: normalizePathForDisplay(resolvedPath),
|
||||
recentPaths: getRecentPaths()
|
||||
};
|
||||
}
|
||||
|
||||
// Scan and aggregate data
|
||||
const sessions = await scanSessions(workflowDir);
|
||||
const data = await aggregateData(sessions, workflowDir);
|
||||
|
||||
data.projectPath = normalizePathForDisplay(resolvedPath);
|
||||
data.recentPaths = getRecentPaths();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session detail data (context, summaries, impl-plan, review)
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @param {string} dataType - Type of data to load: context, summary, impl-plan, review, or all
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getSessionDetailData(sessionPath, dataType) {
|
||||
const result = {};
|
||||
|
||||
// Normalize path
|
||||
const normalizedPath = sessionPath.replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
// Load context-package.json (in .process/ subfolder)
|
||||
if (dataType === 'context' || dataType === 'all') {
|
||||
// Try .process/context-package.json first (common location)
|
||||
let contextFile = join(normalizedPath, '.process', 'context-package.json');
|
||||
if (!existsSync(contextFile)) {
|
||||
// Fallback to session root
|
||||
contextFile = join(normalizedPath, 'context-package.json');
|
||||
}
|
||||
if (existsSync(contextFile)) {
|
||||
try {
|
||||
result.context = JSON.parse(readFileSync(contextFile, 'utf8'));
|
||||
} catch (e) {
|
||||
result.context = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load task JSONs from .task/ folder
|
||||
if (dataType === 'tasks' || dataType === 'all') {
|
||||
const taskDir = join(normalizedPath, '.task');
|
||||
result.tasks = [];
|
||||
if (existsSync(taskDir)) {
|
||||
const files = readdirSync(taskDir).filter(f => f.endsWith('.json') && f.startsWith('IMPL-'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(join(taskDir, file), 'utf8'));
|
||||
result.tasks.push({
|
||||
filename: file,
|
||||
task_id: file.replace('.json', ''),
|
||||
...content
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
// Sort by task ID
|
||||
result.tasks.sort((a, b) => a.task_id.localeCompare(b.task_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Load summaries from .summaries/
|
||||
if (dataType === 'summary' || dataType === 'all') {
|
||||
const summariesDir = join(normalizedPath, '.summaries');
|
||||
result.summaries = [];
|
||||
if (existsSync(summariesDir)) {
|
||||
const files = readdirSync(summariesDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(summariesDir, file), 'utf8');
|
||||
result.summaries.push({ name: file.replace('.md', ''), content });
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load plan.json (for lite tasks)
|
||||
if (dataType === 'plan' || dataType === 'all') {
|
||||
const planFile = join(normalizedPath, 'plan.json');
|
||||
if (existsSync(planFile)) {
|
||||
try {
|
||||
result.plan = JSON.parse(readFileSync(planFile, 'utf8'));
|
||||
} catch (e) {
|
||||
result.plan = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load IMPL_PLAN.md
|
||||
if (dataType === 'impl-plan' || dataType === 'all') {
|
||||
const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md');
|
||||
if (existsSync(implPlanFile)) {
|
||||
try {
|
||||
result.implPlan = readFileSync(implPlanFile, 'utf8');
|
||||
} catch (e) {
|
||||
result.implPlan = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load review data from .review/
|
||||
if (dataType === 'review' || dataType === 'all') {
|
||||
const reviewDir = join(normalizedPath, '.review');
|
||||
result.review = {
|
||||
state: null,
|
||||
dimensions: [],
|
||||
severityDistribution: null,
|
||||
totalFindings: 0
|
||||
};
|
||||
|
||||
if (existsSync(reviewDir)) {
|
||||
// Load review-state.json
|
||||
const stateFile = join(reviewDir, 'review-state.json');
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
||||
result.review.state = state;
|
||||
result.review.severityDistribution = state.severity_distribution || {};
|
||||
result.review.totalFindings = state.total_findings || 0;
|
||||
result.review.phase = state.phase || 'unknown';
|
||||
result.review.dimensionSummaries = state.dimension_summaries || {};
|
||||
result.review.crossCuttingConcerns = state.cross_cutting_concerns || [];
|
||||
result.review.criticalFiles = state.critical_files || [];
|
||||
} catch (e) {
|
||||
// Skip unreadable state
|
||||
}
|
||||
}
|
||||
|
||||
// Load dimension findings
|
||||
const dimensionsDir = join(reviewDir, 'dimensions');
|
||||
if (existsSync(dimensionsDir)) {
|
||||
const files = readdirSync(dimensionsDir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const dimName = file.replace('.json', '');
|
||||
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
|
||||
|
||||
// Handle array structure: [ { findings: [...] } ]
|
||||
let findings = [];
|
||||
let summary = null;
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const dimData = data[0];
|
||||
findings = dimData.findings || [];
|
||||
summary = dimData.summary || null;
|
||||
} else if (data.findings) {
|
||||
findings = data.findings;
|
||||
summary = data.summary || null;
|
||||
}
|
||||
|
||||
result.review.dimensions.push({
|
||||
name: dimName,
|
||||
findings: findings,
|
||||
summary: summary,
|
||||
count: findings.length
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading session detail:', error);
|
||||
result.error = error.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard HTML for server mode
|
||||
* @param {string} initialPath
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateServerDashboard(initialPath) {
|
||||
let html = readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
|
||||
// Read CSS and JS files
|
||||
const cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
||||
let jsContent = existsSync(JS_FILE) ? readFileSync(JS_FILE, 'utf8') : '';
|
||||
|
||||
// Inject CSS content
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
|
||||
// Prepare JS content with empty initial data (will be loaded dynamically)
|
||||
const emptyData = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
projectOverview: null,
|
||||
statistics: { totalSessions: 0, activeSessions: 0, totalTasks: 0, completedTasks: 0, reviewFindings: 0, litePlanCount: 0, liteFixCount: 0 }
|
||||
};
|
||||
|
||||
// Replace JS placeholders
|
||||
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(emptyData, null, 2));
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(getRecentPaths()));
|
||||
|
||||
// Add server mode flag and dynamic loading functions at the start of JS
|
||||
const serverModeScript = `
|
||||
// Server mode - load data dynamically
|
||||
window.SERVER_MODE = true;
|
||||
window.INITIAL_PATH = '${normalizePathForDisplay(initialPath).replace(/\\/g, '/')}';
|
||||
|
||||
async function loadDashboardData(path) {
|
||||
try {
|
||||
const res = await fetch('/api/data?path=' + encodeURIComponent(path));
|
||||
if (!res.ok) throw new Error('Failed to load data');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentPaths() {
|
||||
try {
|
||||
const res = await fetch('/api/recent-paths');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.paths || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
// Prepend server mode script to JS content
|
||||
jsContent = serverModeScript + jsContent;
|
||||
|
||||
// Inject JS content
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
|
||||
// Replace any remaining placeholders in HTML
|
||||
html = html.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
|
||||
return html;
|
||||
}
|
||||
385
ccw/src/core/server_original.bak
Normal file
385
ccw/src/core/server_original.bak
Normal file
@@ -0,0 +1,385 @@
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { scanSessions } from './session-scanner.js';
|
||||
import { aggregateData } from './data-aggregator.js';
|
||||
import { resolvePath, getRecentPaths, trackRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js';
|
||||
|
||||
const TEMPLATE_PATH = join(import.meta.dirname, '../templates/dashboard.html');
|
||||
const CSS_FILE = join(import.meta.dirname, '../templates/dashboard.css');
|
||||
const JS_FILE = join(import.meta.dirname, '../templates/dashboard.js');
|
||||
|
||||
/**
|
||||
* Create and start the dashboard server
|
||||
* @param {Object} options - Server options
|
||||
* @param {number} options.port - Port to listen on (default: 3456)
|
||||
* @param {string} options.initialPath - Initial project path
|
||||
* @returns {Promise<http.Server>}
|
||||
*/
|
||||
export async function startServer(options = {}) {
|
||||
const port = options.port || 3456;
|
||||
const initialPath = options.initialPath || process.cwd();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS headers for API requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// API: Get workflow data for a path
|
||||
if (pathname === '/api/data') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const data = await getWorkflowData(projectPath);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get recent paths
|
||||
if (pathname === '/api/recent-paths') {
|
||||
const paths = getRecentPaths();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ paths }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get session detail data (context, summaries, impl-plan, review)
|
||||
if (pathname === '/api/session-detail') {
|
||||
const sessionPath = url.searchParams.get('path');
|
||||
const dataType = url.searchParams.get('type') || 'all';
|
||||
|
||||
if (!sessionPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session path is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await getSessionDetailData(sessionPath, dataType);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(detail));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
const html = generateServerDashboard(initialPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Server error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
console.log(`Dashboard server running at http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow data for a project path
|
||||
* @param {string} projectPath
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getWorkflowData(projectPath) {
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const workflowDir = join(resolvedPath, '.workflow');
|
||||
|
||||
// Track this path
|
||||
trackRecentPath(resolvedPath);
|
||||
|
||||
// Check if .workflow exists
|
||||
if (!existsSync(workflowDir)) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
projectOverview: null,
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
},
|
||||
projectPath: normalizePathForDisplay(resolvedPath),
|
||||
recentPaths: getRecentPaths()
|
||||
};
|
||||
}
|
||||
|
||||
// Scan and aggregate data
|
||||
const sessions = await scanSessions(workflowDir);
|
||||
const data = await aggregateData(sessions, workflowDir);
|
||||
|
||||
data.projectPath = normalizePathForDisplay(resolvedPath);
|
||||
data.recentPaths = getRecentPaths();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session detail data (context, summaries, impl-plan, review)
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @param {string} dataType - Type of data to load: context, summary, impl-plan, review, or all
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getSessionDetailData(sessionPath, dataType) {
|
||||
const result = {};
|
||||
|
||||
// Normalize path
|
||||
const normalizedPath = sessionPath.replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
// Load context-package.json (in .process/ subfolder)
|
||||
if (dataType === 'context' || dataType === 'all') {
|
||||
// Try .process/context-package.json first (common location)
|
||||
let contextFile = join(normalizedPath, '.process', 'context-package.json');
|
||||
if (!existsSync(contextFile)) {
|
||||
// Fallback to session root
|
||||
contextFile = join(normalizedPath, 'context-package.json');
|
||||
}
|
||||
if (existsSync(contextFile)) {
|
||||
try {
|
||||
result.context = JSON.parse(readFileSync(contextFile, 'utf8'));
|
||||
} catch (e) {
|
||||
result.context = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load task JSONs from .task/ folder
|
||||
if (dataType === 'tasks' || dataType === 'all') {
|
||||
const taskDir = join(normalizedPath, '.task');
|
||||
result.tasks = [];
|
||||
if (existsSync(taskDir)) {
|
||||
const files = readdirSync(taskDir).filter(f => f.endsWith('.json') && f.startsWith('IMPL-'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(join(taskDir, file), 'utf8'));
|
||||
result.tasks.push({
|
||||
filename: file,
|
||||
task_id: file.replace('.json', ''),
|
||||
...content
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
// Sort by task ID
|
||||
result.tasks.sort((a, b) => a.task_id.localeCompare(b.task_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Load summaries from .summaries/
|
||||
if (dataType === 'summary' || dataType === 'all') {
|
||||
const summariesDir = join(normalizedPath, '.summaries');
|
||||
result.summaries = [];
|
||||
if (existsSync(summariesDir)) {
|
||||
const files = readdirSync(summariesDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(summariesDir, file), 'utf8');
|
||||
result.summaries.push({ name: file.replace('.md', ''), content });
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load plan.json (for lite tasks)
|
||||
if (dataType === 'plan' || dataType === 'all') {
|
||||
const planFile = join(normalizedPath, 'plan.json');
|
||||
if (existsSync(planFile)) {
|
||||
try {
|
||||
result.plan = JSON.parse(readFileSync(planFile, 'utf8'));
|
||||
} catch (e) {
|
||||
result.plan = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load IMPL_PLAN.md
|
||||
if (dataType === 'impl-plan' || dataType === 'all') {
|
||||
const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md');
|
||||
if (existsSync(implPlanFile)) {
|
||||
try {
|
||||
result.implPlan = readFileSync(implPlanFile, 'utf8');
|
||||
} catch (e) {
|
||||
result.implPlan = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load review data from .review/
|
||||
if (dataType === 'review' || dataType === 'all') {
|
||||
const reviewDir = join(normalizedPath, '.review');
|
||||
result.review = {
|
||||
state: null,
|
||||
dimensions: [],
|
||||
severityDistribution: null,
|
||||
totalFindings: 0
|
||||
};
|
||||
|
||||
if (existsSync(reviewDir)) {
|
||||
// Load review-state.json
|
||||
const stateFile = join(reviewDir, 'review-state.json');
|
||||
if (existsSync(stateFile)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
||||
result.review.state = state;
|
||||
result.review.severityDistribution = state.severity_distribution || {};
|
||||
result.review.totalFindings = state.total_findings || 0;
|
||||
result.review.phase = state.phase || 'unknown';
|
||||
result.review.dimensionSummaries = state.dimension_summaries || {};
|
||||
result.review.crossCuttingConcerns = state.cross_cutting_concerns || [];
|
||||
result.review.criticalFiles = state.critical_files || [];
|
||||
} catch (e) {
|
||||
// Skip unreadable state
|
||||
}
|
||||
}
|
||||
|
||||
// Load dimension findings
|
||||
const dimensionsDir = join(reviewDir, 'dimensions');
|
||||
if (existsSync(dimensionsDir)) {
|
||||
const files = readdirSync(dimensionsDir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const dimName = file.replace('.json', '');
|
||||
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
|
||||
|
||||
// Handle array structure: [ { findings: [...] } ]
|
||||
let findings = [];
|
||||
let summary = null;
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const dimData = data[0];
|
||||
findings = dimData.findings || [];
|
||||
summary = dimData.summary || null;
|
||||
} else if (data.findings) {
|
||||
findings = data.findings;
|
||||
summary = data.summary || null;
|
||||
}
|
||||
|
||||
result.review.dimensions.push({
|
||||
name: dimName,
|
||||
findings: findings,
|
||||
summary: summary,
|
||||
count: findings.length
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading session detail:', error);
|
||||
result.error = error.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard HTML for server mode
|
||||
* @param {string} initialPath
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateServerDashboard(initialPath) {
|
||||
let html = readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
|
||||
// Read CSS and JS files
|
||||
const cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
||||
let jsContent = existsSync(JS_FILE) ? readFileSync(JS_FILE, 'utf8') : '';
|
||||
|
||||
// Inject CSS content
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
|
||||
// Prepare JS content with empty initial data (will be loaded dynamically)
|
||||
const emptyData = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
projectOverview: null,
|
||||
statistics: { totalSessions: 0, activeSessions: 0, totalTasks: 0, completedTasks: 0, reviewFindings: 0, litePlanCount: 0, liteFixCount: 0 }
|
||||
};
|
||||
|
||||
// Replace JS placeholders
|
||||
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(emptyData, null, 2));
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(getRecentPaths()));
|
||||
|
||||
// Add server mode flag and dynamic loading functions at the start of JS
|
||||
const serverModeScript = `
|
||||
// Server mode - load data dynamically
|
||||
window.SERVER_MODE = true;
|
||||
window.INITIAL_PATH = '${normalizePathForDisplay(initialPath).replace(/\\/g, '/')}';
|
||||
|
||||
async function loadDashboardData(path) {
|
||||
try {
|
||||
const res = await fetch('/api/data?path=' + encodeURIComponent(path));
|
||||
if (!res.ok) throw new Error('Failed to load data');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentPaths() {
|
||||
try {
|
||||
const res = await fetch('/api/recent-paths');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.paths || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
// Prepend server mode script to JS content
|
||||
jsContent = serverModeScript + jsContent;
|
||||
|
||||
// Inject JS content
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
|
||||
// Replace any remaining placeholders in HTML
|
||||
html = html.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
|
||||
return html;
|
||||
}
|
||||
235
ccw/src/core/session-scanner.js
Normal file
235
ccw/src/core/session-scanner.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { glob } from 'glob';
|
||||
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
/**
|
||||
* Scan .workflow directory for active and archived sessions
|
||||
* @param {string} workflowDir - Path to .workflow directory
|
||||
* @returns {Promise<{active: Array, archived: Array, hasReviewData: boolean}>}
|
||||
*/
|
||||
export async function scanSessions(workflowDir) {
|
||||
const result = {
|
||||
active: [],
|
||||
archived: [],
|
||||
hasReviewData: false
|
||||
};
|
||||
|
||||
if (!existsSync(workflowDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Scan active sessions
|
||||
const activeDir = join(workflowDir, 'active');
|
||||
if (existsSync(activeDir)) {
|
||||
const activeSessions = await findWfsSessions(activeDir);
|
||||
for (const sessionName of activeSessions) {
|
||||
const sessionPath = join(activeDir, sessionName);
|
||||
const sessionData = readSessionData(sessionPath);
|
||||
if (sessionData) {
|
||||
result.active.push({
|
||||
...sessionData,
|
||||
path: sessionPath,
|
||||
isActive: true
|
||||
});
|
||||
// Check for review data
|
||||
if (existsSync(join(sessionPath, '.review'))) {
|
||||
result.hasReviewData = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan archived sessions
|
||||
const archivesDir = join(workflowDir, 'archives');
|
||||
if (existsSync(archivesDir)) {
|
||||
const archivedSessions = await findWfsSessions(archivesDir);
|
||||
for (const sessionName of archivedSessions) {
|
||||
const sessionPath = join(archivesDir, sessionName);
|
||||
const sessionData = readSessionData(sessionPath);
|
||||
if (sessionData) {
|
||||
result.archived.push({
|
||||
...sessionData,
|
||||
path: sessionPath,
|
||||
isActive: false
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
result.active.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
||||
result.archived.sort((a, b) => new Date(b.archived_at || b.created_at || 0) - new Date(a.archived_at || a.created_at || 0));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find WFS-* directories in a given path
|
||||
* @param {string} dir - Directory to search
|
||||
* @returns {Promise<string[]>} - Array of session directory names
|
||||
*/
|
||||
async function findWfsSessions(dir) {
|
||||
try {
|
||||
// Use glob for cross-platform pattern matching
|
||||
const sessions = await glob('WFS-*', {
|
||||
cwd: dir,
|
||||
onlyDirectories: true,
|
||||
absolute: false
|
||||
});
|
||||
return sessions;
|
||||
} catch {
|
||||
// Fallback: manual directory listing
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('WFS-'))
|
||||
.map(e => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timestamp from session name
|
||||
* Supports formats: WFS-xxx-20251128172537 or WFS-xxx-20251120-170640
|
||||
* @param {string} sessionName - Session directory name
|
||||
* @returns {string|null} - ISO date string or null
|
||||
*/
|
||||
function parseTimestampFromName(sessionName) {
|
||||
// Format: 14-digit timestamp (YYYYMMDDHHmmss)
|
||||
const match14 = sessionName.match(/(\d{14})$/);
|
||||
if (match14) {
|
||||
const ts = match14[1];
|
||||
return `${ts.slice(0,4)}-${ts.slice(4,6)}-${ts.slice(6,8)}T${ts.slice(8,10)}:${ts.slice(10,12)}:${ts.slice(12,14)}Z`;
|
||||
}
|
||||
|
||||
// Format: 8-digit date + 6-digit time separated by hyphen (YYYYMMDD-HHmmss)
|
||||
const match8_6 = sessionName.match(/(\d{8})-(\d{6})$/);
|
||||
if (match8_6) {
|
||||
const d = match8_6[1];
|
||||
const t = match8_6[2];
|
||||
return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}T${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}Z`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer session type from session name pattern
|
||||
* @param {string} sessionName - Session directory name
|
||||
* @returns {string} - Inferred type
|
||||
*/
|
||||
function inferTypeFromName(sessionName) {
|
||||
const name = sessionName.toLowerCase();
|
||||
|
||||
if (name.includes('-review-') || name.includes('-code-review-')) {
|
||||
return 'review';
|
||||
}
|
||||
if (name.includes('-test-')) {
|
||||
return 'test';
|
||||
}
|
||||
if (name.includes('-docs-')) {
|
||||
return 'docs';
|
||||
}
|
||||
if (name.includes('-tdd-')) {
|
||||
return 'tdd';
|
||||
}
|
||||
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session data from workflow-session.json or create minimal from directory
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @returns {Object|null} - Session data object or null if invalid
|
||||
*/
|
||||
function readSessionData(sessionPath) {
|
||||
const sessionFile = join(sessionPath, 'workflow-session.json');
|
||||
const sessionName = basename(sessionPath);
|
||||
|
||||
if (existsSync(sessionFile)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
||||
|
||||
// Multi-level type detection: JSON type > workflow_type > infer from name
|
||||
let type = data.type || data.workflow_type || inferTypeFromName(sessionName);
|
||||
|
||||
// Normalize workflow_type values
|
||||
if (type === 'test_session') type = 'test';
|
||||
if (type === 'implementation') type = 'workflow';
|
||||
|
||||
return {
|
||||
session_id: data.session_id || sessionName,
|
||||
project: data.project || data.description || '',
|
||||
status: data.status || 'active',
|
||||
created_at: data.created_at || data.initialized_at || data.timestamp || null,
|
||||
archived_at: data.archived_at || null,
|
||||
type: type,
|
||||
workflow_type: data.workflow_type || null // Keep original for reference
|
||||
};
|
||||
} catch {
|
||||
// Fall through to minimal session
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: create minimal session from directory info
|
||||
// Try to extract timestamp from session name first
|
||||
const timestampFromName = parseTimestampFromName(sessionName);
|
||||
const inferredType = inferTypeFromName(sessionName);
|
||||
|
||||
try {
|
||||
const stats = statSync(sessionPath);
|
||||
return {
|
||||
session_id: sessionName,
|
||||
project: '',
|
||||
status: 'unknown',
|
||||
created_at: timestampFromName || stats.birthtime.toISOString(),
|
||||
archived_at: null,
|
||||
type: inferredType,
|
||||
workflow_type: null
|
||||
};
|
||||
} catch {
|
||||
// Even if stat fails, return with name-extracted data
|
||||
if (timestampFromName) {
|
||||
return {
|
||||
session_id: sessionName,
|
||||
project: '',
|
||||
status: 'unknown',
|
||||
created_at: timestampFromName,
|
||||
archived_at: null,
|
||||
type: inferredType,
|
||||
workflow_type: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has review data
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasReviewData(sessionPath) {
|
||||
const reviewDir = join(sessionPath, '.review');
|
||||
return existsSync(reviewDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of task files in session
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function getTaskFiles(sessionPath) {
|
||||
const taskDir = join(sessionPath, '.task');
|
||||
if (!existsSync(taskDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await glob('IMPL-*.json', { cwd: taskDir, absolute: false });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
9
ccw/src/index.js
Normal file
9
ccw/src/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* CCW - Claude Code Workflow CLI
|
||||
* Main exports for programmatic usage
|
||||
*/
|
||||
|
||||
export { run } from './cli.js';
|
||||
export { scanSessions } from './core/session-scanner.js';
|
||||
export { aggregateData } from './core/data-aggregator.js';
|
||||
export { generateDashboard } from './core/dashboard-generator.js';
|
||||
200
ccw/src/templates/dashboard-js/api.js
Normal file
200
ccw/src/templates/dashboard-js/api.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// ========================================
|
||||
// API and Data Loading
|
||||
// ========================================
|
||||
// Server communication and data loading functions
|
||||
// Note: Some functions are only available in server mode
|
||||
|
||||
// ========== Data Loading ==========
|
||||
|
||||
/**
|
||||
* Load dashboard data from API (server mode only)
|
||||
* @param {string} path - Project path to load data for
|
||||
* @returns {Promise<Object|null>} Dashboard data object or null if failed
|
||||
*/
|
||||
async function loadDashboardData(path) {
|
||||
if (!window.SERVER_MODE) {
|
||||
console.warn('loadDashboardData called in static mode');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/data?path=${encodeURIComponent(path)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Path Management ==========
|
||||
|
||||
/**
|
||||
* Switch to a new project path (server mode only)
|
||||
* Loads dashboard data and updates UI
|
||||
* @param {string} path - Project path to switch to
|
||||
*/
|
||||
async function switchToPath(path) {
|
||||
// Show loading state
|
||||
const container = document.getElementById('mainContent');
|
||||
container.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const data = await loadDashboardData(path);
|
||||
if (data) {
|
||||
// Update global data
|
||||
workflowData = data;
|
||||
projectPath = data.projectPath;
|
||||
recentPaths = data.recentPaths || [];
|
||||
|
||||
// Update UI
|
||||
document.getElementById('currentPath').textContent = projectPath;
|
||||
renderDashboard();
|
||||
refreshRecentPaths();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch path:', err);
|
||||
container.innerHTML = '<div class="error">Failed to load project data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a path from recent paths list
|
||||
* @param {string} path - Path to select
|
||||
*/
|
||||
async function selectPath(path) {
|
||||
localStorage.setItem('selectedPath', path);
|
||||
|
||||
// Server mode: load data dynamically
|
||||
if (window.SERVER_MODE) {
|
||||
await switchToPath(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static mode: show command to run
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'path-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="path-modal">
|
||||
<div class="path-modal-header">
|
||||
<span class="path-modal-icon">${icons.terminal}</span>
|
||||
<h3>Run Command</h3>
|
||||
</div>
|
||||
<div class="path-modal-body">
|
||||
<p>To view the dashboard for this project, run:</p>
|
||||
<div class="path-modal-command">
|
||||
<code>ccw view -p "${path}"</code>
|
||||
<button class="copy-btn" id="copyCommandBtn">${icons.copy} <span>Copy</span></button>
|
||||
</div>
|
||||
<p class="path-modal-note" style="margin-top: 12px;">
|
||||
Or use <code>ccw serve</code> for live path switching.
|
||||
</p>
|
||||
</div>
|
||||
<div class="path-modal-footer">
|
||||
<button class="path-modal-close" onclick="this.closest('.path-modal-overlay').remove()">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add copy handler
|
||||
document.getElementById('copyCommandBtn').addEventListener('click', function() {
|
||||
navigator.clipboard.writeText('ccw view -p "' + path + '"').then(() => {
|
||||
this.innerHTML = icons.check + ' <span>Copied!</span>';
|
||||
setTimeout(() => { this.innerHTML = icons.copy + ' <span>Copy</span>'; }, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh recent paths dropdown UI
|
||||
*/
|
||||
function refreshRecentPaths() {
|
||||
const recentContainer = document.getElementById('recentPaths');
|
||||
recentContainer.innerHTML = '';
|
||||
|
||||
recentPaths.forEach(path => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'path-item' + (path === projectPath ? ' active' : '');
|
||||
item.dataset.path = path;
|
||||
|
||||
// Path text
|
||||
const pathText = document.createElement('span');
|
||||
pathText.className = 'path-text';
|
||||
pathText.textContent = path;
|
||||
pathText.addEventListener('click', () => selectPath(path));
|
||||
item.appendChild(pathText);
|
||||
|
||||
// Delete button (only for non-current paths)
|
||||
if (path !== projectPath) {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'path-delete-btn';
|
||||
deleteBtn.innerHTML = '×';
|
||||
deleteBtn.title = 'Remove from recent';
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await removeRecentPathFromList(path);
|
||||
});
|
||||
item.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
recentContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a path from recent paths list
|
||||
*/
|
||||
async function removeRecentPathFromList(path) {
|
||||
try {
|
||||
const response = await fetch('/api/remove-recent-path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
recentPaths = data.paths;
|
||||
refreshRecentPaths();
|
||||
showRefreshToast('Path removed', 'success');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to remove path:', err);
|
||||
showRefreshToast('Failed to remove path', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== File System Access ==========
|
||||
|
||||
/**
|
||||
* Browse for folder using File System Access API or fallback to input dialog
|
||||
*/
|
||||
async function browseForFolder() {
|
||||
// Try modern File System Access API first
|
||||
if ('showDirectoryPicker' in window) {
|
||||
try {
|
||||
const dirHandle = await window.showDirectoryPicker({
|
||||
mode: 'read',
|
||||
startIn: 'documents'
|
||||
});
|
||||
// Get the directory name (we can't get full path for security reasons)
|
||||
const dirName = dirHandle.name;
|
||||
showPathSelectedModal(dirName, dirHandle);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
console.warn('Directory picker failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show input dialog
|
||||
showPathInputModal();
|
||||
}
|
||||
112
ccw/src/templates/dashboard-js/components/_conflict_tab.js
Normal file
112
ccw/src/templates/dashboard-js/components/_conflict_tab.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// ==========================================
|
||||
// Conflict Resolution Tab
|
||||
// ==========================================
|
||||
|
||||
async function loadAndRenderConflictTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading conflict resolution...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=conflict`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderConflictCards(data.conflictResolution);
|
||||
return;
|
||||
}
|
||||
}
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">⚖️</div>
|
||||
<div class="empty-title">No Conflict Resolution</div>
|
||||
<div class="empty-text">No conflict-resolution-decisions.json found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load conflict resolution: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflictCards(conflictResolution) {
|
||||
if (!conflictResolution) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">⚖️</div>
|
||||
<div class="empty-title">No Conflict Resolution</div>
|
||||
<div class="empty-text">No conflict decisions found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let cards = [];
|
||||
|
||||
// Header info
|
||||
cards.push(`
|
||||
<div class="conflict-tab-header">
|
||||
<h3>⚖️ Conflict Resolution Decisions</h3>
|
||||
<div class="conflict-meta-info">
|
||||
<span>Session: <strong>${escapeHtml(conflictResolution.session_id || 'N/A')}</strong></span>
|
||||
${conflictResolution.resolved_at ? `<span>Resolved: <strong>${formatDate(conflictResolution.resolved_at)}</strong></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// User Decisions as cards
|
||||
if (conflictResolution.user_decisions && Object.keys(conflictResolution.user_decisions).length > 0) {
|
||||
const decisions = Object.entries(conflictResolution.user_decisions);
|
||||
|
||||
cards.push(`<div class="conflict-section-title">🎯 User Decisions (${decisions.length})</div>`);
|
||||
cards.push('<div class="conflict-cards-grid">');
|
||||
|
||||
for (const [key, decision] of decisions) {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
cards.push(`
|
||||
<div class="conflict-card decision-card">
|
||||
<div class="conflict-card-header">
|
||||
<span class="conflict-card-label">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
<div class="conflict-card-choice">
|
||||
<span class="choice-label">Choice:</span>
|
||||
<span class="choice-value">${escapeHtml(decision.choice || 'N/A')}</span>
|
||||
</div>
|
||||
${decision.description ? `
|
||||
<div class="conflict-card-desc">${escapeHtml(decision.description)}</div>
|
||||
` : ''}
|
||||
${decision.implications && decision.implications.length > 0 ? `
|
||||
<div class="conflict-card-implications">
|
||||
<span class="impl-label">Implications:</span>
|
||||
<ul>
|
||||
${decision.implications.map(impl => `<li>${escapeHtml(impl)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
cards.push('</div>');
|
||||
}
|
||||
|
||||
// Resolved Conflicts as cards
|
||||
if (conflictResolution.resolved_conflicts && conflictResolution.resolved_conflicts.length > 0) {
|
||||
cards.push(`<div class="conflict-section-title">✅ Resolved Conflicts (${conflictResolution.resolved_conflicts.length})</div>`);
|
||||
cards.push('<div class="conflict-cards-grid">');
|
||||
|
||||
for (const conflict of conflictResolution.resolved_conflicts) {
|
||||
cards.push(`
|
||||
<div class="conflict-card resolved-card">
|
||||
<div class="conflict-card-header">
|
||||
<span class="conflict-card-id">${escapeHtml(conflict.id || 'N/A')}</span>
|
||||
<span class="conflict-category-tag">${escapeHtml(conflict.category || 'General')}</span>
|
||||
</div>
|
||||
<div class="conflict-card-brief">${escapeHtml(conflict.brief || '')}</div>
|
||||
<div class="conflict-card-strategy">
|
||||
<span class="strategy-label">Strategy:</span>
|
||||
<span class="strategy-tag">${escapeHtml(conflict.strategy || 'N/A')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
cards.push('</div>');
|
||||
}
|
||||
|
||||
return `<div class="conflict-tab-content">${cards.join('')}</div>`;
|
||||
}
|
||||
54
ccw/src/templates/dashboard-js/components/_exp_helpers.js
Normal file
54
ccw/src/templates/dashboard-js/components/_exp_helpers.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Exploration helpers loaded
|
||||
|
||||
// Helper: Render exploration field with smart type detection
|
||||
function renderExpField(label, value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
let rendered;
|
||||
if (typeof value === 'string') {
|
||||
rendered = `<p>${escapeHtml(value)}</p>`;
|
||||
} else if (Array.isArray(value)) {
|
||||
rendered = renderExpArray(value);
|
||||
} else if (typeof value === 'object') {
|
||||
rendered = renderExpObject(value);
|
||||
} else {
|
||||
rendered = `<p>${escapeHtml(String(value))}</p>`;
|
||||
}
|
||||
return `<div class="exp-field"><label>${escapeHtml(label)}</label>${rendered}</div>`;
|
||||
}
|
||||
|
||||
// Helper: Render array values
|
||||
function renderExpArray(arr) {
|
||||
if (!arr.length) return '<p>-</p>';
|
||||
if (typeof arr[0] === 'object' && arr[0] !== null) {
|
||||
return `<div class="exp-array-objects">${arr.map(item => {
|
||||
if (item.question) {
|
||||
return `<div class="clarification-item">
|
||||
<div class="clarification-question">${escapeHtml(item.question)}</div>
|
||||
${item.impact ? `<div class="clarification-impact">Impact: ${escapeHtml(item.impact)}</div>` : ''}
|
||||
${item.priority ? `<span class="priority-badge priority-${item.priority}">${item.priority}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="exp-object-item">${renderExpObject(item)}</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
return `<ul class="exp-list">${arr.map(item => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
|
||||
}
|
||||
|
||||
// Helper: Render object values recursively
|
||||
function renderExpObject(obj) {
|
||||
if (!obj || typeof obj !== 'object') return '';
|
||||
const entries = Object.entries(obj).filter(([k]) => !k.startsWith('_'));
|
||||
if (!entries.length) return '<p>-</p>';
|
||||
return `<div class="exp-object">${entries.map(([key, val]) => {
|
||||
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'string') {
|
||||
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span> <span class="exp-obj-val">${escapeHtml(val)}</span></div>`;
|
||||
} else if (Array.isArray(val)) {
|
||||
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span>${renderExpArray(val)}</div>`;
|
||||
} else if (typeof val === 'object') {
|
||||
return `<div class="exp-obj-nested"><span class="exp-obj-key">${escapeHtml(label)}</span>${renderExpObject(val)}</div>`;
|
||||
}
|
||||
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span> <span class="exp-obj-val">${escapeHtml(String(val))}</span></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
640
ccw/src/templates/dashboard-js/components/_review_tab.js
Normal file
640
ccw/src/templates/dashboard-js/components/_review_tab.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// ==========================================
|
||||
// Enhanced Review Tab with Multi-Select & Preview
|
||||
// ==========================================
|
||||
|
||||
// Review tab state
|
||||
let reviewTabState = {
|
||||
allFindings: [],
|
||||
filteredFindings: [],
|
||||
selectedFindings: new Set(),
|
||||
currentFilters: {
|
||||
dimension: 'all',
|
||||
severities: new Set(),
|
||||
search: ''
|
||||
},
|
||||
sortConfig: {
|
||||
field: 'severity',
|
||||
order: 'desc'
|
||||
},
|
||||
previewFinding: null,
|
||||
sessionPath: null,
|
||||
sessionId: null
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Main Review Tab Render
|
||||
// ==========================================
|
||||
|
||||
function renderReviewContent(review) {
|
||||
if (!review || !review.dimensions) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Review Data</div>
|
||||
<div class="empty-text">No review findings in .review/</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Convert dimensions object to flat findings array
|
||||
const findings = [];
|
||||
let findingIndex = 0;
|
||||
|
||||
Object.entries(review.dimensions).forEach(([dim, rawFindings]) => {
|
||||
let dimFindings = [];
|
||||
if (Array.isArray(rawFindings)) {
|
||||
dimFindings = rawFindings;
|
||||
} else if (rawFindings && typeof rawFindings === 'object') {
|
||||
if (Array.isArray(rawFindings.findings)) {
|
||||
dimFindings = rawFindings.findings;
|
||||
}
|
||||
}
|
||||
|
||||
dimFindings.forEach(f => {
|
||||
findings.push({
|
||||
id: f.id || `finding-${findingIndex++}`,
|
||||
title: f.title || 'Finding',
|
||||
description: f.description || '',
|
||||
severity: (f.severity || 'medium').toLowerCase(),
|
||||
dimension: dim,
|
||||
category: f.category || '',
|
||||
file: f.file || '',
|
||||
line: f.line || '',
|
||||
code_context: f.code_context || f.snippet || '',
|
||||
recommendations: f.recommendations || (f.recommendation ? [f.recommendation] : []),
|
||||
root_cause: f.root_cause || '',
|
||||
impact: f.impact || '',
|
||||
references: f.references || [],
|
||||
metadata: f.metadata || {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (findings.length === 0) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">No Findings</div>
|
||||
<div class="empty-text">No review findings found.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Store findings in state
|
||||
reviewTabState.allFindings = findings;
|
||||
reviewTabState.filteredFindings = [...findings];
|
||||
reviewTabState.selectedFindings.clear();
|
||||
reviewTabState.previewFinding = null;
|
||||
|
||||
// Get dimensions for tabs
|
||||
const dimensions = [...new Set(findings.map(f => f.dimension))];
|
||||
|
||||
// Count by severity
|
||||
const severityCounts = {
|
||||
critical: findings.filter(f => f.severity === 'critical').length,
|
||||
high: findings.filter(f => f.severity === 'high').length,
|
||||
medium: findings.filter(f => f.severity === 'medium').length,
|
||||
low: findings.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="review-enhanced-container">
|
||||
<!-- Header with Stats & Controls -->
|
||||
<div class="review-header-bar">
|
||||
<div class="review-severity-stats">
|
||||
<span class="severity-stat critical" onclick="filterReviewBySeverity('critical')" title="Filter Critical">
|
||||
🔴 ${severityCounts.critical}
|
||||
</span>
|
||||
<span class="severity-stat high" onclick="filterReviewBySeverity('high')" title="Filter High">
|
||||
🟠 ${severityCounts.high}
|
||||
</span>
|
||||
<span class="severity-stat medium" onclick="filterReviewBySeverity('medium')" title="Filter Medium">
|
||||
🟡 ${severityCounts.medium}
|
||||
</span>
|
||||
<span class="severity-stat low" onclick="filterReviewBySeverity('low')" title="Filter Low">
|
||||
🟢 ${severityCounts.low}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="review-search-box">
|
||||
<input type="text"
|
||||
id="reviewSearchInput"
|
||||
placeholder="Search findings..."
|
||||
oninput="onReviewSearch(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="review-selection-controls">
|
||||
<span class="selection-counter" id="reviewSelectionCounter">0 selected</span>
|
||||
<button class="btn-mini" onclick="selectAllReviewFindings()">Select All</button>
|
||||
<button class="btn-mini" onclick="selectVisibleReviewFindings()">Select Visible</button>
|
||||
<button class="btn-mini" onclick="clearReviewSelection()">Clear</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-export-fix" id="exportFixBtn" onclick="exportReviewFixJson()" disabled>
|
||||
🔧 Export Fix JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="review-filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Severity:</span>
|
||||
<div class="filter-chips">
|
||||
<label class="filter-chip" id="filter-critical">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('critical')">
|
||||
<span>Critical</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-high">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('high')">
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-medium">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('medium')">
|
||||
<span>Medium</span>
|
||||
</label>
|
||||
<label class="filter-chip" id="filter-low">
|
||||
<input type="checkbox" onchange="toggleReviewSeverityFilter('low')">
|
||||
<span>Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Sort:</span>
|
||||
<select id="reviewSortSelect" class="sort-select" onchange="sortReviewFindings()">
|
||||
<option value="severity">By Severity</option>
|
||||
<option value="dimension">By Dimension</option>
|
||||
<option value="file">By File</option>
|
||||
</select>
|
||||
<button class="btn-sort-order" id="reviewSortOrderBtn" onclick="toggleReviewSortOrder()">
|
||||
<span id="reviewSortOrderIcon">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-mini" onclick="resetReviewFilters()">Reset Filters</button>
|
||||
</div>
|
||||
|
||||
<!-- Dimension Tabs -->
|
||||
<div class="review-dimension-tabs">
|
||||
<button class="dim-tab active" data-dimension="all" onclick="filterReviewByDimension('all')">
|
||||
All (${findings.length})
|
||||
</button>
|
||||
${dimensions.map(dim => `
|
||||
<button class="dim-tab" data-dimension="${dim}" onclick="filterReviewByDimension('${dim}')">
|
||||
${escapeHtml(dim)} (${findings.filter(f => f.dimension === dim).length})
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Split Panel: List + Preview -->
|
||||
<div class="review-split-panel">
|
||||
<!-- Left: Findings List -->
|
||||
<div class="review-findings-panel">
|
||||
<div class="findings-list-header">
|
||||
<span id="reviewFindingsCount">${findings.length} findings</span>
|
||||
</div>
|
||||
<div class="review-findings-list" id="reviewFindingsList">
|
||||
${renderReviewFindingsList(findings)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview Panel -->
|
||||
<div class="review-preview-panel" id="reviewPreviewPanel">
|
||||
<div class="preview-empty-state">
|
||||
<div class="preview-icon">👆</div>
|
||||
<div class="preview-text">Click on a finding to preview details</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Findings List Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderReviewFindingsList(findings) {
|
||||
if (findings.length === 0) {
|
||||
return `
|
||||
<div class="findings-empty">
|
||||
<span class="empty-icon">✨</span>
|
||||
<span>No findings match your filters</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return findings.map(finding => `
|
||||
<div class="review-finding-item ${finding.severity} ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
data-finding-id="${finding.id}"
|
||||
onclick="previewReviewFinding('${finding.id}')">
|
||||
<input type="checkbox"
|
||||
class="finding-checkbox"
|
||||
${reviewTabState.selectedFindings.has(finding.id) ? 'checked' : ''}
|
||||
onclick="toggleReviewFindingSelection('${finding.id}', event)">
|
||||
<div class="finding-content">
|
||||
<div class="finding-top-row">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
</div>
|
||||
<div class="finding-title">${escapeHtml(finding.title)}</div>
|
||||
${finding.file ? `<div class="finding-file">📄 ${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Preview Panel Rendering
|
||||
// ==========================================
|
||||
|
||||
function previewReviewFinding(findingId) {
|
||||
const finding = reviewTabState.allFindings.find(f => f.id === findingId);
|
||||
if (!finding) return;
|
||||
|
||||
reviewTabState.previewFinding = finding;
|
||||
|
||||
// Update active state in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
item.classList.toggle('previewing', item.dataset.findingId === findingId);
|
||||
});
|
||||
|
||||
const previewPanel = document.getElementById('reviewPreviewPanel');
|
||||
if (!previewPanel) return;
|
||||
|
||||
previewPanel.innerHTML = `
|
||||
<div class="preview-content">
|
||||
<div class="preview-header">
|
||||
<div class="preview-badges">
|
||||
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
|
||||
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
|
||||
${finding.category ? `<span class="category-badge">${escapeHtml(finding.category)}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn-select-finding ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
|
||||
onclick="toggleReviewFindingSelection('${finding.id}', event)">
|
||||
${reviewTabState.selectedFindings.has(finding.id) ? '✓ Selected' : '+ Select for Fix'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="preview-title">${escapeHtml(finding.title)}</h3>
|
||||
|
||||
${finding.file ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📄 Location</div>
|
||||
<div class="preview-location">
|
||||
<code>${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</code>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">📝 Description</div>
|
||||
<div class="preview-description">${escapeHtml(finding.description)}</div>
|
||||
</div>
|
||||
|
||||
${finding.code_context ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">💻 Code Context</div>
|
||||
<pre class="preview-code">${escapeHtml(finding.code_context)}</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.recommendations && finding.recommendations.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">✅ Recommendations</div>
|
||||
<ul class="preview-recommendations">
|
||||
${finding.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.root_cause ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔍 Root Cause</div>
|
||||
<div class="preview-root-cause">${escapeHtml(finding.root_cause)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.impact ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">⚠️ Impact</div>
|
||||
<div class="preview-impact">${escapeHtml(finding.impact)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.references && finding.references.length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">🔗 References</div>
|
||||
<ul class="preview-references">
|
||||
${finding.references.map(ref => {
|
||||
const isUrl = ref.startsWith('http');
|
||||
return `<li>${isUrl ? `<a href="${ref}" target="_blank">${ref}</a>` : ref}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${finding.metadata && Object.keys(finding.metadata).length > 0 ? `
|
||||
<div class="preview-section">
|
||||
<div class="preview-section-title">ℹ️ Metadata</div>
|
||||
<div class="preview-metadata">
|
||||
${Object.entries(finding.metadata).map(([key, value]) => `
|
||||
<div class="metadata-item">
|
||||
<span class="meta-key">${escapeHtml(key)}:</span>
|
||||
<span class="meta-value">${escapeHtml(String(value))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Selection Management
|
||||
// ==========================================
|
||||
|
||||
function toggleReviewFindingSelection(findingId, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (reviewTabState.selectedFindings.has(findingId)) {
|
||||
reviewTabState.selectedFindings.delete(findingId);
|
||||
} else {
|
||||
reviewTabState.selectedFindings.add(findingId);
|
||||
}
|
||||
|
||||
updateReviewSelectionUI();
|
||||
|
||||
// Update preview panel button if this finding is being previewed
|
||||
if (reviewTabState.previewFinding && reviewTabState.previewFinding.id === findingId) {
|
||||
previewReviewFinding(findingId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllReviewFindings() {
|
||||
reviewTabState.allFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function selectVisibleReviewFindings() {
|
||||
reviewTabState.filteredFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function selectReviewBySeverity(severity) {
|
||||
reviewTabState.allFindings
|
||||
.filter(f => f.severity === severity)
|
||||
.forEach(f => reviewTabState.selectedFindings.add(f.id));
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function clearReviewSelection() {
|
||||
reviewTabState.selectedFindings.clear();
|
||||
updateReviewSelectionUI();
|
||||
}
|
||||
|
||||
function updateReviewSelectionUI() {
|
||||
// Update counter
|
||||
const counter = document.getElementById('reviewSelectionCounter');
|
||||
if (counter) {
|
||||
counter.textContent = `${reviewTabState.selectedFindings.size} selected`;
|
||||
}
|
||||
|
||||
// Update export button
|
||||
const exportBtn = document.getElementById('exportFixBtn');
|
||||
if (exportBtn) {
|
||||
exportBtn.disabled = reviewTabState.selectedFindings.size === 0;
|
||||
}
|
||||
|
||||
// Update checkbox states in list
|
||||
document.querySelectorAll('.review-finding-item').forEach(item => {
|
||||
const findingId = item.dataset.findingId;
|
||||
const isSelected = reviewTabState.selectedFindings.has(findingId);
|
||||
item.classList.toggle('selected', isSelected);
|
||||
const checkbox = item.querySelector('.finding-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = isSelected;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Filtering & Sorting
|
||||
// ==========================================
|
||||
|
||||
function filterReviewByDimension(dimension) {
|
||||
reviewTabState.currentFilters.dimension = dimension;
|
||||
|
||||
// Update tab active state
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === dimension);
|
||||
});
|
||||
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function filterReviewBySeverity(severity) {
|
||||
// Toggle the severity filter
|
||||
if (reviewTabState.currentFilters.severities.has(severity)) {
|
||||
reviewTabState.currentFilters.severities.delete(severity);
|
||||
} else {
|
||||
reviewTabState.currentFilters.severities.add(severity);
|
||||
}
|
||||
|
||||
// Update filter chip UI
|
||||
const filterChip = document.getElementById(`filter-${severity}`);
|
||||
if (filterChip) {
|
||||
filterChip.classList.toggle('active', reviewTabState.currentFilters.severities.has(severity));
|
||||
const checkbox = filterChip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = reviewTabState.currentFilters.severities.has(severity);
|
||||
}
|
||||
}
|
||||
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function toggleReviewSeverityFilter(severity) {
|
||||
filterReviewBySeverity(severity);
|
||||
}
|
||||
|
||||
function onReviewSearch(searchText) {
|
||||
reviewTabState.currentFilters.search = searchText.toLowerCase();
|
||||
applyReviewFilters();
|
||||
}
|
||||
|
||||
function applyReviewFilters() {
|
||||
reviewTabState.filteredFindings = reviewTabState.allFindings.filter(finding => {
|
||||
// Dimension filter
|
||||
if (reviewTabState.currentFilters.dimension !== 'all') {
|
||||
if (finding.dimension !== reviewTabState.currentFilters.dimension) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Severity filter (multi-select)
|
||||
if (reviewTabState.currentFilters.severities.size > 0) {
|
||||
if (!reviewTabState.currentFilters.severities.has(finding.severity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (reviewTabState.currentFilters.search) {
|
||||
const searchText = `${finding.title} ${finding.description} ${finding.file} ${finding.category}`.toLowerCase();
|
||||
if (!searchText.includes(reviewTabState.currentFilters.search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function sortReviewFindings() {
|
||||
const sortBy = document.getElementById('reviewSortSelect')?.value || 'severity';
|
||||
reviewTabState.sortConfig.field = sortBy;
|
||||
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
reviewTabState.filteredFindings.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortBy === 'severity') {
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
} else if (sortBy === 'dimension') {
|
||||
comparison = a.dimension.localeCompare(b.dimension);
|
||||
} else if (sortBy === 'file') {
|
||||
comparison = (a.file || '').localeCompare(b.file || '');
|
||||
}
|
||||
|
||||
return reviewTabState.sortConfig.order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
renderFilteredReviewFindings();
|
||||
}
|
||||
|
||||
function toggleReviewSortOrder() {
|
||||
reviewTabState.sortConfig.order = reviewTabState.sortConfig.order === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
const icon = document.getElementById('reviewSortOrderIcon');
|
||||
if (icon) {
|
||||
icon.textContent = reviewTabState.sortConfig.order === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function resetReviewFilters() {
|
||||
// Reset state
|
||||
reviewTabState.currentFilters.dimension = 'all';
|
||||
reviewTabState.currentFilters.severities.clear();
|
||||
reviewTabState.currentFilters.search = '';
|
||||
reviewTabState.sortConfig.field = 'severity';
|
||||
reviewTabState.sortConfig.order = 'desc';
|
||||
|
||||
// Reset UI
|
||||
document.querySelectorAll('.dim-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.dimension === 'all');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
chip.classList.remove('active');
|
||||
const checkbox = chip.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('reviewSearchInput');
|
||||
if (searchInput) searchInput.value = '';
|
||||
|
||||
const sortSelect = document.getElementById('reviewSortSelect');
|
||||
if (sortSelect) sortSelect.value = 'severity';
|
||||
|
||||
const sortIcon = document.getElementById('reviewSortOrderIcon');
|
||||
if (sortIcon) sortIcon.textContent = '↓';
|
||||
|
||||
// Re-apply filters
|
||||
reviewTabState.filteredFindings = [...reviewTabState.allFindings];
|
||||
sortReviewFindings();
|
||||
}
|
||||
|
||||
function renderFilteredReviewFindings() {
|
||||
const listContainer = document.getElementById('reviewFindingsList');
|
||||
const countEl = document.getElementById('reviewFindingsCount');
|
||||
|
||||
if (listContainer) {
|
||||
listContainer.innerHTML = renderReviewFindingsList(reviewTabState.filteredFindings);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = `${reviewTabState.filteredFindings.length} findings`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Export Fix JSON
|
||||
// ==========================================
|
||||
|
||||
function exportReviewFixJson() {
|
||||
if (reviewTabState.selectedFindings.size === 0) {
|
||||
showToast('Please select at least one finding to export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedFindingsData = reviewTabState.allFindings.filter(f =>
|
||||
reviewTabState.selectedFindings.has(f.id)
|
||||
);
|
||||
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
const sessionId = session?.session_id || 'unknown';
|
||||
const exportId = `fix-${Date.now()}`;
|
||||
|
||||
const exportData = {
|
||||
export_id: exportId,
|
||||
export_timestamp: new Date().toISOString(),
|
||||
review_id: `review-${sessionId}`,
|
||||
session_id: sessionId,
|
||||
findings_count: selectedFindingsData.length,
|
||||
findings: selectedFindingsData.map(f => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
severity: f.severity,
|
||||
dimension: f.dimension,
|
||||
category: f.category || 'uncategorized',
|
||||
file: f.file,
|
||||
line: f.line,
|
||||
code_context: f.code_context || null,
|
||||
recommendations: f.recommendations || [],
|
||||
root_cause: f.root_cause || null
|
||||
}))
|
||||
};
|
||||
|
||||
// Convert to JSON and download
|
||||
const jsonStr = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const filename = `fix-export-${exportId}.json`;
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success notification
|
||||
const severityCounts = {
|
||||
critical: selectedFindingsData.filter(f => f.severity === 'critical').length,
|
||||
high: selectedFindingsData.filter(f => f.severity === 'high').length,
|
||||
medium: selectedFindingsData.filter(f => f.severity === 'medium').length,
|
||||
low: selectedFindingsData.filter(f => f.severity === 'low').length
|
||||
};
|
||||
|
||||
showToast(`Exported ${selectedFindingsData.length} findings for fixing (Critical: ${severityCounts.critical}, High: ${severityCounts.high}, Medium: ${severityCounts.medium}, Low: ${severityCounts.low})`, 'success');
|
||||
}
|
||||
398
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
398
ccw/src/templates/dashboard-js/components/carousel.js
Normal file
@@ -0,0 +1,398 @@
|
||||
// ==========================================
|
||||
// CAROUSEL COMPONENT
|
||||
// ==========================================
|
||||
// Active session carousel with detailed task info and smooth transitions
|
||||
|
||||
let carouselIndex = 0;
|
||||
let carouselSessions = [];
|
||||
let carouselInterval = null;
|
||||
let carouselPaused = false;
|
||||
const CAROUSEL_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
function initCarousel() {
|
||||
const prevBtn = document.getElementById('carouselPrev');
|
||||
const nextBtn = document.getElementById('carouselNext');
|
||||
const pauseBtn = document.getElementById('carouselPause');
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
carouselPrev();
|
||||
resetCarouselInterval();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
carouselNext();
|
||||
resetCarouselInterval();
|
||||
});
|
||||
}
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.addEventListener('click', toggleCarouselPause);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCarousel() {
|
||||
// Get active sessions from workflowData
|
||||
const previousSessions = carouselSessions;
|
||||
const previousIndex = carouselIndex;
|
||||
const previousSessionId = previousSessions[previousIndex]?.session_id;
|
||||
|
||||
carouselSessions = workflowData.activeSessions || [];
|
||||
|
||||
// Try to preserve current position
|
||||
if (previousSessionId && carouselSessions.length > 0) {
|
||||
// Find if the same session still exists
|
||||
const newIndex = carouselSessions.findIndex(s => s.session_id === previousSessionId);
|
||||
if (newIndex !== -1) {
|
||||
carouselIndex = newIndex;
|
||||
} else if (previousIndex < carouselSessions.length) {
|
||||
// Keep same index if valid
|
||||
carouselIndex = previousIndex;
|
||||
} else {
|
||||
// Reset to last valid index
|
||||
carouselIndex = Math.max(0, carouselSessions.length - 1);
|
||||
}
|
||||
} else {
|
||||
carouselIndex = 0;
|
||||
}
|
||||
|
||||
renderCarouselDots();
|
||||
renderCarouselSlide('none');
|
||||
startCarouselInterval();
|
||||
}
|
||||
|
||||
function renderCarouselDots() {
|
||||
const dotsContainer = document.getElementById('carouselDots');
|
||||
if (!dotsContainer) return;
|
||||
|
||||
if (carouselSessions.length === 0) {
|
||||
dotsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
dotsContainer.innerHTML = carouselSessions.map((_, index) => `
|
||||
<button class="carousel-dot w-2 h-2 rounded-full transition-all duration-200 ${index === carouselIndex ? 'bg-primary w-4' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60'}"
|
||||
onclick="carouselGoToIndex(${index})" title="Session ${index + 1}"></button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateActiveDot() {
|
||||
const dots = document.querySelectorAll('.carousel-dot');
|
||||
dots.forEach((dot, index) => {
|
||||
if (index === carouselIndex) {
|
||||
dot.classList.remove('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
|
||||
dot.classList.add('bg-primary', 'w-4');
|
||||
} else {
|
||||
dot.classList.remove('bg-primary', 'w-4');
|
||||
dot.classList.add('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function carouselGoToIndex(index) {
|
||||
if (index < 0 || index >= carouselSessions.length) return;
|
||||
const direction = index > carouselIndex ? 'left' : (index < carouselIndex ? 'right' : 'none');
|
||||
carouselIndex = index;
|
||||
renderCarouselSlide(direction);
|
||||
updateActiveDot();
|
||||
resetCarouselInterval();
|
||||
}
|
||||
|
||||
function renderCarouselSlide(direction = 'none') {
|
||||
const container = document.getElementById('carouselContent');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (carouselSessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎯</div>
|
||||
<p class="text-sm">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const session = carouselSessions[carouselIndex];
|
||||
const sessionType = session.type || 'workflow';
|
||||
|
||||
// Use simplified view for review sessions
|
||||
if (sessionType === 'review') {
|
||||
renderReviewCarouselSlide(container, session, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
const taskCount = session.taskCount || tasks.length;
|
||||
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
|
||||
|
||||
// Get session type badge
|
||||
const typeBadgeClass = getSessionTypeBadgeClass(sessionType);
|
||||
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
|
||||
// Animation class based on direction
|
||||
const animClass = direction === 'left' ? 'carousel-slide-left' :
|
||||
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
|
||||
|
||||
// Get recent task activity
|
||||
const recentTasks = getRecentTaskActivity(tasks);
|
||||
|
||||
// Format timestamps
|
||||
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
|
||||
const updatedTime = session.updated_at ? formatRelativeTime(session.updated_at) : '';
|
||||
|
||||
// Get more tasks for display (up to 4)
|
||||
const displayTasks = getRecentTaskActivity(tasks, 4);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="carousel-slide ${animClass} h-full">
|
||||
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
|
||||
onclick="showSessionDetailPage('${sessionKey}')">
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex gap-4 h-full">
|
||||
|
||||
<!-- Left Column: Session Info -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Session Header -->
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">${sessionType}</span>
|
||||
${inProgress > 0 ? `<span class="inline-flex items-center gap-1 text-xs text-warning"><span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>${inProgress} running</span>` : ''}
|
||||
</div>
|
||||
<h4 class="font-semibold text-foreground text-sm line-clamp-1 mb-2" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<span class="text-foreground font-medium">${completed}/${taskCount}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ${progress === 100 ? 'bg-success' : 'bg-primary'}" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Status Summary -->
|
||||
<div class="flex items-center gap-3 text-xs mb-2">
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success"></span>${completed}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-warning ${inProgress > 0 ? 'animate-pulse' : ''}"></span>${inProgress}</span>
|
||||
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-muted-foreground"></span>${pending}</span>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>📅 ${createdTime}</span>
|
||||
${updatedTime && updatedTime !== createdTime ? `<span>🔄 ${updatedTime}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Task List -->
|
||||
<div class="w-[45%] flex flex-col border-l border-border pl-3">
|
||||
<div class="text-xs font-medium text-muted-foreground mb-1.5">Recent Tasks</div>
|
||||
<div class="task-list flex-1 space-y-1 overflow-hidden">
|
||||
${displayTasks.length > 0 ? displayTasks.map(task => `
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<span class="shrink-0">${getTaskStatusEmoji(task.status)}</span>
|
||||
<span class="truncate flex-1 ${task.status === 'in_progress' ? 'text-foreground font-medium' : 'text-muted-foreground'}">${escapeHtml(task.title || task.id || 'Task')}</span>
|
||||
</div>
|
||||
`).join('') : `
|
||||
<div class="text-xs text-muted-foreground">No tasks yet</div>
|
||||
`}
|
||||
</div>
|
||||
<!-- Progress percentage -->
|
||||
<div class="mt-auto pt-1 text-right">
|
||||
<span class="text-xl font-bold ${progress === 100 ? 'text-success' : 'text-primary'}">${progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store session data for navigation
|
||||
if (!sessionDataStore[sessionKey]) {
|
||||
sessionDataStore[sessionKey] = session;
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified carousel slide for review sessions
|
||||
function renderReviewCarouselSlide(container, session, direction) {
|
||||
const typeBadgeClass = getSessionTypeBadgeClass('review');
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const animClass = direction === 'left' ? 'carousel-slide-left' :
|
||||
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
|
||||
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="carousel-slide ${animClass} h-full">
|
||||
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
|
||||
onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">review</span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-foreground text-sm line-clamp-2 mb-3" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
|
||||
|
||||
<!-- Simple info -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🔍</div>
|
||||
<div class="text-xs text-muted-foreground">Click to view findings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-auto text-xs text-muted-foreground">
|
||||
📅 ${createdTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store session data for navigation
|
||||
if (!sessionDataStore[sessionKey]) {
|
||||
sessionDataStore[sessionKey] = session;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionTypeBadgeClass(type) {
|
||||
const classes = {
|
||||
'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
'review': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
'test': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
'docs': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
'workflow': 'bg-primary-light text-primary'
|
||||
};
|
||||
return classes[type] || classes['workflow'];
|
||||
}
|
||||
|
||||
function getRecentTaskActivity(tasks, limit = 4) {
|
||||
if (!tasks || tasks.length === 0) return [];
|
||||
|
||||
// Get in_progress tasks first, then most recently updated
|
||||
const sorted = [...tasks].sort((a, b) => {
|
||||
// in_progress first
|
||||
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
|
||||
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
|
||||
// Then by updated_at
|
||||
const timeA = a.updated_at || a.created_at || '';
|
||||
const timeB = b.updated_at || b.created_at || '';
|
||||
return timeB.localeCompare(timeA);
|
||||
});
|
||||
|
||||
// Return top N tasks
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
|
||||
function getTaskStatusEmoji(status) {
|
||||
const emojis = {
|
||||
'completed': '✅',
|
||||
'in_progress': '🔄',
|
||||
'pending': '⏸️',
|
||||
'blocked': '🚫'
|
||||
};
|
||||
return emojis[status] || '📋';
|
||||
}
|
||||
|
||||
function getTaskStatusIcon(status) {
|
||||
return status === 'in_progress' ? 'animate-spin-slow' : '';
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
// Format as date for older
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function carouselNext() {
|
||||
if (carouselSessions.length === 0) return;
|
||||
carouselIndex = (carouselIndex + 1) % carouselSessions.length;
|
||||
renderCarouselSlide('left');
|
||||
updateActiveDot();
|
||||
}
|
||||
|
||||
function carouselPrev() {
|
||||
if (carouselSessions.length === 0) return;
|
||||
carouselIndex = (carouselIndex - 1 + carouselSessions.length) % carouselSessions.length;
|
||||
renderCarouselSlide('right');
|
||||
updateActiveDot();
|
||||
}
|
||||
|
||||
function startCarouselInterval() {
|
||||
stopCarouselInterval();
|
||||
if (!carouselPaused && carouselSessions.length > 1) {
|
||||
carouselInterval = setInterval(carouselNext, CAROUSEL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function stopCarouselInterval() {
|
||||
if (carouselInterval) {
|
||||
clearInterval(carouselInterval);
|
||||
carouselInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCarouselInterval() {
|
||||
if (!carouselPaused) {
|
||||
startCarouselInterval();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCarouselPause() {
|
||||
carouselPaused = !carouselPaused;
|
||||
const icon = document.getElementById('carouselPauseIcon');
|
||||
|
||||
if (carouselPaused) {
|
||||
stopCarouselInterval();
|
||||
// Change to play icon
|
||||
if (icon) {
|
||||
icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
}
|
||||
} else {
|
||||
startCarouselInterval();
|
||||
// Change to pause icon
|
||||
if (icon) {
|
||||
icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to specific session in carousel
|
||||
function carouselGoTo(sessionId) {
|
||||
const index = carouselSessions.findIndex(s => s.session_id === sessionId);
|
||||
if (index !== -1) {
|
||||
carouselIndex = index;
|
||||
renderCarouselSlide('none');
|
||||
updateActiveDot();
|
||||
resetCarouselInterval();
|
||||
}
|
||||
}
|
||||
493
ccw/src/templates/dashboard-js/components/flowchart.js
Normal file
493
ccw/src/templates/dashboard-js/components/flowchart.js
Normal file
@@ -0,0 +1,493 @@
|
||||
// ==========================================
|
||||
// FLOWCHART RENDERING (D3.js)
|
||||
// ==========================================
|
||||
|
||||
function renderFlowchartForTask(sessionId, task) {
|
||||
// Will render on section expand
|
||||
}
|
||||
|
||||
function renderFlowchart(containerId, steps) {
|
||||
if (!steps || steps.length === 0) return;
|
||||
if (typeof d3 === 'undefined') {
|
||||
document.getElementById(containerId).innerHTML = '<div class="flowchart-fallback">D3.js not loaded</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById(containerId);
|
||||
const width = container.clientWidth || 500;
|
||||
const nodeHeight = 50;
|
||||
const nodeWidth = Math.min(width - 40, 300);
|
||||
const padding = 15;
|
||||
const height = steps.length * (nodeHeight + padding) + padding * 2;
|
||||
|
||||
// Clear existing content
|
||||
container.innerHTML = '';
|
||||
|
||||
const svg = d3.select('#' + containerId)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('class', 'flowchart-svg');
|
||||
|
||||
// Arrow marker
|
||||
svg.append('defs').append('marker')
|
||||
.attr('id', 'arrow-' + containerId)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 8)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', 'hsl(var(--border))');
|
||||
|
||||
// Draw arrows
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
const y1 = padding + i * (nodeHeight + padding) + nodeHeight;
|
||||
const y2 = padding + (i + 1) * (nodeHeight + padding);
|
||||
|
||||
svg.append('line')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', y1)
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', y2)
|
||||
.attr('stroke', 'hsl(var(--border))')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrow-' + containerId + ')');
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
const nodes = svg.selectAll('.node')
|
||||
.data(steps)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'flowchart-node')
|
||||
.attr('transform', (d, i) => `translate(${(width - nodeWidth) / 2}, ${padding + i * (nodeHeight + padding)})`);
|
||||
|
||||
// Node rectangles
|
||||
nodes.append('rect')
|
||||
.attr('width', nodeWidth)
|
||||
.attr('height', nodeHeight)
|
||||
.attr('rx', 6)
|
||||
.attr('fill', (d, i) => i === 0 ? 'hsl(var(--primary))' : 'hsl(var(--card))')
|
||||
.attr('stroke', 'hsl(var(--border))')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
// Step number circle
|
||||
nodes.append('circle')
|
||||
.attr('cx', 20)
|
||||
.attr('cy', nodeHeight / 2)
|
||||
.attr('r', 12)
|
||||
.attr('fill', (d, i) => i === 0 ? 'rgba(255,255,255,0.2)' : 'hsl(var(--muted))');
|
||||
|
||||
nodes.append('text')
|
||||
.attr('x', 20)
|
||||
.attr('y', nodeHeight / 2)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', (d, i) => i === 0 ? 'white' : 'hsl(var(--muted-foreground))')
|
||||
.text((d, i) => i + 1);
|
||||
|
||||
// Node text (step name)
|
||||
nodes.append('text')
|
||||
.attr('x', 45)
|
||||
.attr('y', nodeHeight / 2)
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('fill', (d, i) => i === 0 ? 'white' : 'hsl(var(--foreground))')
|
||||
.attr('font-size', '12px')
|
||||
.text(d => {
|
||||
const text = d.step || d.action || 'Step';
|
||||
return text.length > 35 ? text.substring(0, 32) + '...' : text;
|
||||
});
|
||||
}
|
||||
|
||||
function renderFullFlowchart(flowControl) {
|
||||
if (!flowControl) return;
|
||||
|
||||
const container = document.getElementById('flowchartContainer');
|
||||
if (!container) return;
|
||||
|
||||
const preAnalysis = Array.isArray(flowControl.pre_analysis) ? flowControl.pre_analysis : [];
|
||||
const implSteps = Array.isArray(flowControl.implementation_approach) ? flowControl.implementation_approach : [];
|
||||
|
||||
if (preAnalysis.length === 0 && implSteps.length === 0) {
|
||||
container.innerHTML = '<div class="empty-section">No flowchart data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const width = container.clientWidth || 500;
|
||||
const nodeHeight = 90;
|
||||
const nodeWidth = Math.min(width - 40, 420);
|
||||
const nodeGap = 45;
|
||||
const sectionGap = 30;
|
||||
|
||||
// Calculate total nodes and height
|
||||
const totalPreNodes = preAnalysis.length;
|
||||
const totalImplNodes = implSteps.length;
|
||||
const hasBothSections = totalPreNodes > 0 && totalImplNodes > 0;
|
||||
const height = (totalPreNodes + totalImplNodes) * (nodeHeight + nodeGap) +
|
||||
(hasBothSections ? sectionGap + 60 : 0) + 60;
|
||||
|
||||
// Clear existing
|
||||
d3.select('#flowchartContainer').selectAll('*').remove();
|
||||
|
||||
const svg = d3.select('#flowchartContainer')
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', height)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
// Add arrow markers
|
||||
const defs = svg.append('defs');
|
||||
|
||||
defs.append('marker')
|
||||
.attr('id', 'arrowhead-pre')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 8)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#f59e0b');
|
||||
|
||||
defs.append('marker')
|
||||
.attr('id', 'arrowhead-impl')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 8)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', 'hsl(var(--primary))');
|
||||
|
||||
let currentY = 20;
|
||||
|
||||
// Render Pre-Analysis section
|
||||
if (totalPreNodes > 0) {
|
||||
// Section label
|
||||
svg.append('text')
|
||||
.attr('x', 20)
|
||||
.attr('y', currentY)
|
||||
.attr('fill', '#f59e0b')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '13px')
|
||||
.text('📋 Pre-Analysis Steps');
|
||||
|
||||
currentY += 25;
|
||||
|
||||
preAnalysis.forEach((step, idx) => {
|
||||
const x = (width - nodeWidth) / 2;
|
||||
|
||||
// Connection line to next node
|
||||
if (idx < preAnalysis.length - 1) {
|
||||
svg.append('line')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', currentY + nodeHeight)
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', currentY + nodeHeight + nodeGap - 10)
|
||||
.attr('stroke', '#f59e0b')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrowhead-pre)');
|
||||
}
|
||||
|
||||
// Node group
|
||||
const nodeG = svg.append('g')
|
||||
.attr('class', 'flowchart-node')
|
||||
.attr('transform', `translate(${x}, ${currentY})`);
|
||||
|
||||
// Node rectangle (pre-analysis style - amber/orange)
|
||||
nodeG.append('rect')
|
||||
.attr('width', nodeWidth)
|
||||
.attr('height', nodeHeight)
|
||||
.attr('rx', 10)
|
||||
.attr('fill', 'hsl(var(--card))')
|
||||
.attr('stroke', '#f59e0b')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', '5,3');
|
||||
|
||||
// Step badge
|
||||
nodeG.append('circle')
|
||||
.attr('cx', 25)
|
||||
.attr('cy', 25)
|
||||
.attr('r', 15)
|
||||
.attr('fill', '#f59e0b');
|
||||
|
||||
nodeG.append('text')
|
||||
.attr('x', 25)
|
||||
.attr('y', 30)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'white')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '11px')
|
||||
.text('P' + (idx + 1));
|
||||
|
||||
// Step name
|
||||
const stepName = step.step || step.action || 'Pre-step ' + (idx + 1);
|
||||
nodeG.append('text')
|
||||
.attr('x', 50)
|
||||
.attr('y', 28)
|
||||
.attr('fill', 'hsl(var(--foreground))')
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-size', '13px')
|
||||
.text(truncateText(stepName, 40));
|
||||
|
||||
// Action description
|
||||
if (step.action && step.action !== stepName) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 52)
|
||||
.attr('fill', 'hsl(var(--muted-foreground))')
|
||||
.attr('font-size', '11px')
|
||||
.text(truncateText(step.action, 50));
|
||||
}
|
||||
|
||||
// Output indicator
|
||||
if (step.output_to) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 75)
|
||||
.attr('fill', '#f59e0b')
|
||||
.attr('font-size', '10px')
|
||||
.text('→ ' + truncateText(step.output_to, 45));
|
||||
}
|
||||
|
||||
currentY += nodeHeight + nodeGap;
|
||||
});
|
||||
}
|
||||
|
||||
// Section divider if both sections exist
|
||||
if (hasBothSections) {
|
||||
currentY += 10;
|
||||
svg.append('line')
|
||||
.attr('x1', 40)
|
||||
.attr('y1', currentY)
|
||||
.attr('x2', width - 40)
|
||||
.attr('y2', currentY)
|
||||
.attr('stroke', 'hsl(var(--border))')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', '4,4');
|
||||
|
||||
// Connecting arrow from pre-analysis to implementation
|
||||
svg.append('line')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', currentY - nodeGap + 5)
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', currentY + sectionGap - 5)
|
||||
.attr('stroke', 'hsl(var(--primary))')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrowhead-impl)');
|
||||
|
||||
currentY += sectionGap;
|
||||
}
|
||||
|
||||
// Render Implementation section
|
||||
if (totalImplNodes > 0) {
|
||||
// Section label
|
||||
svg.append('text')
|
||||
.attr('x', 20)
|
||||
.attr('y', currentY)
|
||||
.attr('fill', 'hsl(var(--primary))')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '13px')
|
||||
.text('🔧 Implementation Steps');
|
||||
|
||||
currentY += 25;
|
||||
|
||||
implSteps.forEach((step, idx) => {
|
||||
const x = (width - nodeWidth) / 2;
|
||||
|
||||
// Connection line to next node
|
||||
if (idx < implSteps.length - 1) {
|
||||
svg.append('line')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', currentY + nodeHeight)
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', currentY + nodeHeight + nodeGap - 10)
|
||||
.attr('stroke', 'hsl(var(--primary))')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrowhead-impl)');
|
||||
}
|
||||
|
||||
// Node group
|
||||
const nodeG = svg.append('g')
|
||||
.attr('class', 'flowchart-node')
|
||||
.attr('transform', `translate(${x}, ${currentY})`);
|
||||
|
||||
// Node rectangle (implementation style - blue)
|
||||
nodeG.append('rect')
|
||||
.attr('width', nodeWidth)
|
||||
.attr('height', nodeHeight)
|
||||
.attr('rx', 10)
|
||||
.attr('fill', 'hsl(var(--card))')
|
||||
.attr('stroke', 'hsl(var(--primary))')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Step badge
|
||||
nodeG.append('circle')
|
||||
.attr('cx', 25)
|
||||
.attr('cy', 25)
|
||||
.attr('r', 15)
|
||||
.attr('fill', 'hsl(var(--primary))');
|
||||
|
||||
nodeG.append('text')
|
||||
.attr('x', 25)
|
||||
.attr('y', 30)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'white')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '12px')
|
||||
.text(step.step || idx + 1);
|
||||
|
||||
// Step title
|
||||
nodeG.append('text')
|
||||
.attr('x', 50)
|
||||
.attr('y', 28)
|
||||
.attr('fill', 'hsl(var(--foreground))')
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-size', '13px')
|
||||
.text(truncateText(step.title || 'Step ' + (idx + 1), 40));
|
||||
|
||||
// Description
|
||||
if (step.description) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 52)
|
||||
.attr('fill', 'hsl(var(--muted-foreground))')
|
||||
.attr('font-size', '11px')
|
||||
.text(truncateText(step.description, 50));
|
||||
}
|
||||
|
||||
// Output/depends indicator
|
||||
if (step.depends_on?.length) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 75)
|
||||
.attr('fill', 'var(--warning-color)')
|
||||
.attr('font-size', '10px')
|
||||
.text('← Depends: ' + step.depends_on.join(', '));
|
||||
}
|
||||
|
||||
currentY += nodeHeight + nodeGap;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// D3.js Vertical Flowchart for Implementation Approach (legacy)
|
||||
function renderImplementationFlowchart(steps) {
|
||||
if (!Array.isArray(steps) || steps.length === 0) return;
|
||||
|
||||
const container = document.getElementById('flowchartContainer');
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth || 500;
|
||||
const nodeHeight = 100;
|
||||
const nodeWidth = Math.min(width - 40, 400);
|
||||
const nodeGap = 50;
|
||||
const height = steps.length * (nodeHeight + nodeGap) + 40;
|
||||
|
||||
// Clear existing
|
||||
d3.select('#flowchartContainer').selectAll('*').remove();
|
||||
|
||||
const svg = d3.select('#flowchartContainer')
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', height)
|
||||
.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
// Add arrow marker
|
||||
svg.append('defs').append('marker')
|
||||
.attr('id', 'arrowhead')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 8)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', 'hsl(var(--primary))');
|
||||
|
||||
// Draw nodes and connections
|
||||
steps.forEach((step, idx) => {
|
||||
const y = idx * (nodeHeight + nodeGap) + 20;
|
||||
const x = (width - nodeWidth) / 2;
|
||||
|
||||
// Connection line to next node
|
||||
if (idx < steps.length - 1) {
|
||||
svg.append('line')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', y + nodeHeight)
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', y + nodeHeight + nodeGap - 10)
|
||||
.attr('stroke', 'hsl(var(--primary))')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('marker-end', 'url(#arrowhead)');
|
||||
}
|
||||
|
||||
// Node group
|
||||
const nodeG = svg.append('g')
|
||||
.attr('class', 'flowchart-node')
|
||||
.attr('transform', `translate(${x}, ${y})`);
|
||||
|
||||
// Node rectangle with gradient
|
||||
nodeG.append('rect')
|
||||
.attr('width', nodeWidth)
|
||||
.attr('height', nodeHeight)
|
||||
.attr('rx', 10)
|
||||
.attr('fill', 'hsl(var(--card))')
|
||||
.attr('stroke', 'hsl(var(--primary))')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))');
|
||||
|
||||
// Step number badge
|
||||
nodeG.append('circle')
|
||||
.attr('cx', 25)
|
||||
.attr('cy', 25)
|
||||
.attr('r', 15)
|
||||
.attr('fill', 'hsl(var(--primary))');
|
||||
|
||||
nodeG.append('text')
|
||||
.attr('x', 25)
|
||||
.attr('y', 30)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'white')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('font-size', '12px')
|
||||
.text(step.step || idx + 1);
|
||||
|
||||
// Step title
|
||||
nodeG.append('text')
|
||||
.attr('x', 50)
|
||||
.attr('y', 30)
|
||||
.attr('fill', 'hsl(var(--foreground))')
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-size', '14px')
|
||||
.text(truncateText(step.title || 'Step ' + (idx + 1), 35));
|
||||
|
||||
// Step description (if available)
|
||||
if (step.description) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 55)
|
||||
.attr('fill', 'hsl(var(--muted-foreground))')
|
||||
.attr('font-size', '12px')
|
||||
.text(truncateText(step.description, 45));
|
||||
}
|
||||
|
||||
// Output indicator
|
||||
if (step.output) {
|
||||
nodeG.append('text')
|
||||
.attr('x', 15)
|
||||
.attr('y', 80)
|
||||
.attr('fill', 'var(--success-color)')
|
||||
.attr('font-size', '11px')
|
||||
.text('→ ' + truncateText(step.output, 40));
|
||||
}
|
||||
});
|
||||
}
|
||||
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
273
ccw/src/templates/dashboard-js/components/hook-manager.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// Hook Manager Component
|
||||
// Manages Claude Code hooks configuration from settings.json
|
||||
|
||||
// ========== Hook State ==========
|
||||
let hookConfig = {
|
||||
global: { hooks: {} },
|
||||
project: { hooks: {} }
|
||||
};
|
||||
|
||||
// ========== Hook Templates ==========
|
||||
const HOOK_TEMPLATES = {
|
||||
'ccw-notify': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'curl',
|
||||
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook']
|
||||
},
|
||||
'log-tool': {
|
||||
event: 'PostToolUse',
|
||||
matcher: '',
|
||||
command: 'bash',
|
||||
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log']
|
||||
},
|
||||
'lint-check': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done']
|
||||
},
|
||||
'git-add': {
|
||||
event: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done']
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initHookManager() {
|
||||
// Initialize Hook navigation
|
||||
document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentView = 'hook-manager';
|
||||
currentFilter = null;
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
renderHookManager();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadHookConfig() {
|
||||
try {
|
||||
const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) throw new Error('Failed to load hook config');
|
||||
const data = await response.json();
|
||||
hookConfig = data;
|
||||
updateHookBadge();
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load hook config:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHook(scope, event, hookData) {
|
||||
try {
|
||||
const response = await fetch('/api/hooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
scope: scope,
|
||||
event: event,
|
||||
hookData: hookData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save hook');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadHookConfig();
|
||||
renderHookManager();
|
||||
showRefreshToast(`Hook saved successfully`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to save hook:', err);
|
||||
showRefreshToast(`Failed to save hook: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHook(scope, event, hookIndex) {
|
||||
try {
|
||||
const response = await fetch('/api/hooks', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
scope: scope,
|
||||
event: event,
|
||||
hookIndex: hookIndex
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove hook');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadHookConfig();
|
||||
renderHookManager();
|
||||
showRefreshToast(`Hook removed successfully`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to remove hook:', err);
|
||||
showRefreshToast(`Failed to remove hook: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Badge Update ==========
|
||||
function updateHookBadge() {
|
||||
const badge = document.getElementById('badgeHooks');
|
||||
if (badge) {
|
||||
let totalHooks = 0;
|
||||
|
||||
// Count global hooks
|
||||
if (hookConfig.global?.hooks) {
|
||||
for (const event of Object.keys(hookConfig.global.hooks)) {
|
||||
const hooks = hookConfig.global.hooks[event];
|
||||
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Count project hooks
|
||||
if (hookConfig.project?.hooks) {
|
||||
for (const event of Object.keys(hookConfig.project.hooks)) {
|
||||
const hooks = hookConfig.project.hooks[event];
|
||||
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
|
||||
}
|
||||
}
|
||||
|
||||
badge.textContent = totalHooks;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Hook Modal Functions ==========
|
||||
let editingHookData = null;
|
||||
|
||||
function openHookCreateModal(editData = null) {
|
||||
const modal = document.getElementById('hookCreateModal');
|
||||
const title = document.getElementById('hookModalTitle');
|
||||
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
editingHookData = editData;
|
||||
|
||||
// Set title based on mode
|
||||
title.textContent = editData ? 'Edit Hook' : 'Create Hook';
|
||||
|
||||
// Clear or populate form
|
||||
if (editData) {
|
||||
document.getElementById('hookEvent').value = editData.event || '';
|
||||
document.getElementById('hookMatcher').value = editData.matcher || '';
|
||||
document.getElementById('hookCommand').value = editData.command || '';
|
||||
document.getElementById('hookArgs').value = (editData.args || []).join('\n');
|
||||
|
||||
// Set scope radio
|
||||
const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`);
|
||||
if (scopeRadio) scopeRadio.checked = true;
|
||||
} else {
|
||||
document.getElementById('hookEvent').value = '';
|
||||
document.getElementById('hookMatcher').value = '';
|
||||
document.getElementById('hookCommand').value = '';
|
||||
document.getElementById('hookArgs').value = '';
|
||||
document.querySelector('input[name="hookScope"][value="project"]').checked = true;
|
||||
}
|
||||
|
||||
// Focus on event select
|
||||
document.getElementById('hookEvent').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeHookCreateModal() {
|
||||
const modal = document.getElementById('hookCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
editingHookData = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyHookTemplate(templateName) {
|
||||
const template = HOOK_TEMPLATES[templateName];
|
||||
if (!template) return;
|
||||
|
||||
document.getElementById('hookEvent').value = template.event;
|
||||
document.getElementById('hookMatcher').value = template.matcher;
|
||||
document.getElementById('hookCommand').value = template.command;
|
||||
document.getElementById('hookArgs').value = template.args.join('\n');
|
||||
}
|
||||
|
||||
async function submitHookCreate() {
|
||||
const event = document.getElementById('hookEvent').value;
|
||||
const matcher = document.getElementById('hookMatcher').value.trim();
|
||||
const command = document.getElementById('hookCommand').value.trim();
|
||||
const argsText = document.getElementById('hookArgs').value.trim();
|
||||
const scope = document.querySelector('input[name="hookScope"]:checked').value;
|
||||
|
||||
// Validate required fields
|
||||
if (!event) {
|
||||
showRefreshToast('Hook event is required', 'error');
|
||||
document.getElementById('hookEvent').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
showRefreshToast('Command is required', 'error');
|
||||
document.getElementById('hookCommand').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse args (one per line)
|
||||
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
|
||||
|
||||
// Build hook data
|
||||
const hookData = {
|
||||
command: command
|
||||
};
|
||||
|
||||
if (args.length > 0) {
|
||||
hookData.args = args;
|
||||
}
|
||||
|
||||
if (matcher) {
|
||||
hookData.matcher = matcher;
|
||||
}
|
||||
|
||||
// If editing, include original index for replacement
|
||||
if (editingHookData && editingHookData.index !== undefined) {
|
||||
hookData.replaceIndex = editingHookData.index;
|
||||
}
|
||||
|
||||
// Submit to API
|
||||
await saveHook(scope, event, hookData);
|
||||
closeHookCreateModal();
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
function getHookEventDescription(event) {
|
||||
const descriptions = {
|
||||
'PreToolUse': 'Runs before a tool is executed',
|
||||
'PostToolUse': 'Runs after a tool completes',
|
||||
'Notification': 'Runs when a notification is triggered',
|
||||
'Stop': 'Runs when the agent stops'
|
||||
};
|
||||
return descriptions[event] || event;
|
||||
}
|
||||
|
||||
function getHookEventIcon(event) {
|
||||
const icons = {
|
||||
'PreToolUse': '⏳',
|
||||
'PostToolUse': '✅',
|
||||
'Notification': '🔔',
|
||||
'Stop': '🛑'
|
||||
};
|
||||
return icons[event] || '🪝';
|
||||
}
|
||||
506
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
506
ccw/src/templates/dashboard-js/components/mcp-manager.js
Normal file
@@ -0,0 +1,506 @@
|
||||
// MCP Manager Component
|
||||
// Manages MCP server configuration from .claude.json
|
||||
|
||||
// ========== MCP State ==========
|
||||
let mcpConfig = null;
|
||||
let mcpAllProjects = {};
|
||||
let mcpCurrentProjectServers = {};
|
||||
let mcpCreateMode = 'form'; // 'form' or 'json'
|
||||
|
||||
// ========== Initialization ==========
|
||||
function initMcpManager() {
|
||||
// Initialize MCP navigation
|
||||
document.querySelectorAll('.nav-item[data-view="mcp-manager"]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentView = 'mcp-manager';
|
||||
currentFilter = null;
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
renderMcpManager();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Data Loading ==========
|
||||
async function loadMcpConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-config');
|
||||
if (!response.ok) throw new Error('Failed to load MCP config');
|
||||
const data = await response.json();
|
||||
mcpConfig = data;
|
||||
mcpAllProjects = data.projects || {};
|
||||
|
||||
// Get current project servers
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {};
|
||||
|
||||
// Update badge count
|
||||
updateMcpBadge();
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP config:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMcpServer(serverName, enable) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName,
|
||||
enable: enable
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to toggle MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Reload config and re-render
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" ${enable ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle MCP server:', err);
|
||||
showRefreshToast(`Failed to toggle MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMcpServerToProject(serverName, serverConfig) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName,
|
||||
serverConfig: serverConfig
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to copy MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" added to project`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to copy MCP server:', err);
|
||||
showRefreshToast(`Failed to add MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMcpServerFromProject(serverName) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-remove-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: serverName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to remove MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${serverName}" removed from project`, 'success');
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to remove MCP server:', err);
|
||||
showRefreshToast(`Failed to remove MCP server: ${err.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Badge Update ==========
|
||||
function updateMcpBadge() {
|
||||
const badge = document.getElementById('badgeMcpServers');
|
||||
if (badge) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
const servers = projectData?.mcpServers || {};
|
||||
const disabledServers = projectData?.disabledMcpServers || [];
|
||||
|
||||
const totalServers = Object.keys(servers).length;
|
||||
const enabledServers = totalServers - disabledServers.length;
|
||||
|
||||
badge.textContent = `${enabledServers}/${totalServers}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
function getAllAvailableMcpServers() {
|
||||
const allServers = {};
|
||||
|
||||
// Collect servers from all projects
|
||||
for (const [path, config] of Object.entries(mcpAllProjects)) {
|
||||
const servers = config.mcpServers || {};
|
||||
for (const [name, serverConfig] of Object.entries(servers)) {
|
||||
if (!allServers[name]) {
|
||||
allServers[name] = {
|
||||
config: serverConfig,
|
||||
usedIn: []
|
||||
};
|
||||
}
|
||||
allServers[name].usedIn.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return allServers;
|
||||
}
|
||||
|
||||
function isServerEnabledInCurrentProject(serverName) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
if (!projectData) return false;
|
||||
|
||||
const disabledServers = projectData.disabledMcpServers || [];
|
||||
return !disabledServers.includes(serverName);
|
||||
}
|
||||
|
||||
function isServerInCurrentProject(serverName) {
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath];
|
||||
if (!projectData) return false;
|
||||
|
||||
const servers = projectData.mcpServers || {};
|
||||
return serverName in servers;
|
||||
}
|
||||
|
||||
// ========== MCP Create Modal ==========
|
||||
function openMcpCreateModal() {
|
||||
const modal = document.getElementById('mcpCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
// Reset to form mode
|
||||
mcpCreateMode = 'form';
|
||||
switchMcpCreateTab('form');
|
||||
// Clear form
|
||||
document.getElementById('mcpServerName').value = '';
|
||||
document.getElementById('mcpServerCommand').value = '';
|
||||
document.getElementById('mcpServerArgs').value = '';
|
||||
document.getElementById('mcpServerEnv').value = '';
|
||||
// Clear JSON input
|
||||
document.getElementById('mcpServerJson').value = '';
|
||||
document.getElementById('mcpJsonPreview').classList.add('hidden');
|
||||
// Focus on name input
|
||||
document.getElementById('mcpServerName').focus();
|
||||
// Setup JSON input listener
|
||||
setupMcpJsonListener();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMcpCreateModal() {
|
||||
const modal = document.getElementById('mcpCreateModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function switchMcpCreateTab(tab) {
|
||||
mcpCreateMode = tab;
|
||||
const formMode = document.getElementById('mcpFormMode');
|
||||
const jsonMode = document.getElementById('mcpJsonMode');
|
||||
const tabForm = document.getElementById('mcpTabForm');
|
||||
const tabJson = document.getElementById('mcpTabJson');
|
||||
|
||||
if (tab === 'form') {
|
||||
formMode.classList.remove('hidden');
|
||||
jsonMode.classList.add('hidden');
|
||||
tabForm.classList.add('active');
|
||||
tabJson.classList.remove('active');
|
||||
} else {
|
||||
formMode.classList.add('hidden');
|
||||
jsonMode.classList.remove('hidden');
|
||||
tabForm.classList.remove('active');
|
||||
tabJson.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setupMcpJsonListener() {
|
||||
const jsonInput = document.getElementById('mcpServerJson');
|
||||
if (jsonInput && !jsonInput.hasAttribute('data-listener-attached')) {
|
||||
jsonInput.setAttribute('data-listener-attached', 'true');
|
||||
jsonInput.addEventListener('input', () => {
|
||||
updateMcpJsonPreview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpJsonConfig(jsonText) {
|
||||
if (!jsonText.trim()) {
|
||||
return { servers: {}, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
let servers = {};
|
||||
|
||||
// Support multiple formats:
|
||||
// 1. {"servers": {...}} format (claude desktop style)
|
||||
// 2. {"mcpServers": {...}} format (claude.json style)
|
||||
// 3. {"serverName": {command, args}} format (direct server config)
|
||||
// 4. {command, args} format (single server without name)
|
||||
|
||||
if (parsed.servers && typeof parsed.servers === 'object') {
|
||||
servers = parsed.servers;
|
||||
} else if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
||||
servers = parsed.mcpServers;
|
||||
} else if (parsed.command && typeof parsed.command === 'string') {
|
||||
// Single server without name - will prompt for name
|
||||
servers = { '__unnamed__': parsed };
|
||||
} else {
|
||||
// Check if all values are server configs (have 'command' property)
|
||||
const isDirectServerConfig = Object.values(parsed).every(
|
||||
v => v && typeof v === 'object' && v.command
|
||||
);
|
||||
if (isDirectServerConfig && Object.keys(parsed).length > 0) {
|
||||
servers = parsed;
|
||||
} else {
|
||||
return { servers: {}, error: 'Invalid MCP server JSON format' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each server config
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
if (!config.command || typeof config.command !== 'string') {
|
||||
return { servers: {}, error: `Server "${name}" missing required "command" field` };
|
||||
}
|
||||
if (config.args && !Array.isArray(config.args)) {
|
||||
return { servers: {}, error: `Server "${name}" has invalid "args" (must be array)` };
|
||||
}
|
||||
if (config.env && typeof config.env !== 'object') {
|
||||
return { servers: {}, error: `Server "${name}" has invalid "env" (must be object)` };
|
||||
}
|
||||
}
|
||||
|
||||
return { servers, error: null };
|
||||
} catch (e) {
|
||||
return { servers: {}, error: 'Invalid JSON: ' + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function updateMcpJsonPreview() {
|
||||
const jsonInput = document.getElementById('mcpServerJson');
|
||||
const previewContainer = document.getElementById('mcpJsonPreview');
|
||||
const previewContent = document.getElementById('mcpJsonPreviewContent');
|
||||
|
||||
const jsonText = jsonInput.value;
|
||||
const { servers, error } = parseMcpJsonConfig(jsonText);
|
||||
|
||||
if (!jsonText.trim()) {
|
||||
previewContainer.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
previewContainer.classList.remove('hidden');
|
||||
|
||||
if (error) {
|
||||
previewContent.innerHTML = `<div class="text-destructive">${escapeHtml(error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverCount = Object.keys(servers).length;
|
||||
if (serverCount === 0) {
|
||||
previewContent.innerHTML = `<div class="text-muted-foreground">No servers found</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const previewHtml = Object.entries(servers).map(([name, config]) => {
|
||||
const displayName = name === '__unnamed__' ? '(will prompt for name)' : name;
|
||||
const argsPreview = config.args ? config.args.slice(0, 2).join(' ') + (config.args.length > 2 ? '...' : '') : '';
|
||||
return `
|
||||
<div class="flex items-center gap-2 p-2 bg-background rounded">
|
||||
<span class="text-success">+</span>
|
||||
<span class="font-medium">${escapeHtml(displayName)}</span>
|
||||
<span class="text-muted-foreground text-xs">${escapeHtml(config.command)} ${escapeHtml(argsPreview)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
previewContent.innerHTML = previewHtml;
|
||||
}
|
||||
|
||||
async function submitMcpCreate() {
|
||||
if (mcpCreateMode === 'json') {
|
||||
await submitMcpCreateFromJson();
|
||||
} else {
|
||||
await submitMcpCreateFromForm();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMcpCreateFromForm() {
|
||||
const name = document.getElementById('mcpServerName').value.trim();
|
||||
const command = document.getElementById('mcpServerCommand').value.trim();
|
||||
const argsText = document.getElementById('mcpServerArgs').value.trim();
|
||||
const envText = document.getElementById('mcpServerEnv').value.trim();
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
showRefreshToast('Server name is required', 'error');
|
||||
document.getElementById('mcpServerName').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
showRefreshToast('Command is required', 'error');
|
||||
document.getElementById('mcpServerCommand').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse args (one per line)
|
||||
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
|
||||
|
||||
// Parse env vars (KEY=VALUE per line)
|
||||
const env = {};
|
||||
if (envText) {
|
||||
envText.split('\n').forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
if (key) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build server config
|
||||
const serverConfig = {
|
||||
command: command,
|
||||
args: args
|
||||
};
|
||||
|
||||
// Only add env if there are values
|
||||
if (Object.keys(env).length > 0) {
|
||||
serverConfig.env = env;
|
||||
}
|
||||
|
||||
await createMcpServerWithConfig(name, serverConfig);
|
||||
}
|
||||
|
||||
async function submitMcpCreateFromJson() {
|
||||
const jsonText = document.getElementById('mcpServerJson').value.trim();
|
||||
|
||||
if (!jsonText) {
|
||||
showRefreshToast('Please enter JSON configuration', 'error');
|
||||
document.getElementById('mcpServerJson').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const { servers, error } = parseMcpJsonConfig(jsonText);
|
||||
|
||||
if (error) {
|
||||
showRefreshToast(error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
showRefreshToast('No valid servers found in JSON', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unnamed server case
|
||||
if (servers['__unnamed__']) {
|
||||
const serverName = prompt('Enter a name for this MCP server:');
|
||||
if (!serverName || !serverName.trim()) {
|
||||
showRefreshToast('Server name is required', 'error');
|
||||
return;
|
||||
}
|
||||
servers[serverName.trim()] = servers['__unnamed__'];
|
||||
delete servers['__unnamed__'];
|
||||
}
|
||||
|
||||
// Add all servers
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const serverNames = Object.keys(servers);
|
||||
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: name,
|
||||
serverConfig: config
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to create MCP server "${name}":`, err);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
closeMcpCreateModal();
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
|
||||
if (failCount === 0) {
|
||||
showRefreshToast(`${successCount} MCP server${successCount > 1 ? 's' : ''} created successfully`, 'success');
|
||||
} else if (successCount > 0) {
|
||||
showRefreshToast(`${successCount} created, ${failCount} failed`, 'warning');
|
||||
} else {
|
||||
showRefreshToast('Failed to create MCP servers', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createMcpServerWithConfig(name, serverConfig) {
|
||||
// Submit to API
|
||||
try {
|
||||
const response = await fetch('/api/mcp-copy-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: projectPath,
|
||||
serverName: name,
|
||||
serverConfig: serverConfig
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create MCP server');
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
closeMcpCreateModal();
|
||||
await loadMcpConfig();
|
||||
renderMcpManager();
|
||||
showRefreshToast(`MCP server "${name}" created successfully`, 'success');
|
||||
} else {
|
||||
showRefreshToast(result.error || 'Failed to create MCP server', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create MCP server:', err);
|
||||
showRefreshToast(`Failed to create MCP server: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
260
ccw/src/templates/dashboard-js/components/modals.js
Normal file
260
ccw/src/templates/dashboard-js/components/modals.js
Normal file
@@ -0,0 +1,260 @@
|
||||
// ==========================================
|
||||
// MODAL DIALOGS
|
||||
// ==========================================
|
||||
|
||||
// SVG Icons
|
||||
const icons = {
|
||||
folder: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>',
|
||||
check: '<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"></polyline></svg>',
|
||||
copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>',
|
||||
terminal: '<svg viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>'
|
||||
};
|
||||
|
||||
function showPathSelectedModal(dirName, dirHandle) {
|
||||
// Try to guess full path based on current project path
|
||||
const currentPath = projectPath || '';
|
||||
const basePath = currentPath.substring(0, currentPath.lastIndexOf('/')) || 'D:/projects';
|
||||
const suggestedPath = basePath + '/' + dirName;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'path-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="path-modal">
|
||||
<div class="path-modal-header">
|
||||
<span class="path-modal-icon">${icons.folder}</span>
|
||||
<h3>Folder Selected</h3>
|
||||
</div>
|
||||
<div class="path-modal-body">
|
||||
<div class="selected-folder">
|
||||
<strong>${dirName}</strong>
|
||||
</div>
|
||||
<p class="path-modal-note">
|
||||
Confirm or edit the full path:
|
||||
</p>
|
||||
<div class="path-input-group" style="margin-top: 12px;">
|
||||
<label>Full path:</label>
|
||||
<input type="text" id="fullPathInput" value="${suggestedPath}" />
|
||||
<button class="path-go-btn" id="pathGoBtn">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-modal-footer">
|
||||
<button class="path-modal-close" id="pathCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add event listeners (use arrow functions to ensure proper scope)
|
||||
document.getElementById('pathGoBtn').addEventListener('click', () => {
|
||||
console.log('Open button clicked');
|
||||
goToPath();
|
||||
});
|
||||
document.getElementById('pathCancelBtn').addEventListener('click', () => closePathModal());
|
||||
|
||||
// Focus input, select all text, and add enter key listener
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('fullPathInput');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
input?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') goToPath();
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function showPathInputModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'path-modal-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="path-modal">
|
||||
<div class="path-modal-header">
|
||||
<span class="path-modal-icon">${icons.folder}</span>
|
||||
<h3>Open Project</h3>
|
||||
</div>
|
||||
<div class="path-modal-body">
|
||||
<div class="path-input-group" style="margin-top: 0;">
|
||||
<label>Project path:</label>
|
||||
<input type="text" id="fullPathInput" placeholder="D:/projects/my-project" />
|
||||
<button class="path-go-btn" id="pathGoBtn">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-modal-footer">
|
||||
<button class="path-modal-close" id="pathCancelBtn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add event listeners (use arrow functions to ensure proper scope)
|
||||
document.getElementById('pathGoBtn').addEventListener('click', () => {
|
||||
console.log('Open button clicked');
|
||||
goToPath();
|
||||
});
|
||||
document.getElementById('pathCancelBtn').addEventListener('click', () => closePathModal());
|
||||
|
||||
// Focus input and add enter key listener
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('fullPathInput');
|
||||
input?.focus();
|
||||
input?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') goToPath();
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function goToPath() {
|
||||
const input = document.getElementById('fullPathInput');
|
||||
const path = input?.value?.trim();
|
||||
if (path) {
|
||||
closePathModal();
|
||||
selectPath(path);
|
||||
} else {
|
||||
// Show error - input is empty
|
||||
input.style.borderColor = 'var(--danger-color)';
|
||||
input.placeholder = 'Please enter a path';
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closePathModal() {
|
||||
const modal = document.querySelector('.path-modal-overlay');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function copyCommand(btn, dirName) {
|
||||
const input = document.getElementById('fullPathInput');
|
||||
const path = input?.value?.trim() || `[full-path-to-${dirName}]`;
|
||||
const command = `ccw view -p "${path}"`;
|
||||
navigator.clipboard.writeText(command).then(() => {
|
||||
btn.innerHTML = icons.check + ' <span>Copied!</span>';
|
||||
setTimeout(() => { btn.innerHTML = icons.copy + ' <span>Copy</span>'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function showJsonModal(jsonId, taskId) {
|
||||
// Get JSON from memory store instead of DOM
|
||||
const rawTask = taskJsonStore[jsonId];
|
||||
if (!rawTask) return;
|
||||
|
||||
const jsonContent = JSON.stringify(rawTask, null, 2);
|
||||
|
||||
// Create modal
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'json-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="json-modal">
|
||||
<div class="json-modal-header">
|
||||
<div class="json-modal-title">
|
||||
<span class="task-id-badge">${escapeHtml(taskId)}</span>
|
||||
<span>Task JSON</span>
|
||||
</div>
|
||||
<button class="json-modal-close" onclick="closeJsonModal(this)">×</button>
|
||||
</div>
|
||||
<div class="json-modal-body">
|
||||
<pre class="json-modal-content">${escapeHtml(jsonContent)}</pre>
|
||||
</div>
|
||||
<div class="json-modal-footer">
|
||||
<button class="btn-copy-json" onclick="copyJsonToClipboard(this)">Copy JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
// Close on overlay click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeJsonModal(overlay.querySelector('.json-modal-close'));
|
||||
});
|
||||
|
||||
// Close on Escape key
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeJsonModal(overlay.querySelector('.json-modal-close'));
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
function closeJsonModal(btn) {
|
||||
const overlay = btn.closest('.json-modal-overlay');
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
}
|
||||
|
||||
function copyJsonToClipboard(btn) {
|
||||
const content = btn.closest('.json-modal').querySelector('.json-modal-content').textContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = original, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function openMarkdownModal(title, content, type = 'markdown') {
|
||||
const modal = document.getElementById('markdownModal');
|
||||
const titleEl = document.getElementById('markdownModalTitle');
|
||||
const rawEl = document.getElementById('markdownRaw');
|
||||
const previewEl = document.getElementById('markdownPreview');
|
||||
|
||||
// Normalize line endings
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
|
||||
titleEl.textContent = title;
|
||||
rawEl.textContent = normalizedContent;
|
||||
|
||||
// Render preview based on type
|
||||
if (typeof marked !== 'undefined' && type === 'markdown') {
|
||||
previewEl.innerHTML = marked.parse(normalizedContent);
|
||||
} else if (type === 'json') {
|
||||
// For JSON, try to parse and re-stringify with formatting
|
||||
try {
|
||||
const parsed = typeof normalizedContent === 'string' ? JSON.parse(normalizedContent) : normalizedContent;
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
previewEl.innerHTML = '<pre class="whitespace-pre-wrap language-json">' + escapeHtml(formatted) + '</pre>';
|
||||
} catch (e) {
|
||||
// If not valid JSON, show as-is
|
||||
previewEl.innerHTML = '<pre class="whitespace-pre-wrap">' + escapeHtml(normalizedContent) + '</pre>';
|
||||
}
|
||||
} else {
|
||||
// Fallback: simple text with line breaks
|
||||
previewEl.innerHTML = '<pre class="whitespace-pre-wrap">' + escapeHtml(normalizedContent) + '</pre>';
|
||||
}
|
||||
|
||||
// Show modal and default to preview tab
|
||||
modal.classList.remove('hidden');
|
||||
switchMarkdownTab('preview');
|
||||
}
|
||||
|
||||
function closeMarkdownModal() {
|
||||
const modal = document.getElementById('markdownModal');
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function switchMarkdownTab(tab) {
|
||||
const rawEl = document.getElementById('markdownRaw');
|
||||
const previewEl = document.getElementById('markdownPreview');
|
||||
const rawTabBtn = document.getElementById('mdTabRaw');
|
||||
const previewTabBtn = document.getElementById('mdTabPreview');
|
||||
|
||||
if (tab === 'raw') {
|
||||
rawEl.classList.remove('hidden');
|
||||
previewEl.classList.add('hidden');
|
||||
rawTabBtn.classList.add('active', 'bg-background', 'text-foreground');
|
||||
rawTabBtn.classList.remove('text-muted-foreground');
|
||||
previewTabBtn.classList.remove('active', 'bg-background', 'text-foreground');
|
||||
previewTabBtn.classList.add('text-muted-foreground');
|
||||
} else {
|
||||
rawEl.classList.add('hidden');
|
||||
previewEl.classList.remove('hidden');
|
||||
previewTabBtn.classList.add('active', 'bg-background', 'text-foreground');
|
||||
previewTabBtn.classList.remove('text-muted-foreground');
|
||||
rawTabBtn.classList.remove('active', 'bg-background', 'text-foreground');
|
||||
rawTabBtn.classList.add('text-muted-foreground');
|
||||
}
|
||||
}
|
||||
239
ccw/src/templates/dashboard-js/components/navigation.js
Normal file
239
ccw/src/templates/dashboard-js/components/navigation.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// Navigation and Routing
|
||||
// Manages navigation events, active state, content title updates, search, and path selector
|
||||
|
||||
// Path Selector
|
||||
function initPathSelector() {
|
||||
const btn = document.getElementById('pathButton');
|
||||
const menu = document.getElementById('pathMenu');
|
||||
const recentContainer = document.getElementById('recentPaths');
|
||||
|
||||
// Render recent paths
|
||||
if (recentPaths && recentPaths.length > 0) {
|
||||
recentPaths.forEach(path => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'path-item' + (path === projectPath ? ' active' : '');
|
||||
item.dataset.path = path;
|
||||
|
||||
// Path text
|
||||
const pathText = document.createElement('span');
|
||||
pathText.className = 'path-text';
|
||||
pathText.textContent = path;
|
||||
pathText.addEventListener('click', () => selectPath(path));
|
||||
item.appendChild(pathText);
|
||||
|
||||
// Delete button (only for non-current paths)
|
||||
if (path !== projectPath) {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'path-delete-btn';
|
||||
deleteBtn.innerHTML = '×';
|
||||
deleteBtn.title = 'Remove from recent';
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await removeRecentPathFromList(path);
|
||||
});
|
||||
item.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
recentContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
menu.classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('browsePath').addEventListener('click', async () => {
|
||||
await browseForFolder();
|
||||
});
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function initNavigation() {
|
||||
document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentFilter = item.dataset.filter;
|
||||
currentLiteType = null;
|
||||
currentView = 'sessions';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderSessions();
|
||||
});
|
||||
});
|
||||
|
||||
// Lite Tasks Navigation
|
||||
document.querySelectorAll('.nav-item[data-lite]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentLiteType = item.dataset.lite;
|
||||
currentFilter = null;
|
||||
currentView = 'liteTasks';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderLiteTasks();
|
||||
});
|
||||
});
|
||||
|
||||
// View Navigation (Project Overview, MCP Manager, etc.)
|
||||
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveNavItem(item);
|
||||
currentView = item.dataset.view;
|
||||
currentFilter = null;
|
||||
currentLiteType = null;
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
|
||||
// Route to appropriate view
|
||||
if (currentView === 'mcp-manager') {
|
||||
renderMcpManager();
|
||||
} else if (currentView === 'project-overview') {
|
||||
renderProjectOverview();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveNavItem(item) {
|
||||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
}
|
||||
|
||||
function updateContentTitle() {
|
||||
const titleEl = document.getElementById('contentTitle');
|
||||
if (currentView === 'project-overview') {
|
||||
titleEl.textContent = 'Project Overview';
|
||||
} else if (currentView === 'mcp-manager') {
|
||||
titleEl.textContent = 'MCP Server Management';
|
||||
} else if (currentView === 'liteTasks') {
|
||||
const names = { 'lite-plan': 'Lite Plan Sessions', 'lite-fix': 'Lite Fix Sessions' };
|
||||
titleEl.textContent = names[currentLiteType] || 'Lite Tasks';
|
||||
} else if (currentView === 'sessionDetail') {
|
||||
titleEl.textContent = 'Session Detail';
|
||||
} else if (currentView === 'liteTaskDetail') {
|
||||
titleEl.textContent = 'Lite Task Detail';
|
||||
} else {
|
||||
const names = { 'all': 'All Sessions', 'active': 'Active Sessions', 'archived': 'Archived Sessions' };
|
||||
titleEl.textContent = names[currentFilter] || 'Sessions';
|
||||
}
|
||||
}
|
||||
|
||||
// Search
|
||||
function initSearch() {
|
||||
const input = document.getElementById('searchInput');
|
||||
input.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.session-card').forEach(card => {
|
||||
const text = card.textContent.toLowerCase();
|
||||
card.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh Workspace
|
||||
function initRefreshButton() {
|
||||
const btn = document.getElementById('refreshWorkspace');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', refreshWorkspace);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkspace() {
|
||||
const btn = document.getElementById('refreshWorkspace');
|
||||
|
||||
// Add spinning animation
|
||||
btn.classList.add('refreshing');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE) {
|
||||
// Reload data from server
|
||||
const data = await loadDashboardData(projectPath);
|
||||
if (data) {
|
||||
// Update stores
|
||||
sessionDataStore = {};
|
||||
liteTaskDataStore = {};
|
||||
|
||||
// Populate stores
|
||||
[...(data.activeSessions || []), ...(data.archivedSessions || [])].forEach(s => {
|
||||
sessionDataStore[s.session_id] = s;
|
||||
});
|
||||
|
||||
[...(data.liteTasks?.litePlan || []), ...(data.liteTasks?.liteFix || [])].forEach(s => {
|
||||
liteTaskDataStore[s.session_id] = s;
|
||||
});
|
||||
|
||||
// Update global data
|
||||
window.workflowData = data;
|
||||
|
||||
// Update sidebar counts
|
||||
updateSidebarCounts(data);
|
||||
|
||||
// Re-render current view
|
||||
if (currentView === 'sessions') {
|
||||
renderSessions();
|
||||
} else if (currentView === 'liteTasks') {
|
||||
renderLiteTasks();
|
||||
} else if (currentView === 'sessionDetail' && currentSessionDetailKey) {
|
||||
showSessionDetailPage(currentSessionDetailKey);
|
||||
} else if (currentView === 'liteTaskDetail' && currentSessionDetailKey) {
|
||||
showLiteTaskDetailPage(currentSessionDetailKey);
|
||||
} else if (currentView === 'project-overview') {
|
||||
renderProjectOverview();
|
||||
}
|
||||
|
||||
showRefreshToast('Workspace refreshed', 'success');
|
||||
}
|
||||
} else {
|
||||
// Non-server mode: just reload page
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Refresh failed:', error);
|
||||
showRefreshToast('Refresh failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
btn.classList.remove('refreshing');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSidebarCounts(data) {
|
||||
// Update session counts
|
||||
const activeCount = document.querySelector('.nav-item[data-filter="active"] .nav-count');
|
||||
const archivedCount = document.querySelector('.nav-item[data-filter="archived"] .nav-count');
|
||||
const allCount = document.querySelector('.nav-item[data-filter="all"] .nav-count');
|
||||
|
||||
if (activeCount) activeCount.textContent = data.activeSessions?.length || 0;
|
||||
if (archivedCount) archivedCount.textContent = data.archivedSessions?.length || 0;
|
||||
if (allCount) allCount.textContent = (data.activeSessions?.length || 0) + (data.archivedSessions?.length || 0);
|
||||
|
||||
// Update lite task counts
|
||||
const litePlanCount = document.querySelector('.nav-item[data-lite="lite-plan"] .nav-count');
|
||||
const liteFixCount = document.querySelector('.nav-item[data-lite="lite-fix"] .nav-count');
|
||||
|
||||
if (litePlanCount) litePlanCount.textContent = data.liteTasks?.litePlan?.length || 0;
|
||||
if (liteFixCount) liteFixCount.textContent = data.liteTasks?.liteFix?.length || 0;
|
||||
}
|
||||
|
||||
function showRefreshToast(message, type) {
|
||||
// Remove existing toast
|
||||
const existing = document.querySelector('.status-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `status-toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade-out');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
194
ccw/src/templates/dashboard-js/components/notifications.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// ==========================================
|
||||
// NOTIFICATIONS COMPONENT
|
||||
// ==========================================
|
||||
// Real-time silent refresh (no notification bubbles)
|
||||
|
||||
let wsConnection = null;
|
||||
let autoRefreshInterval = null;
|
||||
let lastDataHash = null;
|
||||
const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
||||
|
||||
// ========== WebSocket Connection ==========
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
wsConnection = new WebSocket(wsUrl);
|
||||
|
||||
wsConnection.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
};
|
||||
|
||||
wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleNotification(data);
|
||||
} catch (e) {
|
||||
console.error('[WS] Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
wsConnection.onclose = () => {
|
||||
console.log('[WS] Disconnected, reconnecting in 5s...');
|
||||
setTimeout(initWebSocket, 5000);
|
||||
};
|
||||
|
||||
wsConnection.onerror = (error) => {
|
||||
console.error('[WS] Error:', error);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log('[WS] WebSocket not available, using polling');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Notification Handler ==========
|
||||
function handleNotification(data) {
|
||||
const { type, payload } = data;
|
||||
|
||||
// Silent refresh - no notification bubbles
|
||||
switch (type) {
|
||||
case 'session_updated':
|
||||
case 'summary_written':
|
||||
case 'task_completed':
|
||||
case 'new_session':
|
||||
// Just refresh data silently
|
||||
refreshIfNeeded();
|
||||
// Optionally highlight in carousel if it's the current session
|
||||
if (payload.sessionId && typeof carouselGoTo === 'function') {
|
||||
carouselGoTo(payload.sessionId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[WS] Unknown notification type:', type);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Auto Refresh ==========
|
||||
function initAutoRefresh() {
|
||||
// Calculate initial hash
|
||||
lastDataHash = calculateDataHash();
|
||||
|
||||
// Start polling interval
|
||||
autoRefreshInterval = setInterval(checkForChanges, AUTO_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function calculateDataHash() {
|
||||
if (!workflowData) return null;
|
||||
|
||||
// Simple hash based on key data points
|
||||
const hashData = {
|
||||
activeSessions: (workflowData.activeSessions || []).length,
|
||||
archivedSessions: (workflowData.archivedSessions || []).length,
|
||||
totalTasks: workflowData.statistics?.totalTasks || 0,
|
||||
completedTasks: workflowData.statistics?.completedTasks || 0,
|
||||
generatedAt: workflowData.generatedAt
|
||||
};
|
||||
|
||||
return JSON.stringify(hashData);
|
||||
}
|
||||
|
||||
async function checkForChanges() {
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const newData = await response.json();
|
||||
const newHash = JSON.stringify({
|
||||
activeSessions: (newData.activeSessions || []).length,
|
||||
archivedSessions: (newData.archivedSessions || []).length,
|
||||
totalTasks: newData.statistics?.totalTasks || 0,
|
||||
completedTasks: newData.statistics?.completedTasks || 0,
|
||||
generatedAt: newData.generatedAt
|
||||
});
|
||||
|
||||
if (newHash !== lastDataHash) {
|
||||
lastDataHash = newHash;
|
||||
// Silent refresh - no notification
|
||||
await refreshWorkspaceData(newData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AutoRefresh] Check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIfNeeded() {
|
||||
if (!window.SERVER_MODE) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const newData = await response.json();
|
||||
await refreshWorkspaceData(newData);
|
||||
} catch (e) {
|
||||
console.error('[Refresh] Failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkspaceData(newData) {
|
||||
// Update global data
|
||||
window.workflowData = newData;
|
||||
|
||||
// Clear and repopulate stores
|
||||
Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]);
|
||||
Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]);
|
||||
|
||||
[...(newData.activeSessions || []), ...(newData.archivedSessions || [])].forEach(s => {
|
||||
const key = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
sessionDataStore[key] = s;
|
||||
});
|
||||
|
||||
[...(newData.liteTasks?.litePlan || []), ...(newData.liteTasks?.liteFix || [])].forEach(s => {
|
||||
const key = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
liteTaskDataStore[key] = s;
|
||||
});
|
||||
|
||||
// Update UI silently
|
||||
updateStats();
|
||||
updateBadges();
|
||||
updateCarousel();
|
||||
|
||||
// Re-render current view if needed
|
||||
if (currentView === 'sessions') {
|
||||
renderSessions();
|
||||
} else if (currentView === 'liteTasks') {
|
||||
renderLiteTasks();
|
||||
}
|
||||
|
||||
lastDataHash = calculateDataHash();
|
||||
}
|
||||
|
||||
// ========== Cleanup ==========
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeWebSocket() {
|
||||
if (wsConnection) {
|
||||
wsConnection.close();
|
||||
wsConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Navigation Helper ==========
|
||||
function goToSession(sessionId) {
|
||||
// Find session in carousel and navigate
|
||||
const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
|
||||
// Jump to session in carousel if visible
|
||||
if (typeof carouselGoTo === 'function') {
|
||||
carouselGoTo(sessionId);
|
||||
}
|
||||
|
||||
// Navigate to session detail
|
||||
if (sessionDataStore[sessionKey]) {
|
||||
showSessionDetailPage(sessionKey);
|
||||
}
|
||||
}
|
||||
31
ccw/src/templates/dashboard-js/components/sidebar.js
Normal file
31
ccw/src/templates/dashboard-js/components/sidebar.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// ==========================================
|
||||
// SIDEBAR MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
function initSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
const menuToggle = document.getElementById('menuToggle');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
// Restore collapsed state
|
||||
if (localStorage.getItem('sidebarCollapsed') === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
});
|
||||
|
||||
// Mobile menu
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('open');
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', () => {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
}
|
||||
1093
ccw/src/templates/dashboard-js/components/tabs-context.js
Normal file
1093
ccw/src/templates/dashboard-js/components/tabs-context.js
Normal file
File diff suppressed because it is too large
Load Diff
273
ccw/src/templates/dashboard-js/components/tabs-other.js
Normal file
273
ccw/src/templates/dashboard-js/components/tabs-other.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// ==========================================
|
||||
// Tab Content Renderers - Other Tabs
|
||||
// ==========================================
|
||||
// Functions for rendering Summary, IMPL Plan, Review, and Lite Context tabs
|
||||
|
||||
// ==========================================
|
||||
// Summary Tab Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderSummaryContent(summaries) {
|
||||
if (!summaries || summaries.length === 0) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<div class="empty-title">No Summaries</div>
|
||||
<div class="empty-text">No summaries found in .summaries/</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Store summaries in global variable for modal access
|
||||
window._currentSummaries = summaries;
|
||||
|
||||
return `
|
||||
<div class="summary-tab-content space-y-4">
|
||||
${summaries.map((s, idx) => {
|
||||
const normalizedContent = normalizeLineEndings(s.content || '');
|
||||
// Extract first 3 lines for preview
|
||||
const previewLines = normalizedContent.split('\n').slice(0, 3).join('\n');
|
||||
const hasMore = normalizedContent.split('\n').length > 3;
|
||||
return `
|
||||
<div class="summary-item-card">
|
||||
<div class="summary-item-header">
|
||||
<h4 class="summary-item-title">📄 ${escapeHtml(s.name || 'Summary')}</h4>
|
||||
<button class="btn-view-modal" onclick="openMarkdownModal('${escapeHtml(s.name || 'Summary')}', window._currentSummaries[${idx}].content, 'markdown');">
|
||||
👁️ View
|
||||
</button>
|
||||
</div>
|
||||
<div class="summary-item-preview">
|
||||
<pre class="summary-preview-text">${escapeHtml(previewLines)}${hasMore ? '\n...' : ''}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// IMPL Plan Tab Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderImplPlanContent(implPlan) {
|
||||
if (!implPlan) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📐</div>
|
||||
<div class="empty-title">No IMPL Plan</div>
|
||||
<div class="empty-text">No IMPL_PLAN.md found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Normalize and store in global variable for modal access
|
||||
const normalizedContent = normalizeLineEndings(implPlan);
|
||||
window._currentImplPlan = normalizedContent;
|
||||
|
||||
// Extract first 5 lines for preview
|
||||
const previewLines = normalizedContent.split('\n').slice(0, 5).join('\n');
|
||||
const hasMore = normalizedContent.split('\n').length > 5;
|
||||
|
||||
return `
|
||||
<div class="impl-plan-tab-content">
|
||||
<div class="impl-plan-card">
|
||||
<div class="impl-plan-header">
|
||||
<h3 class="impl-plan-title">📐 Implementation Plan</h3>
|
||||
<button class="btn-view-modal" onclick="openMarkdownModal('IMPL_PLAN.md', window._currentImplPlan, 'markdown')">
|
||||
👁️ View
|
||||
</button>
|
||||
</div>
|
||||
<div class="impl-plan-preview">
|
||||
<pre class="impl-plan-preview-text">${escapeHtml(previewLines)}${hasMore ? '\n...' : ''}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Review Tab Rendering
|
||||
// ==========================================
|
||||
// NOTE: Enhanced review tab with multi-select, filtering, and preview panel
|
||||
// is now in _review_tab.js - renderReviewContent() function defined there
|
||||
|
||||
// ==========================================
|
||||
// Lite Context Tab Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderLiteContextContent(context, explorations, session) {
|
||||
const plan = session.plan || {};
|
||||
let sections = [];
|
||||
|
||||
// Render explorations if available (from exploration-*.json files)
|
||||
if (explorations && explorations.manifest) {
|
||||
sections.push(renderExplorationContext(explorations));
|
||||
}
|
||||
|
||||
// If we have context from context-package.json
|
||||
if (context) {
|
||||
sections.push(`
|
||||
<div class="context-package-section">
|
||||
<div class="collapsible-section">
|
||||
<div class="collapsible-header">
|
||||
<span class="collapse-icon">▶</span>
|
||||
<span class="section-label">Context Package</span>
|
||||
</div>
|
||||
<div class="collapsible-content collapsed">
|
||||
<pre class="json-content">${escapeHtml(JSON.stringify(context, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Fallback: show context from plan
|
||||
if (plan.focus_paths?.length || plan.summary) {
|
||||
sections.push(`
|
||||
<div class="plan-context-section">
|
||||
${plan.summary ? `
|
||||
<div class="context-section">
|
||||
<h4>Summary</h4>
|
||||
<p>${escapeHtml(plan.summary)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
${plan.focus_paths?.length ? `
|
||||
<div class="context-section">
|
||||
<h4>Focus Paths</h4>
|
||||
<div class="path-tags">
|
||||
${plan.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// If we have any sections, wrap them
|
||||
if (sections.length > 0) {
|
||||
return `<div class="context-tab-content">${sections.join('')}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📦</div>
|
||||
<div class="empty-title">No Context Data</div>
|
||||
<div class="empty-text">No context-package.json or exploration files found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// Exploration Context Rendering
|
||||
// ==========================================
|
||||
|
||||
function renderExplorationContext(explorations) {
|
||||
if (!explorations || !explorations.manifest) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const manifest = explorations.manifest;
|
||||
const data = explorations.data || {};
|
||||
|
||||
let sections = [];
|
||||
|
||||
// Header with manifest info
|
||||
sections.push(`
|
||||
<div class="exploration-header">
|
||||
<h4>${escapeHtml(manifest.task_description || 'Exploration Context')}</h4>
|
||||
<div class="exploration-meta">
|
||||
<span class="meta-item">Complexity: <strong>${escapeHtml(manifest.complexity || 'N/A')}</strong></span>
|
||||
<span class="meta-item">Explorations: <strong>${manifest.exploration_count || 0}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Render each exploration angle as collapsible section
|
||||
const explorationOrder = ['architecture', 'dependencies', 'patterns', 'integration-points'];
|
||||
const explorationTitles = {
|
||||
'architecture': '🏗️ Architecture',
|
||||
'dependencies': '📦 Dependencies',
|
||||
'patterns': '🔄 Patterns',
|
||||
'integration-points': '🔌 Integration Points'
|
||||
};
|
||||
|
||||
for (const angle of explorationOrder) {
|
||||
const expData = data[angle];
|
||||
if (!expData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const angleContent = renderExplorationAngle(angle, expData);
|
||||
|
||||
sections.push(`
|
||||
<div class="exploration-section collapsible-section">
|
||||
<div class="collapsible-header">
|
||||
<span class="collapse-icon">▶</span>
|
||||
<span class="section-label">${explorationTitles[angle] || angle}</span>
|
||||
</div>
|
||||
<div class="collapsible-content collapsed">
|
||||
${angleContent}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return `<div class="exploration-context">${sections.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderExplorationAngle(angle, data) {
|
||||
let content = [];
|
||||
|
||||
// Project structure - handle string or object
|
||||
if (data.project_structure) {
|
||||
content.push(renderExpField('Project Structure', data.project_structure));
|
||||
}
|
||||
|
||||
// Relevant files
|
||||
if (data.relevant_files && data.relevant_files.length) {
|
||||
content.push(`
|
||||
<div class="exp-field">
|
||||
<label>Relevant Files (${data.relevant_files.length})</label>
|
||||
<div class="relevant-files-list">
|
||||
${data.relevant_files.slice(0, 10).map(f => `
|
||||
<div class="file-item-exp">
|
||||
<div class="file-path"><code>${escapeHtml(f.path || '')}</code></div>
|
||||
<div class="file-relevance">Relevance: ${f.relevance ? (f.relevance * 100).toFixed(0) : 0}%</div>
|
||||
${f.rationale ? `<div class="file-rationale">${escapeHtml((f.rationale || "").substring(0, 200))}...</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
${data.relevant_files.length > 10 ? `<div class="more-files">... and ${data.relevant_files.length - 10} more files</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Patterns - handle string or object
|
||||
if (data.patterns) {
|
||||
content.push(renderExpField('Patterns', data.patterns));
|
||||
}
|
||||
|
||||
// Dependencies - handle string or object
|
||||
if (data.dependencies) {
|
||||
content.push(renderExpField('Dependencies', data.dependencies));
|
||||
}
|
||||
|
||||
// Integration points - handle string or object
|
||||
if (data.integration_points) {
|
||||
content.push(renderExpField('Integration Points', data.integration_points));
|
||||
}
|
||||
|
||||
// Constraints - handle string or object
|
||||
if (data.constraints) {
|
||||
content.push(renderExpField('Constraints', data.constraints));
|
||||
}
|
||||
|
||||
// Clarification needs - handle array or object
|
||||
if (data.clarification_needs) {
|
||||
content.push(renderExpField('Clarification Needs', data.clarification_needs));
|
||||
}
|
||||
|
||||
return content.join('') || '<p>No data available</p>';
|
||||
}
|
||||
477
ccw/src/templates/dashboard-js/components/task-drawer-core.js
Normal file
477
ccw/src/templates/dashboard-js/components/task-drawer-core.js
Normal file
@@ -0,0 +1,477 @@
|
||||
// ==========================================
|
||||
// TASK DRAWER CORE
|
||||
// ==========================================
|
||||
// Core drawer functionality and main rendering functions
|
||||
|
||||
let currentDrawerTasks = [];
|
||||
|
||||
function openTaskDrawer(taskId) {
|
||||
const task = currentDrawerTasks.find(t => (t.task_id || t.id) === taskId);
|
||||
if (!task) {
|
||||
console.error('Task not found:', taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
|
||||
document.getElementById('drawerContent').innerHTML = renderTaskDrawerContent(task);
|
||||
document.getElementById('taskDetailDrawer').classList.add('open');
|
||||
document.getElementById('drawerOverlay').classList.add('active');
|
||||
|
||||
// Initialize flowchart after DOM is updated
|
||||
setTimeout(() => {
|
||||
renderFullFlowchart(task.flow_control);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function openTaskDrawerForLite(sessionId, taskId) {
|
||||
const session = liteTaskDataStore[currentSessionDetailKey];
|
||||
if (!session) return;
|
||||
|
||||
const task = session.tasks?.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Set current drawer tasks and session context
|
||||
currentDrawerTasks = session.tasks || [];
|
||||
window._currentDrawerSession = session;
|
||||
|
||||
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
|
||||
// Use dedicated lite task drawer renderer
|
||||
document.getElementById('drawerContent').innerHTML = renderLiteTaskDrawerContent(task, session);
|
||||
document.getElementById('taskDetailDrawer').classList.add('open');
|
||||
document.getElementById('drawerOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
function closeTaskDrawer() {
|
||||
document.getElementById('taskDetailDrawer').classList.remove('open');
|
||||
document.getElementById('drawerOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function switchDrawerTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.drawer-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Update tab panels
|
||||
document.querySelectorAll('.drawer-panel').forEach(panel => {
|
||||
panel.classList.toggle('active', panel.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Render flowchart if switching to flowchart tab
|
||||
if (tabName === 'flowchart') {
|
||||
const taskId = document.getElementById('drawerTaskTitle').textContent;
|
||||
const task = currentDrawerTasks.find(t => t.title === taskId || t.task_id === taskId);
|
||||
if (task?.flow_control) {
|
||||
setTimeout(() => renderFullFlowchart(task.flow_control), 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskDrawerContent(task) {
|
||||
const fc = task.flow_control || {};
|
||||
|
||||
return `
|
||||
<!-- Task Header -->
|
||||
<div class="drawer-task-header">
|
||||
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
|
||||
<span class="task-status-badge ${task.status || 'pending'}">${task.status || 'pending'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="drawer-tabs">
|
||||
<button class="drawer-tab active" data-tab="overview" onclick="switchDrawerTab('overview')">Overview</button>
|
||||
<button class="drawer-tab" data-tab="flowchart" onclick="switchDrawerTab('flowchart')">Flowchart</button>
|
||||
<button class="drawer-tab" data-tab="files" onclick="switchDrawerTab('files')">Files</button>
|
||||
<button class="drawer-tab" data-tab="raw" onclick="switchDrawerTab('raw')">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="drawer-tab-content">
|
||||
<!-- Overview Tab (default) -->
|
||||
<div class="drawer-panel active" data-tab="overview">
|
||||
${renderPreAnalysisSteps(fc.pre_analysis)}
|
||||
${renderImplementationStepsList(fc.implementation_approach)}
|
||||
</div>
|
||||
|
||||
<!-- Flowchart Tab -->
|
||||
<div class="drawer-panel" data-tab="flowchart">
|
||||
<div id="flowchartContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Files Tab -->
|
||||
<div class="drawer-panel" data-tab="files">
|
||||
${renderTargetFiles(fc.target_files)}
|
||||
${fc.test_commands ? renderTestCommands(fc.test_commands) : ''}
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON Tab -->
|
||||
<div class="drawer-panel" data-tab="raw">
|
||||
<pre class="json-view">${escapeHtml(JSON.stringify(task, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLiteTaskDrawerContent(task, session) {
|
||||
const rawTask = task._raw || task;
|
||||
|
||||
return `
|
||||
<!-- Task Header -->
|
||||
<div class="drawer-task-header">
|
||||
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
|
||||
${rawTask.action ? `<span class="action-badge">${escapeHtml(rawTask.action)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="drawer-tabs">
|
||||
<button class="drawer-tab active" data-tab="overview" onclick="switchDrawerTab('overview')">Overview</button>
|
||||
<button class="drawer-tab" data-tab="implementation" onclick="switchDrawerTab('implementation')">Implementation</button>
|
||||
<button class="drawer-tab" data-tab="files" onclick="switchDrawerTab('files')">Files</button>
|
||||
<button class="drawer-tab" data-tab="raw" onclick="switchDrawerTab('raw')">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="drawer-tab-content">
|
||||
<!-- Overview Tab (default) -->
|
||||
<div class="drawer-panel active" data-tab="overview">
|
||||
${renderLiteTaskOverview(rawTask)}
|
||||
</div>
|
||||
|
||||
<!-- Implementation Tab -->
|
||||
<div class="drawer-panel" data-tab="implementation">
|
||||
${renderLiteTaskImplementation(rawTask)}
|
||||
</div>
|
||||
|
||||
<!-- Files Tab -->
|
||||
<div class="drawer-panel" data-tab="files">
|
||||
${renderLiteTaskFiles(rawTask)}
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON Tab -->
|
||||
<div class="drawer-panel" data-tab="raw">
|
||||
<pre class="json-view">${escapeHtml(JSON.stringify(rawTask, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render plan.json task details in drawer (for lite tasks)
|
||||
function renderPlanTaskDetails(task, session) {
|
||||
if (!task) return '';
|
||||
|
||||
// Get corresponding plan task if available
|
||||
const planTask = session?.plan?.tasks?.find(pt => pt.id === task.id);
|
||||
if (!planTask) {
|
||||
// Fallback: task itself might have plan-like structure
|
||||
return renderTaskImplementationDetails(task);
|
||||
}
|
||||
|
||||
return renderTaskImplementationDetails(planTask);
|
||||
}
|
||||
|
||||
function renderTaskImplementationDetails(task) {
|
||||
const sections = [];
|
||||
|
||||
// Description
|
||||
if (task.description) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Description</h4>
|
||||
<p class="task-description">${escapeHtml(task.description)}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Modification Points
|
||||
if (task.modification_points?.length) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Modification Points</h4>
|
||||
<div class="modification-points-list">
|
||||
${task.modification_points.map(mp => `
|
||||
<div class="mod-point-item">
|
||||
<div class="mod-point-file">
|
||||
<span class="file-icon">📄</span>
|
||||
<code>${escapeHtml(mp.file || mp.path || '')}</code>
|
||||
</div>
|
||||
${mp.target ? `<div class="mod-point-target">Target: <code>${escapeHtml(mp.target)}</code></div>` : ''}
|
||||
${mp.change ? `<div class="mod-point-change">${escapeHtml(mp.change)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Implementation Steps
|
||||
if (task.implementation?.length) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Implementation Steps</h4>
|
||||
<ol class="implementation-steps-list">
|
||||
${task.implementation.map(step => `
|
||||
<li class="impl-step-item">${escapeHtml(typeof step === 'string' ? step : step.step || JSON.stringify(step))}</li>
|
||||
`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Reference
|
||||
if (task.reference) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Reference</h4>
|
||||
${task.reference.pattern ? `<div class="ref-pattern"><strong>Pattern:</strong> ${escapeHtml(task.reference.pattern)}</div>` : ''}
|
||||
${task.reference.files?.length ? `
|
||||
<div class="ref-files">
|
||||
<strong>Files:</strong>
|
||||
<ul>
|
||||
${task.reference.files.map(f => `<li><code>${escapeHtml(f)}</code></li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${task.reference.examples ? `<div class="ref-examples"><strong>Examples:</strong> ${escapeHtml(task.reference.examples)}</div>` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Acceptance Criteria
|
||||
if (task.acceptance?.length) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Acceptance Criteria</h4>
|
||||
<ul class="acceptance-list">
|
||||
${task.acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
if (task.depends_on?.length) {
|
||||
sections.push(`
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Dependencies</h4>
|
||||
<div class="dependencies-list">
|
||||
${task.depends_on.map(dep => `<span class="dep-badge">${escapeHtml(dep)}</span>`).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.join('');
|
||||
}
|
||||
|
||||
// Render lite task overview
|
||||
function renderLiteTaskOverview(task) {
|
||||
let sections = [];
|
||||
|
||||
// Description Card
|
||||
if (task.description) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📝</span>
|
||||
<h4 class="lite-card-title">Description</h4>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<p class="lite-description">${escapeHtml(task.description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Scope Card
|
||||
if (task.scope) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📂</span>
|
||||
<h4 class="lite-card-title">Scope</h4>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="lite-scope-box">
|
||||
<code>${escapeHtml(task.scope)}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Acceptance Criteria Card
|
||||
if (task.acceptance && task.acceptance.length > 0) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">✅</span>
|
||||
<h4 class="lite-card-title">Acceptance Criteria</h4>
|
||||
<span class="lite-count-badge">${task.acceptance.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<ul class="lite-checklist">
|
||||
${task.acceptance.map(a => `
|
||||
<li class="lite-check-item">
|
||||
<span class="lite-check-icon">○</span>
|
||||
<span class="lite-check-text">${escapeHtml(a)}</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Reference Card
|
||||
if (task.reference) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📚</span>
|
||||
<h4 class="lite-card-title">Reference</h4>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
${task.reference.pattern ? `
|
||||
<div class="lite-ref-section">
|
||||
<span class="lite-ref-label">Pattern:</span>
|
||||
<span class="lite-ref-value">${escapeHtml(task.reference.pattern)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${task.reference.files && task.reference.files.length > 0 ? `
|
||||
<div class="lite-ref-section">
|
||||
<span class="lite-ref-label">Files:</span>
|
||||
<div class="lite-ref-files">
|
||||
${task.reference.files.map(f => `<code class="lite-file-tag">${escapeHtml(f)}</code>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${task.reference.examples ? `
|
||||
<div class="lite-ref-section">
|
||||
<span class="lite-ref-label">Examples:</span>
|
||||
<span class="lite-ref-value">${escapeHtml(task.reference.examples)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Dependencies Card
|
||||
if (task.depends_on && task.depends_on.length > 0) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">🔗</span>
|
||||
<h4 class="lite-card-title">Dependencies</h4>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="lite-deps-tags">
|
||||
${task.depends_on.map(dep => `<span class="lite-dep-tag">${escapeHtml(dep)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.length > 0 ? sections.join('') : '<div class="empty-section">No overview data</div>';
|
||||
}
|
||||
|
||||
// Render lite task implementation steps
|
||||
function renderLiteTaskImplementation(task) {
|
||||
let sections = [];
|
||||
|
||||
// Implementation Steps Card
|
||||
if (task.implementation && task.implementation.length > 0) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📋</span>
|
||||
<h4 class="lite-card-title">Implementation Steps</h4>
|
||||
<span class="lite-count-badge">${task.implementation.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="lite-impl-steps">
|
||||
${task.implementation.map((step, idx) => `
|
||||
<div class="lite-impl-step">
|
||||
<div class="lite-step-num">${idx + 1}</div>
|
||||
<div class="lite-step-content">
|
||||
<p class="lite-step-text">${escapeHtml(typeof step === 'string' ? step : step.step || JSON.stringify(step))}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Modification Points Card
|
||||
if (task.modification_points && task.modification_points.length > 0) {
|
||||
sections.push(`
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">🔧</span>
|
||||
<h4 class="lite-card-title">Modification Points</h4>
|
||||
<span class="lite-count-badge">${task.modification_points.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="lite-mod-points">
|
||||
${task.modification_points.map(mp => `
|
||||
<div class="lite-mod-card">
|
||||
<div class="lite-mod-header">
|
||||
<code class="lite-mod-file">${escapeHtml(mp.file || '')}</code>
|
||||
</div>
|
||||
${mp.target ? `
|
||||
<div class="lite-mod-target">
|
||||
<span class="lite-mod-label">Target:</span>
|
||||
<span class="lite-mod-value">${escapeHtml(mp.target)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${mp.change ? `
|
||||
<div class="lite-mod-change">${escapeHtml(mp.change)}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.length > 0 ? sections.join('') : '<div class="empty-section">No implementation data</div>';
|
||||
}
|
||||
|
||||
// Render lite task files
|
||||
function renderLiteTaskFiles(task) {
|
||||
const files = [];
|
||||
|
||||
// Collect from modification_points
|
||||
if (task.modification_points) {
|
||||
task.modification_points.forEach(mp => {
|
||||
if (mp.file && !files.includes(mp.file)) files.push(mp.file);
|
||||
});
|
||||
}
|
||||
|
||||
// Collect from scope
|
||||
if (task.scope && !files.includes(task.scope)) {
|
||||
files.push(task.scope);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return '<div class="empty-section">No files specified</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">Target Files</h4>
|
||||
<ul class="target-files-list">
|
||||
${files.map(f => `
|
||||
<li class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<code>${escapeHtml(f)}</code>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// ==========================================
|
||||
// TASK DRAWER RENDERERS
|
||||
// ==========================================
|
||||
// Detailed content renderers and helper functions for task drawer
|
||||
|
||||
function renderPreAnalysisSteps(preAnalysis) {
|
||||
if (!Array.isArray(preAnalysis) || preAnalysis.length === 0) {
|
||||
return '<div class="empty-section">No pre-analysis steps</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">🔍</span>
|
||||
<h4 class="lite-card-title">Pre-Analysis Steps</h4>
|
||||
<span class="lite-count-badge">${preAnalysis.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="lite-impl-steps">
|
||||
${preAnalysis.map((item, idx) => `
|
||||
<div class="lite-impl-step">
|
||||
<div class="lite-step-num">${idx + 1}</div>
|
||||
<div class="lite-step-content">
|
||||
<p class="lite-step-text">${escapeHtml(item.step || item.action || 'Step ' + (idx + 1))}</p>
|
||||
${item.action && item.action !== item.step ? `
|
||||
<div class="lite-step-meta">
|
||||
<span class="lite-step-label">Action:</span>
|
||||
<span class="lite-step-value">${escapeHtml(item.action)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${item.commands?.length ? `
|
||||
<div class="lite-step-commands">
|
||||
${item.commands.map(c => `<code class="lite-cmd-tag">${escapeHtml(typeof c === 'string' ? c : JSON.stringify(c))}</code>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.output_to ? `
|
||||
<div class="lite-step-meta">
|
||||
<span class="lite-step-label">Output:</span>
|
||||
<code class="lite-file-tag">${escapeHtml(item.output_to)}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderImplementationStepsList(steps) {
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
return '<div class="empty-section">No implementation steps</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📋</span>
|
||||
<h4 class="lite-card-title">Implementation Approach</h4>
|
||||
<span class="lite-count-badge">${steps.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="session-impl-steps">
|
||||
${steps.map((step, idx) => {
|
||||
const hasMods = step.modification_points?.length;
|
||||
const hasFlow = step.logic_flow?.length;
|
||||
|
||||
return `
|
||||
<div class="session-impl-step">
|
||||
<div class="session-step-header">
|
||||
<div class="lite-step-num">${step.step || idx + 1}</div>
|
||||
<div class="session-step-title">${escapeHtml(step.title || 'Untitled Step')}</div>
|
||||
</div>
|
||||
${step.description ? `<div class="session-step-desc">${escapeHtml(step.description)}</div>` : ''}
|
||||
${hasMods ? `
|
||||
<div class="session-step-section">
|
||||
<div class="session-section-label">
|
||||
<span class="session-section-icon">🔧</span>
|
||||
<span>Modifications</span>
|
||||
<span class="lite-count-badge">${step.modification_points.length}</span>
|
||||
</div>
|
||||
<div class="session-mods-list">
|
||||
${step.modification_points.map(mp => `
|
||||
<div class="session-mod-item">
|
||||
${typeof mp === 'string' ? `<code class="lite-file-tag">${escapeHtml(mp)}</code>` : `
|
||||
<code class="lite-file-tag">${escapeHtml(mp.file || mp.path || '')}</code>
|
||||
${mp.changes ? `<span class="session-mod-change">${escapeHtml(mp.changes)}</span>` : ''}
|
||||
`}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${hasFlow ? `
|
||||
<div class="session-step-section">
|
||||
<div class="session-section-label">
|
||||
<span class="session-section-icon">⚡</span>
|
||||
<span>Logic Flow</span>
|
||||
<span class="lite-count-badge">${step.logic_flow.length}</span>
|
||||
</div>
|
||||
<div class="session-flow-list">
|
||||
${step.logic_flow.map((lf, lfIdx) => `
|
||||
<div class="session-flow-item">
|
||||
<span class="session-flow-num">${lfIdx + 1}</span>
|
||||
<span class="session-flow-text">${escapeHtml(typeof lf === 'string' ? lf : lf.action || JSON.stringify(lf))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${step.depends_on?.length ? `
|
||||
<div class="session-step-deps">
|
||||
<span class="session-deps-label">Dependencies:</span>
|
||||
<div class="lite-deps-tags">
|
||||
${step.depends_on.map(d => `<span class="lite-dep-tag">${escapeHtml(d)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTargetFiles(files) {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
return '<div class="empty-section">No target files</div>';
|
||||
}
|
||||
|
||||
// Get current project path for building full paths
|
||||
const projectPath = window.currentProjectPath || '';
|
||||
|
||||
return `
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">📁</span>
|
||||
<h4 class="lite-card-title">Target Files</h4>
|
||||
<span class="lite-count-badge">${files.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="session-files-list">
|
||||
${files.map(f => {
|
||||
const filePath = typeof f === 'string' ? f : (f.path || JSON.stringify(f));
|
||||
// Build full path for vscode link
|
||||
const fullPath = filePath.startsWith('/') || filePath.includes(':')
|
||||
? filePath
|
||||
: (projectPath ? `${projectPath}/${filePath}` : filePath);
|
||||
const vscodeUri = `vscode://file/${fullPath.replace(/\\/g, '/')}`;
|
||||
|
||||
return `
|
||||
<a href="${vscodeUri}" class="session-file-item" title="Open in VS Code: ${escapeHtml(fullPath)}">
|
||||
<span class="session-file-icon">📄</span>
|
||||
<code class="session-file-path">${escapeHtml(filePath)}</code>
|
||||
<span class="session-file-action">↗</span>
|
||||
</a>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTestCommands(testCommands) {
|
||||
if (!testCommands || typeof testCommands !== 'object') return '';
|
||||
|
||||
const entries = Object.entries(testCommands);
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div class="lite-card">
|
||||
<div class="lite-card-header">
|
||||
<span class="lite-card-icon">🧪</span>
|
||||
<h4 class="lite-card-title">Test Commands</h4>
|
||||
<span class="lite-count-badge">${entries.length}</span>
|
||||
</div>
|
||||
<div class="lite-card-body">
|
||||
<div class="session-test-commands">
|
||||
${entries.map(([key, val]) => `
|
||||
<div class="session-test-item">
|
||||
<span class="session-test-label">${escapeHtml(key)}</span>
|
||||
<code class="session-test-cmd">${escapeHtml(typeof val === 'string' ? val : JSON.stringify(val))}</code>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTaskDetail(sessionId, task) {
|
||||
// Get raw task data for JSON view
|
||||
const rawTask = task._raw || task;
|
||||
const taskJsonId = `task-json-${sessionId}-${task.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
|
||||
// Store JSON in memory instead of inline script tag
|
||||
taskJsonStore[taskJsonId] = rawTask;
|
||||
|
||||
return `
|
||||
<div class="task-detail" id="task-${sessionId}-${task.id}">
|
||||
<div class="task-detail-header">
|
||||
<span class="task-id-badge">${escapeHtml(task.id)}</span>
|
||||
<span class="task-title">${escapeHtml(task.title || 'Untitled')}</span>
|
||||
<span class="task-status-badge ${task.status}">${task.status}</span>
|
||||
<div class="task-header-actions">
|
||||
<button class="btn-view-json" onclick="showJsonModal('${taskJsonId}', '${escapeHtml(task.id)}')">{ } JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Meta -->
|
||||
<div class="collapsible-section">
|
||||
<div class="collapsible-header">
|
||||
<span class="collapse-icon">▶</span>
|
||||
<span class="section-label">meta</span>
|
||||
<span class="section-preview">${escapeHtml((task.meta?.type || task.meta?.action || '') + (task.meta?.scope ? ' | ' + task.meta.scope : ''))}</span>
|
||||
</div>
|
||||
<div class="collapsible-content collapsed">
|
||||
${renderDynamicFields(task.meta || rawTask, ['type', 'action', 'agent', 'scope', 'module', 'execution_group'])}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Context -->
|
||||
<div class="collapsible-section">
|
||||
<div class="collapsible-header">
|
||||
<span class="collapse-icon">▶</span>
|
||||
<span class="section-label">context</span>
|
||||
<span class="section-preview">${escapeHtml(getContextPreview(task.context, rawTask))}</span>
|
||||
</div>
|
||||
<div class="collapsible-content collapsed">
|
||||
${renderContextFields(task.context, rawTask)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Flow Control (with Flowchart) -->
|
||||
<div class="collapsible-section">
|
||||
<div class="collapsible-header">
|
||||
<span class="collapse-icon">▶</span>
|
||||
<span class="section-label">flow_control</span>
|
||||
<span class="section-preview">${escapeHtml(getFlowControlPreview(task.flow_control, rawTask))}</span>
|
||||
</div>
|
||||
<div class="collapsible-content collapsed">
|
||||
<div class="flowchart-container" id="flowchart-${sessionId}-${task.id}"></div>
|
||||
${renderFlowControlDetails(task.flow_control, rawTask)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getContextPreview(context, rawTask) {
|
||||
const items = [];
|
||||
if (context?.requirements?.length) items.push(`${context.requirements.length} reqs`);
|
||||
if (context?.acceptance?.length) items.push(`${context.acceptance.length} acceptance`);
|
||||
if (context?.focus_paths?.length) items.push(`${context.focus_paths.length} paths`);
|
||||
if (rawTask?.modification_points?.length) items.push(`${rawTask.modification_points.length} mods`);
|
||||
return items.join(' | ') || 'No context';
|
||||
}
|
||||
|
||||
function getFlowControlPreview(flowControl, rawTask) {
|
||||
const steps = flowControl?.implementation_approach?.length || rawTask?.implementation?.length || 0;
|
||||
return steps > 0 ? `${steps} steps` : 'No steps';
|
||||
}
|
||||
|
||||
function renderDynamicFields(obj, priorityKeys = []) {
|
||||
if (!obj || typeof obj !== 'object') return '<div class="field-value json-value-null">null</div>';
|
||||
|
||||
const entries = Object.entries(obj).filter(([k, v]) => v !== null && v !== undefined && k !== '_raw');
|
||||
if (entries.length === 0) return '<div class="field-value json-value-null">Empty</div>';
|
||||
|
||||
// Sort: priority keys first, then alphabetically
|
||||
entries.sort(([a], [b]) => {
|
||||
const aIdx = priorityKeys.indexOf(a);
|
||||
const bIdx = priorityKeys.indexOf(b);
|
||||
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||
if (aIdx !== -1) return -1;
|
||||
if (bIdx !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return `<div class="field-group">${entries.map(([key, value]) => renderFieldRow(key, value)).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderFieldRow(key, value) {
|
||||
return `
|
||||
<div class="field-row">
|
||||
<span class="field-label">${escapeHtml(key)}:</span>
|
||||
<div class="field-value">${renderFieldValue(key, value)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFieldValue(key, value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '<span class="json-value-null">null</span>';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return `<span class="json-value-boolean">${value}</span>`;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return `<span class="json-value-number">${value}</span>`;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Check if it's a path
|
||||
if (key.includes('path') || key.includes('file') || value.includes('/') || value.includes('\\')) {
|
||||
return `<span class="array-item path-item">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
return `<span class="json-value-string">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '<span class="json-value-null">[]</span>';
|
||||
|
||||
// Check if array contains objects or strings
|
||||
if (typeof value[0] === 'object') {
|
||||
return `<div class="nested-array">${value.map((item, i) => `
|
||||
<div class="array-object">
|
||||
<div class="array-object-header">[${i + 1}]</div>
|
||||
${renderDynamicFields(item)}
|
||||
</div>
|
||||
`).join('')}</div>`;
|
||||
}
|
||||
|
||||
// Array of strings/primitives
|
||||
const isPathArray = key.includes('path') || key.includes('file');
|
||||
return `<div class="array-value">${value.map(v =>
|
||||
`<span class="array-item ${isPathArray ? 'path-item' : ''}">${escapeHtml(String(v))}</span>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return renderDynamicFields(value);
|
||||
}
|
||||
|
||||
return escapeHtml(String(value));
|
||||
}
|
||||
|
||||
function renderContextFields(context, rawTask) {
|
||||
const sections = [];
|
||||
|
||||
// Requirements / Description
|
||||
const requirements = context?.requirements || [];
|
||||
const description = rawTask?.description;
|
||||
if (requirements.length > 0 || description) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>requirements:</label>
|
||||
${description ? `<p style="margin-bottom: 8px;">${escapeHtml(description)}</p>` : ''}
|
||||
${requirements.length > 0 ? `<ul>${requirements.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Focus paths / Modification points
|
||||
const focusPaths = context?.focus_paths || [];
|
||||
const modPoints = rawTask?.modification_points || [];
|
||||
if (focusPaths.length > 0 || modPoints.length > 0) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>${modPoints.length > 0 ? 'modification_points:' : 'focus_paths:'}</label>
|
||||
${modPoints.length > 0 ? `
|
||||
<div class="mod-points">
|
||||
${modPoints.map(m => `
|
||||
<div class="mod-point">
|
||||
<span class="array-item path-item">${escapeHtml(m.file || m)}</span>
|
||||
${m.target ? `<span class="mod-target">→ ${escapeHtml(m.target)}</span>` : ''}
|
||||
${m.change ? `<p class="mod-change">${escapeHtml(m.change)}</p>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div class="path-tags">${focusPaths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}</div>
|
||||
`}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Acceptance criteria
|
||||
const acceptance = context?.acceptance || rawTask?.acceptance || [];
|
||||
if (acceptance.length > 0) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>acceptance:</label>
|
||||
<ul>${acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
const depends = context?.depends_on || rawTask?.depends_on || [];
|
||||
if (depends.length > 0) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>depends_on:</label>
|
||||
<div class="path-tags">${depends.map(d => `<span class="array-item depends-badge">${escapeHtml(d)}</span>`).join('')}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Reference
|
||||
const reference = rawTask?.reference;
|
||||
if (reference) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>reference:</label>
|
||||
${renderDynamicFields(reference)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.length > 0
|
||||
? `<div class="context-fields">${sections.join('')}</div>`
|
||||
: '<div class="field-value json-value-null">No context data</div>';
|
||||
}
|
||||
|
||||
function renderFlowControlDetails(flowControl, rawTask) {
|
||||
const sections = [];
|
||||
|
||||
// Pre-analysis
|
||||
const preAnalysis = flowControl?.pre_analysis || rawTask?.pre_analysis || [];
|
||||
if (preAnalysis.length > 0) {
|
||||
sections.push(`
|
||||
<div class="context-field" style="margin-top: 16px;">
|
||||
<label>pre_analysis:</label>
|
||||
<ul>${preAnalysis.map(p => `<li>${escapeHtml(p)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Target files
|
||||
const targetFiles = flowControl?.target_files || rawTask?.target_files || [];
|
||||
if (targetFiles.length > 0) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>target_files:</label>
|
||||
<div class="path-tags">${targetFiles.map(f => `<span class="path-tag">${escapeHtml(f)}</span>`).join('')}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.join('');
|
||||
}
|
||||
21
ccw/src/templates/dashboard-js/components/theme.js
Normal file
21
ccw/src/templates/dashboard-js/components/theme.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// ==========================================
|
||||
// THEME MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
updateThemeIcon(saved);
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon(next);
|
||||
});
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
document.getElementById('themeToggle').textContent = theme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
57
ccw/src/templates/dashboard-js/main.js
Normal file
57
ccw/src/templates/dashboard-js/main.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Application Entry Point
|
||||
// Initializes all components and sets up global event handlers
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize components with error handling to prevent cascading failures
|
||||
try { initTheme(); } catch (e) { console.error('Theme init failed:', e); }
|
||||
try { initSidebar(); } catch (e) { console.error('Sidebar init failed:', e); }
|
||||
try { initPathSelector(); } catch (e) { console.error('Path selector init failed:', e); }
|
||||
try { initNavigation(); } catch (e) { console.error('Navigation init failed:', e); }
|
||||
try { initSearch(); } catch (e) { console.error('Search init failed:', e); }
|
||||
try { initRefreshButton(); } catch (e) { console.error('Refresh button init failed:', e); }
|
||||
try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); }
|
||||
try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); }
|
||||
try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); }
|
||||
|
||||
// Initialize real-time features (WebSocket + auto-refresh)
|
||||
try { initWebSocket(); } catch (e) { console.log('WebSocket not available:', e.message); }
|
||||
try { initAutoRefresh(); } catch (e) { console.error('Auto-refresh init failed:', e); }
|
||||
|
||||
// Server mode: load data from API
|
||||
try {
|
||||
if (window.SERVER_MODE) {
|
||||
await switchToPath(window.INITIAL_PATH || projectPath);
|
||||
} else {
|
||||
renderDashboard();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Dashboard render failed:', e);
|
||||
}
|
||||
|
||||
// Global Escape key handler for modals
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeMarkdownModal();
|
||||
|
||||
// Close JSON modal if exists
|
||||
const jsonModal = document.querySelector('.json-modal-overlay');
|
||||
if (jsonModal) {
|
||||
const closeBtn = jsonModal.querySelector('.json-modal-close');
|
||||
if (closeBtn) closeJsonModal(closeBtn);
|
||||
}
|
||||
|
||||
// Close path modal if exists
|
||||
closePathModal();
|
||||
|
||||
// Close MCP create modal if exists
|
||||
if (typeof closeMcpCreateModal === 'function') {
|
||||
closeMcpCreateModal();
|
||||
}
|
||||
|
||||
// Close Hook create modal if exists
|
||||
if (typeof closeHookCreateModal === 'function') {
|
||||
closeHookCreateModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
37
ccw/src/templates/dashboard-js/state.js
Normal file
37
ccw/src/templates/dashboard-js/state.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// ========================================
|
||||
// State Management
|
||||
// ========================================
|
||||
// Global state variables and template placeholders
|
||||
// This module must be loaded first as other modules depend on these variables
|
||||
|
||||
// ========== Data Placeholders ==========
|
||||
// These placeholders are replaced by the dashboard generator at build time
|
||||
let workflowData = {{WORKFLOW_DATA}};
|
||||
let projectPath = '{{PROJECT_PATH}}';
|
||||
let recentPaths = {{RECENT_PATHS}};
|
||||
|
||||
// ========== Application State ==========
|
||||
// Current filter for session list view ('all', 'active', 'archived')
|
||||
let currentFilter = 'all';
|
||||
|
||||
// Current lite task type ('lite-plan', 'lite-fix', or null)
|
||||
let currentLiteType = null;
|
||||
|
||||
// Current view mode ('sessions', 'liteTasks', 'project-overview', 'sessionDetail', 'liteTaskDetail')
|
||||
let currentView = 'sessions';
|
||||
|
||||
// Current session detail key (null when not in detail view)
|
||||
let currentSessionDetailKey = null;
|
||||
|
||||
// ========== Data Stores ==========
|
||||
// Store session data for modal/detail access
|
||||
// Key: session key, Value: session data object
|
||||
const sessionDataStore = {};
|
||||
|
||||
// Store lite task session data for detail page access
|
||||
// Key: session key, Value: lite session data object
|
||||
const liteTaskDataStore = {};
|
||||
|
||||
// Store task JSON data in a global map instead of inline script tags
|
||||
// Key: unique task ID, Value: raw task JSON data
|
||||
const taskJsonStore = {};
|
||||
153
ccw/src/templates/dashboard-js/utils.js
Normal file
153
ccw/src/templates/dashboard-js/utils.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ========================================
|
||||
// Utility Functions
|
||||
// ========================================
|
||||
// General-purpose helper functions used across the application
|
||||
|
||||
// ========== HTML/Text Processing ==========
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS attacks
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} Escaped string safe for HTML insertion
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to specified maximum length
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLen - Maximum length (including ellipsis)
|
||||
* @returns {string} Truncated text with '...' if needed
|
||||
*/
|
||||
function truncateText(text, maxLen) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLen ? text.substring(0, maxLen - 3) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings in content
|
||||
* Handles both literal \r\n escape sequences and actual newlines
|
||||
* @param {string} content - Content to normalize
|
||||
* @returns {string} Content with normalized line endings (LF only)
|
||||
*/
|
||||
function normalizeLineEndings(content) {
|
||||
if (!content) return '';
|
||||
let normalized = content;
|
||||
// If content has literal \r\n or \n as text (escaped), convert to actual newlines
|
||||
if (normalized.includes('\\r\\n')) {
|
||||
normalized = normalized.replace(/\\r\\n/g, '\n');
|
||||
} else if (normalized.includes('\\n')) {
|
||||
normalized = normalized.replace(/\\n/g, '\n');
|
||||
}
|
||||
// Normalize CRLF to LF for consistent rendering
|
||||
normalized = normalized.replace(/\r\n/g, '\n');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ========== Date/Time Formatting ==========
|
||||
|
||||
/**
|
||||
* Format ISO date string to human-readable format
|
||||
* @param {string} dateStr - ISO date string
|
||||
* @returns {string} Formatted date string (YYYY/MM/DD HH:mm) or '-' if invalid
|
||||
*/
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) return '-';
|
||||
// Format: YYYY/MM/DD HH:mm
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
||||
} catch (e) {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== UI Helpers ==========
|
||||
|
||||
/**
|
||||
* Get color for relevance score visualization
|
||||
* @param {number} score - Relevance score (0-1)
|
||||
* @returns {string} CSS color value
|
||||
*/
|
||||
function getRelevanceColor(score) {
|
||||
if (score >= 0.95) return '#10b981';
|
||||
if (score >= 0.90) return '#3b82f6';
|
||||
if (score >= 0.80) return '#f59e0b';
|
||||
return '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for role badge styling
|
||||
* @param {string} role - Role identifier
|
||||
* @returns {string} CSS class name
|
||||
*/
|
||||
function getRoleBadgeClass(role) {
|
||||
const roleMap = {
|
||||
'core-hook': 'primary',
|
||||
'api-client': 'success',
|
||||
'api-router': 'info',
|
||||
'service-layer': 'warning',
|
||||
'pydantic-schemas': 'secondary',
|
||||
'orm-model': 'secondary',
|
||||
'typescript-types': 'info'
|
||||
};
|
||||
return roleMap[role] || 'secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle collapsible section visibility
|
||||
* @param {HTMLElement} header - Section header element
|
||||
*/
|
||||
function toggleSection(header) {
|
||||
const content = header.nextElementSibling;
|
||||
const icon = header.querySelector('.collapse-icon');
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
|
||||
content.classList.toggle('collapsed');
|
||||
header.classList.toggle('expanded');
|
||||
icon.textContent = isCollapsed ? '▼' : '▶';
|
||||
|
||||
// Render flowchart if expanding flow_control section
|
||||
if (isCollapsed && header.querySelector('.section-label')?.textContent === 'flow_control') {
|
||||
const taskId = content.closest('[data-task-id]')?.dataset.taskId;
|
||||
if (taskId) {
|
||||
const task = taskJsonStore[taskId];
|
||||
if (task?.flow_control) {
|
||||
setTimeout(() => renderFullFlowchart(task.flow_control), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize collapsible sections within a container
|
||||
* @param {HTMLElement} container - Container element to search within
|
||||
*/
|
||||
function initCollapsibleSections(container) {
|
||||
setTimeout(() => {
|
||||
const headers = container.querySelectorAll('.collapsible-header');
|
||||
headers.forEach(header => {
|
||||
if (!header._clickBound) {
|
||||
header._clickBound = true;
|
||||
header.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
toggleSection(this);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
180
ccw/src/templates/dashboard-js/views/fix-session.js
Normal file
180
ccw/src/templates/dashboard-js/views/fix-session.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// ============================================
|
||||
// FIX SESSION VIEW
|
||||
// ============================================
|
||||
// Fix session detail page rendering
|
||||
|
||||
function renderFixSessionDetailPage(session) {
|
||||
const isActive = session._isActive !== false;
|
||||
const tasks = session.tasks || [];
|
||||
|
||||
// Calculate fix statistics
|
||||
const totalTasks = tasks.length;
|
||||
const fixedCount = tasks.filter(t => t.status === 'completed' && t.result === 'fixed').length;
|
||||
const failedCount = tasks.filter(t => t.status === 'completed' && t.result === 'failed').length;
|
||||
const pendingCount = tasks.filter(t => t.status === 'pending').length;
|
||||
const inProgressCount = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const percentComplete = totalTasks > 0 ? ((fixedCount + failedCount) / totalTasks * 100) : 0;
|
||||
|
||||
return `
|
||||
<div class="session-detail-page session-type-fix">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<button class="btn-back" onclick="goBackToSessions()">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to Sessions</span>
|
||||
</button>
|
||||
<div class="detail-title-row">
|
||||
<h2 class="detail-session-id">🔧 ${escapeHtml(session.session_id)}</h2>
|
||||
<div class="detail-badges">
|
||||
<span class="session-type-badge test-fix">Fix</span>
|
||||
<span class="session-status ${isActive ? 'active' : 'archived'}">
|
||||
${isActive ? 'ACTIVE' : 'ARCHIVED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fix Progress Section -->
|
||||
<div class="fix-progress-section">
|
||||
<div class="fix-progress-header">
|
||||
<h3>🔧 Fix Progress</h3>
|
||||
<span class="phase-badge ${session.phase || 'execution'}">${session.phase || 'Execution'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="fix-progress-bar">
|
||||
<div class="fix-progress-bar-fill" style="width: ${percentComplete}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
<strong>${fixedCount + failedCount}/${totalTasks}</strong> completed (${percentComplete.toFixed(1)}%)
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="fix-summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon">📊</div>
|
||||
<div class="summary-value">${totalTasks}</div>
|
||||
<div class="summary-label">Total Tasks</div>
|
||||
</div>
|
||||
<div class="summary-card fixed">
|
||||
<div class="summary-icon">✅</div>
|
||||
<div class="summary-value">${fixedCount}</div>
|
||||
<div class="summary-label">Fixed</div>
|
||||
</div>
|
||||
<div class="summary-card failed">
|
||||
<div class="summary-icon">❌</div>
|
||||
<div class="summary-value">${failedCount}</div>
|
||||
<div class="summary-label">Failed</div>
|
||||
</div>
|
||||
<div class="summary-card pending">
|
||||
<div class="summary-icon">⏳</div>
|
||||
<div class="summary-value">${pendingCount}</div>
|
||||
<div class="summary-label">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage Timeline (if available) -->
|
||||
${session.stages && session.stages.length > 0 ? `
|
||||
<div class="stage-timeline">
|
||||
${session.stages.map((stage, idx) => `
|
||||
<div class="stage-item ${stage.status || 'pending'}">
|
||||
<div class="stage-number">Stage ${idx + 1}</div>
|
||||
<div class="stage-mode">${stage.execution_mode === 'parallel' ? '⚡ Parallel' : '➡️ Serial'}</div>
|
||||
<div class="stage-groups">${stage.groups?.length || 0} groups</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Fix Tasks Grid -->
|
||||
<div class="fix-tasks-section">
|
||||
<div class="tasks-header">
|
||||
<h3>📋 Fix Tasks</h3>
|
||||
<div class="task-filters">
|
||||
<button class="filter-btn active" data-status="all" onclick="filterFixTasks('all')">All</button>
|
||||
<button class="filter-btn" data-status="pending" onclick="filterFixTasks('pending')">Pending</button>
|
||||
<button class="filter-btn" data-status="in_progress" onclick="filterFixTasks('in_progress')">In Progress</button>
|
||||
<button class="filter-btn" data-status="fixed" onclick="filterFixTasks('fixed')">Fixed</button>
|
||||
<button class="filter-btn" data-status="failed" onclick="filterFixTasks('failed')">Failed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fix-tasks-grid" id="fixTasksGrid">
|
||||
${renderFixTasksGrid(tasks)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Info -->
|
||||
<div class="detail-info-bar">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created:</span>
|
||||
<span class="info-value">${formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
${session.archived_at ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Archived:</span>
|
||||
<span class="info-value">${formatDate(session.archived_at)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Project:</span>
|
||||
<span class="info-value">${escapeHtml(session.project || '-')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFixTasksGrid(tasks) {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-text">No fix tasks found</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return tasks.map(task => {
|
||||
const statusClass = task.status === 'completed' ? (task.result || 'completed') : task.status;
|
||||
const statusText = task.status === 'completed' ? (task.result || 'completed') : task.status;
|
||||
|
||||
return `
|
||||
<div class="fix-task-card status-${statusClass}" data-status="${statusClass}">
|
||||
<div class="task-card-header">
|
||||
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
|
||||
<span class="task-status-badge ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="task-card-title">${escapeHtml(task.title || 'Untitled Task')}</div>
|
||||
${task.finding_title ? `<div class="task-finding">${escapeHtml(task.finding_title)}</div>` : ''}
|
||||
${task.file ? `<div class="task-file">📄 ${escapeHtml(task.file)}${task.line ? ':' + task.line : ''}</div>` : ''}
|
||||
<div class="task-card-meta">
|
||||
${task.dimension ? `<span class="task-dimension">${escapeHtml(task.dimension)}</span>` : ''}
|
||||
${task.attempts && task.attempts > 1 ? `<span class="task-attempts">🔄 ${task.attempts} attempts</span>` : ''}
|
||||
${task.commit_hash ? `<span class="task-commit">💾 ${task.commit_hash.substring(0, 7)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function initFixSessionPage(session) {
|
||||
// Initialize event handlers for fix session page
|
||||
// Filter handlers are inline onclick
|
||||
}
|
||||
|
||||
function filterFixTasks(status) {
|
||||
// Update filter buttons
|
||||
document.querySelectorAll('.task-filters .filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.status === status);
|
||||
});
|
||||
|
||||
// Filter task cards
|
||||
document.querySelectorAll('.fix-task-card').forEach(card => {
|
||||
if (status === 'all' || card.dataset.status === status) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
193
ccw/src/templates/dashboard-js/views/home.js
Normal file
193
ccw/src/templates/dashboard-js/views/home.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// ==========================================
|
||||
// HOME VIEW - Dashboard Homepage
|
||||
// ==========================================
|
||||
|
||||
function renderDashboard() {
|
||||
// Show stats grid and search (may be hidden by MCP view)
|
||||
showStatsAndSearch();
|
||||
|
||||
updateStats();
|
||||
updateBadges();
|
||||
updateCarousel();
|
||||
renderSessions();
|
||||
document.getElementById('generatedAt').textContent = workflowData.generatedAt || new Date().toISOString();
|
||||
}
|
||||
|
||||
function showStatsAndSearch() {
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = '';
|
||||
if (searchInput) searchInput.parentElement.style.display = '';
|
||||
}
|
||||
|
||||
function hideStatsAndCarousel() {
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const stats = workflowData.statistics || {};
|
||||
document.getElementById('statTotalSessions').textContent = stats.totalSessions || 0;
|
||||
document.getElementById('statActiveSessions').textContent = stats.activeSessions || 0;
|
||||
document.getElementById('statTotalTasks').textContent = stats.totalTasks || 0;
|
||||
document.getElementById('statCompletedTasks').textContent = stats.completedTasks || 0;
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
const active = workflowData.activeSessions || [];
|
||||
const archived = workflowData.archivedSessions || [];
|
||||
|
||||
document.getElementById('badgeAll').textContent = active.length + archived.length;
|
||||
document.getElementById('badgeActive').textContent = active.length;
|
||||
document.getElementById('badgeArchived').textContent = archived.length;
|
||||
|
||||
// Lite Tasks badges
|
||||
const liteTasks = workflowData.liteTasks || {};
|
||||
document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0;
|
||||
document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0;
|
||||
|
||||
// MCP badge - load async if needed
|
||||
if (typeof loadMcpConfig === 'function') {
|
||||
loadMcpConfig().then(() => {
|
||||
if (typeof updateMcpBadge === 'function') {
|
||||
updateMcpBadge();
|
||||
}
|
||||
}).catch(e => console.error('MCP badge update failed:', e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSessions() {
|
||||
const container = document.getElementById('mainContent');
|
||||
|
||||
let sessions = [];
|
||||
|
||||
if (currentFilter === 'all' || currentFilter === 'active') {
|
||||
sessions = sessions.concat((workflowData.activeSessions || []).map(s => ({ ...s, _isActive: true })));
|
||||
}
|
||||
if (currentFilter === 'all' || currentFilter === 'archived') {
|
||||
sessions = sessions.concat((workflowData.archivedSessions || []).map(s => ({ ...s, _isActive: false })));
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="grid-column: 1/-1;">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div class="empty-title">No Sessions Found</div>
|
||||
<div class="empty-text">No workflow sessions match your current filter.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="sessions-grid">${sessions.map(session => renderSessionCard(session)).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderSessionCard(session) {
|
||||
const tasks = session.tasks || [];
|
||||
const taskCount = session.taskCount || tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
|
||||
|
||||
// Use _isActive flag set during rendering, default to true
|
||||
const isActive = session._isActive !== false;
|
||||
const date = session.created_at;
|
||||
|
||||
// Get session type badge
|
||||
const sessionType = session.type || 'workflow';
|
||||
const typeBadge = sessionType !== 'workflow' ? `<span class="session-type-badge ${sessionType}">${sessionType}</span>` : '';
|
||||
|
||||
// Store session data for modal
|
||||
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
sessionDataStore[sessionKey] = session;
|
||||
|
||||
// Special rendering for review sessions
|
||||
if (sessionType === 'review') {
|
||||
return renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="session-header">
|
||||
<div class="session-title">${escapeHtml(session.session_id || 'Unknown')}</div>
|
||||
<div class="session-badges">
|
||||
${typeBadge}
|
||||
<span class="session-status ${isActive ? 'active' : 'archived'}">
|
||||
${isActive ? 'ACTIVE' : 'ARCHIVED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-meta">
|
||||
<span class="session-meta-item">📅 ${formatDate(date)}</span>
|
||||
<span class="session-meta-item">📋 ${taskCount} tasks</span>
|
||||
</div>
|
||||
${taskCount > 0 ? `
|
||||
<div class="progress-container">
|
||||
<span class="progress-label">Progress</span>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">${completed}/${taskCount} (${progress}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special card rendering for review sessions
|
||||
function renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date) {
|
||||
// Calculate findings stats from reviewDimensions
|
||||
const dimensions = session.reviewDimensions || [];
|
||||
let totalFindings = 0;
|
||||
let criticalCount = 0;
|
||||
let highCount = 0;
|
||||
let mediumCount = 0;
|
||||
let lowCount = 0;
|
||||
|
||||
dimensions.forEach(dim => {
|
||||
const findings = dim.findings || [];
|
||||
totalFindings += findings.length;
|
||||
criticalCount += findings.filter(f => f.severity === 'critical').length;
|
||||
highCount += findings.filter(f => f.severity === 'high').length;
|
||||
mediumCount += findings.filter(f => f.severity === 'medium').length;
|
||||
lowCount += findings.filter(f => f.severity === 'low').length;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
|
||||
<div class="session-header">
|
||||
<div class="session-title">${escapeHtml(session.session_id || 'Unknown')}</div>
|
||||
<div class="session-badges">
|
||||
${typeBadge}
|
||||
<span class="session-status ${isActive ? 'active' : 'archived'}">
|
||||
${isActive ? 'ACTIVE' : 'ARCHIVED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-meta">
|
||||
<span class="session-meta-item">📅 ${formatDate(date)}</span>
|
||||
<span class="session-meta-item">🔍 ${totalFindings} findings</span>
|
||||
</div>
|
||||
${totalFindings > 0 ? `
|
||||
<div class="review-findings-summary">
|
||||
<div class="findings-severity-row">
|
||||
${criticalCount > 0 ? `<span class="finding-count critical">🔴 ${criticalCount}</span>` : ''}
|
||||
${highCount > 0 ? `<span class="finding-count high">🟠 ${highCount}</span>` : ''}
|
||||
${mediumCount > 0 ? `<span class="finding-count medium">🟡 ${mediumCount}</span>` : ''}
|
||||
${lowCount > 0 ? `<span class="finding-count low">🟢 ${lowCount}</span>` : ''}
|
||||
</div>
|
||||
<div class="dimensions-info">
|
||||
${dimensions.length} dimensions
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
387
ccw/src/templates/dashboard-js/views/hook-manager.js
Normal file
387
ccw/src/templates/dashboard-js/views/hook-manager.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// Hook Manager View
|
||||
// Renders the Claude Code hooks management interface
|
||||
|
||||
async function renderHookManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search for Hook view
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Load hook config if not already loaded
|
||||
if (!hookConfig.global.hooks && !hookConfig.project.hooks) {
|
||||
await loadHookConfig();
|
||||
}
|
||||
|
||||
const globalHooks = hookConfig.global?.hooks || {};
|
||||
const projectHooks = hookConfig.project?.hooks || {};
|
||||
|
||||
// Count hooks
|
||||
const globalHookCount = countHooks(globalHooks);
|
||||
const projectHookCount = countHooks(projectHooks);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="hook-manager">
|
||||
<!-- Project Hooks -->
|
||||
<div class="hook-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Project Hooks</h3>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-light text-primary">.claude/settings.json</span>
|
||||
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="openHookCreateModal()">
|
||||
<span>+</span> New Hook
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${projectHookCount} hooks configured</span>
|
||||
</div>
|
||||
|
||||
${projectHookCount === 0 ? `
|
||||
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-3xl mb-3">🪝</div>
|
||||
<p class="text-muted-foreground">No hooks configured for this project</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Create a hook to automate actions on tool usage</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="hook-grid grid gap-3">
|
||||
${renderHooksByEvent(projectHooks, 'project')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Global Hooks -->
|
||||
<div class="hook-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Global Hooks</h3>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-muted text-muted-foreground">~/.claude/settings.json</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${globalHookCount} hooks configured</span>
|
||||
</div>
|
||||
|
||||
${globalHookCount === 0 ? `
|
||||
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-muted-foreground">No global hooks configured</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Global hooks apply to all Claude Code sessions</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="hook-grid grid gap-3">
|
||||
${renderHooksByEvent(globalHooks, 'global')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Quick Install Templates -->
|
||||
<div class="hook-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Quick Install Templates</h3>
|
||||
<span class="text-sm text-muted-foreground">One-click hook installation</span>
|
||||
</div>
|
||||
|
||||
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
${renderQuickInstallCard('ccw-notify', 'CCW Dashboard Notify', 'Notify CCW dashboard when files are written', 'PostToolUse', 'Write')}
|
||||
${renderQuickInstallCard('log-tool', 'Tool Usage Logger', 'Log all tool executions to a file', 'PostToolUse', 'All')}
|
||||
${renderQuickInstallCard('lint-check', 'Auto Lint Check', 'Run ESLint on JavaScript/TypeScript files after write', 'PostToolUse', 'Write')}
|
||||
${renderQuickInstallCard('git-add', 'Auto Git Stage', 'Automatically stage written files to git', 'PostToolUse', 'Write')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook Environment Variables Reference -->
|
||||
<div class="hook-section mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Environment Variables Reference</h3>
|
||||
</div>
|
||||
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_FILE_PATHS</code>
|
||||
<span class="text-muted-foreground">Space-separated file paths affected</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_NAME</code>
|
||||
<span class="text-muted-foreground">Name of the tool being executed</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_INPUT</code>
|
||||
<span class="text-muted-foreground">JSON input passed to the tool</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_SESSION_ID</code>
|
||||
<span class="text-muted-foreground">Current Claude session ID</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_PROJECT_DIR</code>
|
||||
<span class="text-muted-foreground">Current project directory path</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_WORKING_DIR</code>
|
||||
<span class="text-muted-foreground">Current working directory</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners
|
||||
attachHookEventListeners();
|
||||
}
|
||||
|
||||
function countHooks(hooks) {
|
||||
let count = 0;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const hookList = hooks[event];
|
||||
count += Array.isArray(hookList) ? hookList.length : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderHooksByEvent(hooks, scope) {
|
||||
const events = Object.keys(hooks);
|
||||
if (events.length === 0) return '';
|
||||
|
||||
return events.map(event => {
|
||||
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
|
||||
|
||||
return hookList.map((hook, index) => {
|
||||
const matcher = hook.matcher || 'All tools';
|
||||
const command = hook.command || 'N/A';
|
||||
const args = hook.args || [];
|
||||
|
||||
return `
|
||||
<div class="hook-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${getHookEventIcon(event)}</span>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${event}</h4>
|
||||
<p class="text-xs text-muted-foreground">${getHookEventDescription(event)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded transition-colors"
|
||||
data-scope="${scope}"
|
||||
data-event="${event}"
|
||||
data-index="${index}"
|
||||
data-action="edit"
|
||||
title="Edit hook">
|
||||
✏️
|
||||
</button>
|
||||
<button class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
data-scope="${scope}"
|
||||
data-event="${event}"
|
||||
data-index="${index}"
|
||||
data-action="delete"
|
||||
title="Delete hook">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hook-details text-sm space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">matcher</span>
|
||||
<span class="text-muted-foreground">${escapeHtml(matcher)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">command</span>
|
||||
<span class="font-mono text-xs text-foreground">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${args.length > 0 ? `
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
|
||||
<span class="font-mono text-xs text-muted-foreground truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderQuickInstallCard(templateId, title, description, event, matcher) {
|
||||
const isInstalled = isHookTemplateInstalled(templateId);
|
||||
|
||||
return `
|
||||
<div class="hook-template-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isInstalled ? 'border-success bg-success-light/30' : ''}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${isInstalled ? '✅' : '🪝'}</span>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(title)}</h4>
|
||||
<p class="text-xs text-muted-foreground">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hook-template-meta text-xs text-muted-foreground mb-3 flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="font-mono bg-muted px-1 py-0.5 rounded">${event}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
Matches: <span class="font-medium">${matcher}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
${isInstalled ? `
|
||||
<button class="flex-1 px-3 py-1.5 text-sm bg-destructive/10 text-destructive rounded hover:bg-destructive/20 transition-colors"
|
||||
data-template="${templateId}"
|
||||
data-action="uninstall">
|
||||
Uninstall
|
||||
</button>
|
||||
` : `
|
||||
<button class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-template="${templateId}"
|
||||
data-action="install-project">
|
||||
Install (Project)
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm bg-muted text-foreground rounded hover:bg-hover transition-colors"
|
||||
data-template="${templateId}"
|
||||
data-action="install-global">
|
||||
Global
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function isHookTemplateInstalled(templateId) {
|
||||
const template = HOOK_TEMPLATES[templateId];
|
||||
if (!template) return false;
|
||||
|
||||
// Check project hooks
|
||||
const projectHooks = hookConfig.project?.hooks?.[template.event];
|
||||
if (projectHooks) {
|
||||
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
|
||||
if (hookList.some(h => h.command === template.command)) return true;
|
||||
}
|
||||
|
||||
// Check global hooks
|
||||
const globalHooks = hookConfig.global?.hooks?.[template.event];
|
||||
if (globalHooks) {
|
||||
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
|
||||
if (hookList.some(h => h.command === template.command)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function installHookTemplate(templateId, scope) {
|
||||
const template = HOOK_TEMPLATES[templateId];
|
||||
if (!template) {
|
||||
showRefreshToast('Template not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const hookData = {
|
||||
command: template.command,
|
||||
args: template.args
|
||||
};
|
||||
|
||||
if (template.matcher) {
|
||||
hookData.matcher = template.matcher;
|
||||
}
|
||||
|
||||
await saveHook(scope, template.event, hookData);
|
||||
}
|
||||
|
||||
async function uninstallHookTemplate(templateId) {
|
||||
const template = HOOK_TEMPLATES[templateId];
|
||||
if (!template) return;
|
||||
|
||||
// Find and remove from project hooks
|
||||
const projectHooks = hookConfig.project?.hooks?.[template.event];
|
||||
if (projectHooks) {
|
||||
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
|
||||
const index = hookList.findIndex(h => h.command === template.command);
|
||||
if (index !== -1) {
|
||||
await removeHook('project', template.event, index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and remove from global hooks
|
||||
const globalHooks = hookConfig.global?.hooks?.[template.event];
|
||||
if (globalHooks) {
|
||||
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
|
||||
const index = hookList.findIndex(h => h.command === template.command);
|
||||
if (index !== -1) {
|
||||
await removeHook('global', template.event, index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachHookEventListeners() {
|
||||
// Edit buttons
|
||||
document.querySelectorAll('.hook-card button[data-action="edit"]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const scope = e.target.dataset.scope;
|
||||
const event = e.target.dataset.event;
|
||||
const index = parseInt(e.target.dataset.index);
|
||||
|
||||
const hooks = scope === 'global' ? hookConfig.global.hooks : hookConfig.project.hooks;
|
||||
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
|
||||
const hook = hookList[index];
|
||||
|
||||
if (hook) {
|
||||
openHookCreateModal({
|
||||
scope: scope,
|
||||
event: event,
|
||||
index: index,
|
||||
matcher: hook.matcher || '',
|
||||
command: hook.command,
|
||||
args: hook.args || []
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete buttons
|
||||
document.querySelectorAll('.hook-card button[data-action="delete"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const scope = e.target.dataset.scope;
|
||||
const event = e.target.dataset.event;
|
||||
const index = parseInt(e.target.dataset.index);
|
||||
|
||||
if (confirm(`Remove this ${event} hook?`)) {
|
||||
await removeHook(scope, event, index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Install project buttons
|
||||
document.querySelectorAll('button[data-action="install-project"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const templateId = e.target.dataset.template;
|
||||
await installHookTemplate(templateId, 'project');
|
||||
});
|
||||
});
|
||||
|
||||
// Install global buttons
|
||||
document.querySelectorAll('button[data-action="install-global"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const templateId = e.target.dataset.template;
|
||||
await installHookTemplate(templateId, 'global');
|
||||
});
|
||||
});
|
||||
|
||||
// Uninstall buttons
|
||||
document.querySelectorAll('button[data-action="uninstall"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const templateId = e.target.dataset.template;
|
||||
await uninstallHookTemplate(templateId);
|
||||
});
|
||||
});
|
||||
}
|
||||
390
ccw/src/templates/dashboard-js/views/lite-tasks.js
Normal file
390
ccw/src/templates/dashboard-js/views/lite-tasks.js
Normal file
@@ -0,0 +1,390 @@
|
||||
// ============================================
|
||||
// LITE TASKS VIEW
|
||||
// ============================================
|
||||
// Lite-plan and lite-fix task list and detail rendering
|
||||
|
||||
function renderLiteTasks() {
|
||||
const container = document.getElementById('mainContent');
|
||||
|
||||
const liteTasks = workflowData.liteTasks || {};
|
||||
const sessions = currentLiteType === 'lite-plan'
|
||||
? liteTasks.litePlan || []
|
||||
: liteTasks.liteFix || [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚡</div>
|
||||
<div class="empty-title">No ${currentLiteType} Sessions</div>
|
||||
<div class="empty-text">No sessions found in .workflow/.${currentLiteType}/</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="sessions-grid">${sessions.map(session => renderLiteTaskCard(session)).join('')}</div>`;
|
||||
|
||||
// Initialize collapsible sections
|
||||
document.querySelectorAll('.collapsible-header').forEach(header => {
|
||||
header.addEventListener('click', () => toggleSection(header));
|
||||
});
|
||||
|
||||
// Render flowcharts for expanded tasks
|
||||
sessions.forEach(session => {
|
||||
session.tasks?.forEach(task => {
|
||||
if (task.flow_control?.implementation_approach) {
|
||||
renderFlowchartForTask(session.id, task);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderLiteTaskCard(session) {
|
||||
const tasks = session.tasks || [];
|
||||
|
||||
// Store session data for detail page
|
||||
const sessionKey = `lite-${session.type}-${session.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
liteTaskDataStore[sessionKey] = session;
|
||||
|
||||
return `
|
||||
<div class="session-card lite-task-card" onclick="showLiteTaskDetailPage('${sessionKey}')" style="cursor: pointer;">
|
||||
<div class="session-header">
|
||||
<div class="session-title">${escapeHtml(session.id)}</div>
|
||||
<span class="session-status ${session.type}">
|
||||
${session.type === 'lite-plan' ? '📝 PLAN' : '🔧 FIX'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-meta">
|
||||
<span class="session-meta-item">📅 ${formatDate(session.createdAt)}</span>
|
||||
<span class="session-meta-item">📋 ${tasks.length} tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Lite Task Detail Page
|
||||
function showLiteTaskDetailPage(sessionKey) {
|
||||
const session = liteTaskDataStore[sessionKey];
|
||||
if (!session) return;
|
||||
|
||||
currentView = 'liteTaskDetail';
|
||||
currentSessionDetailKey = sessionKey;
|
||||
|
||||
// Hide stats grid and carousel on detail pages
|
||||
hideStatsAndCarousel();
|
||||
|
||||
// Also store in sessionDataStore for tab switching compatibility
|
||||
sessionDataStore[sessionKey] = {
|
||||
...session,
|
||||
session_id: session.id,
|
||||
created_at: session.createdAt,
|
||||
path: session.path,
|
||||
type: session.type
|
||||
};
|
||||
|
||||
const container = document.getElementById('mainContent');
|
||||
const tasks = session.tasks || [];
|
||||
const plan = session.plan || {};
|
||||
const progress = session.progress || { total: 0, completed: 0, percentage: 0 };
|
||||
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="session-detail-page lite-task-detail-page">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<button class="btn-back" onclick="goBackToLiteTasks()">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to ${session.type === 'lite-plan' ? 'Lite Plan' : 'Lite Fix'}</span>
|
||||
</button>
|
||||
<div class="detail-title-row">
|
||||
<h2 class="detail-session-id">${session.type === 'lite-plan' ? '📝' : '🔧'} ${escapeHtml(session.id)}</h2>
|
||||
<div class="detail-badges">
|
||||
<span class="session-type-badge ${session.type}">${session.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Info Bar -->
|
||||
<div class="detail-info-bar">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created:</span>
|
||||
<span class="info-value">${formatDate(session.createdAt)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Tasks:</span>
|
||||
<span class="info-value">${tasks.length} tasks</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="detail-tabs">
|
||||
<button class="detail-tab active" data-tab="tasks" onclick="switchLiteDetailTab('tasks')">
|
||||
<span class="tab-icon">📋</span>
|
||||
<span class="tab-text">Tasks</span>
|
||||
<span class="tab-count">${tasks.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="plan" onclick="switchLiteDetailTab('plan')">
|
||||
<span class="tab-icon">📐</span>
|
||||
<span class="tab-text">Plan</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="context" onclick="switchLiteDetailTab('context')">
|
||||
<span class="tab-icon">📦</span>
|
||||
<span class="tab-text">Context</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="summary" onclick="switchLiteDetailTab('summary')">
|
||||
<span class="tab-icon">📝</span>
|
||||
<span class="tab-text">Summary</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="detail-tab-content" id="liteDetailTabContent">
|
||||
${renderLiteTasksTab(session, tasks, completed, inProgress, pending)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize collapsible sections
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.collapsible-header').forEach(header => {
|
||||
header.addEventListener('click', () => toggleSection(header));
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function goBackToLiteTasks() {
|
||||
currentView = 'liteTasks';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderLiteTasks();
|
||||
}
|
||||
|
||||
function switchLiteDetailTab(tabName) {
|
||||
// Update active tab
|
||||
document.querySelectorAll('.detail-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
const session = liteTaskDataStore[currentSessionDetailKey];
|
||||
if (!session) return;
|
||||
|
||||
const contentArea = document.getElementById('liteDetailTabContent');
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
|
||||
switch (tabName) {
|
||||
case 'tasks':
|
||||
contentArea.innerHTML = renderLiteTasksTab(session, tasks, completed, inProgress, pending);
|
||||
// Re-initialize collapsible sections
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.collapsible-header').forEach(header => {
|
||||
header.addEventListener('click', () => toggleSection(header));
|
||||
});
|
||||
}, 50);
|
||||
break;
|
||||
case 'plan':
|
||||
contentArea.innerHTML = renderLitePlanTab(session);
|
||||
break;
|
||||
case 'context':
|
||||
loadAndRenderLiteContextTab(session, contentArea);
|
||||
break;
|
||||
case 'summary':
|
||||
loadAndRenderLiteSummaryTab(session, contentArea);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLiteTasksTab(session, tasks, completed, inProgress, pending) {
|
||||
// Populate drawer tasks for click-to-open functionality
|
||||
currentDrawerTasks = tasks;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-title">No Tasks</div>
|
||||
<div class="empty-text">This session has no tasks defined.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="tasks-tab-content">
|
||||
<div class="tasks-list" id="liteTasksListContent">
|
||||
${tasks.map(task => renderLiteTaskDetailItem(session.id, task)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLiteTaskDetailItem(sessionId, task) {
|
||||
const rawTask = task._raw || task;
|
||||
const taskJsonId = `task-json-${sessionId}-${task.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
taskJsonStore[taskJsonId] = rawTask;
|
||||
|
||||
// Get preview info for lite tasks
|
||||
const action = rawTask.action || '';
|
||||
const scope = rawTask.scope || '';
|
||||
const modCount = rawTask.modification_points?.length || 0;
|
||||
const implCount = rawTask.implementation?.length || 0;
|
||||
const acceptCount = rawTask.acceptance?.length || 0;
|
||||
|
||||
return `
|
||||
<div class="detail-task-item-full lite-task-item" onclick="openTaskDrawerForLite('${sessionId}', '${escapeHtml(task.id)}')" style="cursor: pointer;" title="Click to view details">
|
||||
<div class="task-item-header-lite">
|
||||
<span class="task-id-badge">${escapeHtml(task.id)}</span>
|
||||
<span class="task-title">${escapeHtml(task.title || 'Untitled')}</span>
|
||||
<button class="btn-view-json" onclick="event.stopPropagation(); showJsonModal('${taskJsonId}', '${escapeHtml(task.id)}')">{ } JSON</button>
|
||||
</div>
|
||||
<div class="task-item-meta-lite">
|
||||
${action ? `<span class="meta-badge action">${escapeHtml(action)}</span>` : ''}
|
||||
${scope ? `<span class="meta-badge scope">${escapeHtml(scope)}</span>` : ''}
|
||||
${modCount > 0 ? `<span class="meta-badge mods">${modCount} mods</span>` : ''}
|
||||
${implCount > 0 ? `<span class="meta-badge impl">${implCount} steps</span>` : ''}
|
||||
${acceptCount > 0 ? `<span class="meta-badge accept">${acceptCount} acceptance</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getMetaPreviewForLite(task, rawTask) {
|
||||
const meta = task.meta || {};
|
||||
const parts = [];
|
||||
if (meta.type || rawTask.action) parts.push(meta.type || rawTask.action);
|
||||
if (meta.scope || rawTask.scope) parts.push(meta.scope || rawTask.scope);
|
||||
return parts.join(' | ') || 'No meta';
|
||||
}
|
||||
|
||||
function openTaskDrawerForLite(sessionId, taskId) {
|
||||
const session = liteTaskDataStore[currentSessionDetailKey];
|
||||
if (!session) return;
|
||||
|
||||
const task = session.tasks?.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Set current drawer tasks and session context
|
||||
currentDrawerTasks = session.tasks || [];
|
||||
window._currentDrawerSession = session;
|
||||
|
||||
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
|
||||
// Use dedicated lite task drawer renderer
|
||||
document.getElementById('drawerContent').innerHTML = renderLiteTaskDrawerContent(task, session);
|
||||
document.getElementById('taskDetailDrawer').classList.add('open');
|
||||
document.getElementById('drawerOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
function renderLitePlanTab(session) {
|
||||
const plan = session.plan;
|
||||
|
||||
if (!plan) {
|
||||
return `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📐</div>
|
||||
<div class="empty-title">No Plan Data</div>
|
||||
<div class="empty-text">No plan.json found for this session.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="plan-tab-content">
|
||||
<!-- Summary -->
|
||||
${plan.summary ? `
|
||||
<div class="plan-section">
|
||||
<h4 class="plan-section-title">📋 Summary</h4>
|
||||
<p class="plan-summary-text">${escapeHtml(plan.summary)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Approach -->
|
||||
${plan.approach ? `
|
||||
<div class="plan-section">
|
||||
<h4 class="plan-section-title">🎯 Approach</h4>
|
||||
<p class="plan-approach-text">${escapeHtml(plan.approach)}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Focus Paths -->
|
||||
${plan.focus_paths?.length ? `
|
||||
<div class="plan-section">
|
||||
<h4 class="plan-section-title">📁 Focus Paths</h4>
|
||||
<div class="path-tags">
|
||||
${plan.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="plan-section">
|
||||
<h4 class="plan-section-title">ℹ️ Metadata</h4>
|
||||
<div class="plan-meta-grid">
|
||||
${plan.estimated_time ? `<div class="meta-item"><span class="meta-label">Estimated Time:</span> ${escapeHtml(plan.estimated_time)}</div>` : ''}
|
||||
${plan.complexity ? `<div class="meta-item"><span class="meta-label">Complexity:</span> ${escapeHtml(plan.complexity)}</div>` : ''}
|
||||
${plan.recommended_execution ? `<div class="meta-item"><span class="meta-label">Execution:</span> ${escapeHtml(plan.recommended_execution)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON -->
|
||||
<div class="plan-section">
|
||||
<h4 class="plan-section-title">{ } Raw JSON</h4>
|
||||
<pre class="json-content">${escapeHtml(JSON.stringify(plan, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAndRenderLiteContextTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading context data...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session);
|
||||
|
||||
// Re-initialize collapsible sections for explorations (scoped to contentArea)
|
||||
initCollapsibleSections(contentArea);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: show plan context if available
|
||||
contentArea.innerHTML = renderLiteContextContent(null, null, session);
|
||||
initCollapsibleSections(contentArea);
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderLiteSummaryTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading summaries...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderSummaryContent(data.summaries);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<div class="empty-title">No Summaries</div>
|
||||
<div class="empty-text">No summaries found in .summaries/</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load summaries: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
271
ccw/src/templates/dashboard-js/views/mcp-manager.js
Normal file
271
ccw/src/templates/dashboard-js/views/mcp-manager.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// MCP Manager View
|
||||
// Renders the MCP server management interface
|
||||
|
||||
async function renderMcpManager() {
|
||||
const container = document.getElementById('mainContent');
|
||||
if (!container) return;
|
||||
|
||||
// Hide stats grid and search for MCP view
|
||||
const statsGrid = document.getElementById('statsGrid');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (statsGrid) statsGrid.style.display = 'none';
|
||||
if (searchInput) searchInput.parentElement.style.display = 'none';
|
||||
|
||||
// Load MCP config if not already loaded
|
||||
if (!mcpConfig) {
|
||||
await loadMcpConfig();
|
||||
}
|
||||
|
||||
const currentPath = projectPath.replace(/\//g, '\\');
|
||||
const projectData = mcpAllProjects[currentPath] || {};
|
||||
const projectServers = projectData.mcpServers || {};
|
||||
const disabledServers = projectData.disabledMcpServers || [];
|
||||
|
||||
// Get all available servers from all projects
|
||||
const allAvailableServers = getAllAvailableMcpServers();
|
||||
|
||||
// Separate current project servers and available servers
|
||||
const currentProjectServerNames = Object.keys(projectServers);
|
||||
const otherAvailableServers = Object.entries(allAvailableServers)
|
||||
.filter(([name]) => !currentProjectServerNames.includes(name));
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mcp-manager">
|
||||
<!-- Current Project MCP Servers -->
|
||||
<div class="mcp-section mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Current Project MCP Servers</h3>
|
||||
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
|
||||
onclick="openMcpCreateModal()">
|
||||
<span>+</span> New Server
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">${currentProjectServerNames.length} servers configured</span>
|
||||
</div>
|
||||
|
||||
${currentProjectServerNames.length === 0 ? `
|
||||
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<div class="text-3xl mb-3">🔌</div>
|
||||
<p class="text-muted-foreground">No MCP servers configured for this project</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">Add servers from the available list below</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${currentProjectServerNames.map(serverName => {
|
||||
const serverConfig = projectServers[serverName];
|
||||
const isEnabled = !disabledServers.includes(serverName);
|
||||
return renderMcpServerCard(serverName, serverConfig, isEnabled, true);
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Available MCP Servers from Other Projects -->
|
||||
<div class="mcp-section">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Available from Other Projects</h3>
|
||||
<span class="text-sm text-muted-foreground">${otherAvailableServers.length} servers available</span>
|
||||
</div>
|
||||
|
||||
${otherAvailableServers.length === 0 ? `
|
||||
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
|
||||
<p class="text-muted-foreground">No additional MCP servers found in other projects</p>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mcp-server-grid grid gap-3">
|
||||
${otherAvailableServers.map(([serverName, serverInfo]) => {
|
||||
return renderAvailableServerCard(serverName, serverInfo);
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- All Projects MCP Overview Table -->
|
||||
<div class="mcp-section mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">All Projects MCP Overview</h3>
|
||||
<span class="text-sm text-muted-foreground">${Object.keys(mcpAllProjects).length} projects</span>
|
||||
</div>
|
||||
|
||||
<div class="mcp-projects-table bg-card border border-border rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/50">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-foreground border-b border-border">Project</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-semibold text-foreground border-b border-border">MCP Servers</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-foreground border-b border-border w-24">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${Object.entries(mcpAllProjects).map(([path, config]) => {
|
||||
const servers = config.mcpServers || {};
|
||||
const projectDisabled = config.disabledMcpServers || [];
|
||||
const serverNames = Object.keys(servers);
|
||||
const isCurrentProject = path === currentPath;
|
||||
const enabledCount = serverNames.filter(s => !projectDisabled.includes(s)).length;
|
||||
|
||||
return `
|
||||
<tr class="border-b border-border last:border-b-0 ${isCurrentProject ? 'bg-primary/5' : 'hover:bg-hover/50'}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-base shrink-0">${isCurrentProject ? '📍' : '📁'}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-foreground truncate text-sm" title="${escapeHtml(path)}">
|
||||
${escapeHtml(path.split('\\').pop() || path)}
|
||||
${isCurrentProject ? '<span class="ml-2 text-xs text-primary font-medium">(Current)</span>' : ''}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground truncate">${escapeHtml(path)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
${serverNames.length === 0
|
||||
? '<span class="text-xs text-muted-foreground italic">No MCP servers</span>'
|
||||
: serverNames.map(serverName => {
|
||||
const isEnabled = !projectDisabled.includes(serverName);
|
||||
return `
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${isEnabled ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${isEnabled ? 'bg-success' : 'bg-muted-foreground'}"></span>
|
||||
${escapeHtml(serverName)}
|
||||
</span>
|
||||
`;
|
||||
}).join('')
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${serverNames.length > 0 ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">
|
||||
${enabledCount}/${serverNames.length}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach event listeners for toggle switches
|
||||
attachMcpEventListeners();
|
||||
}
|
||||
|
||||
function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentProject) {
|
||||
const command = serverConfig.command || 'N/A';
|
||||
const args = serverConfig.args || [];
|
||||
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isEnabled ? '' : 'opacity-60'}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">${isEnabled ? '🟢' : '🔴'}</span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
</div>
|
||||
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer"
|
||||
${isEnabled ? 'checked' : ''}
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-action="toggle">
|
||||
<div class="w-9 h-5 bg-hover peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-success"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
|
||||
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
${args.length > 0 ? `
|
||||
<div class="flex items-start gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
|
||||
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${hasEnv ? `
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
|
||||
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${isInCurrentProject ? `
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-action="remove">
|
||||
Remove from project
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvailableServerCard(serverName, serverInfo) {
|
||||
const serverConfig = serverInfo.config;
|
||||
const usedIn = serverInfo.usedIn || [];
|
||||
const command = serverConfig.command || 'N/A';
|
||||
|
||||
return `
|
||||
<div class="mcp-server-card mcp-server-available bg-card border border-border border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">⚪</span>
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
|
||||
</div>
|
||||
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
data-server-name="${escapeHtml(serverName)}"
|
||||
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "'")}'
|
||||
data-action="add">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-server-details text-sm space-y-1">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
|
||||
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<span class="text-xs">Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachMcpEventListeners() {
|
||||
// Toggle switches
|
||||
document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => {
|
||||
input.addEventListener('change', async (e) => {
|
||||
const serverName = e.target.dataset.serverName;
|
||||
const enable = e.target.checked;
|
||||
await toggleMcpServer(serverName, enable);
|
||||
});
|
||||
});
|
||||
|
||||
// Add buttons
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="add"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = e.target.dataset.serverName;
|
||||
const serverConfig = JSON.parse(e.target.dataset.serverConfig);
|
||||
await copyMcpServerToProject(serverName, serverConfig);
|
||||
});
|
||||
});
|
||||
|
||||
// Remove buttons
|
||||
document.querySelectorAll('.mcp-server-card button[data-action="remove"]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const serverName = e.target.dataset.serverName;
|
||||
if (confirm(`Remove MCP server "${serverName}" from this project?`)) {
|
||||
await removeMcpServerFromProject(serverName);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
246
ccw/src/templates/dashboard-js/views/project-overview.js
Normal file
246
ccw/src/templates/dashboard-js/views/project-overview.js
Normal file
@@ -0,0 +1,246 @@
|
||||
// ==========================================
|
||||
// PROJECT OVERVIEW VIEW
|
||||
// ==========================================
|
||||
|
||||
function renderProjectOverview() {
|
||||
// Show stats grid and search (may be hidden by MCP view)
|
||||
if (typeof showStatsAndSearch === 'function') showStatsAndSearch();
|
||||
|
||||
const container = document.getElementById('mainContent');
|
||||
const project = workflowData.projectOverview;
|
||||
|
||||
if (!project) {
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="text-6xl mb-4">📋</div>
|
||||
<h3 class="text-xl font-semibold text-foreground mb-2">No Project Overview</h3>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
Run <code class="px-2 py-1 bg-muted rounded text-sm font-mono">/workflow:init</code> to initialize project analysis
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Project Header -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">${escapeHtml(project.projectName)}</h2>
|
||||
<p class="text-muted-foreground">${escapeHtml(project.description || 'No description available')}</p>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground text-right">
|
||||
<div>Initialized: ${formatDate(project.initializedAt)}</div>
|
||||
<div class="mt-1">Mode: <span class="font-mono text-xs px-2 py-0.5 bg-muted rounded">${escapeHtml(project.metadata?.analysis_mode || 'unknown')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technology Stack -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span>💻</span> Technology Stack
|
||||
</h3>
|
||||
|
||||
<!-- Languages -->
|
||||
<div class="mb-5">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Languages</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
${project.technologyStack.languages.map(lang => `
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg ${lang.primary ? 'ring-2 ring-primary' : ''}">
|
||||
<span class="font-semibold text-foreground">${escapeHtml(lang.name)}</span>
|
||||
<span class="text-xs text-muted-foreground">${lang.file_count} files</span>
|
||||
${lang.primary ? '<span class="text-xs px-1.5 py-0.5 bg-primary text-primary-foreground rounded">Primary</span>' : ''}
|
||||
</div>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">No languages detected</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frameworks -->
|
||||
<div class="mb-5">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Frameworks</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${project.technologyStack.frameworks.map(fw => `
|
||||
<span class="px-3 py-1.5 bg-success-light text-success rounded-lg text-sm font-medium">${escapeHtml(fw)}</span>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">No frameworks detected</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Tools -->
|
||||
<div class="mb-5">
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Build Tools</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${project.technologyStack.build_tools.map(tool => `
|
||||
<span class="px-3 py-1.5 bg-warning-light text-warning rounded-lg text-sm font-medium">${escapeHtml(tool)}</span>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">No build tools detected</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Frameworks -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Test Frameworks</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${project.technologyStack.test_frameworks.map(fw => `
|
||||
<span class="px-3 py-1.5 bg-accent text-accent-foreground rounded-lg text-sm font-medium">${escapeHtml(fw)}</span>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">No test frameworks detected</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Architecture -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span>🏗️</span> Architecture
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<!-- Style -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Style</h4>
|
||||
<div class="px-3 py-2 bg-background border border-border rounded-lg">
|
||||
<span class="text-foreground font-medium">${escapeHtml(project.architecture.style)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layers -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Layers</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${project.architecture.layers.map(layer => `
|
||||
<span class="px-2 py-1 bg-muted text-foreground rounded text-sm">${escapeHtml(layer)}</span>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">None</span>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patterns -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Patterns</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${project.architecture.patterns.map(pattern => `
|
||||
<span class="px-2 py-1 bg-muted text-foreground rounded text-sm">${escapeHtml(pattern)}</span>
|
||||
`).join('') || '<span class="text-muted-foreground text-sm">None</span>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Components -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span>⚙️</span> Key Components
|
||||
</h3>
|
||||
|
||||
${project.keyComponents.length > 0 ? `
|
||||
<div class="space-y-3">
|
||||
${project.keyComponents.map(comp => {
|
||||
const importanceColors = {
|
||||
high: 'border-l-4 border-l-destructive bg-destructive/5',
|
||||
medium: 'border-l-4 border-l-warning bg-warning/5',
|
||||
low: 'border-l-4 border-l-muted-foreground bg-muted'
|
||||
};
|
||||
const importanceBadges = {
|
||||
high: '<span class="px-2 py-0.5 text-xs font-semibold bg-destructive text-destructive-foreground rounded">High</span>',
|
||||
medium: '<span class="px-2 py-0.5 text-xs font-semibold bg-warning text-foreground rounded">Medium</span>',
|
||||
low: '<span class="px-2 py-0.5 text-xs font-semibold bg-muted text-muted-foreground rounded">Low</span>'
|
||||
};
|
||||
return `
|
||||
<div class="p-4 ${importanceColors[comp.importance] || importanceColors.low} rounded-lg">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h4 class="font-semibold text-foreground">${escapeHtml(comp.name)}</h4>
|
||||
${importanceBadges[comp.importance] || ''}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-2">${escapeHtml(comp.description)}</p>
|
||||
<code class="text-xs font-mono text-primary">${escapeHtml(comp.path)}</code>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : '<p class="text-muted-foreground text-sm">No key components identified</p>'}
|
||||
</div>
|
||||
|
||||
<!-- Development Index -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span>📝</span> Development History
|
||||
</h3>
|
||||
|
||||
${renderDevelopmentIndex(project.developmentIndex)}
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span>📊</span> Statistics
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="text-center p-4 bg-background rounded-lg">
|
||||
<div class="text-3xl font-bold text-primary mb-1">${project.statistics.total_features || 0}</div>
|
||||
<div class="text-sm text-muted-foreground">Total Features</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-background rounded-lg">
|
||||
<div class="text-3xl font-bold text-success mb-1">${project.statistics.total_sessions || 0}</div>
|
||||
<div class="text-sm text-muted-foreground">Total Sessions</div>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-background rounded-lg">
|
||||
<div class="text-sm text-muted-foreground mb-1">Last Updated</div>
|
||||
<div class="text-sm font-medium text-foreground">${formatDate(project.statistics.last_updated)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDevelopmentIndex(devIndex) {
|
||||
if (!devIndex) return '<p class="text-muted-foreground text-sm">No development history available</p>';
|
||||
|
||||
const categories = [
|
||||
{ key: 'feature', label: 'Features', icon: '✨', badgeClass: 'bg-primary-light text-primary' },
|
||||
{ key: 'enhancement', label: 'Enhancements', icon: '⚡', badgeClass: 'bg-success-light text-success' },
|
||||
{ key: 'bugfix', label: 'Bug Fixes', icon: '🐛', badgeClass: 'bg-destructive/10 text-destructive' },
|
||||
{ key: 'refactor', label: 'Refactorings', icon: '🔧', badgeClass: 'bg-warning-light text-warning' },
|
||||
{ key: 'docs', label: 'Documentation', icon: '📚', badgeClass: 'bg-muted text-muted-foreground' }
|
||||
];
|
||||
|
||||
const totalEntries = categories.reduce((sum, cat) => sum + (devIndex[cat.key]?.length || 0), 0);
|
||||
|
||||
if (totalEntries === 0) {
|
||||
return '<p class="text-muted-foreground text-sm">No development history entries</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="space-y-4">
|
||||
${categories.map(cat => {
|
||||
const entries = devIndex[cat.key] || [];
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<span>${cat.icon}</span>
|
||||
<span>${cat.label}</span>
|
||||
<span class="text-xs px-2 py-0.5 ${cat.badgeClass} rounded-full">${entries.length}</span>
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
${entries.slice(0, 5).map(entry => `
|
||||
<div class="p-3 bg-background border border-border rounded-lg hover:shadow-sm transition-shadow">
|
||||
<div class="flex items-start justify-between mb-1">
|
||||
<h5 class="font-medium text-foreground text-sm">${escapeHtml(entry.title)}</h5>
|
||||
<span class="text-xs text-muted-foreground">${formatDate(entry.date)}</span>
|
||||
</div>
|
||||
${entry.description ? `<p class="text-sm text-muted-foreground mb-1">${escapeHtml(entry.description)}</p>` : ''}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
${entry.sub_feature ? `<span class="px-2 py-0.5 bg-muted rounded">${escapeHtml(entry.sub_feature)}</span>` : ''}
|
||||
${entry.status ? `<span class="px-2 py-0.5 ${entry.status === 'completed' ? 'bg-success-light text-success' : 'bg-warning-light text-warning'} rounded">${escapeHtml(entry.status)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
${entries.length > 5 ? `<div class="text-sm text-muted-foreground text-center py-2">... and ${entries.length - 5} more</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
1030
ccw/src/templates/dashboard-js/views/review-session.js
Normal file
1030
ccw/src/templates/dashboard-js/views/review-session.js
Normal file
File diff suppressed because it is too large
Load Diff
770
ccw/src/templates/dashboard-js/views/session-detail.js
Normal file
770
ccw/src/templates/dashboard-js/views/session-detail.js
Normal file
@@ -0,0 +1,770 @@
|
||||
// ============================================
|
||||
// SESSION DETAIL VIEW
|
||||
// ============================================
|
||||
// Standard workflow session detail page rendering
|
||||
|
||||
function showSessionDetailPage(sessionKey) {
|
||||
const session = sessionDataStore[sessionKey];
|
||||
if (!session) return;
|
||||
|
||||
currentView = 'sessionDetail';
|
||||
currentSessionDetailKey = sessionKey;
|
||||
updateContentTitle();
|
||||
|
||||
// Hide stats grid and carousel on detail pages
|
||||
hideStatsAndCarousel();
|
||||
|
||||
const container = document.getElementById('mainContent');
|
||||
const sessionType = session.type || 'workflow';
|
||||
|
||||
// Render specialized pages for review and test-fix sessions
|
||||
if (sessionType === 'review' || sessionType === 'review-cycle') {
|
||||
container.innerHTML = renderReviewSessionDetailPage(session);
|
||||
initReviewSessionPage(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionType === 'test-fix' || sessionType === 'fix') {
|
||||
container.innerHTML = renderFixSessionDetailPage(session);
|
||||
initFixSessionPage(session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default workflow session detail page
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
const isActive = session._isActive !== false;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="session-detail-page">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<button class="btn-back" onclick="goBackToSessions()">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to Sessions</span>
|
||||
</button>
|
||||
<div class="detail-title-row">
|
||||
<h2 class="detail-session-id">${escapeHtml(session.session_id)}</h2>
|
||||
<div class="detail-badges">
|
||||
<span class="session-type-badge ${session.type || 'workflow'}">${session.type || 'workflow'}</span>
|
||||
<span class="session-status ${isActive ? 'active' : 'archived'}">
|
||||
${isActive ? 'ACTIVE' : 'ARCHIVED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Info Bar -->
|
||||
<div class="detail-info-bar">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Created:</span>
|
||||
<span class="info-value">${formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
${session.archived_at ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Archived:</span>
|
||||
<span class="info-value">${formatDate(session.archived_at)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Project:</span>
|
||||
<span class="info-value">${escapeHtml(session.project || '-')}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Tasks:</span>
|
||||
<span class="info-value">${completed}/${tasks.length} completed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="detail-tabs">
|
||||
<button class="detail-tab active" data-tab="tasks" onclick="switchDetailTab('tasks')">
|
||||
<span class="tab-icon">📋</span>
|
||||
<span class="tab-text">Tasks</span>
|
||||
<span class="tab-count">${tasks.length}</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="context" onclick="switchDetailTab('context')">
|
||||
<span class="tab-icon">📦</span>
|
||||
<span class="tab-text">Context</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="summary" onclick="switchDetailTab('summary')">
|
||||
<span class="tab-icon">📝</span>
|
||||
<span class="tab-text">Summary</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="impl-plan" onclick="switchDetailTab('impl-plan')">
|
||||
<span class="tab-icon">📐</span>
|
||||
<span class="tab-text">IMPL Plan</span>
|
||||
</button>
|
||||
<button class="detail-tab" data-tab="conflict" onclick="switchDetailTab('conflict')"> <span class="tab-icon">⚖️</span> <span class="tab-text">Conflict</span> </button>
|
||||
${session.hasReview ? `
|
||||
<button class="detail-tab" data-tab="review" onclick="switchDetailTab('review')">
|
||||
<span class="tab-icon">🔍</span>
|
||||
<span class="tab-text">Review</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="detail-tab-content" id="detailTabContent">
|
||||
${renderTasksTab(session, tasks, completed, inProgress, pending)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function goBackToSessions() {
|
||||
currentView = 'sessions';
|
||||
currentSessionDetailKey = null;
|
||||
updateContentTitle();
|
||||
showStatsAndSearch();
|
||||
renderSessions();
|
||||
}
|
||||
|
||||
function switchDetailTab(tabName) {
|
||||
// Update active tab
|
||||
document.querySelectorAll('.detail-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session) return;
|
||||
|
||||
const contentArea = document.getElementById('detailTabContent');
|
||||
const tasks = session.tasks || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter(t => t.status === 'pending').length;
|
||||
|
||||
switch (tabName) {
|
||||
case 'tasks':
|
||||
contentArea.innerHTML = renderTasksTab(session, tasks, completed, inProgress, pending);
|
||||
break;
|
||||
case 'context':
|
||||
loadAndRenderContextTab(session, contentArea);
|
||||
break;
|
||||
case 'summary':
|
||||
loadAndRenderSummaryTab(session, contentArea);
|
||||
break;
|
||||
case 'impl-plan':
|
||||
loadAndRenderImplPlanTab(session, contentArea);
|
||||
break;
|
||||
case 'review':
|
||||
loadAndRenderReviewTab(session, contentArea);
|
||||
break;
|
||||
case 'conflict': loadAndRenderConflictTab(session, contentArea); break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasksTab(session, tasks, completed, inProgress, pending) {
|
||||
// Populate drawer tasks for click-to-open functionality
|
||||
currentDrawerTasks = tasks;
|
||||
|
||||
// Auto-load full task details in server mode
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
// Schedule auto-load after DOM render
|
||||
setTimeout(() => loadFullTaskDetails(), 50);
|
||||
}
|
||||
|
||||
// Show task list with loading state or basic list
|
||||
const showLoading = window.SERVER_MODE && session.path;
|
||||
|
||||
return `
|
||||
<div class="tasks-tab-content">
|
||||
<!-- Combined Stats & Actions Bar -->
|
||||
<div class="task-toolbar">
|
||||
<div class="task-stats-bar">
|
||||
<span class="task-stat completed">✓ ${completed} completed</span>
|
||||
<span class="task-stat in-progress">⟳ ${inProgress} in progress</span>
|
||||
<span class="task-stat pending">○ ${pending} pending</span>
|
||||
</div>
|
||||
<div class="toolbar-divider"></div>
|
||||
<div class="task-bulk-actions">
|
||||
<span class="bulk-label">Quick Actions:</span>
|
||||
<button class="bulk-action-btn" onclick="bulkSetAllStatus('pending')" title="Set all tasks to pending">
|
||||
<span class="bulk-icon">○</span> All Pending
|
||||
</button>
|
||||
<button class="bulk-action-btn" onclick="bulkSetAllStatus('in_progress')" title="Set all tasks to in progress">
|
||||
<span class="bulk-icon">⟳</span> All In Progress
|
||||
</button>
|
||||
<button class="bulk-action-btn completed" onclick="bulkSetAllStatus('completed')" title="Set all tasks to completed">
|
||||
<span class="bulk-icon">✓</span> All Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tasks-list" id="tasksListContent">
|
||||
${showLoading ? `
|
||||
<div class="tab-loading">Loading task details...</div>
|
||||
` : (tasks.length === 0 ? `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-title">No Tasks</div>
|
||||
<div class="empty-text">This session has no tasks defined.</div>
|
||||
</div>
|
||||
` : tasks.map(task => renderDetailTaskItem(task)).join(''))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadFullTaskDetails() {
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session || !window.SERVER_MODE || !session.path) return;
|
||||
|
||||
const tasksContainer = document.getElementById('tasksListContent');
|
||||
tasksContainer.innerHTML = '<div class="tab-loading">Loading full task details...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=tasks`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.tasks && data.tasks.length > 0) {
|
||||
// Populate drawer tasks for click-to-open functionality
|
||||
currentDrawerTasks = data.tasks;
|
||||
tasksContainer.innerHTML = data.tasks.map(task => renderDetailTaskItem(task)).join('');
|
||||
} else {
|
||||
tasksContainer.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<div class="empty-title">No Task Files</div>
|
||||
<div class="empty-text">No IMPL-*.json files found in .task/</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
tasksContainer.innerHTML = `<div class="tab-error">Failed to load tasks: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetailTaskItem(task) {
|
||||
const taskId = task.task_id || task.id || 'Unknown';
|
||||
const status = task.status || 'pending';
|
||||
|
||||
// Status options for dropdown
|
||||
const statusOptions = ['pending', 'in_progress', 'completed'];
|
||||
|
||||
return `
|
||||
<div class="detail-task-item ${status} status-${status}" data-task-id="${escapeHtml(taskId)}">
|
||||
<div class="task-item-header">
|
||||
<span class="task-id-badge">${escapeHtml(taskId)}</span>
|
||||
<span class="task-title" onclick="openTaskDrawer('${escapeHtml(taskId)}')" style="cursor: pointer; flex: 1;">
|
||||
${escapeHtml(task.title || task.meta?.title || 'Untitled')}
|
||||
</span>
|
||||
<div class="task-status-control" onclick="event.stopPropagation()">
|
||||
<select class="task-status-select ${status}" onchange="updateSingleTaskStatus('${escapeHtml(taskId)}', this.value)" data-current="${status}">
|
||||
${statusOptions.map(opt => `
|
||||
<option value="${opt}" ${opt === status ? 'selected' : ''}>${formatStatusLabel(opt)}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status) {
|
||||
const labels = {
|
||||
'pending': '○ Pending',
|
||||
'in_progress': '⟳ In Progress',
|
||||
'completed': '✓ Completed'
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function getMetaPreview(task) {
|
||||
const meta = task.meta || {};
|
||||
const parts = [];
|
||||
if (meta.type) parts.push(meta.type);
|
||||
if (meta.action) parts.push(meta.action);
|
||||
if (meta.scope) parts.push(meta.scope);
|
||||
return parts.join(' | ') || 'No meta';
|
||||
}
|
||||
|
||||
function getTaskContextPreview(task) {
|
||||
const items = [];
|
||||
const ctx = task.context || {};
|
||||
if (ctx.requirements?.length) items.push(`${ctx.requirements.length} reqs`);
|
||||
if (ctx.focus_paths?.length) items.push(`${ctx.focus_paths.length} paths`);
|
||||
if (task.modification_points?.length) items.push(`${task.modification_points.length} mods`);
|
||||
if (task.description) items.push('desc');
|
||||
return items.join(' | ') || 'No context';
|
||||
}
|
||||
|
||||
function getFlowPreview(task) {
|
||||
const steps = task.flow_control?.implementation_approach?.length || task.implementation?.length || 0;
|
||||
return steps > 0 ? `${steps} steps` : 'No steps';
|
||||
}
|
||||
|
||||
function renderTaskContext(task) {
|
||||
const sections = [];
|
||||
const ctx = task.context || {};
|
||||
|
||||
// Description
|
||||
if (task.description) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>description:</label>
|
||||
<p>${escapeHtml(task.description)}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
if (ctx.requirements?.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>requirements:</label>
|
||||
<ul>${ctx.requirements.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Focus paths
|
||||
if (ctx.focus_paths?.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>focus_paths:</label>
|
||||
<div class="path-tags">${ctx.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Modification points
|
||||
if (task.modification_points?.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>modification_points:</label>
|
||||
<div class="mod-points">
|
||||
${task.modification_points.map(m => `
|
||||
<div class="mod-point">
|
||||
<span class="array-item path-item">${escapeHtml(m.file || m)}</span>
|
||||
${m.target ? `<span class="mod-target">→ ${escapeHtml(m.target)}</span>` : ''}
|
||||
${m.change ? `<p class="mod-change">${escapeHtml(m.change)}</p>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Acceptance criteria
|
||||
const acceptance = ctx.acceptance || task.acceptance || [];
|
||||
if (acceptance.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>acceptance:</label>
|
||||
<ul>${acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.length > 0
|
||||
? `<div class="context-fields">${sections.join('')}</div>`
|
||||
: '<div class="field-value json-value-null">No context data</div>';
|
||||
}
|
||||
|
||||
function renderFlowControl(task) {
|
||||
const sections = [];
|
||||
const fc = task.flow_control || {};
|
||||
|
||||
// Implementation approach
|
||||
const steps = fc.implementation_approach || task.implementation || [];
|
||||
if (steps.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>implementation_approach:</label>
|
||||
<ol class="impl-steps">
|
||||
${steps.map(s => `<li>${escapeHtml(typeof s === 'string' ? s : s.step || s.action || JSON.stringify(s))}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Pre-analysis
|
||||
const preAnalysis = fc.pre_analysis || task.pre_analysis || [];
|
||||
if (preAnalysis.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>pre_analysis:</label>
|
||||
<ul>${preAnalysis.map(p => `<li>${escapeHtml(p)}</li>`).join('')}</ul>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Target files
|
||||
const targetFiles = fc.target_files || task.target_files || [];
|
||||
if (targetFiles.length) {
|
||||
sections.push(`
|
||||
<div class="context-field">
|
||||
<label>target_files:</label>
|
||||
<div class="path-tags">${targetFiles.map(f => `<span class="path-tag">${escapeHtml(f)}</span>`).join('')}</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
return sections.length > 0
|
||||
? `<div class="context-fields">${sections.join('')}</div>`
|
||||
: '<div class="field-value json-value-null">No flow control data</div>';
|
||||
}
|
||||
|
||||
async function loadAndRenderContextTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading context data...</div>';
|
||||
|
||||
try {
|
||||
// Try to load context data from server (includes context, explorations, conflictResolution)
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderSessionContextContent(data.context, data.explorations, data.conflictResolution);
|
||||
|
||||
// Initialize collapsible sections for explorations
|
||||
initCollapsibleSections(contentArea);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: show placeholder
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📦</div>
|
||||
<div class="empty-title">Context Data</div>
|
||||
<div class="empty-text">Context data will be loaded from context-package.json</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderSummaryTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading summaries...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderSummaryContent(data.summaries);
|
||||
return;
|
||||
}
|
||||
}
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<div class="empty-title">Summaries</div>
|
||||
<div class="empty-text">Session summaries will be loaded from .summaries/</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load summaries: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderImplPlanTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading IMPL plan...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=impl-plan`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderImplPlanContent(data.implPlan);
|
||||
return;
|
||||
}
|
||||
}
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">📐</div>
|
||||
<div class="empty-title">IMPL Plan</div>
|
||||
<div class="empty-text">IMPL plan will be loaded from IMPL_PLAN.md</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load IMPL plan: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderReviewTab(session, contentArea) {
|
||||
contentArea.innerHTML = '<div class="tab-loading">Loading review data...</div>';
|
||||
|
||||
try {
|
||||
if (window.SERVER_MODE && session.path) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=review`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderReviewContent(data.review);
|
||||
return;
|
||||
}
|
||||
}
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<div class="empty-title">Review Data</div>
|
||||
<div class="empty-text">Review data will be loaded from review files</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
contentArea.innerHTML = `<div class="tab-error">Failed to load review: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showRawSessionJson(sessionKey) {
|
||||
const session = sessionDataStore[sessionKey];
|
||||
if (!session) return;
|
||||
|
||||
// Close current modal
|
||||
const currentModal = document.querySelector('.session-modal-overlay');
|
||||
if (currentModal) currentModal.remove();
|
||||
|
||||
// Show JSON modal
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'json-modal-overlay active';
|
||||
overlay.innerHTML = `
|
||||
<div class="json-modal">
|
||||
<div class="json-modal-header">
|
||||
<div class="json-modal-title">
|
||||
<span class="session-id-badge">${escapeHtml(session.session_id)}</span>
|
||||
<span>Session JSON</span>
|
||||
</div>
|
||||
<button class="json-modal-close" onclick="closeJsonModal(this)">×</button>
|
||||
</div>
|
||||
<div class="json-modal-body">
|
||||
<pre class="json-modal-content">${escapeHtml(JSON.stringify(session, null, 2))}</pre>
|
||||
</div>
|
||||
<div class="json-modal-footer">
|
||||
<button class="json-modal-copy" onclick="copyJsonToClipboard(this)">Copy to Clipboard</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close on overlay click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeJsonModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TASK STATUS MANAGEMENT
|
||||
// ==========================================
|
||||
|
||||
async function updateSingleTaskStatus(taskId, newStatus) {
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session || !window.SERVER_MODE || !session.path) {
|
||||
showToast('Status update requires server mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionPath: session.path,
|
||||
taskId: taskId,
|
||||
newStatus: newStatus
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Update UI
|
||||
updateTaskItemUI(taskId, newStatus);
|
||||
updateTaskStatsBar();
|
||||
showToast(`Task ${taskId} → ${formatStatusLabel(newStatus)}`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to update status', 'error');
|
||||
// Revert select
|
||||
revertTaskSelect(taskId);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error updating status: ' + error.message, 'error');
|
||||
revertTaskSelect(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSetAllStatus(newStatus) {
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session || !window.SERVER_MODE || !session.path) {
|
||||
showToast('Bulk update requires server mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const taskIds = currentDrawerTasks.map(t => t.task_id || t.id);
|
||||
if (taskIds.length === 0) return;
|
||||
|
||||
if (!confirm(`Set all ${taskIds.length} tasks to "${formatStatusLabel(newStatus)}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bulk-update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionPath: session.path,
|
||||
taskIds: taskIds,
|
||||
newStatus: newStatus
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Update all task UIs
|
||||
taskIds.forEach(id => updateTaskItemUI(id, newStatus));
|
||||
updateTaskStatsBar();
|
||||
showToast(`All ${taskIds.length} tasks → ${formatStatusLabel(newStatus)}`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to bulk update', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error in bulk update: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSetPendingToInProgress() {
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session || !window.SERVER_MODE || !session.path) {
|
||||
showToast('Bulk update requires server mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingTaskIds = currentDrawerTasks
|
||||
.filter(t => (t.status || 'pending') === 'pending')
|
||||
.map(t => t.task_id || t.id);
|
||||
|
||||
if (pendingTaskIds.length === 0) {
|
||||
showToast('No pending tasks to start', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bulk-update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionPath: session.path,
|
||||
taskIds: pendingTaskIds,
|
||||
newStatus: 'in_progress'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
pendingTaskIds.forEach(id => updateTaskItemUI(id, 'in_progress'));
|
||||
updateTaskStatsBar();
|
||||
showToast(`${pendingTaskIds.length} tasks: Pending → In Progress`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to update', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSetInProgressToCompleted() {
|
||||
const session = sessionDataStore[currentSessionDetailKey];
|
||||
if (!session || !window.SERVER_MODE || !session.path) {
|
||||
showToast('Bulk update requires server mode', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const inProgressTaskIds = currentDrawerTasks
|
||||
.filter(t => t.status === 'in_progress')
|
||||
.map(t => t.task_id || t.id);
|
||||
|
||||
if (inProgressTaskIds.length === 0) {
|
||||
showToast('No in-progress tasks to complete', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bulk-update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionPath: session.path,
|
||||
taskIds: inProgressTaskIds,
|
||||
newStatus: 'completed'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
inProgressTaskIds.forEach(id => updateTaskItemUI(id, 'completed'));
|
||||
updateTaskStatsBar();
|
||||
showToast(`${inProgressTaskIds.length} tasks: In Progress → Completed`, 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to update', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskItemUI(taskId, newStatus) {
|
||||
const taskItem = document.querySelector(`.detail-task-item[data-task-id="${taskId}"]`);
|
||||
if (!taskItem) return;
|
||||
|
||||
// Update classes
|
||||
taskItem.className = `detail-task-item ${newStatus} status-${newStatus}`;
|
||||
|
||||
// Update select
|
||||
const select = taskItem.querySelector('.task-status-select');
|
||||
if (select) {
|
||||
select.value = newStatus;
|
||||
select.className = `task-status-select ${newStatus}`;
|
||||
select.dataset.current = newStatus;
|
||||
}
|
||||
|
||||
// Update drawer tasks data
|
||||
const task = currentDrawerTasks.find(t => (t.task_id || t.id) === taskId);
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskStatsBar() {
|
||||
const completed = currentDrawerTasks.filter(t => t.status === 'completed').length;
|
||||
const inProgress = currentDrawerTasks.filter(t => t.status === 'in_progress').length;
|
||||
const pending = currentDrawerTasks.filter(t => (t.status || 'pending') === 'pending').length;
|
||||
|
||||
const statsBar = document.querySelector('.task-stats-bar');
|
||||
if (statsBar) {
|
||||
statsBar.innerHTML = `
|
||||
<span class="task-stat completed">✓ ${completed} completed</span>
|
||||
<span class="task-stat in-progress">⟳ ${inProgress} in progress</span>
|
||||
<span class="task-stat pending">○ ${pending} pending</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function revertTaskSelect(taskId) {
|
||||
const taskItem = document.querySelector(`.detail-task-item[data-task-id="${taskId}"]`);
|
||||
if (!taskItem) return;
|
||||
|
||||
const select = taskItem.querySelector('.task-status-select');
|
||||
if (select) {
|
||||
select.value = select.dataset.current;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
// Remove existing toast
|
||||
const existing = document.querySelector('.status-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `status-toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade-out');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
7931
ccw/src/templates/dashboard.css
Normal file
7931
ccw/src/templates/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
630
ccw/src/templates/dashboard.html
Normal file
630
ccw/src/templates/dashboard.html
Normal file
@@ -0,0 +1,630 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCW Dashboard</title>
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
safelist: [
|
||||
// Background colors
|
||||
'bg-card', 'bg-background', 'bg-hover', 'bg-accent', 'bg-muted', 'bg-primary', 'bg-success', 'bg-warning',
|
||||
'bg-success-light', 'bg-warning-light', 'bg-primary-light', 'bg-sidebar-background', 'bg-destructive',
|
||||
'bg-destructive/5', 'bg-destructive/10', 'bg-warning/5',
|
||||
// Text colors
|
||||
'text-foreground', 'text-muted-foreground', 'text-primary', 'text-card-foreground', 'text-success', 'text-warning',
|
||||
'text-primary-foreground', 'text-accent-foreground', 'text-sidebar-foreground', 'text-destructive',
|
||||
// Border colors
|
||||
'border', 'border-border', 'border-primary', 'border-success', 'border-warning', 'border-muted',
|
||||
'border-l-success', 'border-l-warning', 'border-l-muted-foreground', 'border-l-primary',
|
||||
// Layout
|
||||
'rounded', 'rounded-lg', 'rounded-md', 'rounded-full', 'shadow', 'shadow-sm', 'shadow-md', 'shadow-lg',
|
||||
'p-2', 'p-3', 'p-4', 'p-5', 'px-3', 'px-4', 'px-5', 'py-2', 'py-3', 'py-4',
|
||||
'm-2', 'mb-2', 'mb-3', 'mb-4', 'mt-2', 'mt-3', 'mt-4', 'mx-2', 'my-2',
|
||||
'gap-2', 'gap-3', 'gap-4', 'space-y-2', 'space-y-3',
|
||||
// Flex & Grid
|
||||
'flex', 'flex-1', 'flex-col', 'flex-wrap', 'items-center', 'items-start', 'justify-between', 'justify-center',
|
||||
'grid', 'grid-cols-1', 'grid-cols-2', 'grid-cols-3',
|
||||
// Sizing
|
||||
'w-full', 'w-5', 'w-8', 'h-2', 'h-5', 'h-8', 'min-w-0', 'max-w-full',
|
||||
// Text
|
||||
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl',
|
||||
'font-medium', 'font-semibold', 'font-bold', 'font-mono', 'truncate', 'uppercase',
|
||||
// States & Transitions
|
||||
'hover:shadow-md', 'hover:bg-hover', 'hover:-translate-y-1', 'hover:text-foreground',
|
||||
'transition-all', 'duration-200', 'duration-300', 'cursor-pointer',
|
||||
// Opacity & visibility
|
||||
'opacity-50', 'opacity-80', 'hidden', 'block', 'inline', 'inline-flex',
|
||||
// Position
|
||||
'relative', 'absolute', 'fixed', 'sticky', 'top-0', 'right-0', 'left-0', 'bottom-0',
|
||||
'z-10', 'z-40', 'z-50', 'overflow-hidden', 'overflow-y-auto',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: 'hsl(var(--card))',
|
||||
'card-foreground': 'hsl(var(--card-foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
primary: 'hsl(var(--primary))',
|
||||
'primary-foreground': 'hsl(var(--primary-foreground))',
|
||||
'primary-light': 'hsl(var(--primary-light))',
|
||||
secondary: 'hsl(var(--secondary))',
|
||||
'secondary-foreground': 'hsl(var(--secondary-foreground))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
'accent-foreground': 'hsl(var(--accent-foreground))',
|
||||
destructive: 'hsl(var(--destructive))',
|
||||
'destructive-foreground': 'hsl(var(--destructive-foreground))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted-foreground))',
|
||||
'sidebar-background': 'hsl(var(--sidebar-background))',
|
||||
'sidebar-foreground': 'hsl(var(--sidebar-foreground))',
|
||||
hover: 'hsl(var(--hover))',
|
||||
success: 'hsl(var(--success))',
|
||||
'success-light': 'hsl(var(--success-light))',
|
||||
warning: 'hsl(var(--warning))',
|
||||
'warning-light': 'hsl(var(--warning-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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* 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%;
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
|
||||
/* 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)); }
|
||||
|
||||
/* Sidebar collapse state */
|
||||
.sidebar.collapsed { width: 60px; }
|
||||
.sidebar.collapsed .nav-text,
|
||||
.sidebar.collapsed .nav-section-title,
|
||||
.sidebar.collapsed .badge,
|
||||
.sidebar.collapsed .toggle-text { display: none; }
|
||||
.sidebar.collapsed .nav-section-header { justify-content: center; padding: 12px 0; }
|
||||
.sidebar.collapsed .nav-item { justify-content: center; padding: 10px 0; }
|
||||
.sidebar.collapsed .toggle-icon { transform: rotate(180deg); }
|
||||
|
||||
/* Path menu open state */
|
||||
.path-menu.open { display: block; }
|
||||
|
||||
/* Mobile sidebar */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -260px;
|
||||
top: 56px;
|
||||
height: calc(100vh - 56px);
|
||||
z-index: 200;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
.sidebar.open { left: 0; }
|
||||
.sidebar-overlay.open { display: block; }
|
||||
.menu-toggle-btn { display: block !important; }
|
||||
}
|
||||
|
||||
/* Task drawer */
|
||||
.task-detail-drawer {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.task-detail-drawer.open { transform: translateX(0); }
|
||||
.drawer-overlay.active { display: block; }
|
||||
|
||||
/* Injected from dashboard.css */
|
||||
{{CSS_CONTENT}}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans bg-background text-foreground leading-normal">
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<!-- Top Bar -->
|
||||
<header class="flex items-center justify-between px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="hidden md:hidden p-2 text-foreground hover:bg-hover rounded menu-toggle-btn" id="menuToggle">☰</button>
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<span class="text-2xl">⚡</span>
|
||||
<span class="hidden sm:inline">Claude Code Workflow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Path Selector -->
|
||||
<div class="flex items-center gap-2 relative">
|
||||
<label class="hidden sm:inline text-sm text-muted-foreground">Project:</label>
|
||||
<div class="relative">
|
||||
<button class="flex items-center gap-2 px-3 py-1.5 bg-background border border-border rounded text-sm text-foreground hover:bg-hover max-w-[300px]" id="pathButton">
|
||||
<span class="truncate max-w-[240px]" id="currentPath">{{PROJECT_PATH}}</span>
|
||||
<span class="text-xs text-muted-foreground">▼</span>
|
||||
</button>
|
||||
<div class="path-menu hidden absolute top-full right-0 mt-1 bg-card border border-border rounded-lg shadow-lg min-w-[280px] z-50" id="pathMenu">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Recent Projects</div>
|
||||
<div id="recentPaths" class="border-t border-border">
|
||||
<!-- Dynamic recent paths -->
|
||||
</div>
|
||||
<div class="p-2 border-t border-border">
|
||||
<button class="w-full flex items-center justify-center gap-2 px-3 py-2 bg-background border border-border rounded text-sm text-muted-foreground hover:bg-hover" id="browsePath">
|
||||
📂 Browse...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Refresh Button -->
|
||||
<button class="refresh-btn p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded" id="refreshWorkspace" title="Refresh workspace">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 21h5v-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button class="p-2 text-xl hover:bg-hover rounded" id="themeToggle" title="Toggle theme">🌙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar Overlay (mobile) -->
|
||||
<div class="sidebar-overlay hidden fixed inset-0 bg-black/50 z-40" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="flex flex-1">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar w-64 bg-sidebar-background border-r border-border flex flex-col sticky top-14 h-[calc(100vh-56px)] overflow-y-auto transition-all duration-300" id="sidebar">
|
||||
<nav class="flex-1 py-3">
|
||||
<!-- Project Overview Section -->
|
||||
<div class="mb-2" id="projectOverviewNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">🏗️</span>
|
||||
<span class="nav-section-title">Project</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="project-overview" data-tooltip="Project Overview">
|
||||
<span>📊</span>
|
||||
<span class="nav-text flex-1">Overview</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sessions Section -->
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">📁</span>
|
||||
<span class="nav-section-title">Sessions</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors active" data-filter="all" data-tooltip="All Sessions">
|
||||
<span>📋</span>
|
||||
<span class="nav-text flex-1">All</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeAll">0</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-filter="active" data-tooltip="Active Sessions">
|
||||
<span>🟢</span>
|
||||
<span class="nav-text flex-1">Active</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-success-light text-success" id="badgeActive">0</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-filter="archived" data-tooltip="Archived Sessions">
|
||||
<span>📦</span>
|
||||
<span class="nav-text flex-1">Archived</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeArchived">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Lite Tasks Section -->
|
||||
<div class="mb-2" id="liteTasksNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">⚡</span>
|
||||
<span class="nav-section-title">Lite Tasks</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-lite="lite-plan" data-tooltip="Lite Plan Sessions">
|
||||
<span>📝</span>
|
||||
<span class="nav-text flex-1">Lite Plan</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeLitePlan">0</span>
|
||||
</li>
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-lite="lite-fix" data-tooltip="Lite Fix Sessions">
|
||||
<span>🔧</span>
|
||||
<span class="nav-text flex-1">Lite Fix</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeLiteFix">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- MCP Servers Section -->
|
||||
<div class="mb-2" id="mcpServersNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">🔌</span>
|
||||
<span class="nav-section-title">MCP Servers</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="mcp-manager" data-tooltip="MCP Server Management">
|
||||
<span>⚙️</span>
|
||||
<span class="nav-text flex-1">Manage</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeMcpServers">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Hooks Section -->
|
||||
<div class="mb-2" id="hooksNav">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span class="mr-2">🪝</span>
|
||||
<span class="nav-section-title">Hooks</span>
|
||||
</div>
|
||||
<ul class="space-y-0.5">
|
||||
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="hook-manager" data-tooltip="Hook Management">
|
||||
<span>⚙️</span>
|
||||
<span class="nav-text flex-1">Manage</span>
|
||||
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeHooks">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="p-3 border-t border-border">
|
||||
<button class="flex items-center justify-center gap-2 w-full px-3 py-2 border border-border rounded text-sm text-muted-foreground hover:bg-hover transition-colors" id="sidebarToggle">
|
||||
<span class="toggle-icon transition-transform duration-300">◀</span>
|
||||
<span class="toggle-text">Collapse</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="flex-1 p-6 overflow-y-auto min-w-0">
|
||||
<!-- Stats Section: Left Metrics + Right Carousel -->
|
||||
<section id="statsGrid" class="stats-section flex gap-4 mb-6">
|
||||
<!-- Left: 4 Metrics Grid -->
|
||||
<div class="stats-metrics grid grid-cols-2 gap-3 shrink-0">
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">📊</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statTotalSessions">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">🟢</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statActiveSessions">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Active Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">📋</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statTotalTasks">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Total Tasks</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
|
||||
<div class="text-xl mb-1">✅</div>
|
||||
<div class="text-2xl font-bold text-foreground" id="statCompletedTasks">0</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">Completed Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Active Session Carousel (Image-style with dots) -->
|
||||
<div class="stats-carousel flex-1 bg-card border border-border rounded-lg overflow-hidden min-h-[180px] flex flex-col relative">
|
||||
<!-- Carousel Content (Full height) -->
|
||||
<div class="carousel-content flex-1 relative overflow-hidden" id="carouselContent">
|
||||
<!-- Dynamic carousel slides -->
|
||||
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">🎯</div>
|
||||
<p class="text-sm">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Dots Indicator & Controls -->
|
||||
<div class="carousel-footer flex items-center justify-center gap-3 py-2 border-t border-border bg-muted/20">
|
||||
<!-- Previous Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselPrev" title="Previous">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Dots Indicator -->
|
||||
<div class="carousel-dots flex items-center gap-1.5" id="carouselDots">
|
||||
<!-- Dots will be rendered dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselNext" title="Next">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Pause Button -->
|
||||
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground ml-1" id="carouselPause" title="Pause auto-play">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="carouselPauseIcon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Content Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3 mb-5">
|
||||
<h2 class="text-xl font-semibold text-foreground" id="contentTitle">All Sessions</h2>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-sm">🔍</span>
|
||||
<input type="text" placeholder="Search..." id="searchInput"
|
||||
class="pl-9 pr-4 py-2 w-60 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<section class="main-content" id="mainContent">
|
||||
<!-- Dynamic content: sessions grid or session detail page -->
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="flex items-center justify-between px-5 h-10 bg-card border-t border-border text-xs text-muted-foreground">
|
||||
<div>Generated: <span id="generatedAt">-</span></div>
|
||||
<div>CCW Dashboard v1.0</div>
|
||||
</footer>
|
||||
|
||||
<!-- Task Detail Drawer -->
|
||||
<div class="task-detail-drawer fixed top-0 right-0 w-1/2 max-w-full h-full bg-card border-l border-border shadow-lg z-50 flex flex-col" id="taskDetailDrawer">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground" id="drawerTaskTitle">Task Details</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeTaskDrawer()">×</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-5" id="drawerContent">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-overlay hidden fixed inset-0 bg-black/50 z-40" id="drawerOverlay" onclick="closeTaskDrawer()"></div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown Preview Modal -->
|
||||
<div id="markdownModal" class="markdown-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="markdown-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMarkdownModal()"></div>
|
||||
<div class="markdown-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-4xl h-[85vh] flex flex-col">
|
||||
<div class="markdown-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground" id="markdownModalTitle">Content Preview</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex bg-muted rounded-lg p-0.5">
|
||||
<button id="mdTabRaw" class="md-tab-btn px-3 py-1 text-sm rounded-md transition-colors" onclick="switchMarkdownTab('raw')">Raw</button>
|
||||
<button id="mdTabPreview" class="md-tab-btn px-3 py-1 text-sm rounded-md transition-colors active" onclick="switchMarkdownTab('preview')">Preview</button>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMarkdownModal()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="markdown-modal-body flex-1 overflow-auto p-4">
|
||||
<pre id="markdownRaw" class="hidden whitespace-pre-wrap text-sm font-mono text-foreground bg-muted p-4 rounded-lg overflow-auto h-full"></pre>
|
||||
<div id="markdownPreview" class="markdown-preview prose prose-sm max-w-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Server Create Modal -->
|
||||
<div id="mcpCreateModal" class="mcp-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="mcp-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMcpCreateModal()"></div>
|
||||
<div class="mcp-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col">
|
||||
<div class="mcp-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
|
||||
<div class="flex bg-muted rounded-lg p-0.5">
|
||||
<button id="mcpTabForm" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors active" onclick="switchMcpCreateTab('form')">Form</button>
|
||||
<button id="mcpTabJson" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors" onclick="switchMcpCreateTab('json')">JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMcpCreateModal()">×</button>
|
||||
</div>
|
||||
<!-- Form Mode -->
|
||||
<div id="mcpFormMode" class="mcp-modal-body p-4 space-y-4">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="mcpServerName" placeholder="e.g., my-mcp-server"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="mcpServerCommand" placeholder="e.g., npx, uvx, node, python"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
|
||||
<textarea id="mcpServerArgs" placeholder="e.g., -y @smithery/cli@latest run exa" rows="4"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Environment Variables (KEY=VALUE per line)</label>
|
||||
<textarea id="mcpServerEnv" placeholder="e.g., API_KEY=your-api-key DEBUG=true" rows="3"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON Mode -->
|
||||
<div id="mcpJsonMode" class="mcp-modal-body p-4 space-y-4 hidden">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Paste MCP Server JSON Configuration</label>
|
||||
<textarea id="mcpServerJson" placeholder='{
|
||||
"servers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@package/server"],
|
||||
"env": {
|
||||
"API_KEY": "your-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}' rows="12"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
<p class="text-xs text-muted-foreground mt-2">Supports <code class="bg-muted px-1 rounded">{"servers": {...}}</code>, <code class="bg-muted px-1 rounded">{"mcpServers": {...}}</code>, and direct server config formats.</p>
|
||||
</div>
|
||||
<div id="mcpJsonPreview" class="hidden">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Preview (servers to be added):</label>
|
||||
<div id="mcpJsonPreviewContent" class="bg-muted rounded-lg p-3 text-sm space-y-2 max-h-32 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeMcpCreateModal()">Cancel</button>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitMcpCreate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook Create Modal -->
|
||||
<div id="hookCreateModal" class="hook-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div class="hook-modal-backdrop absolute inset-0 bg-black/60" onclick="closeHookCreateModal()"></div>
|
||||
<div class="hook-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col max-h-[90vh]">
|
||||
<div class="hook-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground" id="hookModalTitle">Create Hook</h3>
|
||||
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeHookCreateModal()">×</button>
|
||||
</div>
|
||||
<div class="hook-modal-body p-4 space-y-4 overflow-y-auto">
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Hook Event <span class="text-destructive">*</span></label>
|
||||
<select id="hookEvent" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
<option value="">Select an event...</option>
|
||||
<option value="PreToolUse">PreToolUse - Before a tool is executed</option>
|
||||
<option value="PostToolUse">PostToolUse - After a tool completes</option>
|
||||
<option value="Notification">Notification - On notifications</option>
|
||||
<option value="Stop">Stop - When agent stops</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Matcher (optional)</label>
|
||||
<input type="text" id="hookMatcher" placeholder="e.g., Write, Edit, Bash (leave empty for all)"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
<p class="text-xs text-muted-foreground mt-1">Tool name to match. Leave empty to match all tools.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
|
||||
<input type="text" id="hookCommand" placeholder="e.g., curl, bash, node"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
|
||||
<textarea id="hookArgs" placeholder="e.g., -X POST http://localhost:3456/api/hook" rows="4"
|
||||
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Scope</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="hookScope" value="project" checked class="text-primary focus:ring-primary">
|
||||
<span class="text-sm text-foreground">Project (.claude/settings.json)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="hookScope" value="global" class="text-primary focus:ring-primary">
|
||||
<span class="text-sm text-foreground">Global (~/.claude/settings.json)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Quick Templates</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('ccw-notify')">
|
||||
<span class="font-medium">CCW Notify</span>
|
||||
<span class="block text-muted-foreground">Notify dashboard on Write</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('log-tool')">
|
||||
<span class="font-medium">Log Tool Usage</span>
|
||||
<span class="block text-muted-foreground">Log all tool executions</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('lint-check')">
|
||||
<span class="font-medium">Lint Check</span>
|
||||
<span class="block text-muted-foreground">Run eslint on file changes</span>
|
||||
</button>
|
||||
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('git-add')">
|
||||
<span class="font-medium">Git Add</span>
|
||||
<span class="block text-muted-foreground">Auto stage written files</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hook-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeHookCreateModal()">Cancel</button>
|
||||
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitHookCreate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D3.js for Flowchart -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
<!-- Marked.js for Markdown rendering -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
<script>
|
||||
{{JS_CONTENT}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
ccw/src/templates/dashboard_tailwind.html
Normal file
42
ccw/src/templates/dashboard_tailwind.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCW Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--color-background))',
|
||||
foreground: 'hsl(var(--color-foreground))',
|
||||
card: 'hsl(var(--color-card))',
|
||||
border: 'hsl(var(--color-border))',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>:root{--color-background:0 0% 100%;--color-foreground:0 0% 25%;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen bg-background text-foreground">
|
||||
<header class="flex items-center justify-between px-5 h-14 bg-card border-b border-border sticky top-0 z-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">⚡</span>
|
||||
<span class="font-semibold">Claude Code Workflow</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 p-6">
|
||||
<h1>Dashboard</h1>
|
||||
</main>
|
||||
</div>
|
||||
<script>{{JS_CONTENT}}</script>
|
||||
</body>
|
||||
</html>
|
||||
37
ccw/src/templates/dashboard_test.html
Normal file
37
ccw/src/templates/dashboard_test.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCW Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--color-background))',
|
||||
foreground: 'hsl(var(--color-foreground))',
|
||||
card: 'hsl(var(--color-card))',
|
||||
border: 'hsl(var(--color-border))',
|
||||
input: 'hsl(var(--color-input))',
|
||||
ring: 'hsl(var(--color-ring))',
|
||||
primary: 'hsl(var(--color-interactive-primary-default))',
|
||||
accent: 'hsl(var(--color-interactive-accent-default))',
|
||||
muted: 'hsl(var(--color-muted))',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>:root{--color-background:0 0% 100%;--color-foreground:0 0% 25%;--color-card:0 0% 100%;--color-border:0 0% 90%;--color-input:0 0% 90%;--color-ring:220 65% 50%;--color-interactive-primary-default:220 65% 50%;--color-interactive-accent-default:220 40% 95%;--color-muted:0 0% 97%;--color-muted-foreground:0 0% 50%;--color-sidebar-background:0 0% 97.5%;}[data-theme="dark"]{--color-background:0 0% 10%;--color-foreground:0 0% 90%;--color-card:0 0% 15%;--color-border:0 0% 25%;}</style>
|
||||
</head>
|
||||
<body>Test</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
212
ccw/src/templates/tailwind-base.css
Normal file
212
ccw/src/templates/tailwind-base.css
Normal file
@@ -0,0 +1,212 @@
|
||||
/* Tailwind Base Styles with Design Tokens */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* CSS Custom Properties - Light Mode (Default) */
|
||||
:root {
|
||||
/* Base Colors */
|
||||
--color-background: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
--color-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
|
||||
--color-card: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
--color-card-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
|
||||
--color-border: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
|
||||
--color-input: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
|
||||
--color-ring: 220 65% 50%; /* oklch(0.5 0.15 250) -> primary blue */
|
||||
|
||||
/* Interactive Colors - Primary */
|
||||
--color-interactive-primary-default: 220 65% 50%; /* oklch(0.5 0.15 250) -> #4066bf */
|
||||
--color-interactive-primary-hover: 220 65% 55%; /* oklch(0.55 0.15 250) -> lighter blue */
|
||||
--color-interactive-primary-active: 220 65% 45%; /* oklch(0.45 0.15 250) -> darker blue */
|
||||
--color-interactive-primary-disabled: 220 30% 70%; /* oklch(0.7 0.05 250) -> muted blue */
|
||||
--color-interactive-primary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
|
||||
/* Interactive Colors - Secondary */
|
||||
--color-interactive-secondary-default: 220 60% 65%; /* oklch(0.65 0.12 250) -> #6b8ccc */
|
||||
--color-interactive-secondary-hover: 220 60% 70%; /* oklch(0.7 0.12 250) -> lighter */
|
||||
--color-interactive-secondary-active: 220 60% 60%; /* oklch(0.6 0.12 250) -> darker */
|
||||
--color-interactive-secondary-disabled: 220 30% 80%; /* oklch(0.8 0.05 250) -> muted */
|
||||
--color-interactive-secondary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
|
||||
/* Interactive Colors - Accent */
|
||||
--color-interactive-accent-default: 220 40% 95%; /* oklch(0.95 0.02 250) -> #eef3fa */
|
||||
--color-interactive-accent-hover: 220 45% 92%; /* oklch(0.92 0.03 250) -> slightly darker */
|
||||
--color-interactive-accent-active: 220 35% 97%; /* oklch(0.97 0.02 250) -> slightly lighter */
|
||||
--color-interactive-accent-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
|
||||
|
||||
/* Interactive Colors - Destructive */
|
||||
--color-interactive-destructive-default: 8 75% 55%; /* oklch(0.55 0.22 25) -> #d93025 */
|
||||
--color-interactive-destructive-hover: 8 75% 60%; /* oklch(0.6 0.22 25) -> lighter red */
|
||||
--color-interactive-destructive-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-muted: 0 0% 97%; /* oklch(0.97 0 0) -> very light gray */
|
||||
--color-muted-foreground: 0 0% 50%; /* oklch(0.5 0 0) -> medium gray */
|
||||
|
||||
/* Sidebar Colors */
|
||||
--color-sidebar-background: 0 0% 97.5%; /* oklch(0.975 0 0) -> #f8f8f8 */
|
||||
--color-sidebar-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
|
||||
--color-sidebar-primary: 220 65% 50%; /* oklch(0.5 0.15 250) -> primary blue */
|
||||
--color-sidebar-primary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
|
||||
--color-sidebar-accent: 220 40% 95%; /* oklch(0.95 0.02 250) -> light blue */
|
||||
--color-sidebar-accent-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
|
||||
--color-sidebar-border: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 1.875rem; /* 30px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
--letter-spacing-tight: -0.025em;
|
||||
--letter-spacing-normal: 0;
|
||||
--letter-spacing-wide: 0.025em;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-0: 0;
|
||||
--spacing-1: 0.25rem; /* 4px */
|
||||
--spacing-2: 0.5rem; /* 8px */
|
||||
--spacing-3: 0.75rem; /* 12px */
|
||||
--spacing-4: 1rem; /* 16px */
|
||||
--spacing-6: 1.5rem; /* 24px */
|
||||
--spacing-8: 2rem; /* 32px */
|
||||
--spacing-12: 3rem; /* 48px */
|
||||
--spacing-16: 4rem; /* 64px */
|
||||
|
||||
/* Effects */
|
||||
--opacity-disabled: 0.5;
|
||||
--opacity-hover: 0.8;
|
||||
--opacity-active: 1;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-2xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-sm: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
|
||||
/* Border Radius */
|
||||
--border-radius-sm: calc(0.375rem - 4px); /* 2px */
|
||||
--border-radius-md: calc(0.375rem - 2px); /* 4px */
|
||||
--border-radius-lg: 0.375rem; /* 6px */
|
||||
--border-radius-xl: calc(0.375rem + 4px); /* 10px */
|
||||
--border-radius-default: 0.375rem; /* 6px */
|
||||
|
||||
/* Animations */
|
||||
--duration-instant: 0ms;
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-medium: 300ms;
|
||||
--duration-slow: 500ms;
|
||||
|
||||
--easing-linear: linear;
|
||||
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--easing-spring: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
[data-theme="dark"] {
|
||||
/* Base Colors - Dark Mode */
|
||||
--color-background: 0 0% 10%; /* Dark background */
|
||||
--color-foreground: 0 0% 90%; /* Light text */
|
||||
--color-card: 0 0% 15%; /* Dark card background */
|
||||
--color-card-foreground: 0 0% 90%; /* Light card text */
|
||||
--color-border: 0 0% 25%; /* Dark border */
|
||||
--color-input: 0 0% 25%; /* Dark input border */
|
||||
--color-ring: 220 65% 60%; /* Brighter ring for dark mode */
|
||||
|
||||
/* Interactive Colors - Primary (Dark Mode) */
|
||||
--color-interactive-primary-default: 220 70% 60%; /* Brighter blue for dark mode */
|
||||
--color-interactive-primary-hover: 220 70% 65%; /* Even brighter on hover */
|
||||
--color-interactive-primary-active: 220 70% 55%; /* Slightly darker on active */
|
||||
--color-interactive-primary-disabled: 220 30% 40%; /* Muted blue for dark mode */
|
||||
--color-interactive-primary-foreground: 0 0% 100%; /* White text */
|
||||
|
||||
/* Interactive Colors - Secondary (Dark Mode) */
|
||||
--color-interactive-secondary-default: 220 60% 70%; /* Brighter secondary */
|
||||
--color-interactive-secondary-hover: 220 60% 75%; /* Brighter on hover */
|
||||
--color-interactive-secondary-active: 220 60% 65%; /* Slightly darker on active */
|
||||
--color-interactive-secondary-disabled: 220 30% 50%; /* Muted */
|
||||
--color-interactive-secondary-foreground: 0 0% 100%; /* White text */
|
||||
|
||||
/* Interactive Colors - Accent (Dark Mode) */
|
||||
--color-interactive-accent-default: 220 30% 25%; /* Dark accent */
|
||||
--color-interactive-accent-hover: 220 35% 30%; /* Slightly lighter on hover */
|
||||
--color-interactive-accent-active: 220 25% 20%; /* Darker on active */
|
||||
--color-interactive-accent-foreground: 0 0% 90%; /* Light text */
|
||||
|
||||
/* Interactive Colors - Destructive (Dark Mode) */
|
||||
--color-interactive-destructive-default: 8 75% 60%; /* Brighter red for visibility */
|
||||
--color-interactive-destructive-hover: 8 75% 65%; /* Even brighter on hover */
|
||||
--color-interactive-destructive-foreground: 0 0% 100%; /* White text */
|
||||
|
||||
/* Semantic Colors (Dark Mode) */
|
||||
--color-muted: 0 0% 20%; /* Dark muted background */
|
||||
--color-muted-foreground: 0 0% 60%; /* Lighter muted text */
|
||||
|
||||
/* Sidebar Colors (Dark Mode) */
|
||||
--color-sidebar-background: 0 0% 12%; /* Slightly lighter than background */
|
||||
--color-sidebar-foreground: 0 0% 90%; /* Light text */
|
||||
--color-sidebar-primary: 220 70% 60%; /* Brighter blue */
|
||||
--color-sidebar-primary-foreground: 0 0% 100%; /* White text */
|
||||
--color-sidebar-accent: 220 30% 25%; /* Dark accent */
|
||||
--color-sidebar-accent-foreground: 0 0% 90%; /* Light text */
|
||||
--color-sidebar-border: 0 0% 25%; /* Dark border */
|
||||
}
|
||||
|
||||
/* Base typography */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans leading-normal;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus-visible {
|
||||
outline: 2px solid hsl(var(--color-ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Custom utility classes */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.transition-default {
|
||||
transition: all var(--duration-normal) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
.transition-fast {
|
||||
transition: all var(--duration-fast) var(--easing-ease-out);
|
||||
}
|
||||
|
||||
.transition-medium {
|
||||
transition: all var(--duration-medium) var(--easing-ease-in-out);
|
||||
}
|
||||
|
||||
.transition-slow {
|
||||
transition: all var(--duration-slow) var(--easing-ease-in-out);
|
||||
}
|
||||
}
|
||||
401
ccw/src/templates/workflow-dashboard.html
Normal file
401
ccw/src/templates/workflow-dashboard.html
Normal file
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Dashboard - Task Board</title>
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
safelist: [
|
||||
'bg-card', 'bg-background', 'bg-hover', 'bg-accent', 'bg-muted', 'bg-primary', 'bg-success', 'bg-warning',
|
||||
'bg-success-light', 'bg-warning-light', 'text-foreground', 'text-muted-foreground', 'text-primary',
|
||||
'text-success', 'text-warning', 'text-primary-foreground', 'border', 'border-border', 'border-primary',
|
||||
'border-l-success', 'border-l-warning', 'border-l-muted-foreground', 'rounded', 'rounded-lg', 'rounded-full',
|
||||
'shadow', 'shadow-sm', 'shadow-md', 'p-2', 'p-3', 'p-4', 'p-5', 'px-3', 'px-4', 'py-2', 'py-3',
|
||||
'mb-2', 'mb-3', 'mb-4', 'mt-2', 'mt-4', 'mx-auto', 'gap-2', 'gap-3', 'gap-4', 'space-y-2',
|
||||
'flex', 'flex-1', 'flex-col', 'flex-wrap', 'items-center', 'items-start', 'justify-between', 'justify-center',
|
||||
'grid', 'w-full', 'w-5', 'h-2', 'h-5', 'text-xs', 'text-sm', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl',
|
||||
'font-medium', 'font-semibold', 'font-bold', 'font-mono', 'truncate', 'uppercase',
|
||||
'hover:shadow-md', 'hover:bg-hover', 'hover:-translate-y-1', 'hover:text-foreground', 'hover:opacity-90',
|
||||
'hover:scale-110', 'transition-all', 'duration-200', 'duration-300', 'cursor-pointer',
|
||||
'opacity-50', 'hidden', 'block', 'relative', 'absolute', 'fixed', 'z-50', 'overflow-hidden',
|
||||
'col-span-full', 'text-center', 'min-h-screen', 'max-w-7xl',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: 'hsl(var(--card))',
|
||||
'card-foreground': 'hsl(var(--card-foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
primary: 'hsl(var(--primary))',
|
||||
'primary-foreground': 'hsl(var(--primary-foreground))',
|
||||
accent: 'hsl(var(--accent))',
|
||||
'accent-foreground': 'hsl(var(--accent-foreground))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted-foreground))',
|
||||
hover: 'hsl(var(--hover))',
|
||||
success: 'hsl(var(--success))',
|
||||
'success-light': 'hsl(var(--success-light))',
|
||||
warning: 'hsl(var(--warning))',
|
||||
'warning-light': 'hsl(var(--warning-light))',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* 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%;
|
||||
--primary: 220 65% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--accent: 220 40% 95%;
|
||||
--accent-foreground: 0 0% 13%;
|
||||
--muted: 0 0% 96%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
--hover: 0 0% 93%;
|
||||
--success: 142 71% 45%;
|
||||
--success-light: 142 76% 90%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-light: 48 96% 89%;
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
--primary: 220 65% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--accent: 220 30% 20%;
|
||||
--accent-foreground: 0 0% 90%;
|
||||
--muted: 220 13% 18%;
|
||||
--muted-foreground: 0 0% 55%;
|
||||
--hover: 220 13% 22%;
|
||||
--success: 142 71% 40%;
|
||||
--success-light: 142 50% 20%;
|
||||
--warning: 38 85% 45%;
|
||||
--warning-light: 40 50% 20%;
|
||||
}
|
||||
|
||||
/* Progress bar gradient */
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--success)));
|
||||
}
|
||||
|
||||
/* Task checkbox pseudo-elements */
|
||||
.task-checkbox.completed::after { content: '✓'; }
|
||||
.task-checkbox.in_progress::after { content: '⟳'; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans bg-background text-foreground leading-normal min-h-screen">
|
||||
<div class="max-w-7xl mx-auto px-5 py-5">
|
||||
<!-- Header -->
|
||||
<header class="bg-card shadow rounded-lg p-5 mb-7 border border-border">
|
||||
<h1 class="text-2xl font-bold text-primary mb-2">🚀 Workflow Dashboard</h1>
|
||||
<p class="text-muted-foreground">Task Board - Active and Archived Sessions</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-center mt-4">
|
||||
<!-- Search Box -->
|
||||
<div class="flex-1 min-w-[250px] relative">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Search tasks or sessions..."
|
||||
class="w-full px-4 py-2.5 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" />
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all" data-filter="all">All</button>
|
||||
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-card text-foreground hover:bg-hover transition-all" data-filter="active">Active</button>
|
||||
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-card text-foreground hover:bg-hover transition-all" data-filter="archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-5 mb-7">
|
||||
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||
<div class="text-3xl font-bold text-primary" id="totalSessions">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Total Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||
<div class="text-3xl font-bold text-primary" id="activeSessionsCount">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Active Sessions</div>
|
||||
</div>
|
||||
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||
<div class="text-3xl font-bold text-primary" id="totalTasks">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Total Tasks</div>
|
||||
</div>
|
||||
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||
<div class="text-3xl font-bold text-primary" id="completedTasks">0</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">Completed Tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Section -->
|
||||
<div class="mb-10" id="activeSectionContainer">
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<h2 class="text-xl font-semibold text-foreground">📋 Active Sessions</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] gap-5" id="activeSessionsGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Archived Sessions Section -->
|
||||
<div class="mb-10" id="archivedSectionContainer">
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<h2 class="text-xl font-semibold text-foreground">📦 Archived Sessions</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] gap-5" id="archivedSessionsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle Button -->
|
||||
<button class="fixed bottom-7 right-7 w-14 h-14 rounded-full bg-primary text-primary-foreground text-2xl shadow-lg hover:scale-110 transition-all duration-200 z-50" id="themeToggle">🌙</button>
|
||||
|
||||
<!-- Workflow data injected by dashboard-generator -->
|
||||
<script id="workflow-data" type="application/json">{{WORKFLOW_DATA}}</script>
|
||||
|
||||
<script>
|
||||
// Parse workflow data from JSON script tag, with fallback for direct file access
|
||||
let workflowData;
|
||||
try {
|
||||
const dataScript = document.getElementById('workflow-data');
|
||||
const rawData = dataScript ? dataScript.textContent.trim() : '';
|
||||
// Check if placeholder was replaced (doesn't start with '{{')
|
||||
if (rawData && !rawData.startsWith('{{')) {
|
||||
workflowData = JSON.parse(rawData);
|
||||
} else {
|
||||
throw new Error('Data not injected');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Workflow data not injected, using empty defaults');
|
||||
workflowData = { activeSessions: [], archivedSessions: [] };
|
||||
}
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
updateThemeIcon(savedTheme);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
updateThemeIcon(newTheme);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
// Statistics calculation
|
||||
function updateStatistics() {
|
||||
const stats = {
|
||||
totalSessions: workflowData.activeSessions.length + workflowData.archivedSessions.length,
|
||||
activeSessions: workflowData.activeSessions.length,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0
|
||||
};
|
||||
|
||||
workflowData.activeSessions.forEach(session => {
|
||||
stats.totalTasks += session.tasks.length;
|
||||
stats.completedTasks += session.tasks.filter(t => t.status === 'completed').length;
|
||||
});
|
||||
|
||||
workflowData.archivedSessions.forEach(session => {
|
||||
stats.totalTasks += session.taskCount || 0;
|
||||
stats.completedTasks += session.taskCount || 0;
|
||||
});
|
||||
|
||||
document.getElementById('totalSessions').textContent = stats.totalSessions;
|
||||
document.getElementById('activeSessionsCount').textContent = stats.activeSessions;
|
||||
document.getElementById('totalTasks').textContent = stats.totalTasks;
|
||||
document.getElementById('completedTasks').textContent = stats.completedTasks;
|
||||
}
|
||||
|
||||
// Render session card with Tailwind classes
|
||||
function createSessionCard(session, isActive) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-card rounded-lg border border-border shadow-sm p-5 transition-all duration-300 hover:-translate-y-1 hover:shadow-md';
|
||||
card.dataset.sessionType = isActive ? 'active' : 'archived';
|
||||
|
||||
const completedTasks = isActive
|
||||
? session.tasks.filter(t => t.status === 'completed').length
|
||||
: (session.taskCount || 0);
|
||||
const totalTasks = isActive ? session.tasks.length : (session.taskCount || 0);
|
||||
const progress = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0;
|
||||
|
||||
let tasksHtml = '';
|
||||
if (isActive && session.tasks.length > 0) {
|
||||
tasksHtml = `
|
||||
<div class="mt-4 space-y-2">
|
||||
${session.tasks.map(task => {
|
||||
const statusClasses = {
|
||||
completed: 'border-l-success bg-success-light/30',
|
||||
in_progress: 'border-l-warning bg-warning-light/30',
|
||||
pending: 'border-l-muted-foreground bg-muted/30'
|
||||
};
|
||||
const checkboxClasses = {
|
||||
completed: 'bg-success border-success text-white',
|
||||
in_progress: 'border-warning text-warning',
|
||||
pending: 'border-border'
|
||||
};
|
||||
return `
|
||||
<div class="flex items-center gap-3 p-2.5 rounded border-l-[3px] ${statusClasses[task.status] || statusClasses.pending}">
|
||||
<div class="task-checkbox w-5 h-5 rounded-full border-2 flex items-center justify-center text-xs font-bold flex-shrink-0 ${checkboxClasses[task.status] || checkboxClasses.pending} ${task.status}"></div>
|
||||
<div class="flex-1 text-sm text-foreground">${task.title || 'Untitled Task'}</div>
|
||||
<span class="text-xs font-mono text-muted-foreground">${task.task_id || ''}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const statusBadgeClass = isActive
|
||||
? 'bg-success-light text-success'
|
||||
: 'bg-hover text-muted-foreground';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">${session.session_id || 'Unknown Session'}</h3>
|
||||
<div class="text-sm text-muted-foreground mt-1">${session.project || ''}</div>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-xs font-semibold uppercase rounded-full ${statusBadgeClass}">
|
||||
${isActive ? 'Active' : 'Archived'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 text-sm text-muted-foreground mb-3">
|
||||
<span>📅 ${session.created_at || session.archived_at || 'N/A'}</span>
|
||||
<span>📊 ${completedTasks}/${totalTasks} tasks</span>
|
||||
</div>
|
||||
|
||||
${totalTasks > 0 ? `
|
||||
<div class="h-2 bg-hover rounded overflow-hidden my-4">
|
||||
<div class="progress-fill h-full rounded transition-all duration-300" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<div class="text-center text-sm text-muted-foreground">${Math.round(progress)}% Complete</div>
|
||||
` : ''}
|
||||
|
||||
${tasksHtml}
|
||||
|
||||
${!isActive && session.archive_path ? `
|
||||
<div class="mt-4 pt-4 border-t border-border text-sm text-muted-foreground">
|
||||
📁 Archive: ${session.archive_path}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Render all sessions
|
||||
function renderSessions(filter = 'all') {
|
||||
const activeContainer = document.getElementById('activeSessionsGrid');
|
||||
const archivedContainer = document.getElementById('archivedSessionsGrid');
|
||||
|
||||
activeContainer.innerHTML = '';
|
||||
archivedContainer.innerHTML = '';
|
||||
|
||||
if (filter === 'all' || filter === 'active') {
|
||||
if (workflowData.activeSessions.length === 0) {
|
||||
activeContainer.innerHTML = `
|
||||
<div class="text-center py-16 text-muted-foreground col-span-full">
|
||||
<div class="text-5xl mb-4 opacity-50">📭</div>
|
||||
<p>No active sessions</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
workflowData.activeSessions.forEach(session => {
|
||||
activeContainer.appendChild(createSessionCard(session, true));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (filter === 'all' || filter === 'archived') {
|
||||
if (workflowData.archivedSessions.length === 0) {
|
||||
archivedContainer.innerHTML = `
|
||||
<div class="text-center py-16 text-muted-foreground col-span-full">
|
||||
<div class="text-5xl mb-4 opacity-50">📦</div>
|
||||
<p>No archived sessions</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
workflowData.archivedSessions.forEach(session => {
|
||||
archivedContainer.appendChild(createSessionCard(session, false));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide sections
|
||||
document.getElementById('activeSectionContainer').style.display =
|
||||
(filter === 'all' || filter === 'active') ? 'block' : 'none';
|
||||
document.getElementById('archivedSectionContainer').style.display =
|
||||
(filter === 'all' || filter === 'archived') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function setupSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
const cards = document.querySelectorAll('[data-session-type]');
|
||||
|
||||
cards.forEach(card => {
|
||||
const text = card.textContent.toLowerCase();
|
||||
card.style.display = text.includes(query) ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter functionality
|
||||
function setupFilters() {
|
||||
const filterButtons = document.querySelectorAll('[data-filter]');
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterButtons.forEach(b => {
|
||||
b.classList.remove('bg-primary', 'text-primary-foreground');
|
||||
b.classList.add('bg-card', 'text-foreground');
|
||||
});
|
||||
btn.classList.remove('bg-card', 'text-foreground');
|
||||
btn.classList.add('bg-primary', 'text-primary-foreground');
|
||||
renderSessions(btn.dataset.filter);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
updateStatistics();
|
||||
renderSessions();
|
||||
setupSearch();
|
||||
setupFilters();
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
49
ccw/src/utils/browser-launcher.js
Normal file
49
ccw/src/utils/browser-launcher.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import open from 'open';
|
||||
import { platform } from 'os';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Launch a file in the default browser
|
||||
* Cross-platform compatible (Windows/macOS/Linux)
|
||||
* @param {string} filePath - Path to HTML file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function launchBrowser(filePath) {
|
||||
const absolutePath = resolve(filePath);
|
||||
|
||||
// Construct file:// URL based on platform
|
||||
let url;
|
||||
if (platform() === 'win32') {
|
||||
// Windows: file:///C:/path/to/file.html
|
||||
url = `file:///${absolutePath.replace(/\\/g, '/')}`;
|
||||
} else {
|
||||
// Unix: file:///path/to/file.html
|
||||
url = `file://${absolutePath}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the 'open' package which handles cross-platform browser launching
|
||||
await open(url);
|
||||
} catch (error) {
|
||||
// Fallback: try opening the file path directly
|
||||
try {
|
||||
await open(absolutePath);
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to open browser: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running in a headless/CI environment
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isHeadlessEnvironment() {
|
||||
return !!(
|
||||
process.env.CI ||
|
||||
process.env.CONTINUOUS_INTEGRATION ||
|
||||
process.env.GITHUB_ACTIONS ||
|
||||
process.env.GITLAB_CI ||
|
||||
process.env.JENKINS_URL
|
||||
);
|
||||
}
|
||||
48
ccw/src/utils/file-utils.js
Normal file
48
ccw/src/utils/file-utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Safely read a JSON file
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @returns {Object|null} - Parsed JSON or null on error
|
||||
*/
|
||||
export function readJsonFile(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read a text file
|
||||
* @param {string} filePath - Path to text file
|
||||
* @returns {string|null} - File contents or null on error
|
||||
*/
|
||||
export function readTextFile(filePath) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write content to a file
|
||||
* @param {string} filePath - Path to file
|
||||
* @param {string} content - Content to write
|
||||
*/
|
||||
export function writeTextFile(filePath, content) {
|
||||
writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pathExists(filePath) {
|
||||
return existsSync(filePath);
|
||||
}
|
||||
279
ccw/src/utils/path-resolver.js
Normal file
279
ccw/src/utils/path-resolver.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import { resolve, join, relative, isAbsolute } from 'path';
|
||||
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Resolve a path, handling ~ for home directory
|
||||
* @param {string} inputPath - Path to resolve
|
||||
* @returns {string} - Absolute path
|
||||
*/
|
||||
export function resolvePath(inputPath) {
|
||||
if (!inputPath) return process.cwd();
|
||||
|
||||
// Handle ~ for home directory
|
||||
if (inputPath.startsWith('~')) {
|
||||
return join(homedir(), inputPath.slice(1));
|
||||
}
|
||||
|
||||
return resolve(inputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize a user-provided path
|
||||
* Prevents path traversal attacks and validates path is within allowed boundaries
|
||||
* @param {string} inputPath - User-provided path
|
||||
* @param {Object} options - Validation options
|
||||
* @param {string} options.baseDir - Base directory to restrict paths within (optional)
|
||||
* @param {boolean} options.mustExist - Whether path must exist (default: false)
|
||||
* @param {boolean} options.allowHome - Whether to allow home directory paths (default: true)
|
||||
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
|
||||
*/
|
||||
export function validatePath(inputPath, options = {}) {
|
||||
const { baseDir = null, mustExist = false, allowHome = true } = options;
|
||||
|
||||
// Check for empty/null input
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
return { valid: false, path: null, error: 'Path is required' };
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedPath = inputPath.trim();
|
||||
|
||||
// Check for suspicious patterns (null bytes, control characters)
|
||||
if (/[\x00-\x1f]/.test(trimmedPath)) {
|
||||
return { valid: false, path: null, error: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
// Resolve the path
|
||||
let resolvedPath;
|
||||
try {
|
||||
resolvedPath = resolvePath(trimmedPath);
|
||||
} catch (err) {
|
||||
return { valid: false, path: null, error: `Invalid path: ${err.message}` };
|
||||
}
|
||||
|
||||
// Check if path exists when required
|
||||
if (mustExist && !existsSync(resolvedPath)) {
|
||||
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
|
||||
}
|
||||
|
||||
// Get real path if it exists (resolves symlinks)
|
||||
let realPath = resolvedPath;
|
||||
if (existsSync(resolvedPath)) {
|
||||
try {
|
||||
realPath = realpathSync(resolvedPath);
|
||||
} catch (err) {
|
||||
return { valid: false, path: null, error: `Cannot resolve path: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if within base directory when specified
|
||||
if (baseDir) {
|
||||
const resolvedBase = resolvePath(baseDir);
|
||||
const relativePath = relative(resolvedBase, realPath);
|
||||
|
||||
// Path traversal detection: relative path should not start with '..'
|
||||
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
return {
|
||||
valid: false,
|
||||
path: null,
|
||||
error: `Path must be within ${resolvedBase}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check home directory restriction
|
||||
if (!allowHome) {
|
||||
const home = homedir();
|
||||
if (realPath === home || realPath.startsWith(home + '/') || realPath.startsWith(home + '\\')) {
|
||||
// This is fine, we're just checking if it's explicitly the home dir itself
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, path: realPath, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate output file path for writing
|
||||
* @param {string} outputPath - Output file path
|
||||
* @param {string} defaultDir - Default directory if path is relative
|
||||
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
|
||||
*/
|
||||
export function validateOutputPath(outputPath, defaultDir = process.cwd()) {
|
||||
if (!outputPath || typeof outputPath !== 'string') {
|
||||
return { valid: false, path: null, error: 'Output path is required' };
|
||||
}
|
||||
|
||||
const trimmedPath = outputPath.trim();
|
||||
|
||||
// Check for suspicious patterns
|
||||
if (/[\x00-\x1f]/.test(trimmedPath)) {
|
||||
return { valid: false, path: null, error: 'Output path contains invalid characters' };
|
||||
}
|
||||
|
||||
// Resolve the path
|
||||
let resolvedPath;
|
||||
try {
|
||||
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
|
||||
resolvedPath = resolve(resolvedPath);
|
||||
} catch (err) {
|
||||
return { valid: false, path: null, error: `Invalid output path: ${err.message}` };
|
||||
}
|
||||
|
||||
// Ensure it's not a directory
|
||||
if (existsSync(resolvedPath)) {
|
||||
try {
|
||||
const stat = statSync(resolvedPath);
|
||||
if (stat.isDirectory()) {
|
||||
return { valid: false, path: null, error: 'Output path is a directory, expected a file' };
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, path: resolvedPath, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get potential template locations
|
||||
* @returns {string[]} - Array of existing template directories
|
||||
*/
|
||||
export function getTemplateLocations() {
|
||||
const locations = [
|
||||
join(homedir(), '.claude', 'templates'),
|
||||
join(process.cwd(), '.claude', 'templates')
|
||||
];
|
||||
|
||||
return locations.filter(loc => existsSync(loc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template file in known locations
|
||||
* @param {string} templateName - Name of template file (e.g., 'workflow-dashboard.html')
|
||||
* @returns {string|null} - Path to template or null if not found
|
||||
*/
|
||||
export function findTemplate(templateName) {
|
||||
const locations = getTemplateLocations();
|
||||
|
||||
for (const loc of locations) {
|
||||
const templatePath = join(loc, templateName);
|
||||
if (existsSync(templatePath)) {
|
||||
return templatePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists, creating if necessary
|
||||
* @param {string} dirPath - Directory path to ensure
|
||||
*/
|
||||
export function ensureDir(dirPath) {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the .workflow directory path from project path
|
||||
* @param {string} projectPath - Path to project
|
||||
* @returns {string} - Path to .workflow directory
|
||||
*/
|
||||
export function getWorkflowDir(projectPath) {
|
||||
return join(resolvePath(projectPath), '.workflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path for display (handle Windows backslashes)
|
||||
* @param {string} filePath - Path to normalize
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizePathForDisplay(filePath) {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Recent paths storage file
|
||||
const RECENT_PATHS_FILE = join(homedir(), '.ccw-recent-paths.json');
|
||||
const MAX_RECENT_PATHS = 10;
|
||||
|
||||
/**
|
||||
* Get recent project paths
|
||||
* @returns {string[]} - Array of recent paths
|
||||
*/
|
||||
export function getRecentPaths() {
|
||||
try {
|
||||
if (existsSync(RECENT_PATHS_FILE)) {
|
||||
const content = readFileSync(RECENT_PATHS_FILE, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
return Array.isArray(data.paths) ? data.paths : [];
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, return empty array
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a project path (add to recent paths)
|
||||
* @param {string} projectPath - Path to track
|
||||
*/
|
||||
export function trackRecentPath(projectPath) {
|
||||
try {
|
||||
const normalized = normalizePathForDisplay(resolvePath(projectPath));
|
||||
let paths = getRecentPaths();
|
||||
|
||||
// Remove if already exists (will be added to front)
|
||||
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
|
||||
|
||||
// Add to front
|
||||
paths.unshift(normalized);
|
||||
|
||||
// Limit to max
|
||||
paths = paths.slice(0, MAX_RECENT_PATHS);
|
||||
|
||||
// Save
|
||||
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recent paths
|
||||
*/
|
||||
export function clearRecentPaths() {
|
||||
try {
|
||||
if (existsSync(RECENT_PATHS_FILE)) {
|
||||
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths: [] }, null, 2), 'utf8');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific path from recent paths
|
||||
* @param {string} pathToRemove - Path to remove
|
||||
* @returns {boolean} - True if removed, false if not found
|
||||
*/
|
||||
export function removeRecentPath(pathToRemove) {
|
||||
try {
|
||||
const normalized = normalizePathForDisplay(resolvePath(pathToRemove));
|
||||
let paths = getRecentPaths();
|
||||
const originalLength = paths.length;
|
||||
|
||||
// Filter out the path to remove
|
||||
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
|
||||
|
||||
if (paths.length < originalLength) {
|
||||
// Save updated list
|
||||
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
148
ccw/src/utils/ui.js
Normal file
148
ccw/src/utils/ui.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import chalk from 'chalk';
|
||||
import figlet from 'figlet';
|
||||
import boxen from 'boxen';
|
||||
import gradient from 'gradient-string';
|
||||
import ora from 'ora';
|
||||
|
||||
// Custom gradient colors
|
||||
const claudeGradient = gradient(['#00d4ff', '#00ff88']);
|
||||
const codeGradient = gradient(['#00ff88', '#ffff00']);
|
||||
const workflowGradient = gradient(['#ffff00', '#ff8800']);
|
||||
|
||||
/**
|
||||
* Display ASCII art banner
|
||||
*/
|
||||
export function showBanner() {
|
||||
console.log('');
|
||||
|
||||
// CLAUDE in cyan gradient
|
||||
try {
|
||||
const claudeText = figlet.textSync('Claude', { font: 'Standard' });
|
||||
console.log(claudeGradient(claudeText));
|
||||
} catch {
|
||||
console.log(chalk.cyan.bold(' Claude'));
|
||||
}
|
||||
|
||||
// CODE in green gradient
|
||||
try {
|
||||
const codeText = figlet.textSync('Code', { font: 'Standard' });
|
||||
console.log(codeGradient(codeText));
|
||||
} catch {
|
||||
console.log(chalk.green.bold(' Code'));
|
||||
}
|
||||
|
||||
// WORKFLOW in yellow gradient
|
||||
try {
|
||||
const workflowText = figlet.textSync('Workflow', { font: 'Standard' });
|
||||
console.log(workflowGradient(workflowText));
|
||||
} catch {
|
||||
console.log(chalk.yellow.bold(' Workflow'));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display header with version info
|
||||
* @param {string} version - Version number
|
||||
* @param {string} mode - Installation mode
|
||||
*/
|
||||
export function showHeader(version, mode = '') {
|
||||
showBanner();
|
||||
|
||||
const versionText = version ? `v${version}` : '';
|
||||
const modeText = mode ? ` (${mode})` : '';
|
||||
|
||||
console.log(boxen(
|
||||
chalk.cyan.bold('Claude Code Workflow System') + '\n' +
|
||||
chalk.gray(`Installer ${versionText}${modeText}`) + '\n\n' +
|
||||
chalk.white('Unified workflow system with comprehensive coordination'),
|
||||
{
|
||||
padding: 1,
|
||||
margin: { top: 0, bottom: 1, left: 2, right: 2 },
|
||||
borderStyle: 'round',
|
||||
borderColor: 'cyan'
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spinner
|
||||
* @param {string} text - Spinner text
|
||||
* @returns {ora.Ora}
|
||||
*/
|
||||
export function createSpinner(text) {
|
||||
return ora({
|
||||
text,
|
||||
color: 'cyan',
|
||||
spinner: 'dots'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display success message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function success(message) {
|
||||
console.log(chalk.green('✓') + ' ' + chalk.green(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display info message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function info(message) {
|
||||
console.log(chalk.cyan('ℹ') + ' ' + chalk.cyan(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display warning message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function warning(message) {
|
||||
console.log(chalk.yellow('⚠') + ' ' + chalk.yellow(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error message
|
||||
* @param {string} message
|
||||
*/
|
||||
export function error(message) {
|
||||
console.log(chalk.red('✖') + ' ' + chalk.red(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display step message
|
||||
* @param {number} step - Step number
|
||||
* @param {number} total - Total steps
|
||||
* @param {string} message - Step message
|
||||
*/
|
||||
export function step(stepNum, total, message) {
|
||||
console.log(chalk.gray(`[${stepNum}/${total}]`) + ' ' + chalk.white(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display summary box
|
||||
* @param {Object} options
|
||||
* @param {string} options.title - Box title
|
||||
* @param {string[]} options.lines - Content lines
|
||||
* @param {string} options.borderColor - Border color
|
||||
*/
|
||||
export function summaryBox({ title, lines, borderColor = 'green' }) {
|
||||
const content = lines.join('\n');
|
||||
console.log(boxen(content, {
|
||||
title,
|
||||
titleAlignment: 'center',
|
||||
padding: 1,
|
||||
margin: { top: 1, bottom: 1, left: 2, right: 2 },
|
||||
borderStyle: 'round',
|
||||
borderColor
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a divider line
|
||||
*/
|
||||
export function divider() {
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
}
|
||||
156
ccw/tailwind.config.js
Normal file
156
ccw/tailwind.config.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/templates/**/*.html",
|
||||
"./src/**/*.py",
|
||||
"./static/**/*.js",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Base colors
|
||||
background: "hsl(var(--color-background))",
|
||||
foreground: "hsl(var(--color-foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--color-card))",
|
||||
foreground: "hsl(var(--color-card-foreground))",
|
||||
},
|
||||
border: "hsl(var(--color-border))",
|
||||
input: "hsl(var(--color-input))",
|
||||
ring: "hsl(var(--color-ring))",
|
||||
|
||||
// Interactive colors
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--color-interactive-primary-default))",
|
||||
hover: "hsl(var(--color-interactive-primary-hover))",
|
||||
active: "hsl(var(--color-interactive-primary-active))",
|
||||
disabled: "hsl(var(--color-interactive-primary-disabled))",
|
||||
foreground: "hsl(var(--color-interactive-primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--color-interactive-secondary-default))",
|
||||
hover: "hsl(var(--color-interactive-secondary-hover))",
|
||||
active: "hsl(var(--color-interactive-secondary-active))",
|
||||
disabled: "hsl(var(--color-interactive-secondary-disabled))",
|
||||
foreground: "hsl(var(--color-interactive-secondary-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--color-interactive-accent-default))",
|
||||
hover: "hsl(var(--color-interactive-accent-hover))",
|
||||
active: "hsl(var(--color-interactive-accent-active))",
|
||||
foreground: "hsl(var(--color-interactive-accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--color-interactive-destructive-default))",
|
||||
hover: "hsl(var(--color-interactive-destructive-hover))",
|
||||
foreground: "hsl(var(--color-interactive-destructive-foreground))",
|
||||
},
|
||||
|
||||
// Semantic colors
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--color-muted))",
|
||||
foreground: "hsl(var(--color-muted-foreground))",
|
||||
},
|
||||
|
||||
// Sidebar colors
|
||||
sidebar: {
|
||||
background: "hsl(var(--color-sidebar-background))",
|
||||
foreground: "hsl(var(--color-sidebar-foreground))",
|
||||
primary: "hsl(var(--color-sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--color-sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--color-sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--color-sidebar-accent-foreground))",
|
||||
border: "hsl(var(--color-sidebar-border))",
|
||||
},
|
||||
},
|
||||
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", "Inter", "system-ui", "-apple-system", "sans-serif"],
|
||||
mono: ["var(--font-mono)", "Consolas", "Monaco", "Courier New", "monospace"],
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: "var(--font-size-xs)",
|
||||
sm: "var(--font-size-sm)",
|
||||
base: "var(--font-size-base)",
|
||||
lg: "var(--font-size-lg)",
|
||||
xl: "var(--font-size-xl)",
|
||||
"2xl": "var(--font-size-2xl)",
|
||||
"3xl": "var(--font-size-3xl)",
|
||||
"4xl": "var(--font-size-4xl)",
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
tight: "var(--line-height-tight)",
|
||||
normal: "var(--line-height-normal)",
|
||||
relaxed: "var(--line-height-relaxed)",
|
||||
},
|
||||
|
||||
letterSpacing: {
|
||||
tight: "var(--letter-spacing-tight)",
|
||||
normal: "var(--letter-spacing-normal)",
|
||||
wide: "var(--letter-spacing-wide)",
|
||||
},
|
||||
|
||||
spacing: {
|
||||
0: "var(--spacing-0)",
|
||||
1: "var(--spacing-1)",
|
||||
2: "var(--spacing-2)",
|
||||
3: "var(--spacing-3)",
|
||||
4: "var(--spacing-4)",
|
||||
6: "var(--spacing-6)",
|
||||
8: "var(--spacing-8)",
|
||||
12: "var(--spacing-12)",
|
||||
16: "var(--spacing-16)",
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
sm: "var(--border-radius-sm)",
|
||||
md: "var(--border-radius-md)",
|
||||
lg: "var(--border-radius-lg)",
|
||||
xl: "var(--border-radius-xl)",
|
||||
DEFAULT: "var(--border-radius-default)",
|
||||
},
|
||||
|
||||
boxShadow: {
|
||||
"2xs": "var(--shadow-2xs)",
|
||||
xs: "var(--shadow-xs)",
|
||||
sm: "var(--shadow-sm)",
|
||||
md: "var(--shadow-md)",
|
||||
lg: "var(--shadow-lg)",
|
||||
xl: "var(--shadow-xl)",
|
||||
},
|
||||
|
||||
opacity: {
|
||||
disabled: "var(--opacity-disabled)",
|
||||
hover: "var(--opacity-hover)",
|
||||
active: "var(--opacity-active)",
|
||||
},
|
||||
|
||||
transitionDuration: {
|
||||
instant: "var(--duration-instant)",
|
||||
fast: "var(--duration-fast)",
|
||||
normal: "var(--duration-normal)",
|
||||
medium: "var(--duration-medium)",
|
||||
slow: "var(--duration-slow)",
|
||||
},
|
||||
|
||||
transitionTimingFunction: {
|
||||
linear: "var(--easing-linear)",
|
||||
"ease-in": "var(--easing-ease-in)",
|
||||
"ease-out": "var(--easing-ease-out)",
|
||||
"ease-in-out": "var(--easing-ease-in-out)",
|
||||
spring: "var(--easing-spring)",
|
||||
},
|
||||
|
||||
animation: {
|
||||
// Add custom animations here if needed
|
||||
},
|
||||
|
||||
keyframes: {
|
||||
// Add custom keyframes here if needed
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
1947
package-lock.json
generated
Normal file
1947
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
package.json
Normal file
66
package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "claude-code-workflow",
|
||||
"version": "6.0.1",
|
||||
"description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution",
|
||||
"type": "module",
|
||||
"main": "ccw/src/index.js",
|
||||
"bin": {
|
||||
"ccw": "./ccw/bin/ccw.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ccw/bin/ccw.js",
|
||||
"test": "node --test",
|
||||
"prepublishOnly": "echo 'Ready to publish @dyw/claude-code-workflow'"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"workflow",
|
||||
"ai",
|
||||
"cli",
|
||||
"dashboard",
|
||||
"code-review",
|
||||
"automation",
|
||||
"development"
|
||||
],
|
||||
"author": "dyw",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"boxen": "^7.1.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^11.0.0",
|
||||
"figlet": "^1.7.0",
|
||||
"glob": "^10.3.0",
|
||||
"gradient-string": "^2.0.2",
|
||||
"inquirer": "^9.2.0",
|
||||
"open": "^9.1.0",
|
||||
"ora": "^7.0.0"
|
||||
},
|
||||
"files": [
|
||||
"ccw/bin/",
|
||||
"ccw/src/",
|
||||
".claude/agents/",
|
||||
".claude/commands/",
|
||||
".claude/output-styles/",
|
||||
".claude/workflows/",
|
||||
".claude/scripts/",
|
||||
".claude/prompt-templates/",
|
||||
".claude/python_script/",
|
||||
".claude/skills/",
|
||||
".codex/",
|
||||
".gemini/",
|
||||
".qwen/",
|
||||
"CLAUDE.md",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/catlog22/Claude-Code-Workflow.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/catlog22/Claude-Code-Workflow/issues"
|
||||
},
|
||||
"homepage": "https://github.com/catlog22/Claude-Code-Workflow#readme"
|
||||
}
|
||||
Reference in New Issue
Block a user