Files
Claude-Code-Workflow/ccw/frontend/src/pages/orchestrator/NodeLibrary.tsx
catlog22 d43696d756 feat: Implement phases 6 to 9 of the review cycle fix process, including discovery, batching, parallel planning, execution, and completion
- Added Phase 6: Fix Discovery & Batching with intelligent grouping and batching of findings.
- Added Phase 7: Fix Parallel Planning to launch planning agents for concurrent analysis and aggregation of partial plans.
- Added Phase 8: Fix Execution for stage-based execution of fixes with conservative test verification.
- Added Phase 9: Fix Completion to aggregate results, generate summary reports, and handle session completion.
- Introduced new frontend components: ResizeHandle for draggable resizing of sidebar panels and useResizablePanel hook for managing panel sizes with localStorage persistence.
- Added PowerShell script for checking TypeScript errors in source code, excluding test files.
2026-02-07 19:28:33 +08:00

357 lines
12 KiB
TypeScript

// ========================================
// Node Library Component
// ========================================
// Displays built-in and custom node templates
// Supports creating, saving, and deleting custom templates with color selection
import { DragEvent, useState } from 'react';
import { useIntl } from 'react-intl';
import {
MessageSquare, ChevronDown, ChevronRight, GripVertical,
Terminal, Plus, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useFlowStore } from '@/stores';
import { QUICK_TEMPLATES } from '@/types/flow';
import type { QuickTemplate } from '@/types/flow';
// ========== Icon Mapping ==========
const TEMPLATE_ICONS: Record<string, React.ElementType> = {
'slash-command-main': Terminal,
'slash-command-async': Terminal,
};
// ========== I18n Key Mapping for Built-in Templates ==========
const TEMPLATE_I18N: Record<string, { labelKey: string; descKey: string }> = {
'prompt-template': {
labelKey: 'orchestrator.nodeLibrary.promptTemplateLabel',
descKey: 'orchestrator.nodeLibrary.promptTemplateDesc',
},
'slash-command-main': {
labelKey: 'orchestrator.nodeLibrary.slashCommandLabel',
descKey: 'orchestrator.nodeLibrary.slashCommandDesc',
},
'slash-command-async': {
labelKey: 'orchestrator.nodeLibrary.slashCommandAsyncLabel',
descKey: 'orchestrator.nodeLibrary.slashCommandAsyncDesc',
},
};
// ========== Color Palette for custom templates ==========
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 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>
<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>
);
}
/**
* Draggable card for a quick template
*/
function QuickTemplateCard({
template,
onDelete,
}: {
template: QuickTemplate;
onDelete?: () => void;
}) {
const { formatMessage } = useIntl();
const Icon = TEMPLATE_ICONS[template.id] || MessageSquare;
const i18n = TEMPLATE_I18N[template.id];
const displayLabel = i18n ? formatMessage({ id: i18n.labelKey }) : template.label;
const displayDesc = i18n ? formatMessage({ id: i18n.descKey }) : template.description;
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
event.dataTransfer.setData('application/reactflow-template-id', template.id);
event.dataTransfer.effectAllowed = 'move';
};
const onDoubleClick = () => {
useFlowStore.getState().addNodeFromTemplate(template.id, { x: 250, y: 200 });
};
return (
<div
draggable
onDragStart={onDragStart}
onDoubleClick={onDoubleClick}
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]',
)}
>
<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">{displayLabel}</div>
<div className="text-xs text-muted-foreground truncate">{displayDesc}</div>
</div>
{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={formatMessage({ id: 'orchestrator.nodeLibrary.deleteTemplate' })}
>
<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>
);
}
/**
* Basic empty prompt template card
*/
function BasicTemplateCard() {
const { formatMessage } = useIntl();
const i18n = TEMPLATE_I18N['prompt-template'];
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
event.dataTransfer.effectAllowed = 'move';
};
const onDoubleClick = () => {
useFlowStore.getState().addNode({ x: 250, y: 200 });
};
return (
<div
draggable
onDragStart={onDragStart}
onDoubleClick={onDoubleClick}
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-dashed border-muted-foreground/50 hover:border-primary',
)}
>
<div className="p-2 rounded-md text-white bg-blue-500 hover:bg-blue-600">
<Plus className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{formatMessage({ id: i18n.labelKey })}</div>
<div className="text-xs text-muted-foreground truncate">{formatMessage({ id: i18n.descKey })}</div>
</div>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
);
}
/**
* Inline form for creating a new custom template
*/
function CreateTemplateForm({ onClose }: { onClose: () => void }) {
const { formatMessage } = useIntl();
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">{formatMessage({ id: 'orchestrator.nodeLibrary.newCustomNode' })}</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={formatMessage({ id: 'orchestrator.nodeLibrary.nodeName' })}
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={formatMessage({ id: 'orchestrator.nodeLibrary.descriptionOptional' })}
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={formatMessage({ id: 'orchestrator.nodeLibrary.defaultInstructionOptional' })}
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">{formatMessage({ id: 'orchestrator.nodeLibrary.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',
)}
>
{formatMessage({ id: 'orchestrator.nodeLibrary.save' })}
</button>
</div>
);
}
// ========== Main Component ==========
interface NodeLibraryProps {
className?: string;
}
/**
* 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 { formatMessage } = useIntl();
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)}>
{/* Built-in templates */}
<TemplateCategory title={formatMessage({ id: 'orchestrator.nodeLibrary.builtIn' })} defaultExpanded>
<BasicTemplateCard />
{QUICK_TEMPLATES.map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
{/* Custom templates */}
<TemplateCategory
title={formatMessage({ id: 'orchestrator.nodeLibrary.custom' }, { count: 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={formatMessage({ id: 'orchestrator.nodeLibrary.createCustomNode' })}
>
<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">
{formatMessage({ id: 'orchestrator.nodeLibrary.noCustomNodes' })}
</div>
)}
</TemplateCategory>
</div>
);
}
export default NodeLibrary;