mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +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:
@@ -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' })}
|
||||
|
||||
Reference in New Issue
Block a user