mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-07 02:04:11 +08:00
Add phases for issue resolution: From Brainstorm and Form Execution Queue
- Implement Phase 3: From Brainstorm to convert brainstorm session output into executable issues and solutions. - Implement Phase 4: Form Execution Queue to analyze bound solutions, resolve conflicts, and create an ordered execution queue. - Introduce new data structures for Issue and Solution schemas. - Enhance CLI commands for issue creation and queue management. - Add error handling and quality checklist for queue formation.
This commit is contained in:
277
.claude/skills/issue-resolve/SKILL.md
Normal file
277
.claude/skills/issue-resolve/SKILL.md
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
name: issue-resolve
|
||||
description: Unified issue resolution pipeline with source selection. Plan issues via AI exploration, convert from artifacts, import from brainstorm sessions, or form execution queues. Triggers on "issue:plan", "issue:queue", "issue:convert-to-plan", "issue:from-brainstorm", "resolve issue", "plan issue", "queue issues", "convert plan to issue".
|
||||
allowed-tools: Task, AskUserQuestion, TodoWrite, Read, Write, Edit, Bash, Glob, Grep, Skill
|
||||
---
|
||||
|
||||
# Issue Resolve
|
||||
|
||||
Unified issue resolution pipeline that orchestrates solution creation from multiple sources and queue formation for execution.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Issue Resolve Orchestrator (SKILL.md) │
|
||||
│ → Source selection → Route to phase → Execute → Summary │
|
||||
└───────────────┬─────────────────────────────────────────────────┘
|
||||
│
|
||||
├─ AskUserQuestion: Select issue source
|
||||
│
|
||||
┌───────────┼───────────┬───────────┬───────────┐
|
||||
↓ ↓ ↓ ↓ │
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ Phase 1 │ │ Phase 2 │ │ Phase 3 │ │ Phase 4 │ │
|
||||
│ Explore │ │ Convert │ │ From │ │ Form │ │
|
||||
│ & Plan │ │Artifact │ │Brainstorm│ │ Queue │ │
|
||||
└─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
↓ ↓ ↓ ↓ │
|
||||
Solutions Solutions Issue+Sol Exec Queue │
|
||||
(bound) (bound) (bound) (ordered) │
|
||||
│
|
||||
┌────────────────────────────────┘
|
||||
↓
|
||||
/issue:execute
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Source-Driven Routing**: AskUserQuestion selects workflow, then load single phase
|
||||
2. **Progressive Phase Loading**: Only read the selected phase document
|
||||
3. **CLI-First Data Access**: All issue/solution CRUD via `ccw issue` CLI commands
|
||||
4. **Auto Mode Support**: `-y` flag skips source selection (defaults to Explore & Plan)
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Skip source selection, use Explore & Plan for issue IDs, or auto-detect source type for paths.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
Skill(skill="issue-resolve", args="<task description or issue IDs>")
|
||||
Skill(skill="issue-resolve", args="[FLAGS] \"<input>\"")
|
||||
|
||||
# Flags
|
||||
-y, --yes Skip all confirmations (auto mode)
|
||||
--source <type> Pre-select source: plan|convert|brainstorm|queue
|
||||
--batch-size <n> Max issues per agent batch (plan mode, default: 3)
|
||||
--issue <id> Bind to existing issue (convert mode)
|
||||
--supplement Add tasks to existing solution (convert mode)
|
||||
--queues <n> Number of parallel queues (queue mode, default: 1)
|
||||
|
||||
# Examples
|
||||
Skill(skill="issue-resolve", args="GH-123,GH-124") # Explore & plan issues
|
||||
Skill(skill="issue-resolve", args="--source plan --all-pending") # Plan all pending issues
|
||||
Skill(skill="issue-resolve", args="--source convert \".workflow/.lite-plan/my-plan\"") # Convert artifact
|
||||
Skill(skill="issue-resolve", args="--source brainstorm SESSION=\"BS-rate-limiting\"") # From brainstorm
|
||||
Skill(skill="issue-resolve", args="--source queue") # Form execution queue
|
||||
Skill(skill="issue-resolve", args="-y GH-123") # Auto mode, plan single issue
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Input Parsing:
|
||||
└─ Parse flags (--source, -y, --issue, etc.) and positional args
|
||||
|
||||
Source Selection:
|
||||
├─ --source flag provided → Route directly
|
||||
├─ Auto-detect from input:
|
||||
│ ├─ Issue IDs (GH-xxx, ISS-xxx) → Explore & Plan
|
||||
│ ├─ SESSION="..." → From Brainstorm
|
||||
│ ├─ File/folder path → Convert from Artifact
|
||||
│ └─ No input or --all-pending → Explore & Plan (all pending)
|
||||
└─ Otherwise → AskUserQuestion to select source
|
||||
|
||||
Phase Execution (load one phase):
|
||||
├─ Phase 1: Explore & Plan → phases/01-issue-plan.md
|
||||
├─ Phase 2: Convert Artifact → phases/02-convert-to-plan.md
|
||||
├─ Phase 3: From Brainstorm → phases/03-from-brainstorm.md
|
||||
└─ Phase 4: Form Queue → phases/04-issue-queue.md
|
||||
|
||||
Post-Phase:
|
||||
└─ Summary + Next steps recommendation
|
||||
```
|
||||
|
||||
### Phase Reference Documents
|
||||
|
||||
| Phase | Document | Load When | Purpose |
|
||||
|-------|----------|-----------|---------|
|
||||
| Phase 1 | [phases/01-issue-plan.md](phases/01-issue-plan.md) | Source = Explore & Plan | Batch plan issues via issue-plan-agent |
|
||||
| Phase 2 | [phases/02-convert-to-plan.md](phases/02-convert-to-plan.md) | Source = Convert Artifact | Convert lite-plan/session/markdown to solutions |
|
||||
| Phase 3 | [phases/03-from-brainstorm.md](phases/03-from-brainstorm.md) | Source = From Brainstorm | Convert brainstorm ideas to issue + solution |
|
||||
| Phase 4 | [phases/04-issue-queue.md](phases/04-issue-queue.md) | Source = Form Queue | Order bound solutions into execution queue |
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Source Selection First**: Always determine source before loading any phase
|
||||
2. **Single Phase Load**: Only read the selected phase document, never load all phases
|
||||
3. **CLI Data Access**: Use `ccw issue` CLI for all issue/solution operations, NEVER read files directly
|
||||
4. **Content Preservation**: Each phase contains complete execution logic from original commands
|
||||
5. **Auto-Detect Input**: Smart input parsing reduces need for explicit --source flag
|
||||
|
||||
## Input Processing
|
||||
|
||||
### Auto-Detection Logic
|
||||
|
||||
```javascript
|
||||
function detectSource(input, flags) {
|
||||
// 1. Explicit --source flag
|
||||
if (flags.source) return flags.source;
|
||||
|
||||
// 2. Auto-detect from input content
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Issue IDs pattern (GH-xxx, ISS-xxx, comma-separated)
|
||||
if (trimmed.match(/^[A-Z]+-\d+/i) || trimmed.includes(',')) {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
// --all-pending or empty input → plan all pending
|
||||
if (flags.allPending || trimmed === '') {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
// SESSION="..." pattern → brainstorm
|
||||
if (trimmed.includes('SESSION=')) {
|
||||
return 'brainstorm';
|
||||
}
|
||||
|
||||
// File/folder path → convert
|
||||
if (trimmed.match(/\.(md|json)$/) || trimmed.includes('.workflow/')) {
|
||||
return 'convert';
|
||||
}
|
||||
|
||||
// Cannot auto-detect → ask user
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Source Selection (AskUserQuestion)
|
||||
|
||||
```javascript
|
||||
// When source cannot be auto-detected
|
||||
const answer = AskUserQuestion({
|
||||
questions: [{
|
||||
question: "How would you like to create/manage issue solutions?",
|
||||
header: "Source",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{
|
||||
label: "Explore & Plan (Recommended)",
|
||||
description: "AI explores codebase and generates solutions for issues"
|
||||
},
|
||||
{
|
||||
label: "Convert from Artifact",
|
||||
description: "Convert existing lite-plan, workflow session, or markdown to solution"
|
||||
},
|
||||
{
|
||||
label: "From Brainstorm",
|
||||
description: "Convert brainstorm session ideas into issue with solution"
|
||||
},
|
||||
{
|
||||
label: "Form Execution Queue",
|
||||
description: "Order bound solutions into execution queue for /issue:execute"
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
// Route based on selection
|
||||
const sourceMap = {
|
||||
"Explore & Plan": "plan",
|
||||
"Convert from Artifact": "convert",
|
||||
"From Brainstorm": "brainstorm",
|
||||
"Form Execution Queue": "queue"
|
||||
};
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User Input (issue IDs / artifact path / session ID / flags)
|
||||
↓
|
||||
[Parse Flags + Auto-Detect Source]
|
||||
↓
|
||||
[Source Selection] ← AskUserQuestion (if needed)
|
||||
↓
|
||||
[Read Selected Phase Document]
|
||||
↓
|
||||
[Execute Phase Logic]
|
||||
↓
|
||||
[Summary + Next Steps]
|
||||
├─ After Plan/Convert/Brainstorm → Suggest /issue:queue or /issue:execute
|
||||
└─ After Queue → Suggest /issue:execute
|
||||
```
|
||||
|
||||
## TodoWrite Pattern
|
||||
|
||||
```json
|
||||
[
|
||||
{"content": "Select issue source", "status": "completed"},
|
||||
{"content": "Execute: [selected phase name]", "status": "in_progress"},
|
||||
{"content": "Summary & next steps", "status": "pending"}
|
||||
]
|
||||
```
|
||||
|
||||
Phase-specific sub-tasks are attached when the phase executes (see individual phase docs for details).
|
||||
|
||||
## Core Guidelines
|
||||
|
||||
**Data Access Principle**: Issues and solutions files can grow very large. To avoid context overflow:
|
||||
|
||||
| Operation | Correct | Incorrect |
|
||||
|-----------|---------|-----------|
|
||||
| List issues (brief) | `ccw issue list --status pending --brief` | `Read('issues.jsonl')` |
|
||||
| Read issue details | `ccw issue status <id> --json` | `Read('issues.jsonl')` |
|
||||
| Update status | `ccw issue update <id> --status ...` | Direct file edit |
|
||||
| Bind solution | `ccw issue bind <id> <sol-id>` | Direct file edit |
|
||||
| Batch solutions | `ccw issue solutions --status planned --brief` | Loop individual queries |
|
||||
|
||||
**Output Options**:
|
||||
- `--brief`: JSON with minimal fields (orchestrator use)
|
||||
- `--json`: Full JSON (agent use only)
|
||||
|
||||
**ALWAYS** use CLI commands for CRUD operations. **NEVER** read entire `issues.jsonl` or `solutions/*.jsonl` directly.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
| No source detected | Show AskUserQuestion with all 4 options |
|
||||
| Invalid source type | Show available sources, re-prompt |
|
||||
| Phase execution fails | Report error, suggest manual intervention |
|
||||
| No pending issues (plan) | Suggest `/issue:new` to create issues first |
|
||||
| No bound solutions (queue) | Suggest running plan/convert/brainstorm first |
|
||||
|
||||
## Post-Phase Next Steps
|
||||
|
||||
After successful phase execution, recommend next action:
|
||||
|
||||
```javascript
|
||||
// After Plan/Convert/Brainstorm (solutions created)
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Solutions created. What next?",
|
||||
header: "Next",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Form Queue", description: "Order solutions for execution (/issue:queue)" },
|
||||
{ label: "Plan More Issues", description: "Continue creating solutions" },
|
||||
{ label: "View Issues", description: "Review issue details" },
|
||||
{ label: "Done", description: "Exit workflow" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
// After Queue (queue formed)
|
||||
// → Suggest /issue:execute directly
|
||||
```
|
||||
|
||||
## Related Skills & Commands
|
||||
|
||||
- `issue-manage` - Interactive issue CRUD operations
|
||||
- `/issue:new` - Create structured issue from GitHub or text
|
||||
- `/issue:execute` - Execute queue with DAG-based parallel orchestration
|
||||
- `ccw issue list` - List all issues
|
||||
- `ccw issue status <id>` - View issue details
|
||||
292
.claude/skills/issue-resolve/phases/01-issue-plan.md
Normal file
292
.claude/skills/issue-resolve/phases/01-issue-plan.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Phase 1: Explore & Plan
|
||||
|
||||
> 来源: `commands/issue/plan.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Batch plan issue resolution using **issue-plan-agent** that combines exploration and planning into a single closed-loop workflow.
|
||||
|
||||
**Behavior:**
|
||||
- Single solution per issue → auto-bind
|
||||
- Multiple solutions → return for user selection
|
||||
- Agent handles file generation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Issue IDs provided (comma-separated) or `--all-pending` flag
|
||||
- `ccw issue` CLI available
|
||||
- `.workflow/issues/` directory exists or will be created
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Auto-bind solutions without confirmation, use recommended settings.
|
||||
|
||||
## Core Guidelines
|
||||
|
||||
**⚠️ Data Access Principle**: Issues and solutions files can grow very large. To avoid context overflow:
|
||||
|
||||
| Operation | Correct | Incorrect |
|
||||
|-----------|---------|-----------|
|
||||
| List issues (brief) | `ccw issue list --status pending --brief` | `Read('issues.jsonl')` |
|
||||
| Read issue details | `ccw issue status <id> --json` | `Read('issues.jsonl')` |
|
||||
| Update status | `ccw issue update <id> --status ...` | Direct file edit |
|
||||
| Bind solution | `ccw issue bind <id> <sol-id>` | Direct file edit |
|
||||
|
||||
**Output Options**:
|
||||
- `--brief`: JSON with minimal fields (id, title, status, priority, tags)
|
||||
- `--json`: Full JSON (agent use only)
|
||||
|
||||
**Orchestration vs Execution**:
|
||||
- **Command (orchestrator)**: Use `--brief` for minimal context
|
||||
- **Agent (executor)**: Fetch full details → `ccw issue status <id> --json`
|
||||
|
||||
**ALWAYS** use CLI commands for CRUD operations. **NEVER** read entire `issues.jsonl` or `solutions/*.jsonl` directly.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1.1: Issue Loading (Brief Info Only)
|
||||
|
||||
```javascript
|
||||
const batchSize = flags.batchSize || 3;
|
||||
let issues = []; // {id, title, tags} - brief info for grouping only
|
||||
|
||||
// Default to --all-pending if no input provided
|
||||
const useAllPending = flags.allPending || !userInput || userInput.trim() === '';
|
||||
|
||||
if (useAllPending) {
|
||||
// Get pending issues with brief metadata via CLI
|
||||
const result = Bash(`ccw issue list --status pending,registered --json`).trim();
|
||||
const parsed = result ? JSON.parse(result) : [];
|
||||
issues = parsed.map(i => ({ id: i.id, title: i.title || '', tags: i.tags || [] }));
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log('No pending issues found.');
|
||||
return;
|
||||
}
|
||||
console.log(`Found ${issues.length} pending issues`);
|
||||
} else {
|
||||
// Parse comma-separated issue IDs, fetch brief metadata
|
||||
const ids = userInput.includes(',')
|
||||
? userInput.split(',').map(s => s.trim())
|
||||
: [userInput.trim()];
|
||||
|
||||
for (const id of ids) {
|
||||
Bash(`ccw issue init ${id} --title "Issue ${id}" 2>/dev/null || true`);
|
||||
const info = Bash(`ccw issue status ${id} --json`).trim();
|
||||
const parsed = info ? JSON.parse(info) : {};
|
||||
issues.push({ id, title: parsed.title || '', tags: parsed.tags || [] });
|
||||
}
|
||||
}
|
||||
// Note: Agent fetches full issue content via `ccw issue status <id> --json`
|
||||
|
||||
// Intelligent grouping: Analyze issues by title/tags, group semantically similar ones
|
||||
// Strategy: Same module/component, related bugs, feature clusters
|
||||
// Constraint: Max ${batchSize} issues per batch
|
||||
|
||||
console.log(`Processing ${issues.length} issues in ${batches.length} batch(es)`);
|
||||
|
||||
TodoWrite({
|
||||
todos: batches.map((_, i) => ({
|
||||
content: `Plan batch ${i+1}`,
|
||||
status: 'pending',
|
||||
activeForm: `Planning batch ${i+1}`
|
||||
}))
|
||||
});
|
||||
```
|
||||
|
||||
### Step 1.2: Unified Explore + Plan (issue-plan-agent) - PARALLEL
|
||||
|
||||
```javascript
|
||||
Bash(`mkdir -p .workflow/issues/solutions`);
|
||||
const pendingSelections = []; // Collect multi-solution issues for user selection
|
||||
const agentResults = []; // Collect all agent results for conflict aggregation
|
||||
|
||||
// Build prompts for all batches
|
||||
const agentTasks = batches.map((batch, batchIndex) => {
|
||||
const issueList = batch.map(i => `- ${i.id}: ${i.title}${i.tags.length ? ` [${i.tags.join(', ')}]` : ''}`).join('\n');
|
||||
const batchIds = batch.map(i => i.id);
|
||||
|
||||
const issuePrompt = `
|
||||
## Plan Issues
|
||||
|
||||
**Issues** (grouped by similarity):
|
||||
${issueList}
|
||||
|
||||
**Project Root**: ${process.cwd()}
|
||||
|
||||
### Project Context (MANDATORY)
|
||||
1. Read: .workflow/project-tech.json (technology stack, architecture)
|
||||
2. Read: .workflow/project-guidelines.json (constraints and conventions)
|
||||
|
||||
### Workflow
|
||||
1. Fetch issue details: ccw issue status <id> --json
|
||||
2. **Analyze failure history** (if issue.feedback exists):
|
||||
- Extract failure details from issue.feedback (type='failure', stage='execute')
|
||||
- Parse error_type, message, task_id, solution_id from content JSON
|
||||
- Identify failure patterns: repeated errors, root causes, blockers
|
||||
- **Constraint**: Avoid repeating failed approaches
|
||||
3. Load project context files
|
||||
4. Explore codebase (ACE semantic search)
|
||||
5. Plan solution with tasks (schema: solution-schema.json)
|
||||
- **If previous solution failed**: Reference failure analysis in solution.approach
|
||||
- Add explicit verification steps to prevent same failure mode
|
||||
6. **If github_url exists**: Add final task to comment on GitHub issue
|
||||
7. Write solution to: .workflow/issues/solutions/{issue-id}.jsonl
|
||||
8. **CRITICAL - Binding Decision**:
|
||||
- Single solution → **MUST execute**: ccw issue bind <issue-id> <solution-id>
|
||||
- Multiple solutions → Return pending_selection only (no bind)
|
||||
|
||||
### Failure-Aware Planning Rules
|
||||
- **Extract failure patterns**: Parse issue.feedback where type='failure' and stage='execute'
|
||||
- **Identify root causes**: Analyze error_type (test_failure, compilation, timeout, etc.)
|
||||
- **Design alternative approach**: Create solution that addresses root cause
|
||||
- **Add prevention steps**: Include explicit verification to catch same error earlier
|
||||
- **Document lessons**: Reference previous failures in solution.approach
|
||||
|
||||
### Rules
|
||||
- Solution ID format: SOL-{issue-id}-{uid} (uid: 4 random alphanumeric chars, e.g., a7x9)
|
||||
- Single solution per issue → auto-bind via ccw issue bind
|
||||
- Multiple solutions → register only, return pending_selection
|
||||
- Tasks must have quantified acceptance.criteria
|
||||
|
||||
### Return Summary
|
||||
{"bound":[{"issue_id":"...","solution_id":"...","task_count":N}],"pending_selection":[{"issue_id":"...","solutions":[{"id":"...","description":"...","task_count":N}]}]}
|
||||
`;
|
||||
|
||||
return { batchIndex, batchIds, issuePrompt, batch };
|
||||
});
|
||||
|
||||
// Launch agents in parallel (max 10 concurrent)
|
||||
const MAX_PARALLEL = 10;
|
||||
for (let i = 0; i < agentTasks.length; i += MAX_PARALLEL) {
|
||||
const chunk = agentTasks.slice(i, i + MAX_PARALLEL);
|
||||
const taskIds = [];
|
||||
|
||||
// Launch chunk in parallel
|
||||
for (const { batchIndex, batchIds, issuePrompt, batch } of chunk) {
|
||||
updateTodo(`Plan batch ${batchIndex + 1}`, 'in_progress');
|
||||
const taskId = Task(
|
||||
subagent_type="issue-plan-agent",
|
||||
run_in_background=true,
|
||||
description=`Explore & plan ${batch.length} issues: ${batchIds.join(', ')}`,
|
||||
prompt=issuePrompt
|
||||
);
|
||||
taskIds.push({ taskId, batchIndex });
|
||||
}
|
||||
|
||||
console.log(`Launched ${taskIds.length} agents (batch ${i/MAX_PARALLEL + 1}/${Math.ceil(agentTasks.length/MAX_PARALLEL)})...`);
|
||||
|
||||
// Collect results from this chunk
|
||||
for (const { taskId, batchIndex } of taskIds) {
|
||||
const result = TaskOutput(task_id=taskId, block=true);
|
||||
|
||||
// Extract JSON from potential markdown code blocks (agent may wrap in ```json...```)
|
||||
const jsonText = extractJsonFromMarkdown(result);
|
||||
let summary;
|
||||
try {
|
||||
summary = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
console.log(`⚠ Batch ${batchIndex + 1}: Failed to parse agent result, skipping`);
|
||||
updateTodo(`Plan batch ${batchIndex + 1}`, 'completed');
|
||||
continue;
|
||||
}
|
||||
agentResults.push(summary); // Store for Phase 3 conflict aggregation
|
||||
|
||||
// Verify binding for bound issues (agent should have executed bind)
|
||||
for (const item of summary.bound || []) {
|
||||
const status = JSON.parse(Bash(`ccw issue status ${item.issue_id} --json`).trim());
|
||||
if (status.bound_solution_id === item.solution_id) {
|
||||
console.log(`✓ ${item.issue_id}: ${item.solution_id} (${item.task_count} tasks)`);
|
||||
} else {
|
||||
// Fallback: agent failed to bind, execute here
|
||||
Bash(`ccw issue bind ${item.issue_id} ${item.solution_id}`);
|
||||
console.log(`✓ ${item.issue_id}: ${item.solution_id} (${item.task_count} tasks) [recovered]`);
|
||||
}
|
||||
}
|
||||
// Collect pending selections for Phase 3
|
||||
for (const pending of summary.pending_selection || []) {
|
||||
pendingSelections.push(pending);
|
||||
}
|
||||
updateTodo(`Plan batch ${batchIndex + 1}`, 'completed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 1.3: Solution Selection (if pending)
|
||||
|
||||
```javascript
|
||||
// Handle multi-solution issues
|
||||
for (const pending of pendingSelections) {
|
||||
if (pending.solutions.length === 0) continue;
|
||||
|
||||
const options = pending.solutions.slice(0, 4).map(sol => ({
|
||||
label: `${sol.id} (${sol.task_count} tasks)`,
|
||||
description: sol.description || sol.approach || 'No description'
|
||||
}));
|
||||
|
||||
const answer = AskUserQuestion({
|
||||
questions: [{
|
||||
question: `Issue ${pending.issue_id}: which solution to bind?`,
|
||||
header: pending.issue_id,
|
||||
options: options,
|
||||
multiSelect: false
|
||||
}]
|
||||
});
|
||||
|
||||
const selected = answer[Object.keys(answer)[0]];
|
||||
if (!selected || selected === 'Other') continue;
|
||||
|
||||
const solId = selected.split(' ')[0];
|
||||
Bash(`ccw issue bind ${pending.issue_id} ${solId}`);
|
||||
console.log(`✓ ${pending.issue_id}: ${solId} bound`);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 1.4: Summary
|
||||
|
||||
```javascript
|
||||
// Count planned issues via CLI
|
||||
const planned = JSON.parse(Bash(`ccw issue list --status planned --brief`) || '[]');
|
||||
const plannedCount = planned.length;
|
||||
|
||||
console.log(`
|
||||
## Done: ${issues.length} issues → ${plannedCount} planned
|
||||
|
||||
Next: \`/issue:queue\` → \`/issue:execute\`
|
||||
`);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
| Issue not found | Auto-create in issues.jsonl |
|
||||
| ACE search fails | Agent falls back to ripgrep |
|
||||
| No solutions generated | Display error, suggest manual planning |
|
||||
| User cancels selection | Skip issue, continue with others |
|
||||
| File conflicts | Agent detects and suggests resolution order |
|
||||
|
||||
## Bash Compatibility
|
||||
|
||||
**Avoid**: `$(cmd)`, `$var`, `for` loops — will be escaped incorrectly
|
||||
|
||||
**Use**: Simple commands + `&&` chains, quote comma params `"pending,registered"`
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before completing, verify:
|
||||
|
||||
- [ ] All input issues have solutions in `solutions/{issue-id}.jsonl`
|
||||
- [ ] Single solution issues are auto-bound (`bound_solution_id` set)
|
||||
- [ ] Multi-solution issues returned in `pending_selection` for user choice
|
||||
- [ ] Each solution has executable tasks with `modification_points`
|
||||
- [ ] Task acceptance criteria are quantified (not vague)
|
||||
- [ ] Conflicts detected and reported (if multiple issues touch same files)
|
||||
- [ ] Issue status updated to `planned` after binding
|
||||
|
||||
## Post-Phase Update
|
||||
|
||||
After plan completion:
|
||||
- All processed issues should have `status: planned` and `bound_solution_id` set
|
||||
- Report: total issues processed, solutions bound, pending selections resolved
|
||||
- Recommend next step: Form execution queue via Phase 4 or `Skill(skill="issue-resolve", args="--source queue")`
|
||||
703
.claude/skills/issue-resolve/phases/02-convert-to-plan.md
Normal file
703
.claude/skills/issue-resolve/phases/02-convert-to-plan.md
Normal file
@@ -0,0 +1,703 @@
|
||||
# Phase 2: Convert from Artifact
|
||||
|
||||
> 来源: `commands/issue/convert-to-plan.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Converts various planning artifact formats into issue workflow solutions with intelligent detection and automatic binding.
|
||||
|
||||
**Supported Sources** (auto-detected):
|
||||
- **lite-plan**: `.workflow/.lite-plan/{slug}/plan.json`
|
||||
- **workflow-session**: `WFS-xxx` ID or `.workflow/active/{session}/` folder
|
||||
- **markdown**: Any `.md` file with implementation/task content
|
||||
- **json**: Direct JSON files matching plan-json-schema
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Source artifact path or WFS-xxx ID provided
|
||||
- `ccw issue` CLI available
|
||||
- `.workflow/issues/` directory exists or will be created
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Skip confirmation, auto-create issue and bind solution.
|
||||
|
||||
## Command Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `<SOURCE>` | Planning artifact path or WFS-xxx ID | Required |
|
||||
| `--issue <id>` | Bind to existing issue instead of creating new | Auto-create |
|
||||
| `--supplement` | Add tasks to existing solution (requires --issue) | false |
|
||||
| `-y, --yes` | Skip all confirmations | false |
|
||||
|
||||
## Core Data Access Principle
|
||||
|
||||
**⚠️ Important**: Use CLI commands for all issue/solution operations.
|
||||
|
||||
| Operation | Correct | Incorrect |
|
||||
|-----------|---------|-----------|
|
||||
| Get issue | `ccw issue status <id> --json` | Read issues.jsonl directly |
|
||||
| Create issue | `ccw issue init <id> --title "..."` | Write to issues.jsonl |
|
||||
| Bind solution | `ccw issue bind <id> <sol-id>` | Edit issues.jsonl |
|
||||
| List solutions | `ccw issue solutions --issue <id> --brief` | Read solutions/*.jsonl |
|
||||
|
||||
## Solution Schema Reference
|
||||
|
||||
Target format for all extracted data (from solution-schema.json):
|
||||
|
||||
```typescript
|
||||
interface Solution {
|
||||
id: string; // SOL-{issue-id}-{4-char-uid}
|
||||
description?: string; // High-level summary
|
||||
approach?: string; // Technical strategy
|
||||
tasks: Task[]; // Required: at least 1 task
|
||||
exploration_context?: object; // Optional: source context
|
||||
analysis?: { risk, impact, complexity };
|
||||
score?: number; // 0.0-1.0
|
||||
is_bound: boolean;
|
||||
created_at: string;
|
||||
bound_at?: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string; // T1, T2, T3... (pattern: ^T[0-9]+$)
|
||||
title: string; // Required: action verb + target
|
||||
scope: string; // Required: module path or feature area
|
||||
action: Action; // Required: Create|Update|Implement|...
|
||||
description?: string;
|
||||
modification_points?: Array<{file, target, change}>;
|
||||
implementation: string[]; // Required: step-by-step guide
|
||||
test?: { unit?, integration?, commands?, coverage_target? };
|
||||
acceptance: { criteria: string[], verification: string[] }; // Required
|
||||
commit?: { type, scope, message_template, breaking? };
|
||||
depends_on?: string[];
|
||||
priority?: number; // 1-5 (default: 3)
|
||||
}
|
||||
|
||||
type Action = 'Create' | 'Update' | 'Implement' | 'Refactor' | 'Add' | 'Delete' | 'Configure' | 'Test' | 'Fix';
|
||||
```
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 2.1: Parse Arguments & Detect Source Type
|
||||
|
||||
```javascript
|
||||
const input = userInput.trim();
|
||||
const flags = parseFlags(userInput); // --issue, --supplement, -y/--yes
|
||||
|
||||
// Extract source path (first non-flag argument)
|
||||
const source = extractSourceArg(input);
|
||||
|
||||
// Detect source type
|
||||
function detectSourceType(source) {
|
||||
// Check for WFS-xxx pattern (workflow session ID)
|
||||
if (source.match(/^WFS-[\w-]+$/)) {
|
||||
return { type: 'workflow-session-id', path: `.workflow/active/${source}` };
|
||||
}
|
||||
|
||||
// Check if directory
|
||||
const isDir = Bash(`test -d "${source}" && echo "dir" || echo "file"`).trim() === 'dir';
|
||||
|
||||
if (isDir) {
|
||||
// Check for lite-plan indicator
|
||||
const hasPlanJson = Bash(`test -f "${source}/plan.json" && echo "yes" || echo "no"`).trim() === 'yes';
|
||||
if (hasPlanJson) {
|
||||
return { type: 'lite-plan', path: source };
|
||||
}
|
||||
|
||||
// Check for workflow session indicator
|
||||
const hasSession = Bash(`test -f "${source}/workflow-session.json" && echo "yes" || echo "no"`).trim() === 'yes';
|
||||
if (hasSession) {
|
||||
return { type: 'workflow-session', path: source };
|
||||
}
|
||||
}
|
||||
|
||||
// Check file extensions
|
||||
if (source.endsWith('.json')) {
|
||||
return { type: 'json-file', path: source };
|
||||
}
|
||||
if (source.endsWith('.md')) {
|
||||
return { type: 'markdown-file', path: source };
|
||||
}
|
||||
|
||||
// Check if path exists at all
|
||||
const exists = Bash(`test -e "${source}" && echo "yes" || echo "no"`).trim() === 'yes';
|
||||
if (!exists) {
|
||||
throw new Error(`E001: Source not found: ${source}`);
|
||||
}
|
||||
|
||||
return { type: 'unknown', path: source };
|
||||
}
|
||||
|
||||
const sourceInfo = detectSourceType(source);
|
||||
if (sourceInfo.type === 'unknown') {
|
||||
throw new Error(`E002: Unable to detect source format for: ${source}`);
|
||||
}
|
||||
|
||||
console.log(`Detected source type: ${sourceInfo.type}`);
|
||||
```
|
||||
|
||||
### Step 2.2: Extract Data Using Format-Specific Extractor
|
||||
|
||||
```javascript
|
||||
let extracted = { title: '', approach: '', tasks: [], metadata: {} };
|
||||
|
||||
switch (sourceInfo.type) {
|
||||
case 'lite-plan':
|
||||
extracted = extractFromLitePlan(sourceInfo.path);
|
||||
break;
|
||||
case 'workflow-session':
|
||||
case 'workflow-session-id':
|
||||
extracted = extractFromWorkflowSession(sourceInfo.path);
|
||||
break;
|
||||
case 'markdown-file':
|
||||
extracted = await extractFromMarkdownAI(sourceInfo.path);
|
||||
break;
|
||||
case 'json-file':
|
||||
extracted = extractFromJsonFile(sourceInfo.path);
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate extraction
|
||||
if (!extracted.tasks || extracted.tasks.length === 0) {
|
||||
throw new Error('E006: No tasks extracted from source');
|
||||
}
|
||||
|
||||
// Ensure task IDs are normalized to T1, T2, T3...
|
||||
extracted.tasks = normalizeTaskIds(extracted.tasks);
|
||||
|
||||
console.log(`Extracted: ${extracted.tasks.length} tasks`);
|
||||
```
|
||||
|
||||
#### Extractor: Lite-Plan
|
||||
|
||||
```javascript
|
||||
function extractFromLitePlan(folderPath) {
|
||||
const planJson = Read(`${folderPath}/plan.json`);
|
||||
const plan = JSON.parse(planJson);
|
||||
|
||||
return {
|
||||
title: plan.summary?.split('.')[0]?.trim() || 'Untitled Plan',
|
||||
description: plan.summary,
|
||||
approach: plan.approach,
|
||||
tasks: plan.tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
scope: t.scope || '',
|
||||
action: t.action || 'Implement',
|
||||
description: t.description || t.title,
|
||||
modification_points: t.modification_points || [],
|
||||
implementation: Array.isArray(t.implementation) ? t.implementation : [t.implementation || ''],
|
||||
test: t.verification ? {
|
||||
unit: t.verification.unit_tests,
|
||||
integration: t.verification.integration_tests,
|
||||
commands: t.verification.manual_checks
|
||||
} : {},
|
||||
acceptance: {
|
||||
criteria: Array.isArray(t.acceptance) ? t.acceptance : [t.acceptance || ''],
|
||||
verification: t.verification?.manual_checks || []
|
||||
},
|
||||
depends_on: t.depends_on || [],
|
||||
priority: 3
|
||||
})),
|
||||
metadata: {
|
||||
source_type: 'lite-plan',
|
||||
source_path: folderPath,
|
||||
complexity: plan.complexity,
|
||||
estimated_time: plan.estimated_time,
|
||||
exploration_angles: plan._metadata?.exploration_angles || [],
|
||||
original_timestamp: plan._metadata?.timestamp
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Extractor: Workflow Session
|
||||
|
||||
```javascript
|
||||
function extractFromWorkflowSession(sessionPath) {
|
||||
// Load session metadata
|
||||
const sessionJson = Read(`${sessionPath}/workflow-session.json`);
|
||||
const session = JSON.parse(sessionJson);
|
||||
|
||||
// Load IMPL_PLAN.md for approach (if exists)
|
||||
let approach = '';
|
||||
const implPlanPath = `${sessionPath}/IMPL_PLAN.md`;
|
||||
const hasImplPlan = Bash(`test -f "${implPlanPath}" && echo "yes" || echo "no"`).trim() === 'yes';
|
||||
if (hasImplPlan) {
|
||||
const implPlan = Read(implPlanPath);
|
||||
// Extract overview/approach section
|
||||
const overviewMatch = implPlan.match(/##\s*(?:Overview|Approach|Strategy)\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
approach = overviewMatch?.[1]?.trim() || implPlan.split('\n').slice(0, 10).join('\n');
|
||||
}
|
||||
|
||||
// Load all task JSONs from .task folder
|
||||
const taskFiles = Glob({ pattern: `${sessionPath}/.task/IMPL-*.json` });
|
||||
const tasks = taskFiles.map(f => {
|
||||
const taskJson = Read(f);
|
||||
const task = JSON.parse(taskJson);
|
||||
return {
|
||||
id: task.id?.replace(/^IMPL-0*/, 'T') || 'T1', // IMPL-001 → T1
|
||||
title: task.title,
|
||||
scope: task.scope || inferScopeFromTask(task),
|
||||
action: capitalizeAction(task.type) || 'Implement',
|
||||
description: task.description,
|
||||
modification_points: task.implementation?.modification_points || [],
|
||||
implementation: task.implementation?.steps || [],
|
||||
test: task.implementation?.test || {},
|
||||
acceptance: {
|
||||
criteria: task.acceptance_criteria || [],
|
||||
verification: task.verification_steps || []
|
||||
},
|
||||
commit: task.commit,
|
||||
depends_on: (task.depends_on || []).map(d => d.replace(/^IMPL-0*/, 'T')),
|
||||
priority: task.priority || 3
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: session.name || session.description?.split('.')[0] || 'Workflow Session',
|
||||
description: session.description || session.name,
|
||||
approach: approach || session.description,
|
||||
tasks: tasks,
|
||||
metadata: {
|
||||
source_type: 'workflow-session',
|
||||
source_path: sessionPath,
|
||||
session_id: session.id,
|
||||
created_at: session.created_at
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function inferScopeFromTask(task) {
|
||||
if (task.implementation?.modification_points?.length) {
|
||||
const files = task.implementation.modification_points.map(m => m.file);
|
||||
// Find common directory prefix
|
||||
const dirs = files.map(f => f.split('/').slice(0, -1).join('/'));
|
||||
return [...new Set(dirs)][0] || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function capitalizeAction(type) {
|
||||
if (!type) return 'Implement';
|
||||
const map = { feature: 'Implement', bugfix: 'Fix', refactor: 'Refactor', test: 'Test', docs: 'Update' };
|
||||
return map[type.toLowerCase()] || type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
```
|
||||
|
||||
#### Extractor: Markdown (AI-Assisted via Gemini)
|
||||
|
||||
```javascript
|
||||
async function extractFromMarkdownAI(filePath) {
|
||||
const fileContent = Read(filePath);
|
||||
|
||||
// Use Gemini CLI for intelligent extraction
|
||||
const cliPrompt = `PURPOSE: Extract implementation plan from markdown document for issue solution conversion. Must output ONLY valid JSON.
|
||||
TASK: • Analyze document structure • Identify title/summary • Extract approach/strategy section • Parse tasks from any format (lists, tables, sections, code blocks) • Normalize each task to solution schema
|
||||
MODE: analysis
|
||||
CONTEXT: Document content provided below
|
||||
EXPECTED: Valid JSON object with format:
|
||||
{
|
||||
"title": "extracted title",
|
||||
"approach": "extracted approach/strategy",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T1",
|
||||
"title": "task title",
|
||||
"scope": "module or feature area",
|
||||
"action": "Implement|Update|Create|Fix|Refactor|Add|Delete|Configure|Test",
|
||||
"description": "what to do",
|
||||
"implementation": ["step 1", "step 2"],
|
||||
"acceptance": ["criteria 1", "criteria 2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
CONSTRAINTS: Output ONLY valid JSON - no markdown, no explanation | Action must be one of: Create, Update, Implement, Refactor, Add, Delete, Configure, Test, Fix | Tasks must have id, title, scope, action, implementation (array), acceptance (array)
|
||||
|
||||
DOCUMENT CONTENT:
|
||||
${fileContent}`;
|
||||
|
||||
// Execute Gemini CLI
|
||||
const result = Bash(`ccw cli -p '${cliPrompt.replace(/'/g, "'\\''")}' --tool gemini --mode analysis`, { timeout: 120000 });
|
||||
|
||||
// Parse JSON from result (may be wrapped in markdown code block)
|
||||
let jsonText = result.trim();
|
||||
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonText = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const extracted = JSON.parse(jsonText);
|
||||
|
||||
// Normalize tasks
|
||||
const tasks = (extracted.tasks || []).map((t, i) => ({
|
||||
id: t.id || `T${i + 1}`,
|
||||
title: t.title || 'Untitled task',
|
||||
scope: t.scope || '',
|
||||
action: validateAction(t.action) || 'Implement',
|
||||
description: t.description || t.title,
|
||||
modification_points: t.modification_points || [],
|
||||
implementation: Array.isArray(t.implementation) ? t.implementation : [t.implementation || ''],
|
||||
test: t.test || {},
|
||||
acceptance: {
|
||||
criteria: Array.isArray(t.acceptance) ? t.acceptance : [t.acceptance || ''],
|
||||
verification: t.verification || []
|
||||
},
|
||||
depends_on: t.depends_on || [],
|
||||
priority: t.priority || 3
|
||||
}));
|
||||
|
||||
return {
|
||||
title: extracted.title || 'Extracted Plan',
|
||||
description: extracted.summary || extracted.title,
|
||||
approach: extracted.approach || '',
|
||||
tasks: tasks,
|
||||
metadata: {
|
||||
source_type: 'markdown',
|
||||
source_path: filePath,
|
||||
extraction_method: 'gemini-ai'
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
// Provide more context for debugging
|
||||
throw new Error(`E005: Failed to extract tasks from markdown. Gemini response was not valid JSON. Error: ${e.message}. Response preview: ${jsonText.substring(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAction(action) {
|
||||
const validActions = ['Create', 'Update', 'Implement', 'Refactor', 'Add', 'Delete', 'Configure', 'Test', 'Fix'];
|
||||
if (!action) return null;
|
||||
const normalized = action.charAt(0).toUpperCase() + action.slice(1).toLowerCase();
|
||||
return validActions.includes(normalized) ? normalized : null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Extractor: JSON File
|
||||
|
||||
```javascript
|
||||
function extractFromJsonFile(filePath) {
|
||||
const content = Read(filePath);
|
||||
const plan = JSON.parse(content);
|
||||
|
||||
// Detect if it's already solution format or plan format
|
||||
if (plan.tasks && Array.isArray(plan.tasks)) {
|
||||
// Map tasks to normalized format
|
||||
const tasks = plan.tasks.map((t, i) => ({
|
||||
id: t.id || `T${i + 1}`,
|
||||
title: t.title,
|
||||
scope: t.scope || '',
|
||||
action: t.action || 'Implement',
|
||||
description: t.description || t.title,
|
||||
modification_points: t.modification_points || [],
|
||||
implementation: Array.isArray(t.implementation) ? t.implementation : [t.implementation || ''],
|
||||
test: t.test || t.verification || {},
|
||||
acceptance: normalizeAcceptance(t.acceptance),
|
||||
depends_on: t.depends_on || [],
|
||||
priority: t.priority || 3
|
||||
}));
|
||||
|
||||
return {
|
||||
title: plan.summary?.split('.')[0] || plan.title || 'JSON Plan',
|
||||
description: plan.summary || plan.description,
|
||||
approach: plan.approach,
|
||||
tasks: tasks,
|
||||
metadata: {
|
||||
source_type: 'json',
|
||||
source_path: filePath,
|
||||
complexity: plan.complexity,
|
||||
original_metadata: plan._metadata
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('E002: JSON file does not contain valid plan structure (missing tasks array)');
|
||||
}
|
||||
|
||||
function normalizeAcceptance(acceptance) {
|
||||
if (!acceptance) return { criteria: [], verification: [] };
|
||||
if (typeof acceptance === 'object' && acceptance.criteria) return acceptance;
|
||||
if (Array.isArray(acceptance)) return { criteria: acceptance, verification: [] };
|
||||
return { criteria: [String(acceptance)], verification: [] };
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.3: Normalize Task IDs
|
||||
|
||||
```javascript
|
||||
function normalizeTaskIds(tasks) {
|
||||
return tasks.map((t, i) => ({
|
||||
...t,
|
||||
id: `T${i + 1}`,
|
||||
// Also normalize depends_on references
|
||||
depends_on: (t.depends_on || []).map(d => {
|
||||
// Handle various ID formats: IMPL-001, T1, 1, etc.
|
||||
const num = d.match(/\d+/)?.[0];
|
||||
return num ? `T${parseInt(num)}` : d;
|
||||
})
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.4: Resolve Issue (Create or Find)
|
||||
|
||||
```javascript
|
||||
let issueId = flags.issue;
|
||||
let existingSolution = null;
|
||||
|
||||
if (issueId) {
|
||||
// Validate issue exists
|
||||
let issueCheck;
|
||||
try {
|
||||
issueCheck = Bash(`ccw issue status ${issueId} --json 2>/dev/null`).trim();
|
||||
if (!issueCheck || issueCheck === '') {
|
||||
throw new Error('empty response');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`E003: Issue not found: ${issueId}`);
|
||||
}
|
||||
|
||||
const issue = JSON.parse(issueCheck);
|
||||
|
||||
// Check if issue already has bound solution
|
||||
if (issue.bound_solution_id && !flags.supplement) {
|
||||
throw new Error(`E004: Issue ${issueId} already has bound solution (${issue.bound_solution_id}). Use --supplement to add tasks.`);
|
||||
}
|
||||
|
||||
// Load existing solution for supplement mode
|
||||
if (flags.supplement && issue.bound_solution_id) {
|
||||
try {
|
||||
const solResult = Bash(`ccw issue solution ${issue.bound_solution_id} --json`).trim();
|
||||
existingSolution = JSON.parse(solResult);
|
||||
console.log(`Loaded existing solution with ${existingSolution.tasks.length} tasks`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to load existing solution: ${e.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new issue via ccw issue create (auto-generates correct ID)
|
||||
// Smart extraction: title from content, priority from complexity
|
||||
const title = extracted.title || 'Converted Plan';
|
||||
const context = extracted.description || extracted.approach || title;
|
||||
|
||||
// Auto-determine priority based on complexity
|
||||
const complexityMap = { high: 2, medium: 3, low: 4 };
|
||||
const priority = complexityMap[extracted.metadata.complexity?.toLowerCase()] || 3;
|
||||
|
||||
try {
|
||||
// Use heredoc to avoid shell escaping issues
|
||||
const createResult = Bash(`ccw issue create << 'EOF'
|
||||
{
|
||||
"title": ${JSON.stringify(title)},
|
||||
"context": ${JSON.stringify(context)},
|
||||
"priority": ${priority},
|
||||
"source": "converted"
|
||||
}
|
||||
EOF`).trim();
|
||||
|
||||
// Parse result to get created issue ID
|
||||
const created = JSON.parse(createResult);
|
||||
issueId = created.id;
|
||||
console.log(`Created issue: ${issueId} (priority: ${priority})`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to create issue: ${e.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.5: Generate Solution
|
||||
|
||||
```javascript
|
||||
// Generate solution ID
|
||||
function generateSolutionId(issueId) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let uid = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
uid += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return `SOL-${issueId}-${uid}`;
|
||||
}
|
||||
|
||||
let solution;
|
||||
const solutionId = generateSolutionId(issueId);
|
||||
|
||||
if (flags.supplement && existingSolution) {
|
||||
// Supplement mode: merge with existing solution
|
||||
const maxTaskId = Math.max(...existingSolution.tasks.map(t => parseInt(t.id.slice(1))));
|
||||
|
||||
const newTasks = extracted.tasks.map((t, i) => ({
|
||||
...t,
|
||||
id: `T${maxTaskId + i + 1}`
|
||||
}));
|
||||
|
||||
solution = {
|
||||
...existingSolution,
|
||||
tasks: [...existingSolution.tasks, ...newTasks],
|
||||
approach: existingSolution.approach + '\n\n[Supplementary] ' + (extracted.approach || ''),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log(`Supplementing: ${existingSolution.tasks.length} existing + ${newTasks.length} new = ${solution.tasks.length} total tasks`);
|
||||
} else {
|
||||
// New solution
|
||||
solution = {
|
||||
id: solutionId,
|
||||
description: extracted.description || extracted.title,
|
||||
approach: extracted.approach,
|
||||
tasks: extracted.tasks,
|
||||
exploration_context: extracted.metadata.exploration_angles ? {
|
||||
exploration_angles: extracted.metadata.exploration_angles
|
||||
} : undefined,
|
||||
analysis: {
|
||||
risk: 'medium',
|
||||
impact: 'medium',
|
||||
complexity: extracted.metadata.complexity?.toLowerCase() || 'medium'
|
||||
},
|
||||
is_bound: false,
|
||||
created_at: new Date().toISOString(),
|
||||
_conversion_metadata: {
|
||||
source_type: extracted.metadata.source_type,
|
||||
source_path: extracted.metadata.source_path,
|
||||
converted_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.6: Confirm & Persist
|
||||
|
||||
```javascript
|
||||
// Display preview
|
||||
console.log(`
|
||||
## Conversion Summary
|
||||
|
||||
**Issue**: ${issueId}
|
||||
**Solution**: ${flags.supplement ? existingSolution.id : solutionId}
|
||||
**Tasks**: ${solution.tasks.length}
|
||||
**Mode**: ${flags.supplement ? 'Supplement' : 'New'}
|
||||
|
||||
### Tasks:
|
||||
${solution.tasks.map(t => `- ${t.id}: ${t.title} [${t.action}]`).join('\n')}
|
||||
`);
|
||||
|
||||
// Confirm if not auto mode
|
||||
if (!flags.yes && !flags.y) {
|
||||
const confirm = AskUserQuestion({
|
||||
questions: [{
|
||||
question: `Create solution for issue ${issueId} with ${solution.tasks.length} tasks?`,
|
||||
header: 'Confirm',
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: 'Yes, create solution', description: 'Create and bind solution' },
|
||||
{ label: 'Cancel', description: 'Abort without changes' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
if (!confirm.answers?.['Confirm']?.includes('Yes')) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist solution (following issue-plan-agent pattern)
|
||||
Bash(`mkdir -p .workflow/issues/solutions`);
|
||||
|
||||
const solutionFile = `.workflow/issues/solutions/${issueId}.jsonl`;
|
||||
|
||||
if (flags.supplement) {
|
||||
// Supplement mode: update existing solution line atomically
|
||||
try {
|
||||
const existingContent = Read(solutionFile);
|
||||
const lines = existingContent.trim().split('\n').filter(l => l);
|
||||
const updatedLines = lines.map(line => {
|
||||
const sol = JSON.parse(line);
|
||||
if (sol.id === existingSolution.id) {
|
||||
return JSON.stringify(solution);
|
||||
}
|
||||
return line;
|
||||
});
|
||||
// Atomic write: write entire content at once
|
||||
Write({ file_path: solutionFile, content: updatedLines.join('\n') + '\n' });
|
||||
console.log(`✓ Updated solution: ${existingSolution.id}`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to update solution: ${e.message}`);
|
||||
}
|
||||
|
||||
// Note: No need to rebind - solution is already bound to issue
|
||||
} else {
|
||||
// New solution: append to JSONL file (following issue-plan-agent pattern)
|
||||
try {
|
||||
const solutionLine = JSON.stringify(solution);
|
||||
|
||||
// Read existing content, append new line, write atomically
|
||||
const existing = Bash(`test -f "${solutionFile}" && cat "${solutionFile}" || echo ""`).trim();
|
||||
const newContent = existing ? existing + '\n' + solutionLine + '\n' : solutionLine + '\n';
|
||||
Write({ file_path: solutionFile, content: newContent });
|
||||
|
||||
console.log(`✓ Created solution: ${solutionId}`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to write solution: ${e.message}`);
|
||||
}
|
||||
|
||||
// Bind solution to issue
|
||||
try {
|
||||
Bash(`ccw issue bind ${issueId} ${solutionId}`);
|
||||
console.log(`✓ Bound solution to issue`);
|
||||
} catch (e) {
|
||||
// Cleanup: remove solution file on bind failure
|
||||
try {
|
||||
Bash(`rm -f "${solutionFile}"`);
|
||||
} catch (cleanupError) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw new Error(`Failed to bind solution: ${e.message}`);
|
||||
}
|
||||
|
||||
// Update issue status to planned
|
||||
try {
|
||||
Bash(`ccw issue update ${issueId} --status planned`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to update issue status: ${e.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.7: Summary
|
||||
|
||||
```javascript
|
||||
console.log(`
|
||||
## Done
|
||||
|
||||
**Issue**: ${issueId}
|
||||
**Solution**: ${flags.supplement ? existingSolution.id : solutionId}
|
||||
**Tasks**: ${solution.tasks.length}
|
||||
**Status**: planned
|
||||
|
||||
### Next Steps:
|
||||
- \`/issue:queue\` → Form execution queue
|
||||
- \`ccw issue status ${issueId}\` → View issue details
|
||||
- \`ccw issue solution ${flags.supplement ? existingSolution.id : solutionId}\` → View solution
|
||||
`);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Code | Resolution |
|
||||
|-------|------|------------|
|
||||
| Source not found | E001 | Check path exists |
|
||||
| Invalid source format | E002 | Verify file contains valid plan structure |
|
||||
| Issue not found | E003 | Check issue ID or omit --issue to create new |
|
||||
| Solution already bound | E004 | Use --supplement to add tasks |
|
||||
| AI extraction failed | E005 | Check markdown structure, try simpler format |
|
||||
| No tasks extracted | E006 | Source must contain at least 1 task |
|
||||
|
||||
## Post-Phase Update
|
||||
|
||||
After conversion completion:
|
||||
- Issue created/updated with `status: planned` and `bound_solution_id` set
|
||||
- Solution persisted in `.workflow/issues/solutions/{issue-id}.jsonl`
|
||||
- Report: issue ID, solution ID, task count, mode (new/supplement)
|
||||
- Recommend next step: Form execution queue via Phase 4 or `Skill(skill="issue-resolve", args="--source queue")`
|
||||
393
.claude/skills/issue-resolve/phases/03-from-brainstorm.md
Normal file
393
.claude/skills/issue-resolve/phases/03-from-brainstorm.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Phase 3: From Brainstorm
|
||||
|
||||
> 来源: `commands/issue/from-brainstorm.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Bridge command that converts **brainstorm-with-file** session output into executable **issue + solution** for parallel-dev-cycle consumption.
|
||||
|
||||
**Core workflow**: Load Session → Select Idea → Convert to Issue → Generate Solution → Bind & Ready
|
||||
|
||||
**Input sources**:
|
||||
- **synthesis.json** - Main brainstorm results with top_ideas
|
||||
- **perspectives.json** - Multi-CLI perspectives (creative/pragmatic/systematic)
|
||||
- **.brainstorming/** - Synthesis artifacts (clarifications, enhancements from role analyses)
|
||||
|
||||
**Output**:
|
||||
- **Issue** (ISS-YYYYMMDD-NNN) - Full context with clarifications
|
||||
- **Solution** (SOL-{issue-id}-{uid}) - Structured tasks for parallel-dev-cycle
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Brainstorm session ID or path (e.g., `SESSION="BS-rate-limiting-2025-01-28"`)
|
||||
- `synthesis.json` must exist in session directory
|
||||
- `ccw issue` CLI available
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Auto-select highest-scored idea, skip confirmations, create issue directly.
|
||||
|
||||
## Arguments
|
||||
|
||||
| Argument | Required | Type | Default | Description |
|
||||
|----------|----------|------|---------|-------------|
|
||||
| SESSION | Yes | String | - | Session ID or path to `.workflow/.brainstorm/BS-xxx` |
|
||||
| --idea | No | Integer | - | Pre-select idea by index (0-based) |
|
||||
| --auto | No | Flag | false | Auto-select highest-scored idea |
|
||||
| -y, --yes | No | Flag | false | Skip all confirmations |
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Issue Schema (Output)
|
||||
|
||||
```typescript
|
||||
interface Issue {
|
||||
id: string; // ISS-YYYYMMDD-NNN
|
||||
title: string; // From idea.title
|
||||
status: 'planned'; // Auto-set after solution binding
|
||||
priority: number; // 1-5 (derived from idea.score)
|
||||
context: string; // Full description with clarifications
|
||||
source: 'brainstorm';
|
||||
labels: string[]; // ['brainstorm', perspective, feasibility]
|
||||
|
||||
// Structured fields
|
||||
expected_behavior: string; // From key_strengths
|
||||
actual_behavior: string; // From main_challenges
|
||||
affected_components: string[]; // Extracted from description
|
||||
|
||||
_brainstorm_metadata: {
|
||||
session_id: string;
|
||||
idea_score: number;
|
||||
novelty: number;
|
||||
feasibility: string;
|
||||
clarifications_count: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Solution Schema (Output)
|
||||
|
||||
```typescript
|
||||
interface Solution {
|
||||
id: string; // SOL-{issue-id}-{4-char-uid}
|
||||
description: string; // idea.title
|
||||
approach: string; // idea.description
|
||||
tasks: Task[]; // Generated from idea.next_steps
|
||||
|
||||
analysis: {
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
impact: 'low' | 'medium' | 'high';
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
is_bound: boolean; // true
|
||||
created_at: string;
|
||||
bound_at: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string; // T1, T2, T3...
|
||||
title: string; // Actionable task name
|
||||
scope: string; // design|implementation|testing|documentation
|
||||
action: string; // Implement|Design|Research|Test|Document
|
||||
description: string;
|
||||
|
||||
implementation: string[]; // Step-by-step guide
|
||||
acceptance: {
|
||||
criteria: string[]; // What defines success
|
||||
verification: string[]; // How to verify
|
||||
};
|
||||
|
||||
priority: number; // 1-5
|
||||
depends_on: string[]; // Task dependencies
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 3.1: Session Loading
|
||||
|
||||
```
|
||||
Phase 1: Session Loading
|
||||
├─ Validate session path
|
||||
├─ Load synthesis.json (required)
|
||||
├─ Load perspectives.json (optional - multi-CLI insights)
|
||||
├─ Load .brainstorming/** (optional - synthesis artifacts)
|
||||
└─ Validate top_ideas array exists
|
||||
```
|
||||
|
||||
### Step 3.2: Idea Selection
|
||||
|
||||
```
|
||||
Phase 2: Idea Selection
|
||||
├─ Auto mode: Select highest scored idea
|
||||
├─ Pre-selected: Use --idea=N index
|
||||
└─ Interactive: Display table, ask user to select
|
||||
```
|
||||
|
||||
### Step 3.3: Enrich Issue Context
|
||||
|
||||
```
|
||||
Phase 3: Enrich Issue Context
|
||||
├─ Base: idea.description + key_strengths + main_challenges
|
||||
├─ Add: Relevant clarifications (Requirements/Architecture/Feasibility)
|
||||
├─ Add: Multi-perspective insights (creative/pragmatic/systematic)
|
||||
└─ Add: Session metadata (session_id, completion date, clarification count)
|
||||
```
|
||||
|
||||
### Step 3.4: Create Issue
|
||||
|
||||
```
|
||||
Phase 4: Create Issue
|
||||
├─ Generate issue data with enriched context
|
||||
├─ Calculate priority from idea.score (0-10 → 1-5)
|
||||
├─ Create via: ccw issue create (heredoc for JSON)
|
||||
└─ Returns: ISS-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
### Step 3.5: Generate Solution Tasks
|
||||
|
||||
```
|
||||
Phase 5: Generate Solution Tasks
|
||||
├─ T1: Research & Validate (if main_challenges exist)
|
||||
├─ T2: Design & Specification (if key_strengths exist)
|
||||
├─ T3+: Implementation tasks (from idea.next_steps)
|
||||
└─ Each task includes: implementation steps + acceptance criteria
|
||||
```
|
||||
|
||||
### Step 3.6: Bind Solution
|
||||
|
||||
```
|
||||
Phase 6: Bind Solution
|
||||
├─ Write solution to .workflow/issues/solutions/{issue-id}.jsonl
|
||||
├─ Bind via: ccw issue bind {issue-id} {solution-id}
|
||||
├─ Update issue status to 'planned'
|
||||
└─ Returns: SOL-{issue-id}-{uid}
|
||||
```
|
||||
|
||||
### Step 3.7: Next Steps
|
||||
|
||||
```
|
||||
Phase 7: Next Steps
|
||||
└─ Offer: Form queue | Convert another idea | View details | Done
|
||||
```
|
||||
|
||||
## Context Enrichment Logic
|
||||
|
||||
### Base Context (Always Included)
|
||||
|
||||
- **Description**: `idea.description`
|
||||
- **Why This Idea**: `idea.key_strengths[]`
|
||||
- **Challenges to Address**: `idea.main_challenges[]`
|
||||
- **Implementation Steps**: `idea.next_steps[]`
|
||||
|
||||
### Enhanced Context (If Available)
|
||||
|
||||
**From Synthesis Artifacts** (`.brainstorming/*/analysis*.md`):
|
||||
- Extract clarifications matching categories: Requirements, Architecture, Feasibility
|
||||
- Format: `**{Category}** ({role}): {question} → {answer}`
|
||||
- Limit: Top 3 most relevant
|
||||
|
||||
**From Perspectives** (`perspectives.json`):
|
||||
- **Creative**: First insight from `perspectives.creative.insights[0]`
|
||||
- **Pragmatic**: First blocker from `perspectives.pragmatic.blockers[0]`
|
||||
- **Systematic**: First pattern from `perspectives.systematic.patterns[0]`
|
||||
|
||||
**Session Metadata**:
|
||||
- Session ID, Topic, Completion Date
|
||||
- Clarifications count (if synthesis artifacts loaded)
|
||||
|
||||
## Task Generation Strategy
|
||||
|
||||
### Task 1: Research & Validation
|
||||
**Trigger**: `idea.main_challenges.length > 0`
|
||||
- **Title**: "Research & Validate Approach"
|
||||
- **Scope**: design
|
||||
- **Action**: Research
|
||||
- **Implementation**: Investigate blockers, review similar implementations, validate with team
|
||||
- **Acceptance**: Blockers documented, feasibility assessed, approach validated
|
||||
|
||||
### Task 2: Design & Specification
|
||||
**Trigger**: `idea.key_strengths.length > 0`
|
||||
- **Title**: "Design & Create Specification"
|
||||
- **Scope**: design
|
||||
- **Action**: Design
|
||||
- **Implementation**: Create design doc, define success criteria, plan phases
|
||||
- **Acceptance**: Design complete, metrics defined, plan outlined
|
||||
|
||||
### Task 3+: Implementation Tasks
|
||||
**Trigger**: `idea.next_steps[]`
|
||||
- **Title**: From `next_steps[i]` (max 60 chars)
|
||||
- **Scope**: Inferred from keywords (test→testing, api→backend, ui→frontend)
|
||||
- **Action**: Detected from verbs (implement, create, update, fix, test, document)
|
||||
- **Implementation**: Execute step + follow design + write tests
|
||||
- **Acceptance**: Step implemented + tests passing + code reviewed
|
||||
|
||||
### Fallback Task
|
||||
**Trigger**: No tasks generated from above
|
||||
- **Title**: `idea.title`
|
||||
- **Scope**: implementation
|
||||
- **Action**: Implement
|
||||
- **Generic implementation + acceptance criteria**
|
||||
|
||||
## Priority Calculation
|
||||
|
||||
### Issue Priority (1-5)
|
||||
```
|
||||
idea.score: 0-10
|
||||
priority = max(1, min(5, ceil((10 - score) / 2)))
|
||||
|
||||
Examples:
|
||||
score 9-10 → priority 1 (critical)
|
||||
score 7-8 → priority 2 (high)
|
||||
score 5-6 → priority 3 (medium)
|
||||
score 3-4 → priority 4 (low)
|
||||
score 0-2 → priority 5 (lowest)
|
||||
```
|
||||
|
||||
### Task Priority (1-5)
|
||||
- Research task: 1 (highest)
|
||||
- Design task: 2
|
||||
- Implementation tasks: 3 by default, decrement for later tasks
|
||||
- Testing/documentation: 4-5
|
||||
|
||||
### Complexity Analysis
|
||||
```
|
||||
risk: main_challenges.length > 2 ? 'high' : 'medium'
|
||||
impact: score >= 8 ? 'high' : score >= 6 ? 'medium' : 'low'
|
||||
complexity: main_challenges > 3 OR tasks > 5 ? 'high'
|
||||
tasks > 3 ? 'medium' : 'low'
|
||||
```
|
||||
|
||||
## CLI Integration
|
||||
|
||||
### Issue Creation
|
||||
```bash
|
||||
# Uses heredoc to avoid shell escaping
|
||||
ccw issue create << 'EOF'
|
||||
{
|
||||
"title": "...",
|
||||
"context": "...",
|
||||
"priority": 3,
|
||||
"source": "brainstorm",
|
||||
"labels": ["brainstorm", "creative", "feasibility-high"],
|
||||
...
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
### Solution Binding
|
||||
```bash
|
||||
# Append solution to JSONL file
|
||||
echo '{"id":"SOL-xxx","tasks":[...]}' >> .workflow/issues/solutions/{issue-id}.jsonl
|
||||
|
||||
# Bind to issue
|
||||
ccw issue bind {issue-id} {solution-id}
|
||||
|
||||
# Update status
|
||||
ccw issue update {issue-id} --status planned
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Message | Resolution |
|
||||
|-------|---------|------------|
|
||||
| Session not found | synthesis.json missing | Check session ID, list available sessions |
|
||||
| No ideas | top_ideas array empty | Complete brainstorm workflow first |
|
||||
| Invalid idea index | Index out of range | Check valid range 0 to N-1 |
|
||||
| Issue creation failed | ccw issue create error | Verify CLI endpoint working |
|
||||
| Solution binding failed | Bind error | Check issue exists, retry |
|
||||
|
||||
## Examples
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
```bash
|
||||
Skill(skill="issue-resolve", args="--source brainstorm SESSION=\"BS-rate-limiting-2025-01-28\"")
|
||||
|
||||
# Output:
|
||||
# | # | Title | Score | Feasibility |
|
||||
# |---|-------|-------|-------------|
|
||||
# | 0 | Token Bucket Algorithm | 8.5 | High |
|
||||
# | 1 | Sliding Window Counter | 7.2 | Medium |
|
||||
# | 2 | Fixed Window | 6.1 | High |
|
||||
|
||||
# User selects: #0
|
||||
|
||||
# Result:
|
||||
# ✓ Created issue: ISS-20250128-001
|
||||
# ✓ Created solution: SOL-ISS-20250128-001-ab3d
|
||||
# ✓ Bound solution to issue
|
||||
# → Next: /issue:queue
|
||||
```
|
||||
|
||||
### Auto Mode
|
||||
|
||||
```bash
|
||||
Skill(skill="issue-resolve", args="--source brainstorm SESSION=\"BS-caching-2025-01-28\" --auto")
|
||||
|
||||
# Result:
|
||||
# Auto-selected: Redis Cache Layer (Score: 9.2/10)
|
||||
# ✓ Created issue: ISS-20250128-002
|
||||
# ✓ Solution with 4 tasks
|
||||
# → Status: planned
|
||||
```
|
||||
|
||||
## Integration Flow
|
||||
|
||||
```
|
||||
brainstorm-with-file
|
||||
│
|
||||
├─ synthesis.json
|
||||
├─ perspectives.json
|
||||
└─ .brainstorming/** (optional)
|
||||
│
|
||||
▼
|
||||
Phase 3: From Brainstorm ◄─── This phase
|
||||
│
|
||||
├─ ISS-YYYYMMDD-NNN (enriched issue)
|
||||
└─ SOL-{issue-id}-{uid} (structured solution)
|
||||
│
|
||||
▼
|
||||
Phase 4: Form Queue (or Skill(skill="issue-resolve", args="--source queue"))
|
||||
│
|
||||
▼
|
||||
/issue:execute
|
||||
│
|
||||
▼
|
||||
RA → EP → CD → VAS
|
||||
```
|
||||
|
||||
## Session Files Reference
|
||||
|
||||
### Input Files
|
||||
|
||||
```
|
||||
.workflow/.brainstorm/BS-{slug}-{date}/
|
||||
├── synthesis.json # REQUIRED - Top ideas with scores
|
||||
├── perspectives.json # OPTIONAL - Multi-CLI insights
|
||||
├── brainstorm.md # Reference only
|
||||
└── .brainstorming/ # OPTIONAL - Synthesis artifacts
|
||||
├── system-architect/
|
||||
│ └── analysis.md # Contains clarifications + enhancements
|
||||
├── api-designer/
|
||||
│ └── analysis.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Output Files
|
||||
|
||||
```
|
||||
.workflow/issues/
|
||||
├── solutions/
|
||||
│ └── ISS-YYYYMMDD-001.jsonl # Created solution (JSONL)
|
||||
└── (managed by ccw issue CLI)
|
||||
```
|
||||
|
||||
## Post-Phase Update
|
||||
|
||||
After brainstorm conversion:
|
||||
- Issue created with `status: planned`, enriched context from brainstorm session
|
||||
- Solution bound with structured tasks derived from idea.next_steps
|
||||
- Report: issue ID, solution ID, task count, idea score
|
||||
- Recommend next step: Form execution queue via Phase 4 or `Skill(skill="issue-resolve", args="--source queue")`
|
||||
389
.claude/skills/issue-resolve/phases/04-issue-queue.md
Normal file
389
.claude/skills/issue-resolve/phases/04-issue-queue.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Phase 4: Form Execution Queue
|
||||
|
||||
> 来源: `commands/issue/queue.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Queue formation command using **issue-queue-agent** that analyzes all bound solutions, resolves **inter-solution** conflicts, and creates an ordered execution queue at **solution level**.
|
||||
|
||||
**Design Principle**: Queue items are **solutions**, not individual tasks. Each executor receives a complete solution with all its tasks.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Issues with `status: planned` and `bound_solution_id` exist
|
||||
- Solutions written in `.workflow/issues/solutions/{issue-id}.jsonl`
|
||||
- `ccw issue` CLI available
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Auto-confirm queue formation, use recommended conflict resolutions.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Agent-driven**: issue-queue-agent handles all ordering logic
|
||||
- **Solution-level granularity**: Queue items are solutions, not tasks
|
||||
- **Conflict clarification**: High-severity conflicts prompt user decision
|
||||
- Semantic priority calculation per solution (0.0-1.0)
|
||||
- Parallel/Sequential group assignment for solutions
|
||||
|
||||
## Core Guidelines
|
||||
|
||||
**⚠️ Data Access Principle**: Issues and queue files can grow very large. To avoid context overflow:
|
||||
|
||||
| Operation | Correct | Incorrect |
|
||||
|-----------|---------|-----------|
|
||||
| List issues (brief) | `ccw issue list --status planned --brief` | `Read('issues.jsonl')` |
|
||||
| **Batch solutions (NEW)** | `ccw issue solutions --status planned --brief` | Loop `ccw issue solution <id>` |
|
||||
| List queue (brief) | `ccw issue queue --brief` | `Read('queues/*.json')` |
|
||||
| Read issue details | `ccw issue status <id> --json` | `Read('issues.jsonl')` |
|
||||
| Get next item | `ccw issue next --json` | `Read('queues/*.json')` |
|
||||
| Update status | `ccw issue update <id> --status ...` | Direct file edit |
|
||||
| Sync from queue | `ccw issue update --from-queue` | Direct file edit |
|
||||
| Read solution (single) | `ccw issue solution <id> --brief` | `Read('solutions/*.jsonl')` |
|
||||
|
||||
**Output Options**:
|
||||
- `--brief`: JSON with minimal fields (id, status, counts)
|
||||
- `--json`: Full JSON (agent use only)
|
||||
|
||||
**Orchestration vs Execution**:
|
||||
- **Command (orchestrator)**: Use `--brief` for minimal context
|
||||
- **Agent (executor)**: Fetch full details → `ccw issue status <id> --json`
|
||||
|
||||
**ALWAYS** use CLI commands for CRUD operations. **NEVER** read entire `issues.jsonl` or `queues/*.json` directly.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--queues <n>` | Number of parallel queues | 1 |
|
||||
| `--issue <id>` | Form queue for specific issue only | All planned |
|
||||
| `--append <id>` | Append issue to active queue (don't create new) | - |
|
||||
| `--force` | Skip active queue check, always create new queue | false |
|
||||
|
||||
## CLI Subcommands Reference
|
||||
|
||||
```bash
|
||||
ccw issue queue list List all queues with status
|
||||
ccw issue queue add <issue-id> Add issue to queue (interactive if active queue exists)
|
||||
ccw issue queue add <issue-id> -f Add to new queue without prompt (force)
|
||||
ccw issue queue merge <src> --queue <target> Merge source queue into target queue
|
||||
ccw issue queue switch <queue-id> Switch active queue
|
||||
ccw issue queue archive Archive current queue
|
||||
ccw issue queue delete <queue-id> Delete queue from history
|
||||
```
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 4.1: Solution Loading & Distribution
|
||||
|
||||
**Data Loading:**
|
||||
- Use `ccw issue solutions --status planned --brief` to get all planned issues with solutions in **one call**
|
||||
- Returns: Array of `{ issue_id, solution_id, is_bound, task_count, files_touched[], priority }`
|
||||
- If no bound solutions found → display message, suggest running plan/convert/brainstorm first
|
||||
|
||||
**Build Solution Objects:**
|
||||
```javascript
|
||||
// Single CLI call replaces N individual queries
|
||||
const result = Bash(`ccw issue solutions --status planned --brief`).trim();
|
||||
const solutions = result ? JSON.parse(result) : [];
|
||||
|
||||
if (solutions.length === 0) {
|
||||
console.log('No bound solutions found. Run /issue:plan first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// solutions already in correct format:
|
||||
// { issue_id, solution_id, is_bound, task_count, files_touched[], priority }
|
||||
```
|
||||
|
||||
**Multi-Queue Distribution** (if `--queues > 1`):
|
||||
- Use `files_touched` from brief output for partitioning
|
||||
- Group solutions with overlapping files into same queue
|
||||
|
||||
**Output:** Array of solution objects (or N arrays if multi-queue)
|
||||
|
||||
### Step 4.2: Agent-Driven Queue Formation
|
||||
|
||||
**Generate Queue IDs** (command layer, pass to agent):
|
||||
```javascript
|
||||
const timestamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
const numQueues = args.queues || 1;
|
||||
const queueIds = numQueues === 1
|
||||
? [`QUE-${timestamp}`]
|
||||
: Array.from({length: numQueues}, (_, i) => `QUE-${timestamp}-${i + 1}`);
|
||||
```
|
||||
|
||||
**Agent Prompt** (same for each queue, with assigned solutions):
|
||||
```
|
||||
## Order Solutions into Execution Queue
|
||||
|
||||
**Queue ID**: ${queueId}
|
||||
**Solutions**: ${solutions.length} from ${issues.length} issues
|
||||
**Project Root**: ${cwd}
|
||||
**Queue Index**: ${queueIndex} of ${numQueues}
|
||||
|
||||
### Input
|
||||
${JSON.stringify(solutions)}
|
||||
// Each object: { issue_id, solution_id, task_count, files_touched[], priority }
|
||||
|
||||
### Workflow
|
||||
|
||||
Step 1: Build dependency graph from solutions (nodes=solutions, edges=file conflicts via files_touched)
|
||||
Step 2: Use Gemini CLI for conflict analysis (5 types: file, API, data, dependency, architecture)
|
||||
Step 3: For high-severity conflicts without clear resolution → add to `clarifications`
|
||||
Step 4: Calculate semantic priority (base from issue priority + task_count boost)
|
||||
Step 5: Assign execution groups: P* (parallel, no overlaps) / S* (sequential, shared files)
|
||||
Step 6: Write queue JSON + update index
|
||||
|
||||
### Output Requirements
|
||||
|
||||
**Write files** (exactly 2):
|
||||
- `.workflow/issues/queues/${queueId}.json` - Full queue with solutions, conflicts, groups
|
||||
- `.workflow/issues/queues/index.json` - Update with new queue entry
|
||||
|
||||
**Return JSON**:
|
||||
\`\`\`json
|
||||
{
|
||||
"queue_id": "${queueId}",
|
||||
"total_solutions": N,
|
||||
"total_tasks": N,
|
||||
"execution_groups": [{"id": "P1", "type": "parallel", "count": N}],
|
||||
"issues_queued": ["ISS-xxx"],
|
||||
"clarifications": [{"conflict_id": "CFT-1", "question": "...", "options": [...]}]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Rules
|
||||
- Solution granularity (NOT individual tasks)
|
||||
- Queue Item ID format: S-1, S-2, S-3, ...
|
||||
- Use provided Queue ID (do NOT generate new)
|
||||
- `clarifications` only present if high-severity unresolved conflicts exist
|
||||
- Use `files_touched` from input (already extracted by orchestrator)
|
||||
|
||||
### Done Criteria
|
||||
- [ ] Queue JSON written with all solutions ordered
|
||||
- [ ] Index updated with active_queue_id
|
||||
- [ ] No circular dependencies
|
||||
- [ ] Parallel groups have no file overlaps
|
||||
- [ ] Return JSON matches required shape
|
||||
```
|
||||
|
||||
**Launch Agents** (parallel if multi-queue):
|
||||
```javascript
|
||||
const numQueues = args.queues || 1;
|
||||
|
||||
if (numQueues === 1) {
|
||||
// Single queue: single agent call
|
||||
const result = Task(
|
||||
subagent_type="issue-queue-agent",
|
||||
prompt=buildPrompt(queueIds[0], solutions),
|
||||
description=`Order ${solutions.length} solutions`
|
||||
);
|
||||
} else {
|
||||
// Multi-queue: parallel agent calls (single message with N Task calls)
|
||||
const agentPromises = solutionGroups.map((group, i) =>
|
||||
Task(
|
||||
subagent_type="issue-queue-agent",
|
||||
prompt=buildPrompt(queueIds[i], group, i + 1, numQueues),
|
||||
description=`Queue ${i + 1}/${numQueues}: ${group.length} solutions`
|
||||
)
|
||||
);
|
||||
// All agents launched in parallel via single message with multiple Task tool calls
|
||||
}
|
||||
```
|
||||
|
||||
**Multi-Queue Index Update:**
|
||||
- First queue sets `active_queue_id`
|
||||
- All queues added to `queues` array with `queue_group` field linking them
|
||||
|
||||
### Step 4.3: Conflict Clarification
|
||||
|
||||
**Collect Agent Results** (multi-queue):
|
||||
```javascript
|
||||
// Collect clarifications from all agents
|
||||
const allClarifications = results.flatMap((r, i) =>
|
||||
(r.clarifications || []).map(c => ({ ...c, queue_id: queueIds[i], agent_id: agentIds[i] }))
|
||||
);
|
||||
```
|
||||
|
||||
**Check Agent Return:**
|
||||
- Parse agent result JSON (or all results if multi-queue)
|
||||
- If any `clarifications` array exists and non-empty → user decision required
|
||||
|
||||
**Clarification Flow:**
|
||||
```javascript
|
||||
if (allClarifications.length > 0) {
|
||||
for (const clarification of allClarifications) {
|
||||
// Present to user via AskUserQuestion
|
||||
const answer = AskUserQuestion({
|
||||
questions: [{
|
||||
question: `[${clarification.queue_id}] ${clarification.question}`,
|
||||
header: clarification.conflict_id,
|
||||
options: clarification.options,
|
||||
multiSelect: false
|
||||
}]
|
||||
});
|
||||
|
||||
// Resume respective agent with user decision
|
||||
Task(
|
||||
subagent_type="issue-queue-agent",
|
||||
resume=clarification.agent_id,
|
||||
prompt=`Conflict ${clarification.conflict_id} resolved: ${answer.selected}`
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4.4: Status Update & Summary
|
||||
|
||||
**Status Update** (MUST use CLI command, NOT direct file operations):
|
||||
|
||||
```bash
|
||||
# Option 1: Batch update from queue (recommended)
|
||||
ccw issue update --from-queue [queue-id] --json
|
||||
ccw issue update --from-queue --json # Use active queue
|
||||
ccw issue update --from-queue QUE-xxx --json # Use specific queue
|
||||
|
||||
# Option 2: Individual issue update
|
||||
ccw issue update <issue-id> --status queued
|
||||
```
|
||||
|
||||
**⚠️ IMPORTANT**: Do NOT directly modify `issues.jsonl`. Always use CLI command to ensure proper validation and history tracking.
|
||||
|
||||
**Output** (JSON):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"queue_id": "QUE-xxx",
|
||||
"queued": ["ISS-001", "ISS-002"],
|
||||
"queued_count": 2,
|
||||
"unplanned": ["ISS-003"],
|
||||
"unplanned_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Updates issues in queue to `status: 'queued'` (skips already queued/executing/completed)
|
||||
- Identifies planned issues with `bound_solution_id` NOT in queue → `unplanned` array
|
||||
- Optional `queue-id`: defaults to active queue if omitted
|
||||
|
||||
**Summary Output:**
|
||||
- Display queue ID, solution count, task count
|
||||
- Show unplanned issues (planned but NOT in queue)
|
||||
- Show next step: `/issue:execute`
|
||||
|
||||
### Step 4.5: Active Queue Check & Decision
|
||||
|
||||
**After agent completes, check for active queue:**
|
||||
|
||||
```bash
|
||||
ccw issue queue list --brief
|
||||
```
|
||||
|
||||
**Decision:**
|
||||
- If `active_queue_id` is null → `ccw issue queue switch <new-queue-id>` (activate new queue)
|
||||
- If active queue exists → Use **AskUserQuestion** to prompt user
|
||||
|
||||
**AskUserQuestion:**
|
||||
```javascript
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Active queue exists. How would you like to proceed?",
|
||||
header: "Queue Action",
|
||||
options: [
|
||||
{ label: "Merge into existing queue", description: "Add new items to active queue, delete new queue" },
|
||||
{ label: "Use new queue", description: "Switch to new queue, keep existing in history" },
|
||||
{ label: "Cancel", description: "Delete new queue, keep existing active" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
**Action Commands:**
|
||||
|
||||
| User Choice | Commands |
|
||||
|-------------|----------|
|
||||
| **Merge into existing** | `ccw issue queue merge <new-queue-id> --queue <active-queue-id>` then `ccw issue queue delete <new-queue-id>` |
|
||||
| **Use new queue** | `ccw issue queue switch <new-queue-id>` |
|
||||
| **Cancel** | `ccw issue queue delete <new-queue-id>` |
|
||||
|
||||
## Storage Structure (Queue History)
|
||||
|
||||
```
|
||||
.workflow/issues/
|
||||
├── issues.jsonl # All issues (one per line)
|
||||
├── queues/ # Queue history directory
|
||||
│ ├── index.json # Queue index (active + history)
|
||||
│ ├── {queue-id}.json # Individual queue files
|
||||
│ └── ...
|
||||
└── solutions/
|
||||
├── {issue-id}.jsonl # Solutions for issue
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Queue Index Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"active_queue_id": "QUE-20251227-143000",
|
||||
"active_queue_group": "QGR-20251227-143000",
|
||||
"queues": [
|
||||
{
|
||||
"id": "QUE-20251227-143000-1",
|
||||
"queue_group": "QGR-20251227-143000",
|
||||
"queue_index": 1,
|
||||
"total_queues": 3,
|
||||
"status": "active",
|
||||
"issue_ids": ["ISS-xxx", "ISS-yyy"],
|
||||
"total_solutions": 3,
|
||||
"completed_solutions": 1,
|
||||
"created_at": "2025-12-27T14:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Multi-Queue Fields:**
|
||||
- `queue_group`: Links queues created in same batch (format: `QGR-{timestamp}`)
|
||||
- `queue_index`: Position in group (1-based)
|
||||
- `total_queues`: Total queues in group
|
||||
- `active_queue_group`: Current active group (for multi-queue execution)
|
||||
|
||||
**Note**: Queue file schema is produced by `issue-queue-agent`. See agent documentation for details.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
| No bound solutions | Display message, suggest phases 1-3 (plan/convert/brainstorm) |
|
||||
| Circular dependency | List cycles, abort queue formation |
|
||||
| High-severity conflict | Return `clarifications`, prompt user decision |
|
||||
| User cancels clarification | Abort queue formation |
|
||||
| **index.json not updated** | Auto-fix: Set active_queue_id to new queue |
|
||||
| **Queue file missing solutions** | Abort with error, agent must regenerate |
|
||||
| **User cancels queue add** | Display message, return without changes |
|
||||
| **Merge with empty source** | Skip merge, display warning |
|
||||
| **All items duplicate** | Skip merge, display "All items already exist" |
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before completing, verify:
|
||||
|
||||
- [ ] All planned issues with `bound_solution_id` are included
|
||||
- [ ] Queue JSON written to `queues/{queue-id}.json` (N files if multi-queue)
|
||||
- [ ] Index updated in `queues/index.json` with `active_queue_id`
|
||||
- [ ] Multi-queue: All queues share same `queue_group`
|
||||
- [ ] No circular dependencies in solution DAG
|
||||
- [ ] All conflicts resolved (auto or via user clarification)
|
||||
- [ ] Parallel groups have no file overlaps
|
||||
- [ ] Cross-queue: No file overlaps between queues
|
||||
- [ ] Issue statuses updated to `queued`
|
||||
|
||||
## Post-Phase Update
|
||||
|
||||
After queue formation:
|
||||
- All planned issues updated to `status: queued`
|
||||
- Queue files written and index updated
|
||||
- Report: queue ID(s), solution count, task count, execution groups
|
||||
- Recommend next step: `/issue:execute` to begin execution
|
||||
@@ -8,15 +8,15 @@
|
||||
============================================================================= */
|
||||
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--ccw-primary-h: 221;
|
||||
--ccw-primary-s: 83%;
|
||||
--ccw-primary-l: 53%;
|
||||
/* Primary Colors - 匹配 Frontend light-blue 主题 */
|
||||
--ccw-primary-h: 220;
|
||||
--ccw-primary-s: 60%;
|
||||
--ccw-primary-l: 65%;
|
||||
--ccw-primary: hsl(var(--ccw-primary-h), var(--ccw-primary-s), var(--ccw-primary-l));
|
||||
--ccw-primary-hover: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 43%);
|
||||
--ccw-primary-active: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 38%);
|
||||
--ccw-primary-hover: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 55%);
|
||||
--ccw-primary-active: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 50%);
|
||||
--ccw-primary-light: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 95%);
|
||||
--ccw-primary-dark: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 23%);
|
||||
--ccw-primary-dark: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 30%);
|
||||
|
||||
/* Secondary Colors */
|
||||
--ccw-secondary-h: 210;
|
||||
@@ -26,38 +26,38 @@
|
||||
--ccw-secondary-hover: hsl(var(--ccw-secondary-h), var(--ccw-secondary-s), 92%);
|
||||
--ccw-secondary-border: hsl(var(--ccw-secondary-h), var(--ccw-secondary-s), 88%);
|
||||
|
||||
/* Accent Colors */
|
||||
--ccw-accent-h: 142;
|
||||
--ccw-accent-s: 76%;
|
||||
--ccw-accent-l: 36%;
|
||||
/* Accent Colors - 匹配 Frontend 蓝色系 */
|
||||
--ccw-accent-h: 220;
|
||||
--ccw-accent-s: 60%;
|
||||
--ccw-accent-l: 65%;
|
||||
--ccw-accent: hsl(var(--ccw-accent-h), var(--ccw-accent-s), var(--ccw-accent-l));
|
||||
--ccw-accent-hover: hsl(var(--ccw-accent-h), var(--ccw-accent-s), 26%);
|
||||
--ccw-accent-light: hsl(var(--ccw-accent-h), var(--ccw-accent-s), 92%);
|
||||
--ccw-accent-hover: hsl(var(--ccw-accent-h), var(--ccw-accent-s), 55%);
|
||||
--ccw-accent-light: hsl(var(--ccw-accent-h), var(--ccw-accent-s), 95%);
|
||||
|
||||
/* Background Colors */
|
||||
/* Background Colors - 匹配 Frontend */
|
||||
--ccw-bg-h: 0;
|
||||
--ccw-bg-s: 0%;
|
||||
--ccw-bg-l: 100%;
|
||||
--ccw-bg-l: 98%;
|
||||
--ccw-bg: hsl(var(--ccw-bg-h), var(--ccw-bg-s), var(--ccw-bg-l));
|
||||
--ccw-bg-secondary: hsl(210, 20%, 98%);
|
||||
--ccw-bg-tertiary: hsl(210, 20%, 96%);
|
||||
--ccw-bg-secondary: hsl(220, 10%, 96%);
|
||||
--ccw-bg-tertiary: hsl(220, 15%, 94%);
|
||||
--ccw-bg-elevated: hsl(0, 0%, 100%);
|
||||
--ccw-bg-overlay: hsla(0, 0%, 0%, 0.5);
|
||||
|
||||
/* Text Colors */
|
||||
--ccw-text-h: 222;
|
||||
--ccw-text-s: 47%;
|
||||
--ccw-text-l: 11%;
|
||||
/* Text Colors - 匹配 Frontend */
|
||||
--ccw-text-h: 220;
|
||||
--ccw-text-s: 30%;
|
||||
--ccw-text-l: 15%;
|
||||
--ccw-text-primary: hsl(var(--ccw-text-h), var(--ccw-text-s), var(--ccw-text-l));
|
||||
--ccw-text-secondary: hsl(215, 16%, 47%);
|
||||
--ccw-text-tertiary: hsl(215, 16%, 65%);
|
||||
--ccw-text-secondary: hsl(220, 15%, 45%);
|
||||
--ccw-text-tertiary: hsl(220, 15%, 60%);
|
||||
--ccw-text-disabled: hsl(215, 16%, 80%);
|
||||
--ccw-text-inverse: hsl(0, 0%, 100%);
|
||||
|
||||
/* Border Colors */
|
||||
--ccw-border-h: 214;
|
||||
--ccw-border-s: 32%;
|
||||
--ccw-border-l: 91%;
|
||||
/* Border Colors - 匹配 Frontend */
|
||||
--ccw-border-h: 220;
|
||||
--ccw-border-s: 20%;
|
||||
--ccw-border-l: 88%;
|
||||
--ccw-border: hsl(var(--ccw-border-h), var(--ccw-border-s), var(--ccw-border-l));
|
||||
--ccw-border-hover: hsl(var(--ccw-border-h), var(--ccw-border-s), 80%);
|
||||
--ccw-border-focus: var(--ccw-primary);
|
||||
@@ -118,11 +118,11 @@
|
||||
============================================================================= */
|
||||
|
||||
[data-theme='dark'] {
|
||||
/* Primary Colors (adjusted for dark mode) */
|
||||
--ccw-primary: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 60%);
|
||||
--ccw-primary-hover: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 50%);
|
||||
--ccw-primary-active: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 45%);
|
||||
--ccw-primary-light: hsl(var(--ccw-primary-h), 20%, 20%);
|
||||
/* Primary Colors - 匹配 Frontend dark-blue 主题 */
|
||||
--ccw-primary: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 65%);
|
||||
--ccw-primary-hover: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 55%);
|
||||
--ccw-primary-active: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 50%);
|
||||
--ccw-primary-light: hsl(var(--ccw-primary-h), 30%, 25%);
|
||||
--ccw-primary-dark: hsl(var(--ccw-primary-h), var(--ccw-primary-s), 30%);
|
||||
|
||||
/* Secondary Colors */
|
||||
@@ -130,29 +130,29 @@
|
||||
--ccw-secondary-hover: hsl(217, 33%, 23%);
|
||||
--ccw-secondary-border: hsl(217, 33%, 28%);
|
||||
|
||||
/* Background Colors */
|
||||
--ccw-bg-h: 222;
|
||||
--ccw-bg-s: 47%;
|
||||
--ccw-bg-l: 11%;
|
||||
/* Background Colors - 匹配 Frontend dark-blue */
|
||||
--ccw-bg-h: 220;
|
||||
--ccw-bg-s: 30%;
|
||||
--ccw-bg-l: 10%;
|
||||
--ccw-bg: hsl(var(--ccw-bg-h), var(--ccw-bg-s), var(--ccw-bg-l));
|
||||
--ccw-bg-secondary: hsl(217, 33%, 17%);
|
||||
--ccw-bg-tertiary: hsl(215, 28%, 22%);
|
||||
--ccw-bg-elevated: hsl(217, 33%, 20%);
|
||||
--ccw-bg-secondary: hsl(220, 25%, 14%);
|
||||
--ccw-bg-tertiary: hsl(220, 25%, 18%);
|
||||
--ccw-bg-elevated: hsl(220, 25%, 16%);
|
||||
--ccw-bg-overlay: hsla(0, 0%, 0%, 0.7);
|
||||
|
||||
/* Text Colors */
|
||||
--ccw-text-primary: hsl(210, 40%, 98%);
|
||||
--ccw-text-secondary: hsl(215, 16%, 75%);
|
||||
--ccw-text-tertiary: hsl(215, 16%, 55%);
|
||||
--ccw-text-disabled: hsl(215, 16%, 40%);
|
||||
/* Text Colors - 匹配 Frontend dark-blue */
|
||||
--ccw-text-primary: hsl(220, 20%, 90%);
|
||||
--ccw-text-secondary: hsl(220, 15%, 60%);
|
||||
--ccw-text-tertiary: hsl(220, 15%, 50%);
|
||||
--ccw-text-disabled: hsl(220, 15%, 40%);
|
||||
--ccw-text-inverse: hsl(0, 0%, 0%);
|
||||
|
||||
/* Border Colors */
|
||||
--ccw-border-h: 217;
|
||||
--ccw-border-s: 33%;
|
||||
--ccw-border-l: 17%;
|
||||
/* Border Colors - 匹配 Frontend dark-blue */
|
||||
--ccw-border-h: 220;
|
||||
--ccw-border-s: 20%;
|
||||
--ccw-border-l: 22%;
|
||||
--ccw-border: hsl(var(--ccw-border-h), var(--ccw-border-s), var(--ccw-border-l));
|
||||
--ccw-border-hover: hsl(var(--ccw-border-h), var(--ccw-border-s), 25%);
|
||||
--ccw-border-hover: hsl(var(--ccw-border-h), var(--ccw-border-s), 30%);
|
||||
--ccw-border-focus: var(--ccw-primary);
|
||||
|
||||
/* Shadow (adjusted for dark mode) */
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// ========================================
|
||||
// CoordinatorEmptyState Component
|
||||
// ========================================
|
||||
// Modern empty state with tech-inspired design for coordinator start page
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Play, Rocket, Zap, GitBranch } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CoordinatorEmptyStateProps {
|
||||
onStart: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state component with modern tech-inspired design
|
||||
* Displays when no coordinator execution is active
|
||||
*/
|
||||
export function CoordinatorEmptyState({
|
||||
onStart,
|
||||
disabled = false,
|
||||
className,
|
||||
}: CoordinatorEmptyStateProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center min-h-[600px] overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated Background - Using theme colors with gradient utilities */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background animate-slow-gradient">
|
||||
{/* Grid Pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(var(--primary) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--primary) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated Gradient Orbs - Using gradient utility classes */}
|
||||
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-15" />
|
||||
<div
|
||||
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse opacity-15 bg-gradient-secondary"
|
||||
style={{ animationDelay: '1s' }}
|
||||
/>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-10" style={{ animationDelay: '2s' }} />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="relative z-10 max-w-2xl mx-auto px-8 text-center">
|
||||
{/* Hero Icon - Using gradient brand background */}
|
||||
<div className="relative mb-8 inline-block">
|
||||
<div className="absolute inset-0 rounded-full blur-2xl opacity-40 animate-pulse bg-gradient-brand" />
|
||||
<div className="relative p-6 rounded-full shadow-2xl text-white bg-primary hover-glow-primary">
|
||||
<Rocket className="w-16 h-16" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-4xl font-bold mb-4 text-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.title' })}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-lg text-muted-foreground mb-12 max-w-lg mx-auto">
|
||||
{formatMessage({ id: 'coordinator.emptyState.subtitle' })}
|
||||
</p>
|
||||
|
||||
{/* Start Button - Using gradient and glow utilities */}
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onStart}
|
||||
disabled={disabled}
|
||||
className="group relative px-8 py-6 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300 bg-primary text-primary-foreground hover-glow-primary"
|
||||
>
|
||||
<Play className="w-6 h-6 mr-2 group-hover:scale-110 transition-transform" />
|
||||
{formatMessage({ id: 'coordinator.emptyState.startButton' })}
|
||||
</Button>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Feature 1 */}
|
||||
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: 'hsl(var(--primary) / 0.05)' }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
|
||||
style={{ background: 'hsl(var(--primary) / 0.1)', color: 'hsl(var(--primary))' }}
|
||||
>
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature1.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature1.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: 'hsl(var(--secondary) / 0.05)' }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
|
||||
style={{ background: 'hsl(var(--secondary) / 0.1)', color: 'hsl(var(--secondary))' }}
|
||||
>
|
||||
<GitBranch className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature2.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature2.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: 'hsl(var(--accent) / 0.05)' }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
|
||||
style={{ background: 'hsl(var(--accent) / 0.1)', color: 'hsl(var(--accent))' }}
|
||||
>
|
||||
<Play className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature3.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.emptyState.feature3.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Guide */}
|
||||
<div className="mt-12 text-left bg-card/50 backdrop-blur-sm rounded-xl p-6 border border-border">
|
||||
<h3 className="font-semibold mb-4 text-foreground flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full flex items-center justify-center text-primary-foreground text-xs font-semibold bg-primary">
|
||||
ok
|
||||
</span>
|
||||
{formatMessage({ id: 'coordinator.emptyState.quickStart.title' })}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-primary">
|
||||
1
|
||||
</span>
|
||||
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step1' })}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-secondary">
|
||||
2
|
||||
</span>
|
||||
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step2' })}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-accent">
|
||||
3
|
||||
</span>
|
||||
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step3' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorEmptyState;
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { CoordinatorInputModal } from './CoordinatorInputModal';
|
||||
|
||||
// Mock zustand stores
|
||||
vi.mock('@/stores/coordinatorStore', () => ({
|
||||
useCoordinatorStore: () => ({
|
||||
startCoordinator: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const mockMessages = {
|
||||
'coordinator.modal.title': 'Start Coordinator',
|
||||
'coordinator.modal.description': 'Describe the task',
|
||||
'coordinator.form.taskDescription': 'Task Description',
|
||||
'coordinator.form.taskDescriptionPlaceholder': 'Enter task description',
|
||||
'coordinator.form.parameters': 'Parameters',
|
||||
'coordinator.form.parametersPlaceholder': '{"key": "value"}',
|
||||
'coordinator.form.parametersHelp': 'Optional JSON parameters',
|
||||
'coordinator.form.characterCount': '{current} / {max} characters (min: {min})',
|
||||
'coordinator.form.start': 'Start',
|
||||
'coordinator.form.starting': 'Starting...',
|
||||
'coordinator.validation.taskDescriptionRequired': 'Task description is required',
|
||||
'coordinator.validation.taskDescriptionTooShort': 'Too short',
|
||||
'coordinator.validation.taskDescriptionTooLong': 'Too long',
|
||||
'coordinator.validation.parametersInvalidJson': 'Invalid JSON',
|
||||
'common.actions.cancel': 'Cancel',
|
||||
};
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<IntlProvider locale="en" messages={mockMessages}>
|
||||
{ui}
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CoordinatorInputModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render when open', () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Start Coordinator')).toBeInTheDocument();
|
||||
expect(screen.getByText('Describe the task')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when closed', () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={false} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByText('Start Coordinator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error for empty task description', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Task description is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error for short task description', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Short' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Too short')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error for invalid JSON parameters', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Valid task description here' } });
|
||||
|
||||
const paramsInput = screen.getByPlaceholderText('{"key": "value"}');
|
||||
fireEvent.change(paramsInput, { target: { value: 'invalid json' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should submit with valid task description', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Valid task description with more than 10 characters' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/coordinator/start',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,441 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Input Modal Component (Multi-Step)
|
||||
// ========================================
|
||||
// Two-step modal: Welcome page -> Template & Parameters
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2, Rocket, Zap, GitBranch, Eye, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorInputModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
taskDescription?: string;
|
||||
parameters?: string;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 'feature-dev', nameKey: 'coordinator.multiStep.step2.templates.featureDev', description: 'Complete feature development workflow' },
|
||||
{ id: 'api-integration', nameKey: 'coordinator.multiStep.step2.templates.apiIntegration', description: 'Third-party API integration' },
|
||||
{ id: 'performance', nameKey: 'coordinator.multiStep.step2.templates.performanceOptimization', description: 'System performance analysis' },
|
||||
{ id: 'documentation', nameKey: 'coordinator.multiStep.step2.templates.documentGeneration', description: 'Auto-generate documentation' },
|
||||
] as const;
|
||||
|
||||
const TOTAL_STEPS = 2;
|
||||
|
||||
// ========== Validation Helper ==========
|
||||
|
||||
function validateForm(taskDescription: string, parameters: string): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!taskDescription.trim()) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionRequired';
|
||||
} else {
|
||||
const length = taskDescription.trim().length;
|
||||
if (length < 10) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionTooShort';
|
||||
} else if (length > 2000) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionTooLong';
|
||||
}
|
||||
}
|
||||
|
||||
if (parameters.trim()) {
|
||||
try {
|
||||
JSON.parse(parameters.trim());
|
||||
} catch {
|
||||
errors.parameters = 'coordinator.validation.parametersInvalidJson';
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ========== Feature Card Data ==========
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: Zap,
|
||||
titleKey: 'coordinator.multiStep.step1.feature1.title',
|
||||
descriptionKey: 'coordinator.multiStep.step1.feature1.description',
|
||||
bgClass: 'bg-primary/10',
|
||||
iconClass: 'text-primary',
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
titleKey: 'coordinator.multiStep.step1.feature2.title',
|
||||
descriptionKey: 'coordinator.multiStep.step1.feature2.description',
|
||||
bgClass: 'bg-secondary/10',
|
||||
iconClass: 'text-secondary-foreground',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
titleKey: 'coordinator.multiStep.step1.feature3.title',
|
||||
descriptionKey: 'coordinator.multiStep.step1.feature3.description',
|
||||
bgClass: 'bg-accent/10',
|
||||
iconClass: 'text-accent-foreground',
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
const { startCoordinator } = useCoordinatorStore();
|
||||
|
||||
// Step state
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
// Form state
|
||||
const [taskDescription, setTaskDescription] = useState('');
|
||||
const [parameters, setParameters] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset all state when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(1);
|
||||
setTaskDescription('');
|
||||
setParameters('');
|
||||
setSelectedTemplate(null);
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle field change
|
||||
const handleFieldChange = (
|
||||
field: 'taskDescription' | 'parameters',
|
||||
value: string
|
||||
) => {
|
||||
if (field === 'taskDescription') {
|
||||
setTaskDescription(value);
|
||||
} else {
|
||||
setParameters(value);
|
||||
}
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
setSelectedTemplate(templateId);
|
||||
const template = TEMPLATES.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setTaskDescription(template.description);
|
||||
if (errors.taskDescription) {
|
||||
setErrors((prev) => ({ ...prev, taskDescription: undefined }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submit - preserved exactly from original
|
||||
const handleSubmit = async () => {
|
||||
const validationErrors = validateForm(taskDescription, parameters);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const parsedParams = parameters.trim() ? JSON.parse(parameters.trim()) : undefined;
|
||||
|
||||
const executionId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const response = await fetch('/api/coordinator/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
executionId,
|
||||
taskDescription: taskDescription.trim(),
|
||||
parameters: parsedParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
|
||||
throw new Error(error.message || 'Failed to start coordinator');
|
||||
}
|
||||
|
||||
await startCoordinator(executionId, taskDescription.trim(), parsedParams);
|
||||
|
||||
success(formatMessage({ id: 'coordinator.success.started' }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
showError('Error', errorMessage);
|
||||
console.error('Failed to start coordinator:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation
|
||||
const handleNext = () => setStep(2);
|
||||
const handleBack = () => setStep(1);
|
||||
|
||||
// ========== Step 1: Welcome ==========
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div className="flex flex-col items-center px-6 py-8">
|
||||
{/* Hero Icon */}
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground mb-6">
|
||||
<Rocket className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
{/* Title & Subtitle */}
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
{formatMessage({ id: 'coordinator.multiStep.step1.title' })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-8 text-center max-w-md">
|
||||
{formatMessage({ id: 'coordinator.multiStep.step1.subtitle' })}
|
||||
</p>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="grid grid-cols-3 gap-4 w-full">
|
||||
{FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div
|
||||
key={feature.titleKey}
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-xl p-5 text-center',
|
||||
feature.bgClass
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-6 w-6 mb-3', feature.iconClass)} />
|
||||
<span className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: feature.titleKey })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: feature.descriptionKey })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ========== Step 2: Template + Parameters ==========
|
||||
|
||||
const renderStep2 = () => (
|
||||
<div className="flex min-h-[380px]">
|
||||
{/* Left Column: Template Selection */}
|
||||
<div className="w-2/5 border-r border-border p-5">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
{formatMessage({ id: 'coordinator.multiStep.step2.templateLabel' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{TEMPLATES.map((template) => {
|
||||
const isSelected = selectedTemplate === template.id;
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => handleTemplateSelect(template.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Radio dot */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
|
||||
isSelected
|
||||
? 'border-primary'
|
||||
: 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-sm',
|
||||
isSelected ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatMessage({ id: template.nameKey })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Parameter Form */}
|
||||
<div className="w-3/5 p-5 space-y-4">
|
||||
{/* Task Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description" className="text-sm font-medium">
|
||||
{formatMessage({ id: 'coordinator.form.taskDescription' })}
|
||||
<span className="text-destructive ml-0.5">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="task-description"
|
||||
value={taskDescription}
|
||||
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
|
||||
rows={5}
|
||||
className={cn(
|
||||
'resize-none',
|
||||
errors.taskDescription && 'border-destructive'
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatMessage(
|
||||
{ id: 'coordinator.form.characterCount' },
|
||||
{ current: taskDescription.length, min: 10, max: 2000 }
|
||||
)}
|
||||
</span>
|
||||
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
|
||||
<span className="text-primary">Valid</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.taskDescription && (
|
||||
<p className="text-xs text-destructive">
|
||||
{formatMessage({ id: errors.taskDescription })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Parameters */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parameters" className="text-sm font-medium">
|
||||
{formatMessage({ id: 'coordinator.form.parameters' })}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="parameters"
|
||||
value={parameters}
|
||||
onChange={(e) => handleFieldChange('parameters', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'resize-none font-mono text-sm',
|
||||
errors.parameters && 'border-destructive'
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
|
||||
</p>
|
||||
{errors.parameters && (
|
||||
<p className="text-xs text-destructive">
|
||||
{formatMessage({ id: errors.parameters })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ========== Footer ==========
|
||||
|
||||
const renderFooter = () => (
|
||||
<div className="flex items-center justify-between border-t border-border px-6 py-4">
|
||||
{/* Left: Step indicator + Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'coordinator.multiStep.progress.step' },
|
||||
{ current: step, total: TOTAL_STEPS }
|
||||
)}
|
||||
</span>
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
{formatMessage({ id: 'coordinator.multiStep.actions.back' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Cancel + Next/Submit */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
|
||||
{step === 1 ? (
|
||||
<Button size="sm" onClick={handleNext}>
|
||||
{formatMessage({ id: 'coordinator.multiStep.actions.next' })}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.form.starting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'coordinator.multiStep.actions.submit' })
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl gap-0 p-0 overflow-hidden">
|
||||
{/* Visually hidden title for accessibility */}
|
||||
<DialogTitle className="sr-only">
|
||||
{formatMessage({ id: 'coordinator.modal.title' })}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Step Content */}
|
||||
{step === 1 ? renderStep1() : renderStep2()}
|
||||
|
||||
{/* Footer */}
|
||||
{renderFooter()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorInputModal;
|
||||
@@ -1,196 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Log Stream Component
|
||||
// ========================================
|
||||
// Real-time log display with level filtering and auto-scroll
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/Card';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useCoordinatorStore, type LogLevel, type CoordinatorLog } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorLogStreamProps {
|
||||
maxHeight?: number;
|
||||
autoScroll?: boolean;
|
||||
showFilter?: boolean;
|
||||
}
|
||||
|
||||
type LogLevelFilter = LogLevel | 'all';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorLogStream({
|
||||
maxHeight = 400,
|
||||
autoScroll = true,
|
||||
showFilter = true,
|
||||
}: CoordinatorLogStreamProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { logs } = useCoordinatorStore();
|
||||
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>('all');
|
||||
const logContainerRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Filter logs by level
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (levelFilter === 'all') {
|
||||
return logs;
|
||||
}
|
||||
return logs.filter((log) => log.level === levelFilter);
|
||||
}, [logs, levelFilter]);
|
||||
|
||||
// Auto-scroll to latest log
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
// Get log level color
|
||||
const getLogLevelColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warn':
|
||||
return 'text-yellow-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'debug':
|
||||
return 'text-blue-600';
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// Get log level background color
|
||||
const getLogLevelBgColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'bg-red-50';
|
||||
case 'warn':
|
||||
return 'bg-yellow-50';
|
||||
case 'success':
|
||||
return 'bg-green-50';
|
||||
case 'debug':
|
||||
return 'bg-blue-50';
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
// Format log entry
|
||||
const formatLogEntry = (log: CoordinatorLog): string => {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const levelLabel = `[${log.level.toUpperCase()}]`;
|
||||
const source = log.source ? `[${log.source}]` : '';
|
||||
return `${timestamp} ${levelLabel} ${source} ${log.message}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'coordinator.logs' })}
|
||||
</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({filteredLogs.length} {formatMessage({ id: 'coordinator.entries' })})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Level Filter */}
|
||||
{showFilter && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'coordinator.logLevel' })}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={levelFilter}
|
||||
onValueChange={(value) => setLevelFilter(value as LogLevelFilter)}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="level-all" />
|
||||
<Label htmlFor="level-all" className="cursor-pointer">
|
||||
{formatMessage({ id: 'coordinator.level.all' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="info" id="level-info" />
|
||||
<Label htmlFor="level-info" className="cursor-pointer text-gray-600">
|
||||
{formatMessage({ id: 'coordinator.level.info' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="warn" id="level-warn" />
|
||||
<Label htmlFor="level-warn" className="cursor-pointer text-yellow-600">
|
||||
{formatMessage({ id: 'coordinator.level.warn' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="error" id="level-error" />
|
||||
<Label htmlFor="level-error" className="cursor-pointer text-red-600">
|
||||
{formatMessage({ id: 'coordinator.level.error' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="debug" id="level-debug" />
|
||||
<Label htmlFor="level-debug" className="cursor-pointer text-blue-600">
|
||||
{formatMessage({ id: 'coordinator.level.debug' })}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Display */}
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground text-sm">
|
||||
{formatMessage({ id: 'coordinator.noLogs' })}
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
ref={logContainerRef}
|
||||
className={cn(
|
||||
'w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto whitespace-pre-wrap break-words font-mono'
|
||||
)}
|
||||
style={{ maxHeight: `${maxHeight}px` }}
|
||||
>
|
||||
{filteredLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn(
|
||||
'py-1 px-2 mb-1 rounded',
|
||||
getLogLevelBgColor(log.level)
|
||||
)}
|
||||
>
|
||||
<span className={getLogLevelColor(log.level)}>
|
||||
{formatLogEntry(log)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorLogStream;
|
||||
@@ -1,289 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Question Modal Component
|
||||
// ========================================
|
||||
// Interactive question dialog for coordinator execution
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
import { useCoordinatorStore, type CoordinatorQuestion } from '@/stores/coordinatorStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorQuestionModalProps {
|
||||
question: CoordinatorQuestion | null;
|
||||
onSubmit?: (questionId: string, answer: string | string[]) => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorQuestionModal({
|
||||
question,
|
||||
onSubmit,
|
||||
}: CoordinatorQuestionModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { submitAnswer } = useCoordinatorStore();
|
||||
const [answer, setAnswer] = useState<string | string[]>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset state when question changes
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
setAnswer(question.type === 'multi' ? [] : '');
|
||||
setError(null);
|
||||
// Auto-focus on input when modal opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [question]);
|
||||
|
||||
// Validate answer
|
||||
const validateAnswer = (): boolean => {
|
||||
if (!question) return false;
|
||||
|
||||
if (question.required) {
|
||||
if (question.type === 'multi') {
|
||||
if (!Array.isArray(answer) || answer.length === 0) {
|
||||
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!answer || (typeof answer === 'string' && !answer.trim())) {
|
||||
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
if (!question) return;
|
||||
|
||||
if (!validateAnswer()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const finalAnswer = typeof answer === 'string' ? answer.trim() : answer;
|
||||
|
||||
// Call store action
|
||||
await submitAnswer(question.id, finalAnswer);
|
||||
|
||||
// Call optional callback
|
||||
onSubmit?.(question.id, finalAnswer);
|
||||
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit answer:', error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: formatMessage({ id: 'coordinator.error.submitFailed' })
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && question?.type === 'text') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle multi-select change
|
||||
const handleMultiSelectChange = (option: string, checked: boolean) => {
|
||||
if (!Array.isArray(answer)) {
|
||||
setAnswer([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
setAnswer([...answer, option]);
|
||||
} else {
|
||||
setAnswer(answer.filter((a) => a !== option));
|
||||
}
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!question} onOpenChange={() => {/* Prevent manual close */}}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{question.title}</DialogTitle>
|
||||
{question.description && (
|
||||
<DialogDescription>{question.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Text Input */}
|
||||
{question.type === 'text' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text-answer">
|
||||
{formatMessage({ id: 'coordinator.question.answer' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="text-answer"
|
||||
ref={inputRef}
|
||||
value={typeof answer === 'string' ? answer : ''}
|
||||
onChange={(e) => {
|
||||
setAnswer(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={formatMessage({ id: 'coordinator.question.textPlaceholder' })}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Select (RadioGroup) */}
|
||||
{question.type === 'single' && question.options && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.selectOne' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={typeof answer === 'string' ? answer : ''}
|
||||
onValueChange={(value) => {
|
||||
setAnswer(value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option} id={`option-${option}`} />
|
||||
<Label htmlFor={`option-${option}`} className="cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi Select (Checkboxes) */}
|
||||
{question.type === 'multi' && question.options && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.selectMultiple' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => {
|
||||
const isChecked = Array.isArray(answer) && answer.includes(option);
|
||||
return (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`multi-${option}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleMultiSelectChange(option, e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<Label htmlFor={`multi-${option}`} className="cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yes/No Buttons */}
|
||||
{question.type === 'yes_no' && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.confirm' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={answer === 'yes' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setAnswer('yes');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'coordinator.question.yes' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={answer === 'no' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setAnswer('no');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'coordinator.question.no' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.question.submitting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'coordinator.question.submit' })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorQuestionModal;
|
||||
@@ -1,137 +0,0 @@
|
||||
// ========================================
|
||||
// CoordinatorTaskCard Component
|
||||
// ========================================
|
||||
// Task card component for displaying task overview in horizontal list
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Clock, CheckCircle, XCircle, Loader2, CircleDashed } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TaskStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: { completed: number; total: number };
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CoordinatorTaskCardProps {
|
||||
task: TaskStatus;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task card component displaying task status and progress
|
||||
* Used in horizontal scrolling task list
|
||||
*/
|
||||
export function CoordinatorTaskCard({
|
||||
task,
|
||||
isSelected,
|
||||
onClick,
|
||||
className,
|
||||
}: CoordinatorTaskCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Map status to badge variant
|
||||
const getStatusVariant = (status: TaskStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'secondary';
|
||||
case 'running':
|
||||
return 'warning';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'cancelled':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = (status: TaskStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <CircleDashed className="w-3 h-3" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-3 h-3 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-3 h-3" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-3 h-3" />;
|
||||
case 'cancelled':
|
||||
return <XCircle className="w-3 h-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Format time display
|
||||
const formatTime = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const displayTime = task.startedAt ? formatTime(task.startedAt) : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'min-w-[180px] max-w-[220px] p-4 cursor-pointer transition-all duration-200',
|
||||
'hover:border-primary/50 hover:shadow-md',
|
||||
isSelected && 'border-primary ring-1 ring-primary/20',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Task Name */}
|
||||
<h3 className="font-medium text-sm text-foreground truncate mb-2" title={task.name}>
|
||||
{task.name}
|
||||
</h3>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-3">
|
||||
<Badge variant={getStatusVariant(task.status)} className="gap-1">
|
||||
{getStatusIcon(task.status)}
|
||||
{formatMessage({ id: `coordinator.status.${task.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
<span className="font-medium">{task.progress.completed}</span>
|
||||
<span>/</span>
|
||||
<span>{task.progress.total}</span>
|
||||
<span className="ml-1">
|
||||
{formatMessage({ id: 'coordinator.taskCard.nodes' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
{displayTime && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{displayTime}</span>
|
||||
<span className="ml-1">
|
||||
{formatMessage({ id: 'coordinator.taskCard.started' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorTaskCard;
|
||||
@@ -1,140 +0,0 @@
|
||||
// ========================================
|
||||
// CoordinatorTaskList Component
|
||||
// ========================================
|
||||
// Horizontal scrolling task list with filter and sort controls
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Filter, ArrowUpDown, Inbox } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { CoordinatorTaskCard, TaskStatus } from './CoordinatorTaskCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type FilterOption = 'all' | 'running' | 'completed' | 'failed';
|
||||
export type SortOption = 'time' | 'name';
|
||||
|
||||
export interface CoordinatorTaskListProps {
|
||||
tasks: TaskStatus[];
|
||||
selectedTaskId: string | null;
|
||||
onTaskSelect: (taskId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal scrolling task list with filtering and sorting
|
||||
* Displays task cards in a row with overflow scroll
|
||||
*/
|
||||
export function CoordinatorTaskList({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onTaskSelect,
|
||||
className,
|
||||
}: CoordinatorTaskListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [filter, setFilter] = useState<FilterOption>('all');
|
||||
const [sort, setSort] = useState<SortOption>('time');
|
||||
|
||||
// Filter tasks
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = [...tasks];
|
||||
|
||||
// Apply filter
|
||||
if (filter !== 'all') {
|
||||
result = result.filter((task) => task.status === filter);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
result.sort((a, b) => {
|
||||
if (sort === 'time') {
|
||||
// Sort by start time (newest first), pending tasks last
|
||||
const timeA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const timeB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
} else {
|
||||
// Sort by name alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [tasks, filter, sort]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Controls Row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Select */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<Select value={filter} onValueChange={(v) => setFilter(v as FilterOption)}>
|
||||
<SelectTrigger className="w-[140px] h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{formatMessage({ id: 'coordinator.taskList.filter.all' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="running">
|
||||
{formatMessage({ id: 'coordinator.taskList.filter.running' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
{formatMessage({ id: 'coordinator.taskList.filter.completed' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
{formatMessage({ id: 'coordinator.taskList.filter.failed' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sort Select */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="w-4 h-4 text-muted-foreground" />
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
||||
<SelectTrigger className="w-[120px] h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="time">
|
||||
{formatMessage({ id: 'coordinator.taskList.sort.time' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="name">
|
||||
{formatMessage({ id: 'coordinator.taskList.sort.name' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Cards - Horizontal Scroll */}
|
||||
{filteredTasks.length > 0 ? (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
{filteredTasks.map((task) => (
|
||||
<CoordinatorTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={task.id === selectedTaskId}
|
||||
onClick={() => onTaskSelect(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{formatMessage({ id: 'coordinator.taskList.empty' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorTaskList;
|
||||
@@ -1,116 +0,0 @@
|
||||
// ========================================
|
||||
// CoordinatorTimeline Component
|
||||
// ========================================
|
||||
// Main horizontal timeline container for coordinator pipeline visualization
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCoordinatorStore, selectCommandChain, selectCurrentNode } from '@/stores/coordinatorStore';
|
||||
import { TimelineNode } from './TimelineNode';
|
||||
import { NodeConnector } from './NodeConnector';
|
||||
|
||||
export interface CoordinatorTimelineProps {
|
||||
className?: string;
|
||||
autoScroll?: boolean;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal scrolling timeline displaying the coordinator command chain
|
||||
* with connectors between nodes
|
||||
*/
|
||||
export function CoordinatorTimeline({
|
||||
className,
|
||||
autoScroll = true,
|
||||
onNodeClick,
|
||||
}: CoordinatorTimelineProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Store selectors
|
||||
const commandChain = useCoordinatorStore(selectCommandChain);
|
||||
const currentNode = useCoordinatorStore(selectCurrentNode);
|
||||
|
||||
// Auto-scroll to the current/latest node
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !scrollContainerRef.current) return;
|
||||
|
||||
// Find the active or latest node
|
||||
const activeNodeIndex = commandChain.findIndex(
|
||||
(node) => node.status === 'running' || node.id === currentNode?.id
|
||||
);
|
||||
|
||||
// If no active node, scroll to the end
|
||||
if (activeNodeIndex === -1) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll the active node into view
|
||||
const nodeElements = scrollContainerRef.current.querySelectorAll('[data-node-id]');
|
||||
const activeElement = nodeElements[activeNodeIndex] as HTMLElement;
|
||||
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
}
|
||||
}, [commandChain, currentNode?.id, autoScroll]);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
// Render empty state
|
||||
if (commandChain.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-8 text-muted-foreground', className)}>
|
||||
<div className="text-center">
|
||||
<p className="text-sm">No pipeline nodes to display</p>
|
||||
<p className="text-xs mt-1">Start a coordinator execution to see the pipeline</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
'flex items-center gap-0 p-4 overflow-x-auto overflow-y-hidden',
|
||||
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent',
|
||||
className
|
||||
)}
|
||||
role="region"
|
||||
aria-label="Coordinator pipeline timeline"
|
||||
>
|
||||
{commandChain.map((node, index) => (
|
||||
<div key={node.id} className="flex items-center" data-node-id={node.id}>
|
||||
{/* Timeline node */}
|
||||
<TimelineNode
|
||||
node={node}
|
||||
isActive={currentNode?.id === node.id}
|
||||
onClick={() => handleNodeClick(node.id)}
|
||||
/>
|
||||
|
||||
{/* Connector to next node (if not last) */}
|
||||
{index < commandChain.length - 1 && (
|
||||
<NodeConnector
|
||||
status={commandChain[index + 1].status}
|
||||
className="mx-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorTimeline;
|
||||
@@ -1,49 +0,0 @@
|
||||
// ========================================
|
||||
// NodeConnector Component
|
||||
// ========================================
|
||||
// Visual connector line between pipeline nodes with status-based styling
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NodeExecutionStatus } from '@/stores/coordinatorStore';
|
||||
|
||||
export interface NodeConnectorProps {
|
||||
status: NodeExecutionStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector line between timeline nodes
|
||||
* Changes color based on the status of the connected node
|
||||
*/
|
||||
export function NodeConnector({ status, className }: NodeConnectorProps) {
|
||||
// Determine connector color and animation based on status
|
||||
const getConnectorStyle = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-gradient-to-r from-green-500 to-green-400';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-red-500 to-red-400';
|
||||
case 'running':
|
||||
return 'bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
|
||||
case 'skipped':
|
||||
return 'bg-gradient-to-r from-yellow-400 to-yellow-300';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-16 h-1 shrink-0 transition-all duration-300',
|
||||
getConnectorStyle(),
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeConnector;
|
||||
@@ -1,254 +0,0 @@
|
||||
// ========================================
|
||||
// Node Details Panel Component
|
||||
// ========================================
|
||||
// Expandable panel showing node logs, error information, and retry/skip actions
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2, RotateCcw, SkipForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCoordinatorStore, type CommandNode } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface NodeDetailsPanelProps {
|
||||
node: CommandNode;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function NodeDetailsPanel({
|
||||
node,
|
||||
isExpanded = true,
|
||||
onToggle,
|
||||
}: NodeDetailsPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { retryNode, skipNode, logs } = useCoordinatorStore();
|
||||
const [expanded, setExpanded] = useState(isExpanded);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const logScrollRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Filter logs for this node
|
||||
const nodeLogs = logs.filter((log) => log.nodeId === node.id);
|
||||
|
||||
// Auto-scroll to latest log
|
||||
useEffect(() => {
|
||||
if (expanded && logScrollRef.current) {
|
||||
logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [expanded, nodeLogs]);
|
||||
|
||||
// Handle expand/collapse
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !expanded;
|
||||
setExpanded(newExpanded);
|
||||
onToggle?.(newExpanded);
|
||||
};
|
||||
|
||||
// Handle retry
|
||||
const handleRetry = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await retryNode(node.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry node:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle skip
|
||||
const handleSkip = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await skipNode(node.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to skip node:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600';
|
||||
case 'failed':
|
||||
return 'text-red-600';
|
||||
case 'running':
|
||||
return 'text-blue-600';
|
||||
case 'skipped':
|
||||
return 'text-yellow-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status label
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'coordinator.status.pending',
|
||||
running: 'coordinator.status.running',
|
||||
completed: 'coordinator.status.completed',
|
||||
failed: 'coordinator.status.failed',
|
||||
skipped: 'coordinator.status.skipped',
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="cursor-pointer" onClick={handleToggle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<button
|
||||
className="inline-flex items-center justify-center"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<CardTitle className="text-lg">
|
||||
{node.name}
|
||||
</CardTitle>
|
||||
<span className={cn('text-sm font-medium', getStatusColor(node.status))}>
|
||||
{formatMessage({ id: getStatusLabel(node.status) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{node.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{expanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Logs Section */}
|
||||
{nodeLogs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{formatMessage({ id: 'coordinator.logs' })}
|
||||
</h4>
|
||||
<pre
|
||||
ref={logScrollRef}
|
||||
className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[200px] whitespace-pre-wrap break-words font-mono"
|
||||
>
|
||||
{nodeLogs
|
||||
.map((log) => {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const levelLabel = `[${log.level.toUpperCase()}]`;
|
||||
return `${timestamp} ${levelLabel} ${log.message}`;
|
||||
})
|
||||
.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Information */}
|
||||
{node.error && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-red-600">
|
||||
{formatMessage({ id: 'coordinator.error' })}
|
||||
</h4>
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
{node.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Section */}
|
||||
{node.output && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{formatMessage({ id: 'coordinator.output' })}
|
||||
</h4>
|
||||
<pre className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[150px] whitespace-pre-wrap break-words font-mono">
|
||||
{node.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Information */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{node.startedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.startedAt' })}:
|
||||
</span>
|
||||
<p>{new Date(node.startedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{node.completedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.completedAt' })}:
|
||||
</span>
|
||||
<p>{new Date(node.completedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{node.status === 'failed' && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.retrying' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-2" />
|
||||
{formatMessage({ id: 'coordinator.retry' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSkip}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.skipping' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SkipForward className="w-3 h-3 mr-2" />
|
||||
{formatMessage({ id: 'coordinator.skip' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeDetailsPanel;
|
||||
@@ -1,279 +0,0 @@
|
||||
# Coordinator Components
|
||||
|
||||
## CoordinatorInputModal
|
||||
|
||||
Modal dialog for starting coordinator execution with task description and optional JSON parameters.
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { CoordinatorInputModal } from '@/components/coordinator';
|
||||
import { useState } from 'react';
|
||||
|
||||
function MyComponent() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
Start Coordinator
|
||||
</Button>
|
||||
|
||||
<CoordinatorInputModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Task Description**: Required text area (10-2000 characters)
|
||||
- **Parameters**: Optional JSON input
|
||||
- **Validation**: Real-time validation for description length and JSON format
|
||||
- **Loading State**: Displays loading indicator during submission
|
||||
- **Error Handling**: Shows appropriate error messages
|
||||
- **Internationalization**: Full i18n support (English/Chinese)
|
||||
- **Notifications**: Success/error toasts via useNotifications hook
|
||||
|
||||
### API Integration
|
||||
|
||||
The component integrates with:
|
||||
- **POST /api/coordinator/start**: Starts coordinator execution
|
||||
- **coordinatorStore**: Updates Zustand store state
|
||||
- **notificationStore**: Shows success/error notifications
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `open` | `boolean` | Yes | Controls modal visibility |
|
||||
| `onClose` | `() => void` | Yes | Callback when modal closes |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- **Task Description**:
|
||||
- Minimum length: 10 characters
|
||||
- Maximum length: 2000 characters
|
||||
- Required field
|
||||
|
||||
- **Parameters**:
|
||||
- Optional field
|
||||
- Must be valid JSON if provided
|
||||
|
||||
### Example Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"executionId": "exec-1738477200000-abc123def",
|
||||
"taskDescription": "Implement user authentication with JWT tokens",
|
||||
"parameters": {
|
||||
"timeout": 3600,
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Timeline View Components
|
||||
|
||||
Horizontal scrolling timeline visualization for coordinator command pipeline execution.
|
||||
|
||||
### CoordinatorTimeline
|
||||
|
||||
Main timeline container that displays the command chain with auto-scrolling to active nodes.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { CoordinatorTimeline } from '@/components/coordinator';
|
||||
|
||||
function MyComponent() {
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
console.log('Node clicked:', nodeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<CoordinatorTimeline
|
||||
autoScroll={true}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `className` | `string` | No | - | Additional CSS classes |
|
||||
| `autoScroll` | `boolean` | No | `true` | Auto-scroll to active/latest node |
|
||||
| `onNodeClick` | `(nodeId: string) => void` | No | - | Callback when node is clicked |
|
||||
|
||||
#### Features
|
||||
|
||||
- **Horizontal Scrolling**: Smooth horizontal scroll with mouse wheel
|
||||
- **Auto-scroll**: Automatically scrolls to the active or latest node
|
||||
- **Empty State**: Shows helpful message when no nodes are present
|
||||
- **Store Integration**: Uses `useCoordinatorStore` for state management
|
||||
|
||||
---
|
||||
|
||||
### TimelineNode
|
||||
|
||||
Individual node card displaying node status, timing, and expandable details.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { TimelineNode } from '@/components/coordinator';
|
||||
import type { CommandNode } from '@/stores/coordinatorStore';
|
||||
|
||||
function MyComponent({ node }: { node: CommandNode }) {
|
||||
return (
|
||||
<TimelineNode
|
||||
node={node}
|
||||
isActive={true}
|
||||
onClick={() => console.log('Clicked:', node.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `node` | `CommandNode` | Yes | Node data from coordinator store |
|
||||
| `isActive` | `boolean` | No | Whether this node is currently active |
|
||||
| `onClick` | `() => void` | No | Callback when node is clicked |
|
||||
| `className` | `string` | No | Additional CSS classes |
|
||||
|
||||
#### Features
|
||||
|
||||
- **Status Indicators**:
|
||||
- `completed`: Green checkmark icon
|
||||
- `failed`: Red X icon
|
||||
- `running`: Blue spinning loader
|
||||
- `pending`: Gray clock icon
|
||||
- `skipped`: Yellow X icon
|
||||
|
||||
- **Status Badges**:
|
||||
- Success (green)
|
||||
- Failed (red)
|
||||
- Running (blue)
|
||||
- Pending (gray outline)
|
||||
- Skipped (yellow)
|
||||
|
||||
- **Expandable Details**:
|
||||
- Error messages (red background)
|
||||
- Output text (scrollable pre)
|
||||
- Result JSON (formatted and scrollable)
|
||||
|
||||
- **Timing Information**:
|
||||
- Start time
|
||||
- Completion time
|
||||
- Duration calculation
|
||||
|
||||
- **Animations**:
|
||||
- Hover scale effect (scale-105)
|
||||
- Smooth transitions (300ms)
|
||||
- Active ring (ring-2 ring-primary)
|
||||
|
||||
---
|
||||
|
||||
### NodeConnector
|
||||
|
||||
Visual connector line between timeline nodes with status-based styling.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { NodeConnector } from '@/components/coordinator';
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<TimelineNode node={node1} />
|
||||
<NodeConnector status="completed" />
|
||||
<TimelineNode node={node2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `status` | `NodeExecutionStatus` | Yes | Status of the connected node |
|
||||
| `className` | `string` | No | Additional CSS classes |
|
||||
|
||||
#### Status Colors
|
||||
|
||||
| Status | Color | Animation |
|
||||
|--------|-------|-----------|
|
||||
| `completed` | Green gradient | None |
|
||||
| `failed` | Red gradient | None |
|
||||
| `running` | Blue gradient | Pulse animation |
|
||||
| `pending` | Gray gradient | None |
|
||||
| `skipped` | Yellow gradient | None |
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CoordinatorInputModal,
|
||||
CoordinatorTimeline,
|
||||
} from '@/components/coordinator';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
function CoordinatorDashboard() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
console.log('Node clicked:', nodeId);
|
||||
// Show node details panel, etc.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border p-4">
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
New Execution
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CoordinatorTimeline
|
||||
autoScroll={true}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Modal */}
|
||||
<CoordinatorInputModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Responsive**: Works on mobile and desktop
|
||||
- **Dark Mode**: Full dark mode support via Tailwind CSS
|
||||
- **Accessibility**: Proper ARIA labels and keyboard navigation
|
||||
- **Performance**: Smooth 60fps animations
|
||||
- **Mobile-first**: Touch-friendly interactions
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// ========================================
|
||||
// TimelineNode Component
|
||||
// ========================================
|
||||
// Individual node card in the coordinator pipeline timeline
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { CommandNode } from '@/stores/coordinatorStore';
|
||||
|
||||
export interface TimelineNodeProps {
|
||||
node: CommandNode;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual timeline node card with status indicator and expandable details
|
||||
*/
|
||||
export function TimelineNode({ node, isActive = false, onClick, className }: TimelineNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = () => {
|
||||
const iconClassName = 'h-5 w-5 shrink-0';
|
||||
switch (node.status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
|
||||
case 'failed':
|
||||
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
|
||||
case 'running':
|
||||
return <Loader2 className={cn(iconClassName, 'text-blue-500 animate-spin')} />;
|
||||
case 'skipped':
|
||||
return <XCircle className={cn(iconClassName, 'text-yellow-500')} />;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Clock className={cn(iconClassName, 'text-gray-400')} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status badge variant
|
||||
const getStatusBadge = () => {
|
||||
switch (node.status) {
|
||||
case 'completed':
|
||||
return <Badge variant="success">Success</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
case 'running':
|
||||
return <Badge variant="info">Running</Badge>;
|
||||
case 'skipped':
|
||||
return <Badge variant="warning">Skipped</Badge>;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (timestamp?: string) => {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
// Calculate duration
|
||||
const getDuration = () => {
|
||||
if (!node.startedAt || !node.completedAt) return null;
|
||||
const start = new Date(node.startedAt).getTime();
|
||||
const end = new Date(node.completedAt).getTime();
|
||||
const durationMs = end - start;
|
||||
|
||||
if (durationMs < 1000) return `${durationMs}ms`;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const handleToggleExpand = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const hasDetails = Boolean(node.output || node.error || node.result);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'w-64 shrink-0 cursor-pointer transition-all duration-300',
|
||||
'hover:shadow-lg hover:scale-105',
|
||||
isActive && 'ring-2 ring-primary',
|
||||
isExpanded && 'w-80',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{getStatusIcon()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-foreground truncate" title={node.name}>
|
||||
{node.name}
|
||||
</h4>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5" title={node.description}>
|
||||
{node.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0">
|
||||
{/* Timing information */}
|
||||
{(node.startedAt || node.completedAt) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 mb-2">
|
||||
{node.startedAt && (
|
||||
<div>Started: {formatTime(node.startedAt)}</div>
|
||||
)}
|
||||
{node.completedAt && (
|
||||
<div>Completed: {formatTime(node.completedAt)}</div>
|
||||
)}
|
||||
{getDuration() && (
|
||||
<div className="font-medium">Duration: {getDuration()}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse toggle for details */}
|
||||
{hasDetails && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors w-full"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Hide details
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Show details
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded details panel */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Error message */}
|
||||
{node.error && (
|
||||
<div className="p-2 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">
|
||||
Error:
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-300 break-words">
|
||||
{node.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output */}
|
||||
{Boolean(node.output) && (
|
||||
<div className="p-2 rounded-md bg-muted/50 border border-border">
|
||||
<div className="text-xs font-semibold text-foreground mb-1">
|
||||
Output:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||
{String(node.output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{Boolean(node.result) && (
|
||||
<div className="p-2 rounded-md bg-muted/50 border border-border">
|
||||
<div className="text-xs font-semibold text-foreground mb-1">
|
||||
Result:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||
{typeof node.result === 'object' && node.result !== null
|
||||
? JSON.stringify(node.result, null, 2)
|
||||
: String(node.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Command information */}
|
||||
{node.command && !isExpanded && (
|
||||
<div className="mt-2 text-xs text-muted-foreground truncate" title={node.command}>
|
||||
<span className="font-mono">{node.command}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineNode;
|
||||
@@ -1,32 +0,0 @@
|
||||
// Coordinator components
|
||||
export { CoordinatorInputModal } from './CoordinatorInputModal';
|
||||
export type { CoordinatorInputModalProps } from './CoordinatorInputModal';
|
||||
|
||||
// Timeline visualization components
|
||||
export { CoordinatorTimeline } from './CoordinatorTimeline';
|
||||
export type { CoordinatorTimelineProps } from './CoordinatorTimeline';
|
||||
|
||||
export { TimelineNode } from './TimelineNode';
|
||||
export type { TimelineNodeProps } from './TimelineNode';
|
||||
|
||||
export { NodeConnector } from './NodeConnector';
|
||||
export type { NodeConnectorProps } from './NodeConnector';
|
||||
|
||||
// Node interaction components
|
||||
export { NodeDetailsPanel } from './NodeDetailsPanel';
|
||||
export type { NodeDetailsPanelProps } from './NodeDetailsPanel';
|
||||
|
||||
export { CoordinatorLogStream } from './CoordinatorLogStream';
|
||||
export type { CoordinatorLogStreamProps } from './CoordinatorLogStream';
|
||||
|
||||
export { CoordinatorQuestionModal } from './CoordinatorQuestionModal';
|
||||
export type { CoordinatorQuestionModalProps } from './CoordinatorQuestionModal';
|
||||
|
||||
export { CoordinatorEmptyState } from './CoordinatorEmptyState';
|
||||
export type { CoordinatorEmptyStateProps } from './CoordinatorEmptyState';
|
||||
|
||||
export { CoordinatorTaskCard } from './CoordinatorTaskCard';
|
||||
export type { CoordinatorTaskCardProps, TaskStatus } from './CoordinatorTaskCard';
|
||||
|
||||
export { CoordinatorTaskList } from './CoordinatorTaskList';
|
||||
export type { CoordinatorTaskListProps, FilterOption, SortOption } from './CoordinatorTaskList';
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
FolderKanban,
|
||||
Workflow,
|
||||
Zap,
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
@@ -27,7 +26,6 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { useLiteTasks } from '@/hooks/useLiteTasks';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface RecentSessionsWidgetProps {
|
||||
@@ -36,7 +34,7 @@ export interface RecentSessionsWidgetProps {
|
||||
}
|
||||
|
||||
// Task type definitions
|
||||
type TaskType = 'all' | 'workflow' | 'lite' | 'orchestrator';
|
||||
type TaskType = 'all' | 'workflow' | 'lite';
|
||||
|
||||
// Unified task item for display
|
||||
interface UnifiedTaskItem {
|
||||
@@ -57,7 +55,6 @@ const TABS: { key: TaskType; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'all', label: 'home.tabs.allTasks', icon: FolderKanban },
|
||||
{ key: 'workflow', label: 'home.tabs.workflow', icon: Workflow },
|
||||
{ key: 'lite', label: 'home.tabs.liteTasks', icon: Zap },
|
||||
{ key: 'orchestrator', label: 'home.tabs.orchestrator', icon: Play },
|
||||
];
|
||||
|
||||
// Status icon mapping
|
||||
@@ -114,15 +111,13 @@ const typeColors: Record<TaskType, string> = {
|
||||
all: 'bg-muted text-muted-foreground',
|
||||
workflow: 'bg-primary/20 text-primary',
|
||||
lite: 'bg-amber-500/20 text-amber-600',
|
||||
orchestrator: 'bg-violet-500/20 text-violet-600',
|
||||
};
|
||||
|
||||
function TaskItemCard({ item, onClick }: { item: UnifiedTaskItem; onClick: () => void }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const StatusIcon = statusIcons[item.status] || Clock;
|
||||
const TypeIcon = item.subType ? (liteTypeIcons[item.subType] || Zap) :
|
||||
item.type === 'workflow' ? Workflow :
|
||||
item.type === 'orchestrator' ? Play : Zap;
|
||||
item.type === 'workflow' ? Workflow : Zap;
|
||||
|
||||
const isAnimated = item.status === 'in_progress' || item.status === 'running' || item.status === 'initializing';
|
||||
|
||||
@@ -226,9 +221,6 @@ function RecentSessionsWidgetComponent({
|
||||
// Fetch lite tasks
|
||||
const { allSessions: liteSessions, isLoading: liteLoading } = useLiteTasks();
|
||||
|
||||
// Get coordinator state
|
||||
const coordinatorState = useCoordinatorStore();
|
||||
|
||||
// Format relative time with fallback
|
||||
const formatRelativeTime = React.useCallback((dateStr: string | undefined): string => {
|
||||
if (!dateStr) return formatMessage({ id: 'common.time.justNow' });
|
||||
@@ -286,28 +278,9 @@ function RecentSessionsWidgetComponent({
|
||||
});
|
||||
});
|
||||
|
||||
// Add current coordinator execution if exists
|
||||
if (coordinatorState.currentExecutionId && coordinatorState.status !== 'idle') {
|
||||
const status = coordinatorState.status;
|
||||
const completedSteps = coordinatorState.commandChain.filter(n => n.status === 'completed').length;
|
||||
const totalSteps = coordinatorState.commandChain.length;
|
||||
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
|
||||
items.push({
|
||||
id: coordinatorState.currentExecutionId,
|
||||
name: coordinatorState.pipelineDetails?.nodes[0]?.name || 'Orchestrator Task',
|
||||
type: 'orchestrator',
|
||||
status,
|
||||
statusKey: statusI18nKeys[status] || status,
|
||||
createdAt: formatRelativeTime(coordinatorState.startedAt),
|
||||
description: `${completedSteps}/${totalSteps} steps completed`,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most recent (use original date for sorting, not formatted string)
|
||||
return items;
|
||||
}, [activeSessions, liteSessions, coordinatorState, formatRelativeTime]);
|
||||
}, [activeSessions, liteSessions, formatRelativeTime]);
|
||||
|
||||
// Filter items by tab
|
||||
const filteredItems = React.useMemo(() => {
|
||||
@@ -324,9 +297,6 @@ function RecentSessionsWidgetComponent({
|
||||
case 'lite':
|
||||
navigate(`/lite-tasks/${item.subType}/${item.id}`);
|
||||
break;
|
||||
case 'orchestrator':
|
||||
navigate(`/orchestrator`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||
{ path: '/coordinator', labelKey: 'navigation.main.coordinator', icon: GitFork },
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
],
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useNotificationStore } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import {
|
||||
OrchestratorMessageSchema,
|
||||
type OrchestratorWebSocketMessage,
|
||||
@@ -28,7 +27,6 @@ function getStoreState() {
|
||||
const execution = useExecutionStore.getState();
|
||||
const flow = useFlowStore.getState();
|
||||
const cliStream = useCliStreamStore.getState();
|
||||
const coordinator = useCoordinatorStore.getState();
|
||||
return {
|
||||
// Notification store
|
||||
setWsStatus: notification.setWsStatus,
|
||||
@@ -48,12 +46,6 @@ function getStoreState() {
|
||||
updateNode: flow.updateNode,
|
||||
// CLI stream store
|
||||
addOutput: cliStream.addOutput,
|
||||
// Coordinator store
|
||||
updateNodeStatus: coordinator.updateNodeStatus,
|
||||
addCoordinatorLog: coordinator.addLog,
|
||||
setActiveQuestion: coordinator.setActiveQuestion,
|
||||
markExecutionComplete: coordinator.markExecutionComplete,
|
||||
coordinatorExecutionId: coordinator.currentExecutionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,57 +157,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Coordinator messages
|
||||
if (data.type?.startsWith('COORDINATOR_')) {
|
||||
const { coordinatorExecutionId } = stores;
|
||||
// Only process messages for current coordinator execution
|
||||
if (coordinatorExecutionId && data.executionId !== coordinatorExecutionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch to coordinator store based on message type
|
||||
switch (data.type) {
|
||||
case 'COORDINATOR_STATE_UPDATE':
|
||||
// Check for completion
|
||||
if (data.status === 'completed') {
|
||||
stores.markExecutionComplete(true);
|
||||
} else if (data.status === 'failed') {
|
||||
stores.markExecutionComplete(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_STARTED':
|
||||
stores.updateNodeStatus(data.nodeId, 'running');
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_COMPLETED':
|
||||
stores.updateNodeStatus(data.nodeId, 'completed', data.result);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_FAILED':
|
||||
stores.updateNodeStatus(data.nodeId, 'failed', undefined, data.error);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_LOG_ENTRY':
|
||||
stores.addCoordinatorLog(
|
||||
data.log.message,
|
||||
data.log.level,
|
||||
data.log.nodeId,
|
||||
data.log.source
|
||||
);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_QUESTION_ASKED':
|
||||
stores.setActiveQuestion(data.question);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_ANSWER_RECEIVED':
|
||||
// Answer received - handled by submitAnswer in the store
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an orchestrator message
|
||||
if (!data.type?.startsWith('ORCHESTRATOR_')) {
|
||||
return;
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Coordinator",
|
||||
"status": "Status: {status}",
|
||||
"startButton": "Start Coordinator",
|
||||
"noNodeSelected": "Select a node to view details"
|
||||
},
|
||||
"taskDetail": {
|
||||
"title": "Task Details",
|
||||
"noSelection": "Select a task to view execution details"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Workflow Coordinator",
|
||||
"subtitle": "Intelligent task orchestration with real-time monitoring for complex workflows",
|
||||
"startButton": "Launch Coordinator",
|
||||
"feature1": {
|
||||
"title": "Intelligent Execution",
|
||||
"description": "Smart task orchestration with dependency management and parallel execution"
|
||||
},
|
||||
"feature2": {
|
||||
"title": "Real-time Monitoring",
|
||||
"description": "Pipeline visualization with detailed logs and execution metrics"
|
||||
},
|
||||
"feature3": {
|
||||
"title": "Flexible Control",
|
||||
"description": "Interactive control with retry, skip, and pause capabilities"
|
||||
},
|
||||
"quickStart": {
|
||||
"title": "Quick Start",
|
||||
"step1": "Click the 'Launch Coordinator' button to begin",
|
||||
"step2": "Describe your workflow task in natural language",
|
||||
"step3": "Monitor execution pipeline and interact with running tasks"
|
||||
}
|
||||
},
|
||||
"multiStep": {
|
||||
"step1": {
|
||||
"title": "Welcome to Coordinator",
|
||||
"subtitle": "Intelligent workflow orchestration for automated task execution",
|
||||
"feature1": { "title": "Intelligent Execution", "description": "Smart task orchestration with dependency management and parallel execution" },
|
||||
"feature2": { "title": "Real-time Monitoring", "description": "Pipeline visualization with detailed logs and execution metrics" },
|
||||
"feature3": { "title": "Flexible Control", "description": "Interactive control with retry, skip, and pause capabilities" }
|
||||
},
|
||||
"step2": {
|
||||
"title": "Configure Parameters",
|
||||
"subtitle": "Select a template or customize parameters",
|
||||
"templateLabel": "Select Template",
|
||||
"templates": {
|
||||
"featureDev": "Feature Development",
|
||||
"apiIntegration": "API Integration",
|
||||
"performanceOptimization": "Performance Optimization",
|
||||
"documentGeneration": "Document Generation"
|
||||
},
|
||||
"taskName": "Task Name",
|
||||
"taskNamePlaceholder": "Enter task name...",
|
||||
"taskDescription": "Task Description",
|
||||
"taskDescriptionPlaceholder": "Describe your task requirements in detail...",
|
||||
"customParameters": "Custom Parameters"
|
||||
},
|
||||
"progress": { "step": "Step {current} / {total}" },
|
||||
"actions": { "next": "Next", "back": "Back", "cancel": "Cancel", "submit": "Submit" }
|
||||
},
|
||||
"modal": {
|
||||
"title": "Start Coordinator",
|
||||
"description": "Describe the task you want the coordinator to execute"
|
||||
},
|
||||
"form": {
|
||||
"taskDescription": "Task Description",
|
||||
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (min 10 characters)...",
|
||||
"parameters": "Parameters (Optional)",
|
||||
"parametersPlaceholder": "{\"key\": \"value\"}",
|
||||
"parametersHelp": "Optional JSON parameters for coordinator execution",
|
||||
"characterCount": "{current} / {max} characters (min: {min})",
|
||||
"start": "Start Coordinator",
|
||||
"starting": "Starting..."
|
||||
},
|
||||
"validation": {
|
||||
"taskDescriptionRequired": "Task description is required",
|
||||
"taskDescriptionTooShort": "Task description must be at least 10 characters",
|
||||
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
|
||||
"parametersInvalidJson": "Parameters must be valid JSON",
|
||||
"answerRequired": "An answer is required"
|
||||
},
|
||||
"success": {
|
||||
"started": "Coordinator started successfully"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"logs": "Logs",
|
||||
"entries": "entries",
|
||||
"error": "Error",
|
||||
"output": "Output",
|
||||
"startedAt": "Started At",
|
||||
"completedAt": "Completed At",
|
||||
"retrying": "Retrying...",
|
||||
"retry": "Retry",
|
||||
"skipping": "Skipping...",
|
||||
"skip": "Skip",
|
||||
"logLevel": "Log Level",
|
||||
"level": {
|
||||
"all": "All",
|
||||
"info": "Info",
|
||||
"warn": "Warning",
|
||||
"error": "Error",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"noLogs": "No logs available",
|
||||
"question": {
|
||||
"answer": "Answer",
|
||||
"textPlaceholder": "Enter your answer...",
|
||||
"selectOne": "Select One",
|
||||
"selectMultiple": "Select Multiple",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"submitting": "Submitting...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"taskList": {
|
||||
"filter": {
|
||||
"all": "All Tasks",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"sort": {
|
||||
"time": "By Time",
|
||||
"name": "By Name"
|
||||
},
|
||||
"empty": "No tasks found"
|
||||
},
|
||||
"taskCard": {
|
||||
"nodes": "nodes",
|
||||
"started": "started"
|
||||
},
|
||||
"steps": "steps"
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import sessions from './sessions.json';
|
||||
import issues from './issues.json';
|
||||
import home from './home.json';
|
||||
import orchestrator from './orchestrator.json';
|
||||
import coordinator from './coordinator.json';
|
||||
import loops from './loops.json';
|
||||
import commands from './commands.json';
|
||||
import memory from './memory.json';
|
||||
@@ -71,7 +70,6 @@ export default {
|
||||
...flattenMessages(issues, 'issues'),
|
||||
...flattenMessages(home, 'home'),
|
||||
...flattenMessages(orchestrator, 'orchestrator'),
|
||||
...flattenMessages(coordinator, 'coordinator'),
|
||||
...flattenMessages(loops, 'loops'),
|
||||
...flattenMessages(commands, 'commands'),
|
||||
...flattenMessages(memory, 'memory'),
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "协调器",
|
||||
"status": "状态:{status}",
|
||||
"startButton": "启动协调器",
|
||||
"noNodeSelected": "选择节点以查看详细信息"
|
||||
},
|
||||
"taskDetail": {
|
||||
"title": "任务详情",
|
||||
"noSelection": "选择任务以查看执行详情"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "欢迎使用工作流协调器",
|
||||
"subtitle": "智能任务编排,实时执行监控,一站式管理复杂工作流",
|
||||
"startButton": "启动协调器",
|
||||
"feature1": {
|
||||
"title": "智能执行",
|
||||
"description": "依赖管理与并行执行的智能任务编排"
|
||||
},
|
||||
"feature2": {
|
||||
"title": "实时监控",
|
||||
"description": "流水线可视化,详细日志与执行指标"
|
||||
},
|
||||
"feature3": {
|
||||
"title": "灵活控制",
|
||||
"description": "支持重试、跳过和暂停的交互式控制"
|
||||
},
|
||||
"quickStart": {
|
||||
"title": "快速开始",
|
||||
"step1": "点击「启动协调器」按钮开始",
|
||||
"step2": "用自然语言描述您的工作流任务",
|
||||
"step3": "监控执行流水线,与运行中的任务交互"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"title": "启动协调器",
|
||||
"description": "描述您希望协调器执行的任务"
|
||||
},
|
||||
"multiStep": {
|
||||
"step1": {
|
||||
"title": "欢迎使用协调器",
|
||||
"subtitle": "智能工作流编排,助力任务自动化执行",
|
||||
"feature1": {
|
||||
"title": "智能执行",
|
||||
"description": "依赖管理与并行执行的智能任务编排"
|
||||
},
|
||||
"feature2": {
|
||||
"title": "实时监控",
|
||||
"description": "流水线可视化,详细日志与执行指标"
|
||||
},
|
||||
"feature3": {
|
||||
"title": "灵活控制",
|
||||
"description": "支持重试、跳过和暂停的交互式控制"
|
||||
}
|
||||
},
|
||||
"step2": {
|
||||
"title": "配置参数",
|
||||
"subtitle": "选择模板或自定义参数",
|
||||
"templateLabel": "选择模板",
|
||||
"templates": {
|
||||
"featureDev": "功能开发",
|
||||
"apiIntegration": "API 集成",
|
||||
"performanceOptimization": "性能优化",
|
||||
"documentGeneration": "文档生成"
|
||||
},
|
||||
"taskName": "任务名称",
|
||||
"taskNamePlaceholder": "输入任务名称...",
|
||||
"taskDescription": "任务描述",
|
||||
"taskDescriptionPlaceholder": "详细描述您的任务需求...",
|
||||
"customParameters": "自定义参数"
|
||||
},
|
||||
"progress": {
|
||||
"step": "步骤 {current} / {total}"
|
||||
},
|
||||
"actions": {
|
||||
"next": "下一步",
|
||||
"back": "返回",
|
||||
"cancel": "取消",
|
||||
"submit": "提交"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"taskDescription": "任务描述",
|
||||
"taskDescriptionPlaceholder": "描述协调器需要执行的任务(至少10个字符)...",
|
||||
"parameters": "参数(可选)",
|
||||
"parametersPlaceholder": "{\"key\": \"value\"}",
|
||||
"parametersHelp": "协调器执行的可选JSON参数",
|
||||
"characterCount": "{current} / {max} 字符(最少:{min})",
|
||||
"start": "启动协调器",
|
||||
"starting": "启动中..."
|
||||
},
|
||||
"validation": {
|
||||
"taskDescriptionRequired": "任务描述为必填项",
|
||||
"taskDescriptionTooShort": "任务描述至少需要10个字符",
|
||||
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
|
||||
"parametersInvalidJson": "参数必须是有效的JSON格式",
|
||||
"answerRequired": "答案为必填项"
|
||||
},
|
||||
"success": {
|
||||
"started": "协调器启动成功"
|
||||
},
|
||||
"status": {
|
||||
"pending": "待执行",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"skipped": "已跳过"
|
||||
},
|
||||
"logs": "日志",
|
||||
"entries": "条日志",
|
||||
"error": "错误",
|
||||
"output": "输出",
|
||||
"startedAt": "开始时间",
|
||||
"completedAt": "完成时间",
|
||||
"retrying": "重试中...",
|
||||
"retry": "重试",
|
||||
"skipping": "跳过中...",
|
||||
"skip": "跳过",
|
||||
"logLevel": "日志级别",
|
||||
"level": {
|
||||
"all": "全部",
|
||||
"info": "信息",
|
||||
"warn": "警告",
|
||||
"error": "错误",
|
||||
"debug": "调试"
|
||||
},
|
||||
"noLogs": "暂无日志",
|
||||
"question": {
|
||||
"answer": "回答",
|
||||
"textPlaceholder": "输入您的回答...",
|
||||
"selectOne": "单选",
|
||||
"selectMultiple": "多选",
|
||||
"confirm": "确认",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"submitting": "提交中...",
|
||||
"submit": "提交"
|
||||
},
|
||||
"taskList": {
|
||||
"filter": {
|
||||
"all": "全部任务",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
},
|
||||
"sort": {
|
||||
"time": "按时间",
|
||||
"name": "按名称"
|
||||
},
|
||||
"empty": "暂无任务"
|
||||
},
|
||||
"taskCard": {
|
||||
"nodes": "节点",
|
||||
"started": "开始"
|
||||
},
|
||||
"steps": "步"
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import sessions from './sessions.json';
|
||||
import issues from './issues.json';
|
||||
import home from './home.json';
|
||||
import orchestrator from './orchestrator.json';
|
||||
import coordinator from './coordinator.json';
|
||||
import loops from './loops.json';
|
||||
import commands from './commands.json';
|
||||
import memory from './memory.json';
|
||||
@@ -71,7 +70,6 @@ export default {
|
||||
...flattenMessages(issues, 'issues'),
|
||||
...flattenMessages(home, 'home'),
|
||||
...flattenMessages(orchestrator, 'orchestrator'),
|
||||
...flattenMessages(coordinator, 'coordinator'),
|
||||
...flattenMessages(loops, 'loops'),
|
||||
...flattenMessages(commands, 'commands'),
|
||||
...flattenMessages(memory, 'memory'),
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Page - Merged Layout
|
||||
// ========================================
|
||||
// Unified page for task list overview and execution details with timeline, logs, and node details
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
CoordinatorInputModal,
|
||||
CoordinatorTimeline,
|
||||
CoordinatorLogStream,
|
||||
NodeDetailsPanel,
|
||||
CoordinatorEmptyState,
|
||||
} from '@/components/coordinator';
|
||||
import {
|
||||
useCoordinatorStore,
|
||||
selectCommandChain,
|
||||
selectCurrentNode,
|
||||
selectCoordinatorStatus,
|
||||
selectIsPipelineLoaded,
|
||||
} from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
interface CoordinatorTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress: {
|
||||
completed: number;
|
||||
total: number;
|
||||
};
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Mock Data (temporary - will be replaced by store)
|
||||
// ========================================
|
||||
|
||||
const MOCK_TASKS: CoordinatorTask[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
name: 'Feature Auth',
|
||||
status: 'running',
|
||||
progress: { completed: 3, total: 5 },
|
||||
startedAt: '2026-02-03T14:23:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
name: 'API Integration',
|
||||
status: 'completed',
|
||||
progress: { completed: 8, total: 8 },
|
||||
startedAt: '2026-02-03T10:00:00Z',
|
||||
completedAt: '2026-02-03T10:15:00Z',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
name: 'Performance Test',
|
||||
status: 'failed',
|
||||
progress: { completed: 2, total: 6 },
|
||||
startedAt: '2026-02-03T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// Task Card Component (inline)
|
||||
// ========================================
|
||||
|
||||
interface TaskCardProps {
|
||||
task: CoordinatorTask;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TaskCard({ task, isSelected, onClick }: TaskCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const statusConfig = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: 'text-muted-foreground',
|
||||
bg: 'bg-muted/50',
|
||||
},
|
||||
running: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-500/10',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
bg: 'bg-green-500/10',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
bg: 'bg-red-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[task.status];
|
||||
const StatusIcon = config.icon;
|
||||
const progressPercent = Math.round((task.progress.completed / task.progress.total) * 100);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-col p-3 rounded-lg border transition-all text-left w-full min-w-[160px] max-w-[200px]',
|
||||
'hover:border-primary/50 hover:shadow-sm',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border bg-card'
|
||||
)}
|
||||
>
|
||||
{/* Task Name */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
'w-4 h-4 flex-shrink-0',
|
||||
config.color,
|
||||
task.status === 'running' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{task.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mb-2 w-fit',
|
||||
config.bg,
|
||||
config.color
|
||||
)}
|
||||
>
|
||||
{formatMessage({ id: `coordinator.status.${task.status}` })}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{task.progress.completed}/{task.progress.total}
|
||||
</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
task.status === 'completed' && 'bg-green-500',
|
||||
task.status === 'running' && 'bg-blue-500',
|
||||
task.status === 'failed' && 'bg-red-500',
|
||||
task.status === 'pending' && 'bg-muted-foreground'
|
||||
)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Main Component
|
||||
// ========================================
|
||||
|
||||
export function CoordinatorPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
|
||||
// Store selectors
|
||||
const commandChain = useCoordinatorStore(selectCommandChain);
|
||||
const currentNode = useCoordinatorStore(selectCurrentNode);
|
||||
const status = useCoordinatorStore(selectCoordinatorStatus);
|
||||
const isPipelineLoaded = useCoordinatorStore(selectIsPipelineLoaded);
|
||||
const syncStateFromServer = useCoordinatorStore((state) => state.syncStateFromServer);
|
||||
|
||||
// Mock tasks (temporary - will be replaced by store)
|
||||
const tasks = useMemo(() => MOCK_TASKS, []);
|
||||
const hasTasks = tasks.length > 0;
|
||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
|
||||
|
||||
// Sync state on mount (for page refresh scenarios)
|
||||
useEffect(() => {
|
||||
if (status === 'running' || status === 'paused' || status === 'initializing') {
|
||||
syncStateFromServer();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle open input modal
|
||||
const handleOpenInputModal = useCallback(() => {
|
||||
setIsInputModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle node click from timeline
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
setSelectedNode(nodeId);
|
||||
}, []);
|
||||
|
||||
// Handle task selection
|
||||
const handleTaskClick = useCallback((taskId: string) => {
|
||||
setSelectedTaskId((prev) => (prev === taskId ? null : taskId));
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
// Get selected node object
|
||||
const selectedNodeObject =
|
||||
commandChain.find((node) => node.id === selectedNode) || currentNode || null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col -m-4 md:-m-6">
|
||||
{/* ======================================== */}
|
||||
{/* Toolbar */}
|
||||
{/* ======================================== */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border-b border-border">
|
||||
{/* Page Title and Status */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Play className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'coordinator.page.title' })}
|
||||
</span>
|
||||
{isPipelineLoaded && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'coordinator.page.status' },
|
||||
{
|
||||
status: formatMessage({ id: `coordinator.status.${status}` }),
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleOpenInputModal}
|
||||
disabled={status === 'running' || status === 'initializing'}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'coordinator.page.startButton' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ======================================== */}
|
||||
{/* Main Content Area */}
|
||||
{/* ======================================== */}
|
||||
{!hasTasks ? (
|
||||
/* Empty State - No tasks */
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<CoordinatorEmptyState
|
||||
onStart={handleOpenInputModal}
|
||||
disabled={status === 'running' || status === 'initializing'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* ======================================== */}
|
||||
{/* Task List Area */}
|
||||
{/* ======================================== */}
|
||||
<div className="p-4 border-b border-border bg-background">
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ======================================== */}
|
||||
{/* Task Detail Area (shown when task is selected) */}
|
||||
{/* ======================================== */}
|
||||
{selectedTask ? (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel: Timeline */}
|
||||
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
|
||||
<CoordinatorTimeline
|
||||
autoScroll={true}
|
||||
onNodeClick={handleNodeClick}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Panel: Log Stream */}
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-card">
|
||||
<div className="flex-1 min-h-0">
|
||||
<CoordinatorLogStream />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Node Details */}
|
||||
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
|
||||
{selectedNodeObject ? (
|
||||
<NodeDetailsPanel
|
||||
node={selectedNodeObject}
|
||||
isExpanded={true}
|
||||
onToggle={(expanded) => {
|
||||
if (!expanded) {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
|
||||
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* No task selected - show selection prompt */
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.taskDetail.noSelection' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ======================================== */}
|
||||
{/* Coordinator Input Modal */}
|
||||
{/* ======================================== */}
|
||||
<CoordinatorInputModal
|
||||
open={isInputModalOpen}
|
||||
onClose={() => setIsInputModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorPage;
|
||||
@@ -1,6 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Page Export
|
||||
// ========================================
|
||||
// Barrel export for CoordinatorPage component
|
||||
|
||||
export { CoordinatorPage } from './CoordinatorPage';
|
||||
@@ -10,7 +10,6 @@ export { ProjectOverviewPage } from './ProjectOverviewPage';
|
||||
export { SessionDetailPage } from './SessionDetailPage';
|
||||
export { HistoryPage } from './HistoryPage';
|
||||
export { OrchestratorPage } from './orchestrator';
|
||||
export { CoordinatorPage } from './coordinator';
|
||||
export { LoopMonitorPage } from './LoopMonitorPage';
|
||||
export { IssueHubPage } from './IssueHubPage';
|
||||
export { QueuePage } from './QueuePage';
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
// ========================================
|
||||
// Execution Monitor
|
||||
// ========================================
|
||||
// Real-time execution monitoring panel with logs and controls
|
||||
// Right-side slide-out panel for real-time execution monitoring
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDownToLine,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -103,12 +102,12 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const logs = useExecutionStore((state) => state.logs);
|
||||
const nodeStates = useExecutionStore((state) => state.nodeStates);
|
||||
const isMonitorExpanded = useExecutionStore((state) => state.isMonitorExpanded);
|
||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
||||
const autoScrollLogs = useExecutionStore((state) => state.autoScrollLogs);
|
||||
const setMonitorExpanded = useExecutionStore((state) => state.setMonitorExpanded);
|
||||
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
|
||||
const startExecution = useExecutionStore((state) => state.startExecution);
|
||||
|
||||
// Local state for elapsed time (calculated from startedAt)
|
||||
// Local state for elapsed time
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
// Flow store state
|
||||
@@ -121,22 +120,17 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
const resumeExecution = useResumeExecution();
|
||||
const stopExecution = useStopExecution();
|
||||
|
||||
// Update elapsed time every second while running (calculated from startedAt)
|
||||
// Update elapsed time every second while running
|
||||
useEffect(() => {
|
||||
if (currentExecution?.status === 'running' && currentExecution.startedAt) {
|
||||
const calculateElapsed = () => {
|
||||
const startTime = new Date(currentExecution.startedAt).getTime();
|
||||
setElapsedMs(Date.now() - startTime);
|
||||
};
|
||||
|
||||
// Calculate immediately
|
||||
calculateElapsed();
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(calculateElapsed, 1000);
|
||||
return () => clearInterval(interval);
|
||||
} else if (currentExecution?.completedAt) {
|
||||
// Use final elapsed time from store when completed
|
||||
setElapsedMs(currentExecution.elapsedMs);
|
||||
} else if (!currentExecution) {
|
||||
setElapsedMs(0);
|
||||
@@ -153,10 +147,8 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
// Handle scroll to detect user scrolling
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!logsContainerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
}, []);
|
||||
|
||||
@@ -169,7 +161,6 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
// Handle execute
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!currentFlow) return;
|
||||
|
||||
try {
|
||||
const result = await executeFlow.mutateAsync(currentFlow.id);
|
||||
startExecution(result.execId, currentFlow.id);
|
||||
@@ -219,241 +210,200 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
const isPaused = currentExecution?.status === 'paused';
|
||||
const canExecute = currentFlow && !isExecuting && !isPaused;
|
||||
|
||||
if (!isMonitorPanelOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border bg-card transition-all duration-300',
|
||||
isMonitorExpanded ? 'h-64' : 'h-12',
|
||||
'w-80 border-l border-border bg-card flex flex-col h-full',
|
||||
'animate-in slide-in-from-right duration-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 h-12 border-b border-border cursor-pointer"
|
||||
onClick={() => setMonitorExpanded(!isMonitorExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Execution Monitor</span>
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate">Monitor</span>
|
||||
{currentExecution && (
|
||||
<>
|
||||
<Badge variant={getStatusBadgeVariant(currentExecution.status)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getStatusIcon(currentExecution.status)}
|
||||
{currentExecution.status}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedMs)}
|
||||
<Badge variant={getStatusBadgeVariant(currentExecution.status)} className="shrink-0">
|
||||
<span className="flex items-center gap-1">
|
||||
{getStatusIcon(currentExecution.status)}
|
||||
{currentExecution.status}
|
||||
</span>
|
||||
|
||||
{totalNodes > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completedNodes}/{totalNodes} nodes
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => setMonitorPanelOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Control buttons */}
|
||||
{canExecute && (
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
{canExecute && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleExecute}
|
||||
disabled={executeFlow.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Execute
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExecuting && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePause}
|
||||
disabled={pauseExecution.isPending}
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExecute();
|
||||
}}
|
||||
disabled={executeFlow.isPending}
|
||||
onClick={handleResume}
|
||||
disabled={resumeExecution.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Execute
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExecuting && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePause();
|
||||
}}
|
||||
disabled={pauseExecution.isPending}
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStop();
|
||||
}}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResume();
|
||||
}}
|
||||
disabled={resumeExecution.isPending}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStop();
|
||||
}}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMonitorExpanded(!isMonitorExpanded);
|
||||
}}
|
||||
>
|
||||
{isMonitorExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{currentExecution && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1 ml-auto">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isMonitorExpanded && (
|
||||
<div className="flex h-[calc(100%-3rem)]">
|
||||
{/* Progress bar */}
|
||||
{currentExecution && (
|
||||
<div className="absolute top-12 left-0 right-0 h-1 bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
{currentExecution && (
|
||||
<div className="h-1 bg-muted shrink-0">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs panel */}
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Logs container */}
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{currentExecution
|
||||
? 'Waiting for logs...'
|
||||
: 'Select a flow and click Execute to start'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'uppercase w-12 shrink-0',
|
||||
getLogLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
[{log.level}]
|
||||
</span>
|
||||
{log.nodeId && (
|
||||
<span className="text-purple-500 shrink-0">
|
||||
[{log.nodeId}]
|
||||
</span>
|
||||
)}
|
||||
<span className="text-foreground break-all">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{isUserScrolling && logs.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-3 right-3"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
{/* Node status */}
|
||||
{currentExecution && Object.keys(nodeStates).length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Node Status ({completedNodes}/{totalNodes})
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{Object.entries(nodeStates).map(([nodeId, state]) => (
|
||||
<div
|
||||
key={nodeId}
|
||||
className="flex items-center gap-2 text-xs p-1 rounded hover:bg-muted"
|
||||
>
|
||||
{state.status === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
|
||||
)}
|
||||
{state.status === 'completed' && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<AlertCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||
)}
|
||||
{state.status === 'pending' && (
|
||||
<Clock className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span className="truncate" title={nodeId}>
|
||||
{nodeId.slice(0, 24)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node states panel (collapsed by default) */}
|
||||
{currentExecution && Object.keys(nodeStates).length > 0 && (
|
||||
<div className="w-48 border-l border-border p-2 overflow-y-auto">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Node Status
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(nodeStates).map(([nodeId, state]) => (
|
||||
<div
|
||||
key={nodeId}
|
||||
className="flex items-center gap-2 text-xs p-1 rounded hover:bg-muted"
|
||||
{/* Logs */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-center">
|
||||
{currentExecution
|
||||
? 'Waiting for logs...'
|
||||
: 'Click Execute to start'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="flex gap-1.5">
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'uppercase w-10 shrink-0 text-[10px]',
|
||||
getLogLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{state.status === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
||||
)}
|
||||
{state.status === 'completed' && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<AlertCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
{state.status === 'pending' && (
|
||||
<Clock className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span className="truncate" title={nodeId}>
|
||||
{nodeId.slice(0, 20)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="text-foreground break-all text-[11px]">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{isUserScrolling && logs.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-3 right-3"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useExecutionStore, selectIsExecuting } from '@/stores/executionStore';
|
||||
import type { FlowNode, FlowEdge } from '@/types/flow';
|
||||
|
||||
// Custom node types (enhanced with execution status in IMPL-A8)
|
||||
@@ -36,6 +37,9 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
// Execution state - lock canvas during execution
|
||||
const isExecuting = useExecutionStore(selectIsExecuting);
|
||||
|
||||
// Get state and actions from store
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const edges = useFlowStore((state) => state.edges);
|
||||
@@ -68,6 +72,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
// Handle new edge connections
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (isExecuting) return;
|
||||
if (connection.source && connection.target) {
|
||||
const newEdge: FlowEdge = {
|
||||
id: `edge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -80,7 +85,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
markModified();
|
||||
}
|
||||
},
|
||||
[edges, setEdges, markModified]
|
||||
[edges, setEdges, markModified, isExecuting]
|
||||
);
|
||||
|
||||
// Handle node selection
|
||||
@@ -115,6 +120,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
if (isExecuting) return;
|
||||
|
||||
// Verify the drop is from node palette
|
||||
const nodeType = event.dataTransfer.getData('application/reactflow-node-type');
|
||||
@@ -138,7 +144,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
addNode(position);
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition, addNode, addNodeFromTemplate]
|
||||
[screenToFlowPosition, addNode, addNodeFromTemplate, isExecuting]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -155,10 +161,13 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={!isExecuting}
|
||||
nodesConnectable={!isExecuting}
|
||||
elementsSelectable={!isExecuting}
|
||||
deleteKeyCode={isExecuting ? null : ['Backspace', 'Delete']}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
className="bg-background"
|
||||
>
|
||||
<Controls
|
||||
@@ -179,6 +188,14 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Execution lock overlay */}
|
||||
{isExecuting && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 px-3 py-1.5 bg-primary/90 text-primary-foreground rounded-full text-xs font-medium shadow-lg flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-primary-foreground animate-pulse" />
|
||||
Execution in progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ========================================
|
||||
// Flow Toolbar Component
|
||||
// ========================================
|
||||
// Toolbar for flow operations: Save, Load, Import Template, Export, Simulate, Run
|
||||
// Toolbar for flow operations: Save, Load, Import Template, Export, Run, Monitor
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
ChevronDown,
|
||||
Library,
|
||||
Play,
|
||||
FlaskConical,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useFlowStore, toast } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useExecuteFlow } from '@/hooks/useFlows';
|
||||
import type { Flow } from '@/types/flow';
|
||||
|
||||
interface FlowToolbarProps {
|
||||
@@ -46,6 +48,18 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
|
||||
// Execution store
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
||||
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
|
||||
const startExecution = useExecutionStore((state) => state.startExecution);
|
||||
|
||||
// Mutations
|
||||
const executeFlow = useExecuteFlow();
|
||||
|
||||
const isExecuting = currentExecution?.status === 'running';
|
||||
const isPaused = currentExecution?.status === 'paused';
|
||||
|
||||
// Load flows on mount
|
||||
useEffect(() => {
|
||||
fetchFlows();
|
||||
@@ -161,6 +175,25 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
toast.success('Flow Exported', 'Flow exported as JSON file');
|
||||
}, [currentFlow]);
|
||||
|
||||
// Handle run workflow
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!currentFlow) return;
|
||||
try {
|
||||
// Open monitor panel automatically
|
||||
setMonitorPanelOpen(true);
|
||||
const result = await executeFlow.mutateAsync(currentFlow.id);
|
||||
startExecution(result.execId, currentFlow.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute flow:', error);
|
||||
toast.error('Execution Failed', 'Could not start flow execution');
|
||||
}
|
||||
}, [currentFlow, executeFlow, startExecution, setMonitorPanelOpen]);
|
||||
|
||||
// Handle monitor toggle
|
||||
const handleToggleMonitor = useCallback(() => {
|
||||
setMonitorPanelOpen(!isMonitorPanelOpen);
|
||||
}, [isMonitorPanelOpen, setMonitorPanelOpen]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
||||
{/* Flow Icon and Name */}
|
||||
@@ -294,14 +327,28 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Run Group */}
|
||||
<Button variant="outline" size="sm" disabled title="Coming soon">
|
||||
<FlaskConical className="w-4 h-4 mr-1" />
|
||||
Simulate
|
||||
{/* Run & Monitor Group */}
|
||||
<Button
|
||||
variant={isMonitorPanelOpen ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleToggleMonitor}
|
||||
title="Toggle execution monitor"
|
||||
>
|
||||
<Activity className={cn('w-4 h-4 mr-1', (isExecuting || isPaused) && 'text-primary animate-pulse')} />
|
||||
Monitor
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" disabled title="Coming soon">
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={!currentFlow || isExecuting || isPaused || executeFlow.isPending}
|
||||
>
|
||||
{executeFlow.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Run Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// ========================================
|
||||
// Node Library Component
|
||||
// ========================================
|
||||
// Displays quick templates organized by category (phase / tool / command)
|
||||
// Extracted from NodePalette for use inside LeftSidebar
|
||||
// Displays built-in and custom node templates
|
||||
// Supports creating, saving, and deleting custom templates with color selection
|
||||
|
||||
import { DragEvent, useState } from 'react';
|
||||
import {
|
||||
MessageSquare, ChevronDown, ChevronRight, GripVertical,
|
||||
Search, Code, Terminal, Plus,
|
||||
FolderOpen, Database, ListTodo, Play, CheckCircle,
|
||||
FolderSearch, GitMerge, ListChecks,
|
||||
Terminal, Plus, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlowStore } from '@/stores';
|
||||
@@ -19,62 +17,59 @@ import type { QuickTemplate } from '@/types/flow';
|
||||
// ========== Icon Mapping ==========
|
||||
|
||||
const TEMPLATE_ICONS: Record<string, React.ElementType> = {
|
||||
// Command templates
|
||||
'slash-command-main': Terminal,
|
||||
'slash-command-async': Terminal,
|
||||
analysis: Search,
|
||||
implementation: Code,
|
||||
// Phase templates
|
||||
'phase-session': FolderOpen,
|
||||
'phase-context': Database,
|
||||
'phase-plan': ListTodo,
|
||||
'phase-execute': Play,
|
||||
'phase-review': CheckCircle,
|
||||
// Tool templates
|
||||
'tool-context-gather': FolderSearch,
|
||||
'tool-conflict-resolution': GitMerge,
|
||||
'tool-task-generate': ListChecks,
|
||||
};
|
||||
|
||||
// ========== Category Configuration ==========
|
||||
// ========== Color Palette for custom templates ==========
|
||||
|
||||
const CATEGORY_CONFIG: Record<QuickTemplate['category'], { title: string; defaultExpanded: boolean }> = {
|
||||
phase: { title: '\u9636\u6BB5\u8282\u70B9', defaultExpanded: true },
|
||||
tool: { title: '\u5DE5\u5177\u8282\u70B9', defaultExpanded: true },
|
||||
command: { title: '\u547D\u4EE4', defaultExpanded: false },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: QuickTemplate['category'][] = ['phase', 'tool', 'command'];
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-purple-500', label: 'Purple' },
|
||||
{ value: 'bg-rose-500', label: 'Rose' },
|
||||
{ value: 'bg-amber-500', label: 'Amber' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-teal-500', label: 'Teal' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-pink-500', label: 'Pink' },
|
||||
];
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
/**
|
||||
* Collapsible category section
|
||||
* Collapsible category section with optional action button
|
||||
*/
|
||||
function TemplateCategory({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 flex-1 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
{action}
|
||||
</div>
|
||||
|
||||
{isExpanded && <div className="space-y-2">{children}</div>}
|
||||
</div>
|
||||
@@ -86,8 +81,10 @@ function TemplateCategory({
|
||||
*/
|
||||
function QuickTemplateCard({
|
||||
template,
|
||||
onDelete,
|
||||
}: {
|
||||
template: QuickTemplate;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const Icon = TEMPLATE_ICONS[template.id] || MessageSquare;
|
||||
|
||||
@@ -110,17 +107,26 @@ function QuickTemplateCard({
|
||||
className={cn(
|
||||
'group flex items-center gap-3 p-3 rounded-lg border bg-card cursor-grab transition-all',
|
||||
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
|
||||
`border-${template.color.replace('bg-', '')}`
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-2 rounded-md text-white', template.color)}>
|
||||
<div className={cn('p-2 rounded-md text-white shrink-0', template.color)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{template.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
|
||||
</div>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
{onDelete ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:text-destructive"
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +170,108 @@ function BasicTemplateCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline form for creating a new custom template
|
||||
*/
|
||||
function CreateTemplateForm({ onClose }: { onClose: () => void }) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [instruction, setInstruction] = useState('');
|
||||
const [color, setColor] = useState('bg-blue-500');
|
||||
const addCustomTemplate = useFlowStore((s) => s.addCustomTemplate);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim()) return;
|
||||
|
||||
const template: QuickTemplate = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label: label.trim(),
|
||||
description: description.trim() || label.trim(),
|
||||
icon: 'MessageSquare',
|
||||
color,
|
||||
category: 'command',
|
||||
data: {
|
||||
label: label.trim(),
|
||||
instruction: instruction.trim(),
|
||||
contextRefs: [],
|
||||
},
|
||||
};
|
||||
|
||||
addCustomTemplate(template);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-primary/50 bg-muted/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">New Custom Node</span>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Node name"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
placeholder="Default instruction (optional)"
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
/>
|
||||
|
||||
{/* Color picker */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1.5">Color</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COLOR_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setColor(opt.value)}
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full transition-all',
|
||||
opt.value,
|
||||
color === opt.value
|
||||
? 'ring-2 ring-offset-2 ring-offset-background ring-primary scale-110'
|
||||
: 'hover:scale-110',
|
||||
)}
|
||||
title={opt.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!label.trim()}
|
||||
className={cn(
|
||||
'w-full text-sm font-medium py-1.5 rounded transition-colors',
|
||||
label.trim()
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface NodeLibraryProps {
|
||||
@@ -171,36 +279,53 @@ interface NodeLibraryProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Node library panel displaying quick templates grouped by category.
|
||||
* Renders a scrollable list of template cards organized into collapsible sections.
|
||||
* Used inside LeftSidebar - does not manage its own header/footer/collapse state.
|
||||
* Node library panel displaying built-in and custom node templates.
|
||||
* Built-in: Slash Command, Slash Command (Async), Prompt Template
|
||||
* Custom: User-created templates persisted to localStorage
|
||||
*/
|
||||
export function NodeLibrary({ className }: NodeLibraryProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const customTemplates = useFlowStore((s) => s.customTemplates);
|
||||
const removeCustomTemplate = useFlowStore((s) => s.removeCustomTemplate);
|
||||
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-y-auto p-4 space-y-4', className)}>
|
||||
{/* Basic / Empty Template */}
|
||||
<TemplateCategory title="Basic" defaultExpanded={false}>
|
||||
{/* Built-in templates */}
|
||||
<TemplateCategory title="Built-in" defaultExpanded>
|
||||
<BasicTemplateCard />
|
||||
{QUICK_TEMPLATES.map((template) => (
|
||||
<QuickTemplateCard key={template.id} template={template} />
|
||||
))}
|
||||
</TemplateCategory>
|
||||
|
||||
{/* Category groups in order: phase -> tool -> command */}
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const templates = QUICK_TEMPLATES.filter((t) => t.category === category);
|
||||
if (templates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<TemplateCategory
|
||||
key={category}
|
||||
title={config.title}
|
||||
defaultExpanded={config.defaultExpanded}
|
||||
{/* Custom templates */}
|
||||
<TemplateCategory
|
||||
title={`Custom (${customTemplates.length})`}
|
||||
defaultExpanded
|
||||
action={
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Create custom node"
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<QuickTemplateCard key={template.id} template={template} />
|
||||
))}
|
||||
</TemplateCategory>
|
||||
);
|
||||
})}
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{isCreating && <CreateTemplateForm onClose={() => setIsCreating(false)} />}
|
||||
{customTemplates.map((template) => (
|
||||
<QuickTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onDelete={() => removeCustomTemplate(template.id)}
|
||||
/>
|
||||
))}
|
||||
{customTemplates.length === 0 && !isCreating && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
No custom nodes yet. Click + to create.
|
||||
</div>
|
||||
)}
|
||||
</TemplateCategory>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { FlowCanvas } from './FlowCanvas';
|
||||
import { LeftSidebar } from './LeftSidebar';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { FlowToolbar } from './FlowToolbar';
|
||||
import { TemplateLibrary } from './TemplateLibrary';
|
||||
import { ExecutionMonitor } from './ExecutionMonitor';
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
||||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||
|
||||
// Load flows on mount
|
||||
@@ -26,7 +29,7 @@ export function OrchestratorPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col -m-4 md:-m-6">
|
||||
<div className="h-[calc(100%+2rem)] md:h-[calc(100%+3rem)] flex flex-col -m-4 md:-m-6">
|
||||
{/* Toolbar */}
|
||||
<FlowToolbar onOpenTemplateLibrary={handleOpenTemplateLibrary} />
|
||||
|
||||
@@ -40,8 +43,11 @@ export function OrchestratorPage() {
|
||||
<FlowCanvas className="absolute inset-0" />
|
||||
</div>
|
||||
|
||||
{/* Property Panel (Right) */}
|
||||
<PropertyPanel />
|
||||
{/* Property Panel (Right) - hidden when monitor is open */}
|
||||
{!isMonitorPanelOpen && <PropertyPanel />}
|
||||
|
||||
{/* Execution Monitor Panel (Right) */}
|
||||
<ExecutionMonitor />
|
||||
</div>
|
||||
|
||||
{/* Template Library Dialog */}
|
||||
@@ -52,5 +58,3 @@ export function OrchestratorPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrchestratorPage;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save, Copy, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save, ChevronDown, ChevronRight, BookmarkPlus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -338,7 +338,7 @@ interface TagEditorProps {
|
||||
/**
|
||||
* Token types for the editor
|
||||
*/
|
||||
type TokenType = 'text' | 'variable';
|
||||
type TokenType = 'text' | 'variable' | 'artifact';
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
@@ -347,21 +347,27 @@ interface Token {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse text into tokens (text segments and variables)
|
||||
* Parse text into tokens (text segments, {{variables}}, and [[artifacts]])
|
||||
*/
|
||||
function tokenize(text: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
const regex = /\{\{([^}]+)\}\}/g;
|
||||
// Match both {{variable}} and [[artifact]] patterns
|
||||
const regex = /\{\{([^}]+)\}\}|\[\[([^\]]+)\]\]/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before variable
|
||||
// Add text before token
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Add variable token
|
||||
tokens.push({ type: 'variable', value: match[1].trim() });
|
||||
if (match[1] !== undefined) {
|
||||
// {{variable}} match
|
||||
tokens.push({ type: 'variable', value: match[1].trim() });
|
||||
} else if (match[2] !== undefined) {
|
||||
// [[artifact]] match
|
||||
tokens.push({ type: 'artifact', value: match[2].trim() });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
@@ -381,6 +387,14 @@ function extractVariables(text: string): string[] {
|
||||
return [...new Set(matches.map(m => m.slice(2, -2).trim()))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique artifact names from text
|
||||
*/
|
||||
function extractArtifacts(text: string): string[] {
|
||||
const matches = text.match(/\[\[([^\]]+)\]\]/g) || [];
|
||||
return [...new Set(matches.map(m => m.slice(2, -2).trim()))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag-based instruction editor with inline variable tags
|
||||
*/
|
||||
@@ -388,11 +402,13 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [newVarName, setNewVarName] = useState('');
|
||||
const [newArtifactName, setNewArtifactName] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [customTemplates, setCustomTemplates] = useState<TemplateItem[]>(() => loadCustomTemplates());
|
||||
|
||||
const tokens = useMemo(() => tokenize(value || ''), [value]);
|
||||
const detectedVars = useMemo(() => extractVariables(value || ''), [value]);
|
||||
const detectedArtifacts = useMemo(() => extractArtifacts(value || ''), [value]);
|
||||
const hasContent = (value || '').length > 0;
|
||||
|
||||
// All templates (builtin + custom)
|
||||
@@ -413,7 +429,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
}, [customTemplates]);
|
||||
|
||||
// Handle content changes from contenteditable
|
||||
// Convert tag elements back to {{variable}} format for storage
|
||||
// Convert tag elements back to {{variable}} / [[artifact]] format for storage
|
||||
const handleInput = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
// Clone the content to avoid modifying the actual DOM
|
||||
@@ -428,6 +444,15 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
}
|
||||
});
|
||||
|
||||
// Convert artifact tags back to [[artifact]] format
|
||||
const artTags = clone.querySelectorAll('[data-artifact]');
|
||||
artTags.forEach((tag) => {
|
||||
const artName = tag.getAttribute('data-artifact');
|
||||
if (artName) {
|
||||
tag.replaceWith(`[[${artName}]]`);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert <br> to newlines
|
||||
clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
|
||||
@@ -453,6 +478,15 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Insert artifact at cursor position
|
||||
const insertArtifact = useCallback((artName: string) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
const artText = `[[${artName}]]`;
|
||||
document.execCommand('insertText', false, artText);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Insert text at cursor position (or append if no focus)
|
||||
const insertText = useCallback((text: string) => {
|
||||
if (editorRef.current) {
|
||||
@@ -469,6 +503,14 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
}
|
||||
}, [newVarName, insertVariable]);
|
||||
|
||||
// Add new artifact
|
||||
const handleAddArtifact = useCallback(() => {
|
||||
if (newArtifactName.trim()) {
|
||||
insertArtifact(newArtifactName.trim());
|
||||
setNewArtifactName('');
|
||||
}
|
||||
}, [newArtifactName, insertArtifact]);
|
||||
|
||||
// Handle key press in new variable input
|
||||
const handleVarInputKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -477,20 +519,22 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
}
|
||||
}, [handleAddVariable]);
|
||||
|
||||
// Render tokens as HTML - variables show as tags without {{}}
|
||||
// Render tokens as HTML - variables show as green tags, artifacts as blue tags
|
||||
const renderContent = useMemo(() => {
|
||||
if (!hasContent) return '';
|
||||
|
||||
return tokens.map((token) => {
|
||||
if (token.type === 'variable') {
|
||||
const isValid = availableVariables.includes(token.value) || token.value.includes('.');
|
||||
// Show only variable name in tag, no {{}}
|
||||
return `<span class="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded text-xs font-semibold align-baseline cursor-default select-none ${
|
||||
isValid
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
|
||||
}" contenteditable="false" data-var="${token.value}">${token.value}</span>`;
|
||||
}
|
||||
if (token.type === 'artifact') {
|
||||
return `<span class="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded text-xs font-semibold align-baseline cursor-default select-none bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300" contenteditable="false" data-artifact="${token.value}">\u2192 ${token.value}</span>`;
|
||||
}
|
||||
// Escape HTML in text and preserve whitespace
|
||||
return token.value
|
||||
.replace(/&/g, '&')
|
||||
@@ -535,7 +579,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Variable toolbar */}
|
||||
{/* Variable & Artifact toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-2 rounded-md bg-muted/30 border border-border">
|
||||
{/* Add new variable input */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -543,8 +587,8 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
value={newVarName}
|
||||
onChange={(e) => setNewVarName(e.target.value)}
|
||||
onKeyDown={handleVarInputKeyDown}
|
||||
placeholder="变量名"
|
||||
className="h-7 w-24 text-xs font-mono"
|
||||
placeholder="{{变量}}"
|
||||
className="h-7 w-20 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -560,9 +604,31 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
{/* Add new artifact input */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={newArtifactName}
|
||||
onChange={(e) => setNewArtifactName(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { e.preventDefault(); handleAddArtifact(); } }}
|
||||
placeholder="[[产物]]"
|
||||
className="h-7 w-20 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddArtifact}
|
||||
disabled={!newArtifactName.trim()}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick insert available variables */}
|
||||
{availableVariables.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">可用:</span>
|
||||
{availableVariables.slice(0, 5).map((varName) => (
|
||||
<button
|
||||
@@ -581,7 +647,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
{detectedVars.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">已用:</span>
|
||||
<span className="text-xs text-muted-foreground">变量:</span>
|
||||
{detectedVars.map((varName) => {
|
||||
const isValid = availableVariables.includes(varName) || varName.includes('.');
|
||||
return (
|
||||
@@ -601,6 +667,22 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detected artifacts summary */}
|
||||
{detectedArtifacts.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">产物:</span>
|
||||
{detectedArtifacts.map((artName) => (
|
||||
<span
|
||||
key={artName}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-mono bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
|
||||
>
|
||||
{'\u2192'} {artName}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates - categorized */}
|
||||
@@ -906,56 +988,6 @@ function ArtifactsList({ artifacts, onChange }: { artifacts: string[]; onChange:
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Script Preview ==========
|
||||
|
||||
function ScriptPreview({ data }: { data: PromptTemplateNodeData }) {
|
||||
const script = useMemo(() => {
|
||||
// Slash command mode
|
||||
if (data.slashCommand) {
|
||||
const args = data.slashArgs ? ` ${data.slashArgs}` : '';
|
||||
return `/${data.slashCommand}${args}`;
|
||||
}
|
||||
|
||||
// CLI tool mode
|
||||
if (data.tool && (data.mode === 'analysis' || data.mode === 'write')) {
|
||||
const parts = ['ccw cli'];
|
||||
parts.push(`--tool ${data.tool}`);
|
||||
parts.push(`--mode ${data.mode}`);
|
||||
if (data.instruction) {
|
||||
const snippet = data.instruction.slice(0, 80).replace(/\n/g, ' ');
|
||||
parts.push(`-p "${snippet}..."`);
|
||||
}
|
||||
return parts.join(' \\\n ');
|
||||
}
|
||||
|
||||
// Plain instruction
|
||||
if (data.instruction) {
|
||||
return `# ${data.instruction.slice(0, 100)}`;
|
||||
}
|
||||
|
||||
return '# 未配置命令';
|
||||
}, [data.slashCommand, data.slashArgs, data.tool, data.mode, data.instruction]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(script);
|
||||
}, [script]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre className="p-3 rounded-md bg-muted/50 font-mono text-xs text-foreground/80 overflow-x-auto whitespace-pre-wrap border border-border">
|
||||
{script}
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Unified PromptTemplate Property Editor ==========
|
||||
|
||||
interface PromptTemplatePropertiesProps {
|
||||
@@ -1030,7 +1062,11 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
||||
</label>
|
||||
<TagEditor
|
||||
value={data.instruction || ''}
|
||||
onChange={(value) => onChange({ instruction: value })}
|
||||
onChange={(value) => {
|
||||
// Auto-extract [[artifact]] names and sync to artifacts field
|
||||
const arts = extractArtifacts(value);
|
||||
onChange({ instruction: value, artifacts: arts.length > 0 ? arts : undefined });
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
|
||||
minHeight={120}
|
||||
availableVariables={availableVariables}
|
||||
@@ -1052,23 +1088,6 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phase */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">阶段</label>
|
||||
<select
|
||||
value={data.phase || ''}
|
||||
onChange={(e) => onChange({ phase: (e.target.value || undefined) as PromptTemplateNodeData['phase'] })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option value="session">Session</option>
|
||||
<option value="context">Context</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="execute">Execute</option>
|
||||
<option value="review">Review</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">标签</label>
|
||||
@@ -1101,11 +1120,104 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Script Preview Section */}
|
||||
<CollapsibleSection title="脚本预览" defaultExpanded={true}>
|
||||
<ScriptPreview data={data} />
|
||||
</CollapsibleSection>
|
||||
// ========== Save As Template Button ==========
|
||||
|
||||
const SAVE_COLOR_OPTIONS = [
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-purple-500', label: 'Purple' },
|
||||
{ value: 'bg-rose-500', label: 'Rose' },
|
||||
{ value: 'bg-amber-500', label: 'Amber' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-teal-500', label: 'Teal' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
];
|
||||
|
||||
function SaveAsTemplateButton({ nodeId, nodeLabel }: { nodeId: string; nodeLabel: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [color, setColor] = useState('bg-blue-500');
|
||||
const saveNodeAsTemplate = useFlowStore((s) => s.saveNodeAsTemplate);
|
||||
const addCustomTemplate = useFlowStore((s) => s.addCustomTemplate);
|
||||
const nodes = useFlowStore((s) => s.nodes);
|
||||
|
||||
const handleSave = () => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node || !name.trim()) return;
|
||||
|
||||
const { executionStatus, executionError, executionResult, ...templateData } = node.data;
|
||||
addCustomTemplate({
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label: name.trim(),
|
||||
description: desc.trim() || name.trim(),
|
||||
icon: 'MessageSquare',
|
||||
color,
|
||||
category: 'command',
|
||||
data: { ...templateData, label: name.trim() },
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setName('');
|
||||
setDesc('');
|
||||
setColor('bg-blue-500');
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => { setName(nodeLabel); setIsOpen(true); }}
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4 mr-2" />
|
||||
Save to Node Library
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2 rounded-md border border-primary/50 bg-muted/50 space-y-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Template name"
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SAVE_COLOR_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setColor(opt.value)}
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-full transition-all',
|
||||
opt.value,
|
||||
color === opt.value ? 'ring-2 ring-offset-1 ring-offset-background ring-primary' : '',
|
||||
)}
|
||||
title={opt.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1" onClick={handleSave} disabled={!name.trim()}>
|
||||
<Save className="w-3.5 h-3.5 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1218,8 +1330,9 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
{/* Footer Actions */}
|
||||
<div className="px-4 py-3 border-t border-border space-y-2">
|
||||
<SaveAsTemplateButton nodeId={selectedNodeId!} nodeLabel={selectedNode.data.label} />
|
||||
<Button variant="destructive" className="w-full" onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.deleteNode' })}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SessionDetailPage,
|
||||
HistoryPage,
|
||||
OrchestratorPage,
|
||||
CoordinatorPage,
|
||||
LoopMonitorPage,
|
||||
IssueHubPage,
|
||||
IssueManagerPage,
|
||||
@@ -87,10 +86,6 @@ const routes: RouteObject[] = [
|
||||
path: 'orchestrator',
|
||||
element: <OrchestratorPage />,
|
||||
},
|
||||
{
|
||||
path: 'coordinator',
|
||||
element: <CoordinatorPage />,
|
||||
},
|
||||
{
|
||||
path: 'loops',
|
||||
element: <LoopMonitorPage />,
|
||||
@@ -205,7 +200,6 @@ export const ROUTES = {
|
||||
PROJECT: '/project',
|
||||
HISTORY: '/history',
|
||||
ORCHESTRATOR: '/orchestrator',
|
||||
COORDINATOR: '/coordinator',
|
||||
LOOPS: '/loops',
|
||||
CLI_VIEWER: '/cli-viewer',
|
||||
ISSUES: '/issues',
|
||||
|
||||
@@ -1,772 +0,0 @@
|
||||
// ========================================
|
||||
// Coordinator Store
|
||||
// ========================================
|
||||
// Zustand store for managing coordinator execution state and command chains
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Execution status of a coordinator
|
||||
*/
|
||||
export type CoordinatorStatus = 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Node execution status within a command chain
|
||||
*/
|
||||
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
/**
|
||||
* Log level for coordinator logs
|
||||
*/
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
|
||||
/**
|
||||
* Command node representing a step in the coordinator pipeline
|
||||
*/
|
||||
export interface CommandNode {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
status: NodeExecutionStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
output?: string;
|
||||
parentId?: string; // For hierarchical structure
|
||||
children?: CommandNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entry for coordinator execution
|
||||
*/
|
||||
export interface CoordinatorLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Question to be answered during coordinator execution
|
||||
*/
|
||||
export interface CoordinatorQuestion {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'text' | 'single' | 'multi' | 'yes_no';
|
||||
options?: string[];
|
||||
required: boolean;
|
||||
answer?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline details fetched from backend
|
||||
*/
|
||||
export interface PipelineDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: CommandNode[];
|
||||
totalSteps: number;
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator state
|
||||
*/
|
||||
export interface CoordinatorState {
|
||||
// Current execution
|
||||
currentExecutionId: string | null;
|
||||
status: CoordinatorStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
totalElapsedMs: number;
|
||||
|
||||
// Command chain
|
||||
commandChain: CommandNode[];
|
||||
currentNodeIndex: number;
|
||||
currentNode: CommandNode | null;
|
||||
|
||||
// Pipeline details
|
||||
pipelineDetails: PipelineDetails | null;
|
||||
isPipelineLoaded: boolean;
|
||||
|
||||
// Logs
|
||||
logs: CoordinatorLog[];
|
||||
maxLogs: number;
|
||||
|
||||
// Interactive questions
|
||||
activeQuestion: CoordinatorQuestion | null;
|
||||
pendingQuestions: CoordinatorQuestion[];
|
||||
|
||||
// Execution metadata
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
// Error tracking
|
||||
lastError?: string;
|
||||
errorDetails?: unknown;
|
||||
|
||||
// UI state
|
||||
isLogPanelExpanded: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
|
||||
// Actions
|
||||
startCoordinator: (executionId: string, taskDescription: string, parameters?: Record<string, unknown>) => Promise<void>;
|
||||
pauseCoordinator: () => Promise<void>;
|
||||
resumeCoordinator: () => Promise<void>;
|
||||
cancelCoordinator: (reason?: string) => Promise<void>;
|
||||
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => void;
|
||||
submitAnswer: (questionId: string, answer: string | string[]) => Promise<void>;
|
||||
retryNode: (nodeId: string) => Promise<void>;
|
||||
skipNode: (nodeId: string) => Promise<void>;
|
||||
fetchPipelineDetails: (executionId: string) => Promise<void>;
|
||||
syncStateFromServer: () => Promise<void>;
|
||||
addLog: (message: string, level?: LogLevel, nodeId?: string, source?: 'system' | 'node' | 'user') => void;
|
||||
clearLogs: () => void;
|
||||
setActiveQuestion: (question: CoordinatorQuestion | null) => void;
|
||||
markExecutionComplete: (success: boolean, finalResult?: unknown) => void;
|
||||
setLogPanelExpanded: (expanded: boolean) => void;
|
||||
setAutoScrollLogs: (autoScroll: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
const LOG_STORAGE_KEY = 'coordinator-storage';
|
||||
const COORDINATOR_STORAGE_VERSION = 1;
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Generate unique ID for logs and questions
|
||||
*/
|
||||
const generateId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find node by ID in command chain (handles hierarchical structure)
|
||||
*/
|
||||
const findNodeById = (nodes: CommandNode[], nodeId: string): CommandNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: CoordinatorState = {
|
||||
currentExecutionId: null,
|
||||
status: 'idle',
|
||||
totalElapsedMs: 0,
|
||||
|
||||
commandChain: [],
|
||||
currentNodeIndex: -1,
|
||||
currentNode: null,
|
||||
|
||||
pipelineDetails: null,
|
||||
isPipelineLoaded: false,
|
||||
|
||||
logs: [],
|
||||
maxLogs: MAX_LOGS,
|
||||
|
||||
activeQuestion: null,
|
||||
pendingQuestions: [],
|
||||
|
||||
metadata: {},
|
||||
|
||||
isLogPanelExpanded: true,
|
||||
autoScrollLogs: true,
|
||||
|
||||
// Actions are added in the create callback
|
||||
startCoordinator: async () => {},
|
||||
pauseCoordinator: async () => {},
|
||||
resumeCoordinator: async () => {},
|
||||
cancelCoordinator: async () => {},
|
||||
updateNodeStatus: () => {},
|
||||
submitAnswer: async () => {},
|
||||
retryNode: async () => {},
|
||||
skipNode: async () => {},
|
||||
fetchPipelineDetails: async () => {},
|
||||
syncStateFromServer: async () => {},
|
||||
addLog: () => {},
|
||||
clearLogs: () => {},
|
||||
setActiveQuestion: () => {},
|
||||
markExecutionComplete: () => {},
|
||||
setLogPanelExpanded: () => {},
|
||||
setAutoScrollLogs: () => {},
|
||||
reset: () => {},
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
/**
|
||||
* Coordinator store for managing orchestrator execution state
|
||||
*
|
||||
* @remarks
|
||||
* Uses Zustand with persist middleware to save execution metadata to localStorage.
|
||||
* The store manages command chains, logs, interactive questions, and execution status.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { startCoordinator, status, logs } = useCoordinatorStore();
|
||||
* await startCoordinator('exec-123', 'Build and deploy application');
|
||||
* ```
|
||||
*/
|
||||
export const useCoordinatorStore = create<CoordinatorState>()(
|
||||
persist(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Coordinator Lifecycle Actions ==========
|
||||
|
||||
startCoordinator: async (
|
||||
executionId: string,
|
||||
taskDescription: string,
|
||||
parameters?: Record<string, unknown>
|
||||
) => {
|
||||
set({
|
||||
currentExecutionId: executionId,
|
||||
status: 'initializing',
|
||||
startedAt: new Date().toISOString(),
|
||||
totalElapsedMs: 0,
|
||||
lastError: undefined,
|
||||
errorDetails: undefined,
|
||||
metadata: parameters || {},
|
||||
}, false, 'coordinator/startCoordinator');
|
||||
|
||||
get().addLog(`Starting coordinator execution: ${taskDescription}`, 'info', undefined, 'system');
|
||||
|
||||
try {
|
||||
// Fetch pipeline details from backend
|
||||
await get().fetchPipelineDetails(executionId);
|
||||
|
||||
const state = get();
|
||||
set({
|
||||
status: 'running',
|
||||
currentNodeIndex: 0,
|
||||
currentNode: state.commandChain.length > 0 ? state.commandChain[0] : null,
|
||||
}, false, 'coordinator/startCoordinator-running');
|
||||
|
||||
get().addLog('Coordinator running', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
set({
|
||||
status: 'failed',
|
||||
lastError: errorMessage,
|
||||
errorDetails: error,
|
||||
}, false, 'coordinator/startCoordinator-error');
|
||||
|
||||
get().addLog(`Failed to start coordinator: ${errorMessage}`, 'error', undefined, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
pauseCoordinator: async () => {
|
||||
const state = get();
|
||||
if (state.status !== 'running') {
|
||||
get().addLog('Cannot pause - coordinator is not running', 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: 'paused' }, false, 'coordinator/pauseCoordinator');
|
||||
get().addLog('Coordinator paused', 'info', undefined, 'system');
|
||||
},
|
||||
|
||||
resumeCoordinator: async () => {
|
||||
const state = get();
|
||||
if (state.status !== 'paused') {
|
||||
get().addLog('Cannot resume - coordinator is not paused', 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: 'running' }, false, 'coordinator/resumeCoordinator');
|
||||
get().addLog('Coordinator resumed', 'info', undefined, 'system');
|
||||
},
|
||||
|
||||
cancelCoordinator: async (reason?: string) => {
|
||||
set({
|
||||
status: 'cancelled',
|
||||
completedAt: new Date().toISOString(),
|
||||
}, false, 'coordinator/cancelCoordinator');
|
||||
|
||||
const message = reason ? `Coordinator cancelled: ${reason}` : 'Coordinator cancelled';
|
||||
get().addLog(message, 'warn', undefined, 'system');
|
||||
},
|
||||
|
||||
// ========== Node Status Management ==========
|
||||
|
||||
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[CoordinatorStore] Node not found: ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a deep copy of the command chain with updated node
|
||||
const updateNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
const updated: CommandNode = { ...n, status };
|
||||
if (status === 'running') {
|
||||
updated.startedAt = new Date().toISOString();
|
||||
} else if (status === 'completed') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
updated.result = result;
|
||||
} else if (status === 'failed') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
updated.error = error;
|
||||
} else if (status === 'skipped') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: updateNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = updateNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/updateNodeStatus');
|
||||
|
||||
// Add logs after state update
|
||||
if (status === 'running') {
|
||||
get().addLog(`Node started: ${node.name}`, 'debug', nodeId, 'system');
|
||||
} else if (status === 'completed') {
|
||||
get().addLog(`Node completed: ${node.name}`, 'success', nodeId, 'system');
|
||||
} else if (status === 'failed') {
|
||||
get().addLog(`Node failed: ${node.name} - ${error || 'Unknown error'}`, 'error', nodeId, 'system');
|
||||
} else if (status === 'skipped') {
|
||||
get().addLog(`Node skipped: ${node.name}`, 'info', nodeId, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Interactive Question Handling ==========
|
||||
|
||||
submitAnswer: async (questionId: string, answer: string | string[]) => {
|
||||
const state = get();
|
||||
const question = state.activeQuestion || state.pendingQuestions.find((q) => q.id === questionId);
|
||||
|
||||
if (!question) {
|
||||
get().addLog(`Question not found: ${questionId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update question with answer
|
||||
const updatedActiveQuestion =
|
||||
state.activeQuestion && state.activeQuestion.id === questionId
|
||||
? { ...state.activeQuestion, answer }
|
||||
: state.activeQuestion;
|
||||
|
||||
const updatedPendingQuestions = state.pendingQuestions.map((q) =>
|
||||
q.id === questionId ? { ...q, answer } : q
|
||||
);
|
||||
|
||||
set(
|
||||
{
|
||||
activeQuestion: updatedActiveQuestion,
|
||||
pendingQuestions: updatedPendingQuestions,
|
||||
},
|
||||
false,
|
||||
'coordinator/submitAnswer'
|
||||
);
|
||||
|
||||
get().addLog(
|
||||
`Answer submitted for question: ${question.title}`,
|
||||
'info',
|
||||
question.nodeId,
|
||||
'user'
|
||||
);
|
||||
|
||||
// Clear active question
|
||||
set({ activeQuestion: null }, false, 'coordinator/submitAnswer-clear');
|
||||
},
|
||||
|
||||
// ========== Node Control Actions ==========
|
||||
|
||||
retryNode: async (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
get().addLog(`Cannot retry - node not found: ${nodeId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
get().addLog(`Retrying node: ${node.name}`, 'info', nodeId, 'system');
|
||||
|
||||
// Recursively update node status to pending
|
||||
const resetNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
return { ...n, status: 'pending', result: undefined, error: undefined };
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: resetNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = resetNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/retryNode');
|
||||
},
|
||||
|
||||
skipNode: async (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
get().addLog(`Cannot skip - node not found: ${nodeId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
get().addLog(`Skipping node: ${node.name}`, 'info', nodeId, 'system');
|
||||
|
||||
// Recursively update node status to skipped
|
||||
const skipNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
return { ...n, status: 'skipped', completedAt: new Date().toISOString() };
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: skipNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = skipNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/skipNode');
|
||||
},
|
||||
|
||||
// ========== Pipeline Details ==========
|
||||
|
||||
fetchPipelineDetails: async (executionId: string) => {
|
||||
try {
|
||||
get().addLog('Fetching pipeline details', 'info', undefined, 'system');
|
||||
|
||||
// Import API function dynamically to avoid circular deps
|
||||
const { fetchCoordinatorPipeline } = await import('../lib/api');
|
||||
|
||||
const response = await fetchCoordinatorPipeline(executionId);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error('Failed to fetch pipeline details');
|
||||
}
|
||||
|
||||
const apiData = response.data;
|
||||
|
||||
// Transform API response to PipelineDetails
|
||||
const pipelineDetails: PipelineDetails = {
|
||||
id: apiData.id,
|
||||
name: apiData.name,
|
||||
description: apiData.description,
|
||||
nodes: apiData.nodes,
|
||||
totalSteps: apiData.totalSteps,
|
||||
estimatedDuration: apiData.estimatedDuration,
|
||||
};
|
||||
|
||||
set({
|
||||
pipelineDetails,
|
||||
isPipelineLoaded: true,
|
||||
commandChain: apiData.nodes,
|
||||
status: apiData.status || get().status,
|
||||
}, false, 'coordinator/fetchPipelineDetails');
|
||||
|
||||
// Load logs if available
|
||||
if (apiData.logs && apiData.logs.length > 0) {
|
||||
set({ logs: apiData.logs }, false, 'coordinator/fetchPipelineDetails-logs');
|
||||
}
|
||||
|
||||
get().addLog('Pipeline details loaded', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
set({
|
||||
isPipelineLoaded: false,
|
||||
lastError: errorMessage,
|
||||
}, false, 'coordinator/fetchPipelineDetails-error');
|
||||
|
||||
get().addLog(`Failed to fetch pipeline details: ${errorMessage}`, 'error', undefined, 'system');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ========== State Synchronization (for WebSocket reconnection) ==========
|
||||
|
||||
syncStateFromServer: async () => {
|
||||
const state = get();
|
||||
|
||||
// Only sync if we have an active execution
|
||||
if (!state.currentExecutionId) {
|
||||
get().addLog('No active execution to sync', 'debug', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
get().addLog('Syncing state from server', 'info', undefined, 'system');
|
||||
|
||||
// Fetch current execution state from server
|
||||
const { fetchExecutionState } = await import('../lib/api');
|
||||
const response = await fetchExecutionState(state.currentExecutionId);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error('Failed to sync execution state');
|
||||
}
|
||||
|
||||
const serverState = response.data;
|
||||
|
||||
// Update local state with server state
|
||||
set({
|
||||
status: serverState.status as CoordinatorStatus,
|
||||
totalElapsedMs: serverState.elapsedMs,
|
||||
}, false, 'coordinator/syncStateFromServer');
|
||||
|
||||
// Fetch full pipeline details if status indicates running/paused
|
||||
if (serverState.status === 'running' || serverState.status === 'paused') {
|
||||
await get().fetchPipelineDetails(state.currentExecutionId);
|
||||
}
|
||||
|
||||
get().addLog('State synchronized with server', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[CoordinatorStore] Failed to sync state:', error);
|
||||
get().addLog(`Failed to sync state from server: ${errorMessage}`, 'warn', undefined, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
addLog: (
|
||||
message: string,
|
||||
level: LogLevel = 'info',
|
||||
nodeId?: string,
|
||||
source: 'system' | 'node' | 'user' = 'system'
|
||||
) => {
|
||||
const state = get();
|
||||
const log: CoordinatorLog = {
|
||||
id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
nodeId,
|
||||
source,
|
||||
};
|
||||
|
||||
let updatedLogs = [...state.logs, log];
|
||||
|
||||
// Keep only the last maxLogs entries
|
||||
if (updatedLogs.length > state.maxLogs) {
|
||||
updatedLogs = updatedLogs.slice(-state.maxLogs);
|
||||
}
|
||||
|
||||
set({ logs: updatedLogs }, false, 'coordinator/addLog');
|
||||
},
|
||||
|
||||
clearLogs: () => {
|
||||
set({ logs: [] }, false, 'coordinator/clearLogs');
|
||||
},
|
||||
|
||||
// ========== Question Management ==========
|
||||
|
||||
setActiveQuestion: (question: CoordinatorQuestion | null) => {
|
||||
const state = get();
|
||||
const updatedPendingQuestions =
|
||||
question && !state.pendingQuestions.find((q) => q.id === question.id)
|
||||
? [...state.pendingQuestions, question]
|
||||
: state.pendingQuestions;
|
||||
|
||||
set({
|
||||
activeQuestion: question,
|
||||
pendingQuestions: updatedPendingQuestions,
|
||||
}, false, 'coordinator/setActiveQuestion');
|
||||
},
|
||||
|
||||
// ========== Execution Completion ==========
|
||||
|
||||
markExecutionComplete: (success: boolean, finalResult?: unknown) => {
|
||||
const state = get();
|
||||
set({
|
||||
status: success ? 'completed' : 'failed',
|
||||
completedAt: new Date().toISOString(),
|
||||
metadata: { ...state.metadata, finalResult },
|
||||
}, false, 'coordinator/markExecutionComplete');
|
||||
|
||||
const message = success
|
||||
? 'Coordinator execution completed successfully'
|
||||
: 'Coordinator execution failed';
|
||||
get().addLog(message, success ? 'success' : 'error', undefined, 'system');
|
||||
},
|
||||
|
||||
// ========== UI State ==========
|
||||
|
||||
setLogPanelExpanded: (expanded: boolean) => {
|
||||
set({ isLogPanelExpanded: expanded }, false, 'coordinator/setLogPanelExpanded');
|
||||
},
|
||||
|
||||
setAutoScrollLogs: (autoScroll: boolean) => {
|
||||
set({ autoScrollLogs: autoScroll }, false, 'coordinator/setAutoScrollLogs');
|
||||
},
|
||||
|
||||
// ========== Reset ==========
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
currentExecutionId: null,
|
||||
status: 'idle',
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
totalElapsedMs: 0,
|
||||
commandChain: [],
|
||||
currentNodeIndex: -1,
|
||||
currentNode: null,
|
||||
pipelineDetails: null,
|
||||
isPipelineLoaded: false,
|
||||
logs: [],
|
||||
activeQuestion: null,
|
||||
pendingQuestions: [],
|
||||
metadata: {},
|
||||
lastError: undefined,
|
||||
errorDetails: undefined,
|
||||
}, false, 'coordinator/reset');
|
||||
|
||||
get().addLog('Coordinator state reset', 'info', undefined, 'system');
|
||||
},
|
||||
}),
|
||||
{ name: 'CoordinatorStore' }
|
||||
),
|
||||
{
|
||||
name: LOG_STORAGE_KEY,
|
||||
version: COORDINATOR_STORAGE_VERSION,
|
||||
// Only persist basic pipeline info (not full nodes/logs or metadata which may contain sensitive data)
|
||||
partialize: (state) => ({
|
||||
currentExecutionId: state.currentExecutionId,
|
||||
status: state.status,
|
||||
startedAt: state.startedAt,
|
||||
completedAt: state.completedAt,
|
||||
totalElapsedMs: state.totalElapsedMs,
|
||||
// Exclude metadata from persistence - it may contain sensitive data (Record<string, unknown>)
|
||||
isLogPanelExpanded: state.isLogPanelExpanded,
|
||||
autoScrollLogs: state.autoScrollLogs,
|
||||
// Only persist basic pipeline info, not full nodes
|
||||
pipelineDetails: state.pipelineDetails ? {
|
||||
id: state.pipelineDetails.id,
|
||||
name: state.pipelineDetails.name,
|
||||
description: state.pipelineDetails.description,
|
||||
nodes: [], // Don't persist nodes - will be fetched from API
|
||||
totalSteps: state.pipelineDetails.totalSteps,
|
||||
estimatedDuration: state.pipelineDetails.estimatedDuration,
|
||||
} : null,
|
||||
}),
|
||||
// Rehydration callback to restore state on page load
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
|
||||
// Check if we have an active execution that needs hydration
|
||||
const needsHydration =
|
||||
state.currentExecutionId &&
|
||||
(state.status === 'running' || state.status === 'paused' || state.status === 'initializing') &&
|
||||
(!state.pipelineDetails || state.pipelineDetails.nodes.length === 0);
|
||||
|
||||
if (needsHydration && state.currentExecutionId) {
|
||||
// Log restoration
|
||||
state.addLog('Restoring coordinator state from localStorage', 'info', undefined, 'system');
|
||||
|
||||
// Fetch full pipeline details from API
|
||||
state.fetchPipelineDetails(state.currentExecutionId).catch((error) => {
|
||||
console.error('[CoordinatorStore] Failed to hydrate pipeline details:', error);
|
||||
state.addLog('Failed to restore pipeline data - session may be incomplete', 'warn', undefined, 'system');
|
||||
});
|
||||
} else if (state.currentExecutionId) {
|
||||
// Just log that we restored the session
|
||||
state.addLog('Session state restored', 'info', undefined, 'system');
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Helper Hooks ==========
|
||||
|
||||
/**
|
||||
* Hook to get coordinator actions
|
||||
* Useful for components that only need actions, not the full state
|
||||
*/
|
||||
export const useCoordinatorActions = () => {
|
||||
return useCoordinatorStore((state) => ({
|
||||
startCoordinator: state.startCoordinator,
|
||||
pauseCoordinator: state.pauseCoordinator,
|
||||
resumeCoordinator: state.resumeCoordinator,
|
||||
cancelCoordinator: state.cancelCoordinator,
|
||||
updateNodeStatus: state.updateNodeStatus,
|
||||
submitAnswer: state.submitAnswer,
|
||||
retryNode: state.retryNode,
|
||||
skipNode: state.skipNode,
|
||||
fetchPipelineDetails: state.fetchPipelineDetails,
|
||||
syncStateFromServer: state.syncStateFromServer,
|
||||
addLog: state.addLog,
|
||||
clearLogs: state.clearLogs,
|
||||
setActiveQuestion: state.setActiveQuestion,
|
||||
markExecutionComplete: state.markExecutionComplete,
|
||||
setLogPanelExpanded: state.setLogPanelExpanded,
|
||||
setAutoScrollLogs: state.setAutoScrollLogs,
|
||||
reset: state.reset,
|
||||
}));
|
||||
};
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/**
|
||||
* Select current execution status
|
||||
*/
|
||||
export const selectCoordinatorStatus = (state: CoordinatorState) => state.status;
|
||||
|
||||
/**
|
||||
* Select current execution ID
|
||||
*/
|
||||
export const selectCurrentExecutionId = (state: CoordinatorState) => state.currentExecutionId;
|
||||
|
||||
/**
|
||||
* Select all logs
|
||||
*/
|
||||
export const selectCoordinatorLogs = (state: CoordinatorState) => state.logs;
|
||||
|
||||
/**
|
||||
* Select active question
|
||||
*/
|
||||
export const selectActiveQuestion = (state: CoordinatorState) => state.activeQuestion;
|
||||
|
||||
/**
|
||||
* Select command chain
|
||||
*/
|
||||
export const selectCommandChain = (state: CoordinatorState) => state.commandChain;
|
||||
|
||||
/**
|
||||
* Select current node
|
||||
*/
|
||||
export const selectCurrentNode = (state: CoordinatorState) => state.currentNode;
|
||||
|
||||
/**
|
||||
* Select pipeline details
|
||||
*/
|
||||
export const selectPipelineDetails = (state: CoordinatorState) => state.pipelineDetails;
|
||||
|
||||
/**
|
||||
* Select is pipeline loaded
|
||||
*/
|
||||
export const selectIsPipelineLoaded = (state: CoordinatorState) => state.isPipelineLoaded;
|
||||
@@ -29,7 +29,7 @@ const initialState = {
|
||||
maxLogs: MAX_LOGS,
|
||||
|
||||
// UI state
|
||||
isMonitorExpanded: true,
|
||||
isMonitorPanelOpen: false,
|
||||
autoScrollLogs: true,
|
||||
};
|
||||
|
||||
@@ -197,8 +197,8 @@ export const useExecutionStore = create<ExecutionStore>()(
|
||||
|
||||
// ========== UI State ==========
|
||||
|
||||
setMonitorExpanded: (expanded: boolean) => {
|
||||
set({ isMonitorExpanded: expanded }, false, 'setMonitorExpanded');
|
||||
setMonitorPanelOpen: (open: boolean) => {
|
||||
set({ isMonitorPanelOpen: open }, false, 'setMonitorPanelOpen');
|
||||
},
|
||||
|
||||
setAutoScrollLogs: (autoScroll: boolean) => {
|
||||
@@ -213,7 +213,7 @@ export const useExecutionStore = create<ExecutionStore>()(
|
||||
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
|
||||
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
|
||||
export const selectLogs = (state: ExecutionStore) => state.logs;
|
||||
export const selectIsMonitorExpanded = (state: ExecutionStore) => state.isMonitorExpanded;
|
||||
export const selectIsMonitorPanelOpen = (state: ExecutionStore) => state.isMonitorPanelOpen;
|
||||
export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollLogs;
|
||||
|
||||
// Helper to check if execution is active
|
||||
|
||||
@@ -12,9 +12,32 @@ import type {
|
||||
FlowEdge,
|
||||
NodeData,
|
||||
FlowEdgeData,
|
||||
QuickTemplate,
|
||||
} from '../types/flow';
|
||||
import { NODE_TYPE_CONFIGS as nodeConfigs, QUICK_TEMPLATES } from '../types/flow';
|
||||
|
||||
// localStorage key for custom templates
|
||||
const CUSTOM_TEMPLATES_KEY = 'ccw-orchestrator-custom-templates';
|
||||
|
||||
// Load custom templates from localStorage
|
||||
function loadCustomTemplatesFromStorage(): QuickTemplate[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_TEMPLATES_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save custom templates to localStorage
|
||||
function saveCustomTemplatesToStorage(templates: QuickTemplate[]): void {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_TEMPLATES_KEY, JSON.stringify(templates));
|
||||
} catch (e) {
|
||||
console.error('Failed to save custom templates:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to generate unique IDs
|
||||
const generateId = (prefix: string): string => {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
@@ -45,6 +68,9 @@ const initialState = {
|
||||
isPaletteOpen: true,
|
||||
isPropertyPanelOpen: true,
|
||||
leftPanelTab: 'nodes' as const,
|
||||
|
||||
// Custom templates (loaded from localStorage)
|
||||
customTemplates: loadCustomTemplatesFromStorage(),
|
||||
};
|
||||
|
||||
export const useFlowStore = create<FlowStore>()(
|
||||
@@ -259,7 +285,9 @@ export const useFlowStore = create<FlowStore>()(
|
||||
},
|
||||
|
||||
addNodeFromTemplate: (templateId: string, position: { x: number; y: number }): string => {
|
||||
const template = QUICK_TEMPLATES.find((t) => t.id === templateId);
|
||||
// Look up in built-in templates first, then custom templates
|
||||
const template = QUICK_TEMPLATES.find((t) => t.id === templateId)
|
||||
|| get().customTemplates.find((t) => t.id === templateId);
|
||||
if (!template) {
|
||||
console.error(`Template not found: ${templateId}`);
|
||||
return get().addNode(position);
|
||||
@@ -434,6 +462,55 @@ export const useFlowStore = create<FlowStore>()(
|
||||
set({ leftPanelTab: tab }, false, 'setLeftPanelTab');
|
||||
},
|
||||
|
||||
// ========== Custom Templates ==========
|
||||
|
||||
addCustomTemplate: (template: QuickTemplate) => {
|
||||
set(
|
||||
(state) => {
|
||||
const updated = [...state.customTemplates, template];
|
||||
saveCustomTemplatesToStorage(updated);
|
||||
return { customTemplates: updated };
|
||||
},
|
||||
false,
|
||||
'addCustomTemplate'
|
||||
);
|
||||
},
|
||||
|
||||
removeCustomTemplate: (id: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const updated = state.customTemplates.filter((t) => t.id !== id);
|
||||
saveCustomTemplatesToStorage(updated);
|
||||
return { customTemplates: updated };
|
||||
},
|
||||
false,
|
||||
'removeCustomTemplate'
|
||||
);
|
||||
},
|
||||
|
||||
saveNodeAsTemplate: (nodeId: string, label: string, description: string): QuickTemplate | null => {
|
||||
const node = get().nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const { executionStatus, executionError, executionResult, ...templateData } = node.data;
|
||||
const template: QuickTemplate = {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label,
|
||||
description,
|
||||
icon: 'MessageSquare',
|
||||
color: 'bg-blue-500',
|
||||
category: 'command',
|
||||
data: { ...templateData, label },
|
||||
};
|
||||
|
||||
get().addCustomTemplate(template);
|
||||
return template;
|
||||
},
|
||||
|
||||
loadCustomTemplates: () => {
|
||||
set({ customTemplates: loadCustomTemplatesFromStorage() }, false, 'loadCustomTemplates');
|
||||
},
|
||||
|
||||
// ========== Utility ==========
|
||||
|
||||
resetFlow: () => {
|
||||
|
||||
@@ -71,26 +71,12 @@ export {
|
||||
selectCurrentExecution,
|
||||
selectNodeStates,
|
||||
selectLogs,
|
||||
selectIsMonitorExpanded,
|
||||
selectIsMonitorPanelOpen,
|
||||
selectAutoScrollLogs,
|
||||
selectIsExecuting,
|
||||
selectNodeStatus,
|
||||
} from './executionStore';
|
||||
|
||||
// Coordinator Store
|
||||
export {
|
||||
useCoordinatorStore,
|
||||
useCoordinatorActions,
|
||||
selectCoordinatorStatus,
|
||||
selectCurrentExecutionId,
|
||||
selectCoordinatorLogs,
|
||||
selectActiveQuestion,
|
||||
selectCommandChain,
|
||||
selectCurrentNode,
|
||||
selectPipelineDetails,
|
||||
selectIsPipelineLoaded,
|
||||
} from './coordinatorStore';
|
||||
|
||||
// Viewer Store
|
||||
export {
|
||||
useViewerStore,
|
||||
@@ -148,16 +134,6 @@ export type {
|
||||
AskQuestionPayload,
|
||||
} from '../types/store';
|
||||
|
||||
// Coordinator Store Types
|
||||
export type {
|
||||
CoordinatorState,
|
||||
CoordinatorStatus,
|
||||
CommandNode,
|
||||
CoordinatorLog,
|
||||
CoordinatorQuestion,
|
||||
PipelineDetails,
|
||||
} from './coordinatorStore';
|
||||
|
||||
// Viewer Store Types
|
||||
export type {
|
||||
PaneId,
|
||||
|
||||
@@ -155,7 +155,7 @@ export interface ExecutionStoreState {
|
||||
maxLogs: number;
|
||||
|
||||
// UI state
|
||||
isMonitorExpanded: boolean;
|
||||
isMonitorPanelOpen: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export interface ExecutionStoreActions {
|
||||
clearLogs: () => void;
|
||||
|
||||
// UI state
|
||||
setMonitorExpanded: (expanded: boolean) => void;
|
||||
setMonitorPanelOpen: (open: boolean) => void;
|
||||
setAutoScrollLogs: (autoScroll: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,9 @@ export interface FlowState {
|
||||
flows: Flow[];
|
||||
isLoadingFlows: boolean;
|
||||
|
||||
// Custom node templates (user-defined, persisted to localStorage)
|
||||
customTemplates: QuickTemplate[];
|
||||
|
||||
// UI state
|
||||
isPaletteOpen: boolean;
|
||||
isPropertyPanelOpen: boolean;
|
||||
@@ -252,6 +255,12 @@ export interface FlowActions {
|
||||
setIsPropertyPanelOpen: (open: boolean) => void;
|
||||
setLeftPanelTab: (tab: 'templates' | 'nodes') => void;
|
||||
|
||||
// Custom templates
|
||||
addCustomTemplate: (template: QuickTemplate) => void;
|
||||
removeCustomTemplate: (id: string) => void;
|
||||
saveNodeAsTemplate: (nodeId: string, label: string, description: string) => QuickTemplate | null;
|
||||
loadCustomTemplates: () => void;
|
||||
|
||||
// Utility
|
||||
resetFlow: () => void;
|
||||
getSelectedNode: () => FlowNode | undefined;
|
||||
@@ -354,170 +363,4 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
|
||||
nodeCategory: 'command',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Analysis',
|
||||
description: 'Code review, architecture analysis',
|
||||
icon: 'Search',
|
||||
color: 'bg-emerald-500',
|
||||
category: 'command',
|
||||
data: {
|
||||
label: 'Analyze',
|
||||
instruction: 'Analyze the code for:\n1. Architecture patterns\n2. Code quality\n3. Potential issues',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
nodeCategory: 'command',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'implementation',
|
||||
label: 'Implementation',
|
||||
description: 'Write code, create files',
|
||||
icon: 'Code',
|
||||
color: 'bg-violet-500',
|
||||
category: 'command',
|
||||
data: {
|
||||
label: 'Implement',
|
||||
instruction: 'Implement the following:\n\n[Describe what to implement]',
|
||||
tool: 'codex',
|
||||
mode: 'write',
|
||||
nodeCategory: 'command',
|
||||
},
|
||||
},
|
||||
// ========== Phase Templates ==========
|
||||
{
|
||||
id: 'phase-session',
|
||||
label: 'Session',
|
||||
description: 'Initialize workflow session and environment',
|
||||
icon: 'FolderOpen',
|
||||
color: 'bg-sky-500',
|
||||
category: 'phase',
|
||||
data: {
|
||||
label: 'Session Setup',
|
||||
instruction: 'Initialize workflow session:\n- Set project context\n- Load configuration\n- Validate environment',
|
||||
phase: 'session',
|
||||
nodeCategory: 'phase',
|
||||
mode: 'mainprocess',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'phase-context',
|
||||
label: 'Context',
|
||||
description: 'Collect and prepare context information',
|
||||
icon: 'Database',
|
||||
color: 'bg-cyan-500',
|
||||
category: 'phase',
|
||||
data: {
|
||||
label: 'Context Gathering',
|
||||
instruction: 'Gather context:\n- Analyze codebase structure\n- Identify relevant files\n- Build context package',
|
||||
phase: 'context',
|
||||
nodeCategory: 'phase',
|
||||
mode: 'analysis',
|
||||
tool: 'gemini',
|
||||
artifacts: ['context-package.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'phase-plan',
|
||||
label: 'Plan',
|
||||
description: 'Generate execution plan and task breakdown',
|
||||
icon: 'ListTodo',
|
||||
color: 'bg-amber-500',
|
||||
category: 'phase',
|
||||
data: {
|
||||
label: 'Planning',
|
||||
instruction: 'Create execution plan:\n- Break requirements into tasks\n- Identify dependencies\n- Evaluate complexity',
|
||||
phase: 'plan',
|
||||
nodeCategory: 'phase',
|
||||
mode: 'analysis',
|
||||
tool: 'gemini',
|
||||
artifacts: ['execution-plan.md'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'phase-execute',
|
||||
label: 'Execute',
|
||||
description: 'Execute tasks according to plan',
|
||||
icon: 'Play',
|
||||
color: 'bg-green-500',
|
||||
category: 'phase',
|
||||
data: {
|
||||
label: 'Execution',
|
||||
instruction: 'Execute planned tasks:\n- Follow dependency order\n- Apply code changes\n- Run validation',
|
||||
phase: 'execute',
|
||||
nodeCategory: 'phase',
|
||||
mode: 'write',
|
||||
tool: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'phase-review',
|
||||
label: 'Review',
|
||||
description: 'Review results and validate output',
|
||||
icon: 'CheckCircle',
|
||||
color: 'bg-purple-500',
|
||||
category: 'phase',
|
||||
data: {
|
||||
label: 'Review',
|
||||
instruction: 'Review execution results:\n- Validate code changes\n- Run tests\n- Check regressions',
|
||||
phase: 'review',
|
||||
nodeCategory: 'phase',
|
||||
mode: 'analysis',
|
||||
tool: 'gemini',
|
||||
},
|
||||
},
|
||||
// ========== Tool Templates ==========
|
||||
{
|
||||
id: 'tool-context-gather',
|
||||
label: 'Context Gather',
|
||||
description: 'Automated context collection tool',
|
||||
icon: 'FolderSearch',
|
||||
color: 'bg-teal-500',
|
||||
category: 'tool',
|
||||
data: {
|
||||
label: 'Context Gather',
|
||||
instruction: 'Collect project context:\n- Scan file structure\n- Identify key modules\n- Extract type definitions\n- Map dependencies',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
nodeCategory: 'tool',
|
||||
phase: 'context',
|
||||
outputName: 'context',
|
||||
artifacts: ['context-package.json'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tool-conflict-resolution',
|
||||
label: 'Conflict Resolution',
|
||||
description: 'Resolve code conflicts and inconsistencies',
|
||||
icon: 'GitMerge',
|
||||
color: 'bg-orange-500',
|
||||
category: 'tool',
|
||||
data: {
|
||||
label: 'Conflict Resolution',
|
||||
instruction: 'Resolve conflicts:\n- Identify conflicting changes\n- Analyze intent of each side\n- Generate merge solution\n- Verify consistency',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
nodeCategory: 'tool',
|
||||
phase: 'execute',
|
||||
outputName: 'resolution',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tool-task-generate',
|
||||
label: 'Task Generate',
|
||||
description: 'Generate task breakdown from requirements',
|
||||
icon: 'ListChecks',
|
||||
color: 'bg-indigo-500',
|
||||
category: 'tool',
|
||||
data: {
|
||||
label: 'Task Generation',
|
||||
instruction: 'Generate tasks:\n- Parse requirements\n- Break into atomic tasks\n- Set dependencies\n- Assign priorities',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
nodeCategory: 'tool',
|
||||
phase: 'plan',
|
||||
outputName: 'tasks',
|
||||
artifacts: ['task-list.json'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user