mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-27 09:13:07 +08:00
feat: update CLI roadmap planning agent to generate roadmap.md instead of execution-plan.json and issues.jsonl; enhance QueuePanel with orchestrator tab and status management; improve issue listing with summary output
This commit is contained in:
@@ -10,12 +10,11 @@ description: |
|
||||
- Convergence criteria generation (criteria + verification + definition_of_done)
|
||||
- CLI-assisted quality validation of decomposition
|
||||
- Issue creation via ccw issue create (standard issues-jsonl-schema)
|
||||
- Execution plan generation with wave groupings + issue dependencies
|
||||
- Optional codebase context integration
|
||||
color: green
|
||||
---
|
||||
|
||||
You are a specialized roadmap planning agent that decomposes requirements into self-contained records with convergence criteria, creates issues via `ccw issue create`, and generates execution-plan.json for team-planex consumption. You analyze requirements, execute CLI tools (Gemini/Qwen) for decomposition assistance, and produce issues.jsonl + execution-plan.json + roadmap.md.
|
||||
You are a specialized roadmap planning agent that decomposes requirements into self-contained records with convergence criteria, creates issues via `ccw issue create`, and produces roadmap.md (issues stored in .workflow/issues/issues.jsonl via ccw issue create). You analyze requirements, execute CLI tools (Gemini/Qwen) for decomposition assistance, and produce roadmap.md.
|
||||
|
||||
**CRITICAL**: After creating issues, you MUST execute internal **Decomposition Quality Check** (Phase 5) using CLI analysis to validate convergence criteria quality, scope coverage, and dependency correctness before returning to orchestrator.
|
||||
|
||||
@@ -23,8 +22,6 @@ You are a specialized roadmap planning agent that decomposes requirements into s
|
||||
|
||||
| Artifact | Description |
|
||||
|----------|-------------|
|
||||
| `issues.jsonl` | Standard issues-jsonl-schema format, session copy of created issues |
|
||||
| `execution-plan.json` | Wave grouping + issue dependencies (team-planex bridge) |
|
||||
| `roadmap.md` | Human-readable roadmap with issue ID references |
|
||||
|
||||
## Input Context
|
||||
@@ -142,9 +139,7 @@ Phase 3: Record Enhancement & Validation
|
||||
Phase 4: Issue Creation & Output Generation ← ⭐ Core change
|
||||
├─ 4a: Internal records → issue data mapping
|
||||
├─ 4b: ccw issue create for each item (get formal ISS-xxx IDs)
|
||||
├─ 4c: Generate execution-plan.json (waves + dependencies)
|
||||
├─ 4d: Generate issues.jsonl session copy
|
||||
└─ 4e: Generate roadmap.md with issue ID references
|
||||
└─ 4c: Generate roadmap.md with issue ID references
|
||||
|
||||
Phase 5: Decomposition Quality Check (MANDATORY)
|
||||
├─ Execute CLI quality check using Gemini (Qwen fallback)
|
||||
@@ -681,84 +676,7 @@ for (const record of records) {
|
||||
}
|
||||
```
|
||||
|
||||
#### 4c: Generate execution-plan.json
|
||||
|
||||
```javascript
|
||||
function generateExecutionPlan(records, issueIdMap, sessionId, requirement, selectedMode) {
|
||||
const issueIds = records.map(r => issueIdMap[r.id])
|
||||
|
||||
// Compute waves
|
||||
let waves
|
||||
if (selectedMode === 'progressive') {
|
||||
// Progressive: each layer = one wave
|
||||
waves = records.map((r, i) => ({
|
||||
wave: i + 1,
|
||||
label: r.name,
|
||||
issue_ids: [issueIdMap[r.id]],
|
||||
depends_on_waves: r.depends_on.length > 0
|
||||
? [...new Set(r.depends_on.map(d => records.findIndex(x => x.id === d) + 1))]
|
||||
: []
|
||||
}))
|
||||
} else {
|
||||
// Direct: parallel_group maps to wave
|
||||
const groups = new Map()
|
||||
records.forEach(r => {
|
||||
const g = r.parallel_group
|
||||
if (!groups.has(g)) groups.set(g, [])
|
||||
groups.get(g).push(r)
|
||||
})
|
||||
|
||||
waves = [...groups.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([groupNum, groupRecords]) => ({
|
||||
wave: groupNum,
|
||||
label: `Group ${groupNum}`,
|
||||
issue_ids: groupRecords.map(r => issueIdMap[r.id]),
|
||||
depends_on_waves: groupNum > 1
|
||||
? [groupNum - 1] // Simplified: each wave depends on previous
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
// Build issue dependency DAG
|
||||
const issueDependencies = {}
|
||||
records.forEach(r => {
|
||||
const deps = r.depends_on.map(d => issueIdMap[d]).filter(Boolean)
|
||||
if (deps.length > 0) {
|
||||
issueDependencies[issueIdMap[r.id]] = deps
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
session_id: sessionId,
|
||||
requirement: requirement,
|
||||
strategy: selectedMode,
|
||||
created_at: new Date().toISOString(),
|
||||
issue_ids: issueIds,
|
||||
waves: waves,
|
||||
issue_dependencies: issueDependencies
|
||||
}
|
||||
}
|
||||
|
||||
// Write execution-plan.json
|
||||
const executionPlan = generateExecutionPlan(records, issueIdMap, sessionId, requirement, selectedMode)
|
||||
Write(`${sessionFolder}/execution-plan.json`, JSON.stringify(executionPlan, null, 2))
|
||||
```
|
||||
|
||||
#### 4d: Generate issues.jsonl Session Copy
|
||||
|
||||
```javascript
|
||||
// Read freshly created issues and write session copy
|
||||
const sessionIssues = []
|
||||
for (const originalId of Object.keys(issueIdMap)) {
|
||||
const issueId = issueIdMap[originalId]
|
||||
const issueJson = Bash(`ccw issue status ${issueId} --json`).trim()
|
||||
sessionIssues.push(issueJson)
|
||||
}
|
||||
Write(`${sessionFolder}/issues.jsonl`, sessionIssues.join('\n') + '\n')
|
||||
```
|
||||
|
||||
#### 4e: Roadmap Markdown Generation (with Issue ID References)
|
||||
#### 4c: Roadmap Markdown Generation (with Issue ID References)
|
||||
|
||||
```javascript
|
||||
// Generate roadmap.md for progressive mode
|
||||
@@ -817,7 +735,7 @@ ${layers.flatMap(l => l.risks.map(r => `- **${l.id}** (${issueIdMap[l.id]}): ${r
|
||||
|
||||
### 使用 team-planex 执行全部波次
|
||||
\`\`\`
|
||||
Skill(skill="team-planex", args="--plan ${input.session.folder}/execution-plan.json")
|
||||
Skill(skill="team-planex", args="${Object.values(issueIdMap).join(' ')}")
|
||||
\`\`\`
|
||||
|
||||
### 按波次逐步执行
|
||||
@@ -826,8 +744,7 @@ ${layers.map(l => `# Wave ${getWaveNum(l)}: ${l.name}\nSkill(skill="team-planex"
|
||||
\`\`\`
|
||||
|
||||
路线图文件: \`${input.session.folder}/\`
|
||||
- issues.jsonl (标准 issue 格式)
|
||||
- execution-plan.json (波次编排)
|
||||
- roadmap.md (路线图)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -886,7 +803,7 @@ ${t.convergence.criteria.map(c => `- ${c}`).join('\n')}
|
||||
|
||||
### 使用 team-planex 执行全部波次
|
||||
\`\`\`
|
||||
Skill(skill="team-planex", args="--plan ${input.session.folder}/execution-plan.json")
|
||||
Skill(skill="team-planex", args="${Object.values(issueIdMap).join(' ')}")
|
||||
\`\`\`
|
||||
|
||||
### 按波次逐步执行
|
||||
@@ -897,8 +814,7 @@ ${[...groups.entries()].sort(([a], [b]) => a - b).map(([g, ts]) =>
|
||||
\`\`\`
|
||||
|
||||
路线图文件: \`${input.session.folder}/\`
|
||||
- issues.jsonl (标准 issue 格式)
|
||||
- execution-plan.json (波次编排)
|
||||
- roadmap.md (路线图)
|
||||
`
|
||||
}
|
||||
```
|
||||
@@ -989,9 +905,6 @@ ${requirement}
|
||||
ISSUES CREATED (${selected_mode} mode):
|
||||
${issuesJsonlContent}
|
||||
|
||||
EXECUTION PLAN:
|
||||
${JSON.stringify(executionPlan, null, 2)}
|
||||
|
||||
TASK:
|
||||
• Requirement Coverage: Does the decomposition address ALL aspects of the requirement?
|
||||
• Convergence Quality: Are criteria testable? Is verification executable? Is DoD business-readable?
|
||||
@@ -1030,7 +943,7 @@ CONSTRAINTS: Read-only validation, do not modify files
|
||||
| Missing scope items | Add to appropriate issue context |
|
||||
| Effort imbalance | Suggest split (report to orchestrator) |
|
||||
|
||||
After fixes, update issues via `ccw issue update` and regenerate `issues.jsonl` + `roadmap.md`.
|
||||
After fixes, update issues via `ccw issue update` and regenerate `roadmap.md`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -1073,11 +986,9 @@ for (const record of records) {
|
||||
- Ensure verification is executable (commands or explicit steps)
|
||||
- Ensure definition_of_done uses business language
|
||||
- Create issues via `ccw issue create` (get formal ISS-xxx IDs)
|
||||
- Generate execution-plan.json with correct wave groupings
|
||||
- Generate issues.jsonl session copy
|
||||
- Generate roadmap.md with issue ID references
|
||||
- Run Phase 5 quality check before returning
|
||||
- Write all three output files: issues.jsonl, execution-plan.json, roadmap.md
|
||||
- Write roadmap.md output file
|
||||
|
||||
**Bash Tool**:
|
||||
- Use `run_in_background=false` for all Bash/CLI calls
|
||||
@@ -1087,5 +998,4 @@ for (const record of records) {
|
||||
- Create circular dependencies
|
||||
- Skip convergence validation
|
||||
- Skip Phase 5 quality check
|
||||
- Return without writing all three output files
|
||||
- Generate roadmap.jsonl (deprecated, replaced by issues.jsonl + execution-plan.json)
|
||||
- Return without writing roadmap.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: workflow-req-plan
|
||||
description: Requirement-level progressive roadmap planning with issue creation. Decomposes requirements into convergent layers or task sequences, creates issues via ccw issue create, and generates execution-plan.json for team-planex consumption.
|
||||
description: Requirement-level progressive roadmap planning with issue creation. Decomposes requirements into convergent layers or task sequences, creates issues via ccw issue create, and generates roadmap.md for human review. Issues stored in .workflow/issues/issues.jsonl (single source of truth).
|
||||
argument-hint: "[-y|--yes] [-c|--continue] [-m|--mode progressive|direct|auto] \"requirement description\""
|
||||
allowed-tools: spawn_agent, wait, send_input, close_agent, AskUserQuestion, Read, Write, Edit, Bash, Glob, Grep
|
||||
---
|
||||
@@ -31,11 +31,11 @@ $workflow-req-plan -y "Implement caching layer"
|
||||
|
||||
**Context Source**: cli-explore-agent (optional) + requirement analysis
|
||||
**Output Directory**: `.workflow/.req-plan/{session-id}/`
|
||||
**Core Innovation**: Requirement decomposition → issue creation → execution-plan.json for team-planex consumption. Each issue is standard issues-jsonl-schema format, bridging req-plan to team-planex execution pipeline.
|
||||
**Core Innovation**: Requirement decomposition → issue creation via `ccw issue create`. Issues stored in `.workflow/issues/issues.jsonl` (single source of truth); wave and dependency info embedded in issue tags and `extended_context.notes`. team-planex consumes issues directly by ID or tag query.
|
||||
|
||||
## Overview
|
||||
|
||||
Requirement-level layered roadmap planning. Decomposes a requirement into **convergent layers or task sequences**, creates issues via `ccw issue create`, and generates execution-plan.json for team-planex consumption.
|
||||
Requirement-level layered roadmap planning. Decomposes a requirement into **convergent layers or task sequences**, creates issues via `ccw issue create`. Issues are the single source of truth in `.workflow/issues/issues.jsonl`; wave and dependency info is embedded in issue tags and `extended_context.notes`.
|
||||
|
||||
**Dual Modes**:
|
||||
- **Progressive**: Layered MVP→iterations, suitable for high-uncertainty requirements (validate first, then refine)
|
||||
@@ -81,8 +81,6 @@ Phase 3: Decomposition & Issue Creation (Inlined Agent)
|
||||
├─ Step 3.3: Issue Creation & Output Generation
|
||||
│ ├─ Internal records → issue data mapping
|
||||
│ ├─ ccw issue create for each item (get ISS-xxx IDs)
|
||||
│ ├─ Generate execution-plan.json (waves + dependencies)
|
||||
│ ├─ Generate issues.jsonl session copy
|
||||
│ └─ Generate roadmap.md with issue ID references
|
||||
└─ Step 3.4: Decomposition Quality Check (MANDATORY)
|
||||
├─ Execute CLI quality check (Gemini, Qwen fallback)
|
||||
@@ -99,8 +97,6 @@ Phase 4: Validation & team-planex Handoff
|
||||
```
|
||||
.workflow/.req-plan/RPLAN-{slug}-{YYYY-MM-DD}/
|
||||
├── roadmap.md # Human-readable roadmap with issue ID references
|
||||
├── issues.jsonl # Standard issues-jsonl-schema format (session copy)
|
||||
├── execution-plan.json # Wave grouping + issue dependencies (team-planex bridge)
|
||||
├── strategy-assessment.json # Strategy assessment result
|
||||
└── exploration-codebase.json # Codebase context (optional)
|
||||
```
|
||||
@@ -110,8 +106,6 @@ Phase 4: Validation & team-planex Handoff
|
||||
| `strategy-assessment.json` | 1 | Uncertainty analysis + mode recommendation + extracted goal/constraints/stakeholders |
|
||||
| `roadmap.md` (skeleton) | 1 | Initial skeleton with placeholders, finalized in Phase 3 |
|
||||
| `exploration-codebase.json` | 2 | Codebase context: relevant modules, patterns, integration points (only when codebase exists) |
|
||||
| `issues.jsonl` | 3 | Standard issues-jsonl-schema records, one per line (session copy of created issues) |
|
||||
| `execution-plan.json` | 3 | Wave grouping with issue dependencies for team-planex consumption |
|
||||
| `roadmap.md` (final) | 3 | Human-readable roadmap with issue ID references, convergence details, team-planex execution guide |
|
||||
|
||||
## Subagent API Reference
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// ========================================
|
||||
// QueuePanel Component
|
||||
// ========================================
|
||||
// Queue list panel for the terminal dashboard middle column.
|
||||
// Consumes existing useIssueQueue() React Query hook for queue data
|
||||
// and bridges queueExecutionStore for execution status per item.
|
||||
// Integrates with issueQueueIntegrationStore for association chain
|
||||
// highlighting and selection state.
|
||||
// Queue list panel for the terminal dashboard with tab switching.
|
||||
// Tab 1 (Queue): Issue queue items from useIssueQueue() hook.
|
||||
// Tab 2 (Orchestrator): Active orchestration plans from orchestratorStore.
|
||||
// Integrates with issueQueueIntegrationStore for association chain.
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ListChecks,
|
||||
@@ -20,8 +19,18 @@ import {
|
||||
Zap,
|
||||
Ban,
|
||||
Terminal,
|
||||
Workflow,
|
||||
Circle,
|
||||
CheckCircle2,
|
||||
SkipForward,
|
||||
Pause,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIssueQueue } from '@/hooks/useIssues';
|
||||
import {
|
||||
@@ -32,9 +41,20 @@ import {
|
||||
useQueueExecutionStore,
|
||||
selectByQueueItem,
|
||||
} from '@/stores/queueExecutionStore';
|
||||
import {
|
||||
useOrchestratorStore,
|
||||
selectActivePlans,
|
||||
selectActivePlanCount,
|
||||
type OrchestrationRunState,
|
||||
} from '@/stores/orchestratorStore';
|
||||
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Status Config ==========
|
||||
// ========== Tab Type ==========
|
||||
|
||||
type QueueTab = 'queue' | 'orchestrator';
|
||||
|
||||
// ========== Queue Tab: Status Config ==========
|
||||
|
||||
type QueueItemStatus = QueueItem['status'];
|
||||
|
||||
@@ -51,7 +71,7 @@ const STATUS_CONFIG: Record<QueueItemStatus, {
|
||||
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
|
||||
};
|
||||
|
||||
// ========== Queue Item Row ==========
|
||||
// ========== Queue Tab: Item Row ==========
|
||||
|
||||
function QueueItemRow({
|
||||
item,
|
||||
@@ -66,7 +86,6 @@ function QueueItemRow({
|
||||
const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
// Bridge to queueExecutionStore for execution status
|
||||
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
|
||||
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
|
||||
|
||||
@@ -129,58 +148,14 @@ function QueueItemRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Empty State ==========
|
||||
// ========== Queue Tab: Content ==========
|
||||
|
||||
function QueueEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-3 py-2">
|
||||
<ListChecks className="h-4 w-4 opacity-30 shrink-0" />
|
||||
<span className="text-xs">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</span>
|
||||
<span className="text-[10px] opacity-70">{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<ListChecks className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Error State ==========
|
||||
|
||||
function QueueErrorState({ error }: { error: Error }) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.error' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
function QueueTabContent({ embedded = false }: { embedded?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queueQuery = useIssueQueue();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
|
||||
|
||||
// Flatten all queue items from grouped_items
|
||||
const allItems = useMemo(() => {
|
||||
if (!queueQuery.data) return [];
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
@@ -188,18 +163,10 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
for (const group of Object.values(grouped)) {
|
||||
items.push(...group);
|
||||
}
|
||||
// Sort by execution_order
|
||||
items.sort((a, b) => a.execution_order - b.execution_order);
|
||||
return items;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
// Count active items (pending + ready + executing)
|
||||
const activeCount = useMemo(() => {
|
||||
return allItems.filter(
|
||||
(item) => item.status === 'pending' || item.status === 'ready' || item.status === 'executing'
|
||||
).length;
|
||||
}, [allItems]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(queueItemId: string) => {
|
||||
buildAssociationChain(queueItemId, 'queue');
|
||||
@@ -207,74 +174,341 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
[buildAssociationChain]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (queueQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (queueQuery.error) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.error' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">{queueQuery.error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (queueQuery.error) {
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<QueueErrorState error={queueQuery.error} />
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<ListChecks className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with flow indicator (hidden when embedded) */}
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4 text-muted-foreground" />
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{allItems.map((item) => (
|
||||
<QueueItemRow
|
||||
key={item.item_id}
|
||||
item={item}
|
||||
isHighlighted={associationChain?.queueItemId === item.item_id}
|
||||
onSelect={() => handleSelect(item.item_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Queue Item List */}
|
||||
{allItems.length === 0 ? (
|
||||
<QueueEmptyState compact={embedded} />
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{allItems.map((item) => (
|
||||
<QueueItemRow
|
||||
key={item.item_id}
|
||||
item={item}
|
||||
isHighlighted={associationChain?.queueItemId === item.item_id}
|
||||
onSelect={() => handleSelect(item.item_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
// ========== Orchestrator Tab: Status Badge ==========
|
||||
|
||||
const orchestratorStatusClass: Record<OrchestrationStatus, string> = {
|
||||
pending: 'bg-muted text-muted-foreground border-border',
|
||||
running: 'bg-primary/10 text-primary border-primary/50',
|
||||
paused: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
|
||||
completed: 'bg-green-500/10 text-green-500 border-green-500/50',
|
||||
failed: 'bg-destructive/10 text-destructive border-destructive/50',
|
||||
cancelled: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
function OrchestratorStatusBadge({ status }: { status: OrchestrationStatus }) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<span className={cn('px-2 py-0.5 rounded text-[10px] font-medium border', orchestratorStatusClass[status])}>
|
||||
{formatMessage({ id: `orchestrator.status.${status}` })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Orchestrator Tab: Step Icon ==========
|
||||
|
||||
function StepIcon({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="w-3.5 h-3.5 text-primary animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-3.5 h-3.5 text-destructive" />;
|
||||
case 'skipped':
|
||||
return <SkipForward className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
case 'paused':
|
||||
return <Pause className="w-3.5 h-3.5 text-amber-500" />;
|
||||
case 'cancelled':
|
||||
return <Square className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
default:
|
||||
return <Circle className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Orchestrator Tab: Plan Controls ==========
|
||||
|
||||
function PlanControls({ planId, status, failedStepId }: {
|
||||
planId: string;
|
||||
status: OrchestrationStatus;
|
||||
failedStepId: string | null;
|
||||
}) {
|
||||
const pauseOrchestration = useOrchestratorStore((s) => s.pauseOrchestration);
|
||||
const resumeOrchestration = useOrchestratorStore((s) => s.resumeOrchestration);
|
||||
const stopOrchestration = useOrchestratorStore((s) => s.stopOrchestration);
|
||||
const retryStep = useOrchestratorStore((s) => s.retryStep);
|
||||
const skipStep = useOrchestratorStore((s) => s.skipStep);
|
||||
|
||||
if (status === 'completed' || status === 'cancelled') return null;
|
||||
|
||||
const isPausedOnError = status === 'paused' && failedStepId !== null;
|
||||
const isPausedByUser = status === 'paused' && failedStepId === null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => pauseOrchestration(planId)}>
|
||||
<Pause className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => stopOrchestration(planId)}>
|
||||
<Square className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPausedByUser && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => resumeOrchestration(planId)}>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => stopOrchestration(planId)}>
|
||||
<Square className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isPausedOnError && failedStepId && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => retryStep(planId, failedStepId)}>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => skipStep(planId, failedStepId)}>
|
||||
<SkipForward className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-6 text-xs gap-1 px-2" onClick={() => stopOrchestration(planId)}>
|
||||
<Square className="w-3 h-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Orchestrator Tab: Plan Card ==========
|
||||
|
||||
const PlanCard = memo(function PlanCard({ runState }: { runState: OrchestrationRunState }) {
|
||||
const { plan, status, stepStatuses, currentStepIndex } = runState;
|
||||
|
||||
const { completedCount, totalCount, progress } = useMemo(() => {
|
||||
const statuses = Object.values(stepStatuses);
|
||||
const total = statuses.length;
|
||||
const completed = statuses.filter((s) => s.status === 'completed' || s.status === 'skipped').length;
|
||||
return { completedCount: completed, totalCount: total, progress: total > 0 ? (completed / total) * 100 : 0 };
|
||||
}, [stepStatuses]);
|
||||
|
||||
const failedStepId = useMemo(() => {
|
||||
for (const [stepId, stepState] of Object.entries(stepStatuses)) {
|
||||
if (stepState.status === 'failed') return stepId;
|
||||
}
|
||||
return null;
|
||||
}, [stepStatuses]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-md border-border bg-card p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-foreground truncate flex-1">{plan.name}</h4>
|
||||
<OrchestratorStatusBadge status={status} />
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
status === 'failed' && 'bg-destructive',
|
||||
status === 'completed' && 'bg-green-500',
|
||||
status === 'cancelled' && 'bg-muted-foreground',
|
||||
(status === 'running' || status === 'pending') && 'bg-primary',
|
||||
status === 'paused' && 'bg-amber-500',
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 max-h-48 overflow-y-auto">
|
||||
{plan.steps.map((step, index) => {
|
||||
const stepState = stepStatuses[step.id];
|
||||
if (!stepState) return null;
|
||||
const isCurrent = index === currentStepIndex && status === 'running';
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1 rounded text-xs',
|
||||
isCurrent && 'bg-primary/5',
|
||||
stepState.status === 'failed' && 'bg-destructive/5',
|
||||
)}
|
||||
>
|
||||
<StepIcon status={stepState.status} />
|
||||
<span className={cn(
|
||||
'truncate flex-1',
|
||||
stepState.status === 'completed' && 'text-muted-foreground',
|
||||
stepState.status === 'skipped' && 'text-muted-foreground line-through',
|
||||
stepState.status === 'failed' && 'text-destructive',
|
||||
)}>
|
||||
{step.name}
|
||||
</span>
|
||||
{stepState.retryCount > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">×{stepState.retryCount}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{failedStepId && stepStatuses[failedStepId]?.error && (
|
||||
<div className="flex items-start gap-1.5 mt-2 px-2">
|
||||
<AlertCircle className="w-3 h-3 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-destructive/80 break-words">
|
||||
{stepStatuses[failedStepId].error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlanControls planId={plan.id} status={status} failedStepId={failedStepId} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ========== Orchestrator Tab: Content ==========
|
||||
|
||||
function OrchestratorTabContent() {
|
||||
const { formatMessage } = useIntl();
|
||||
const activePlans = useOrchestratorStore(selectActivePlans);
|
||||
const planEntries = useMemo(() => Object.entries(activePlans), [activePlans]);
|
||||
|
||||
if (planEntries.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<Workflow className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{formatMessage({ id: 'terminalDashboard.orchestratorPanel.noPlans', defaultMessage: 'No active orchestrations' })}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.orchestratorPanel.noPlansHint', defaultMessage: 'Run a flow from the Orchestrator to see progress here' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
|
||||
{planEntries.map(([planId, runState]) => (
|
||||
<PlanCard key={planId} runState={runState} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<QueueTab>('queue');
|
||||
const orchestratorCount = useOrchestratorStore(selectActivePlanCount);
|
||||
|
||||
const queueQuery = useIssueQueue();
|
||||
const queueActiveCount = useMemo(() => {
|
||||
if (!queueQuery.data) return 0;
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
let count = 0;
|
||||
for (const items of Object.values(grouped)) {
|
||||
count += items.filter(
|
||||
(item) => item.status === 'pending' || item.status === 'ready' || item.status === 'executing'
|
||||
).length;
|
||||
}
|
||||
return count;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab bar */}
|
||||
{!embedded && (
|
||||
<div className="flex items-center border-b border-border shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors',
|
||||
activeTab === 'queue'
|
||||
? 'text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTab('queue')}
|
||||
>
|
||||
<ListChecks className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
{queueActiveCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
{queueActiveCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors',
|
||||
activeTab === 'orchestrator'
|
||||
? 'text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTab('orchestrator')}
|
||||
>
|
||||
<Workflow className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.toolbar.orchestrator', defaultMessage: 'Orchestrator' })}
|
||||
{orchestratorCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
{orchestratorCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'queue' ? (
|
||||
<QueueTabContent embedded={embedded} />
|
||||
) : (
|
||||
<OrchestratorTabContent />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { QueueExecutionListView } from './QueueExecutionListView';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import {
|
||||
fetchCliSessionBuffer,
|
||||
sendCliSessionText,
|
||||
@@ -273,7 +273,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
{/* Content */}
|
||||
{panelView === 'queue' ? (
|
||||
/* Queue View */
|
||||
<QueueExecutionListView />
|
||||
<QueuePanel />
|
||||
) : activeTerminalId ? (
|
||||
/* Terminal View */
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
@@ -85,7 +85,12 @@
|
||||
"modeYolo": "Yolo",
|
||||
"quickCreate": "Quick Create",
|
||||
"configure": "Configure...",
|
||||
"fullscreen": "Fullscreen"
|
||||
"fullscreen": "Fullscreen",
|
||||
"orchestrator": "Orchestrator"
|
||||
},
|
||||
"orchestratorPanel": {
|
||||
"noPlans": "No active orchestrations",
|
||||
"noPlansHint": "Run a flow from the Orchestrator to see progress here"
|
||||
},
|
||||
"cliConfig": {
|
||||
"title": "Create CLI Session",
|
||||
|
||||
@@ -85,7 +85,12 @@
|
||||
"modeYolo": "Yolo",
|
||||
"quickCreate": "快速创建",
|
||||
"configure": "配置...",
|
||||
"fullscreen": "全屏"
|
||||
"fullscreen": "全屏",
|
||||
"orchestrator": "编排器"
|
||||
},
|
||||
"orchestratorPanel": {
|
||||
"noPlans": "没有活跃的编排任务",
|
||||
"noPlansHint": "从编排器运行流程后,进度将显示在这里"
|
||||
},
|
||||
"cliConfig": {
|
||||
"title": "创建 CLI 会话",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SessionDetailPage,
|
||||
HistoryPage,
|
||||
OrchestratorPage,
|
||||
LoopMonitorPage,
|
||||
IssueHubPage,
|
||||
SkillsManagerPage,
|
||||
CommandsManagerPage,
|
||||
@@ -91,7 +90,7 @@ const routes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: 'loops',
|
||||
element: <LoopMonitorPage />,
|
||||
element: <Navigate to="/terminal-dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: 'cli-viewer',
|
||||
@@ -207,6 +206,7 @@ export const ROUTES = {
|
||||
PROJECT: '/project',
|
||||
HISTORY: '/history',
|
||||
ORCHESTRATOR: '/orchestrator',
|
||||
/** @deprecated Redirects to /terminal-dashboard */
|
||||
LOOPS: '/loops',
|
||||
CLI_VIEWER: '/cli-viewer',
|
||||
ISSUES: '/issues',
|
||||
|
||||
@@ -1463,9 +1463,17 @@ async function initAction(issueId: string | undefined, options: IssueOptions): P
|
||||
* list - List issues or tasks
|
||||
*/
|
||||
async function listAction(issueId: string | undefined, options: IssueOptions): Promise<void> {
|
||||
// Always compute summary from ALL issues (unfiltered)
|
||||
const allIssues = readIssues();
|
||||
const statusCounts: Record<string, number> = {};
|
||||
for (const i of allIssues) {
|
||||
statusCounts[i.status] = (statusCounts[i.status] || 0) + 1;
|
||||
}
|
||||
const summary = { total: allIssues.length, by_status: statusCounts };
|
||||
|
||||
if (!issueId) {
|
||||
// List all issues
|
||||
let issues = readIssues();
|
||||
let issues = allIssues;
|
||||
|
||||
// Filter by status if specified
|
||||
if (options.status) {
|
||||
@@ -1483,18 +1491,19 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
|
||||
tags: i.tags || [],
|
||||
bound_solution_id: i.bound_solution_id
|
||||
}));
|
||||
console.log(JSON.stringify(briefIssues, null, 2));
|
||||
console.log(JSON.stringify({ _summary: summary, issues: briefIssues }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(issues, null, 2));
|
||||
console.log(JSON.stringify({ _summary: summary, issues }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log(chalk.yellow('No issues found'));
|
||||
console.log(chalk.gray('Create one with: ccw issue init <issue-id>'));
|
||||
printIssueSummary(summary);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1523,6 +1532,8 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
|
||||
(issue.title || '').substring(0, 30)
|
||||
);
|
||||
}
|
||||
|
||||
printIssueSummary(summary, options.status ? issues.length : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1537,7 +1548,7 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
|
||||
const tasks = solution?.tasks || [];
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ issue, solution, tasks }, null, 2));
|
||||
console.log(JSON.stringify({ _summary: summary, issue, solution, tasks }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1549,6 +1560,7 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log(chalk.yellow('No tasks (bind a solution first)'));
|
||||
printIssueSummary(summary);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1563,6 +1575,32 @@ async function listAction(issueId: string | undefined, options: IssueOptions): P
|
||||
task.title.substring(0, 30)
|
||||
);
|
||||
}
|
||||
|
||||
printIssueSummary(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print issue summary line (total + per-status counts)
|
||||
*/
|
||||
function printIssueSummary(summary: { total: number; by_status: Record<string, number> }, filteredCount?: number): void {
|
||||
const parts = Object.entries(summary.by_status)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([status, count]) => {
|
||||
const color = {
|
||||
'registered': chalk.gray,
|
||||
'planning': chalk.blue,
|
||||
'planned': chalk.cyan,
|
||||
'queued': chalk.yellow,
|
||||
'executing': chalk.yellow,
|
||||
'completed': chalk.green,
|
||||
'failed': chalk.red,
|
||||
'paused': chalk.magenta
|
||||
}[status] || chalk.white;
|
||||
return color(`${status}: ${count}`);
|
||||
});
|
||||
|
||||
const filterInfo = filteredCount !== undefined ? ` (showing ${filteredCount})` : '';
|
||||
console.log(chalk.gray(`\nTotal: ${summary.total}${filterInfo} | ${parts.join(', ')}`));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user