From a19ef94444824550662b04dc3babe396db98cbb3 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 5 Feb 2026 09:57:20 +0800 Subject: [PATCH] feat: add quick template functionality to NodePalette and enhance node creation experience --- .../src/components/shared/NavGroup.tsx | 8 +- ccw/frontend/src/components/ui/Button.tsx | 4 + ccw/frontend/src/components/ui/Card.tsx | 16 ++ ccw/frontend/src/index.css | 46 +++++ .../src/pages/orchestrator/FlowCanvas.tsx | 14 +- .../src/pages/orchestrator/NodePalette.tsx | 166 ++++++++++++++---- ccw/frontend/src/stores/appStore.ts | 12 ++ ccw/frontend/src/stores/flowStore.ts | 40 ++++- ccw/frontend/src/types/flow.ts | 25 +++ 9 files changed, 293 insertions(+), 38 deletions(-) diff --git a/ccw/frontend/src/components/shared/NavGroup.tsx b/ccw/frontend/src/components/shared/NavGroup.tsx index e174ec80..4b17acb9 100644 --- a/ccw/frontend/src/components/shared/NavGroup.tsx +++ b/ccw/frontend/src/components/shared/NavGroup.tsx @@ -65,8 +65,8 @@ export function NavGroup({ to={item.path} onClick={onNavClick} className={cn( - 'flex items-center justify-center gap-3 px-2 py-2.5 rounded-md text-sm transition-colors', - 'hover:bg-hover hover:text-foreground', + 'flex items-center justify-center gap-3 px-2 py-2.5 rounded-md text-sm transition-all duration-200', + 'hover:bg-hover hover:text-foreground hover-glow', isActive ? 'bg-primary/10 text-primary font-medium' : 'text-muted-foreground' @@ -107,8 +107,8 @@ export function NavGroup({ to={item.path} onClick={onNavClick} className={cn( - 'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors pl-6', - 'hover:bg-hover hover:text-foreground', + 'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-all duration-200 pl-6', + 'hover:bg-hover hover:text-foreground hover-glow', (isActive && !searchParams) || isQueryParamActive ? 'bg-primary/10 text-primary font-medium' : 'text-muted-foreground' diff --git a/ccw/frontend/src/components/ui/Button.tsx b/ccw/frontend/src/components/ui/Button.tsx index 274ce73a..f63dc787 100644 --- a/ccw/frontend/src/components/ui/Button.tsx +++ b/ccw/frontend/src/components/ui/Button.tsx @@ -19,6 +19,10 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", + gradient: + "bg-gradient-brand text-primary-foreground hover-glow", + gradientPrimary: + "bg-gradient-primary text-primary-foreground hover-glow-primary", }, size: { default: "h-10 px-4 py-2", diff --git a/ccw/frontend/src/components/ui/Card.tsx b/ccw/frontend/src/components/ui/Card.tsx index 532612cf..d03cf33c 100644 --- a/ccw/frontend/src/components/ui/Card.tsx +++ b/ccw/frontend/src/components/ui/Card.tsx @@ -75,6 +75,21 @@ const CardFooter = React.forwardRef< )); CardFooter.displayName = "CardFooter"; +const CardGradientBorder = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardGradientBorder.displayName = "CardGradientBorder"; + export { Card, CardHeader, @@ -82,4 +97,5 @@ export { CardTitle, CardDescription, CardContent, + CardGradientBorder, }; diff --git a/ccw/frontend/src/index.css b/ccw/frontend/src/index.css index ccdd5a12..b32746d6 100644 --- a/ccw/frontend/src/index.css +++ b/ccw/frontend/src/index.css @@ -535,3 +535,49 @@ animation: none; background-size: 100% 100%; } + +/* =========================== + Global Ambient Gradient Background + Always visible behind content + =========================== */ + +/* Standard ambient gradient - subtle */ +[data-gradient="standard"] body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 30% 30%, hsla(var(--accent), 0.03) 0%, transparent 50%), + radial-gradient(circle at 70% 70%, hsla(var(--primary), 0.03) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + +/* Enhanced ambient gradient - more vibrant with animation */ +[data-gradient="enhanced"] body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 20% 20%, hsla(var(--accent), 0.08) 0%, transparent 40%), + radial-gradient(circle at 80% 80%, hsla(var(--primary), 0.08) 0%, transparent 40%), + radial-gradient(circle at 50% 50%, hsla(var(--secondary), 0.05) 0%, transparent 50%); + pointer-events: none; + z-index: -1; + animation: ambient-shift 20s ease-in-out infinite; +} + +/* Disable ambient gradient when off */ +[data-gradient="off"] body::before { + display: none; +} + +/* Ambient shift animation for enhanced gradient */ +@keyframes ambient-shift { + 0%, 100% { transform: translate(0, 0); } + 50% { transform: translate(-2%, -2%); } +} diff --git a/ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx b/ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx index 78170f57..25dfe326 100644 --- a/ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx +++ b/ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx @@ -42,6 +42,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) { const setNodes = useFlowStore((state) => state.setNodes); const setEdges = useFlowStore((state) => state.setEdges); const addNode = useFlowStore((state) => state.addNode); + const addNodeFromTemplate = useFlowStore((state) => state.addNodeFromTemplate); const setSelectedNodeId = useFlowStore((state) => state.setSelectedNodeId); const setSelectedEdgeId = useFlowStore((state) => state.setSelectedEdgeId); const markModified = useFlowStore((state) => state.markModified); @@ -127,10 +128,17 @@ function FlowCanvasInner({ className }: FlowCanvasProps) { y: event.clientY, }); - // Add prompt-template node at drop position - addNode(position); + // Check if a template ID is provided + const templateId = event.dataTransfer.getData('application/reactflow-template-id'); + if (templateId) { + // Use quick template + addNodeFromTemplate(templateId, position); + } else { + // Use basic empty node + addNode(position); + } }, - [screenToFlowPosition, addNode] + [screenToFlowPosition, addNode, addNodeFromTemplate] ); return ( diff --git a/ccw/frontend/src/pages/orchestrator/NodePalette.tsx b/ccw/frontend/src/pages/orchestrator/NodePalette.tsx index 86886fd7..3c120280 100644 --- a/ccw/frontend/src/pages/orchestrator/NodePalette.tsx +++ b/ccw/frontend/src/pages/orchestrator/NodePalette.tsx @@ -1,44 +1,113 @@ // ======================================== // Node Palette Component // ======================================== -// Draggable node palette for creating new nodes +// Draggable node palette with quick templates for creating nodes import { DragEvent, useState } from 'react'; import { useIntl } from 'react-intl'; -import { MessageSquare, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'; +import { + MessageSquare, ChevronDown, ChevronRight, GripVertical, + Search, Code, FileOutput, GitBranch, GitFork, GitMerge, Plus, Terminal +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { useFlowStore } from '@/stores'; -import { NODE_TYPE_CONFIGS } from '@/types/flow'; +import { NODE_TYPE_CONFIGS, QUICK_TEMPLATES } from '@/types/flow'; interface NodePaletteProps { className?: string; } /** - * Draggable card for the unified Prompt Template node type + * Icon mapping for quick templates */ -function PromptTemplateCard() { - const config = NODE_TYPE_CONFIGS['prompt-template']; +const TEMPLATE_ICONS: Record = { + 'slash-command-main': Terminal, + 'slash-command-async': Terminal, + analysis: Search, + implementation: Code, + 'file-operation': FileOutput, + conditional: GitBranch, + parallel: GitFork, + merge: GitMerge, +}; - // Handle drag start +/** + * Draggable card for a quick template + */ +function QuickTemplateCard({ + template, +}: { + template: typeof QUICK_TEMPLATES[number]; +}) { + const Icon = TEMPLATE_ICONS[template.id] || MessageSquare; + + // Handle drag start - store template ID const onDragStart = (event: DragEvent) => { event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template'); + event.dataTransfer.setData('application/reactflow-template-id', template.id); event.dataTransfer.effectAllowed = 'move'; }; + // Handle double-click to add node at default position + const onDoubleClick = () => { + const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }; + useFlowStore.getState().addNodeFromTemplate(template.id, position); + }; + return (
+
+ +
+
+
{template.label}
+
{template.description}
+
+ +
+ ); +} + +/** + * Basic empty prompt template card + */ +function BasicTemplateCard() { + const config = NODE_TYPE_CONFIGS['prompt-template']; + + const onDragStart = (event: DragEvent) => { + event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template'); + event.dataTransfer.effectAllowed = 'move'; + }; + + const onDoubleClick = () => { + const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 }; + useFlowStore.getState().addNode(position); + }; + + return ( +
- +
{config.label}
@@ -49,9 +118,41 @@ function PromptTemplateCard() { ); } +/** + * Category section with expand/collapse + */ +function TemplateCategory({ + title, + children, + defaultExpanded = true, +}: { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +}) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( +
+ + + {isExpanded &&
{children}
} +
+ ); +} + export function NodePalette({ className }: NodePaletteProps) { const { formatMessage } = useIntl(); - const [isExpanded, setIsExpanded] = useState(true); const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen); const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen); @@ -91,34 +192,39 @@ export function NodePalette({ className }: NodePaletteProps) { {formatMessage({ id: 'orchestrator.palette.instructions' })}
- {/* Node Type Categories */} + {/* Template Categories */}
- {/* Execution Nodes */} -
- + {/* Basic / Empty Template */} + + + - {isExpanded && ( -
- -
- )} -
+ {/* Slash Commands */} + + {QUICK_TEMPLATES.filter(t => t.id.startsWith('slash-command')).map((template) => ( + + ))} + + + {/* CLI Tools */} + + {QUICK_TEMPLATES.filter(t => ['analysis', 'implementation'].includes(t.id)).map((template) => ( + + ))} + + + {/* Flow Control */} + + {QUICK_TEMPLATES.filter(t => ['file-operation', 'conditional', 'parallel', 'merge'].includes(t.id)).map((template) => ( + + ))} +
{/* Footer */}
- {formatMessage({ id: 'orchestrator.palette.tipLabel' })} {formatMessage({ id: 'orchestrator.palette.tip' })} + Tip: Drag to canvas or double-click to add
diff --git a/ccw/frontend/src/stores/appStore.ts b/ccw/frontend/src/stores/appStore.ts index 6f65a26c..fd885866 100644 --- a/ccw/frontend/src/stores/appStore.ts +++ b/ccw/frontend/src/stores/appStore.ts @@ -364,6 +364,18 @@ if (typeof window !== 'undefined') { ); } }); + + // Apply initial theme immediately (before localStorage rehydration) + // This ensures gradient attributes are set from the start + const state = useAppStore.getState(); + applyThemeToDocument( + state.resolvedTheme, + state.colorScheme, + state.customHue, + state.gradientLevel, + state.enableHoverGlow, + state.enableBackgroundAnimation + ); } // Selectors for common access patterns diff --git a/ccw/frontend/src/stores/flowStore.ts b/ccw/frontend/src/stores/flowStore.ts index c56a7314..bd195e8c 100644 --- a/ccw/frontend/src/stores/flowStore.ts +++ b/ccw/frontend/src/stores/flowStore.ts @@ -13,7 +13,7 @@ import type { NodeData, FlowEdgeData, } from '../types/flow'; -import { NODE_TYPE_CONFIGS as nodeConfigs } from '../types/flow'; +import { NODE_TYPE_CONFIGS as nodeConfigs, QUICK_TEMPLATES } from '../types/flow'; // Helper to generate unique IDs const generateId = (prefix: string): string => { @@ -257,6 +257,44 @@ export const useFlowStore = create()( return id; }, + addNodeFromTemplate: (templateId: string, position: { x: number; y: number }): string => { + const template = QUICK_TEMPLATES.find((t) => t.id === templateId); + if (!template) { + console.error(`Template not found: ${templateId}`); + return get().addNode(position); + } + + const id = generateId('node'); + const config = nodeConfigs['prompt-template']; + + // Merge template data with default data + const nodeData: NodeData = { + ...config.defaultData, + ...template.data, + label: template.data.label || template.label, + contextRefs: template.data.contextRefs || [], + }; + + const newNode: FlowNode = { + id, + type: 'prompt-template', + position, + data: nodeData, + }; + + set( + (state) => ({ + nodes: [...state.nodes, newNode], + isModified: true, + selectedNodeId: id, + }), + false, + 'addNodeFromTemplate' + ); + + return id; + }, + updateNode: (id: string, data: Partial) => { set( (state) => ({ diff --git a/ccw/frontend/src/types/flow.ts b/ccw/frontend/src/types/flow.ts index 8fab0f13..6468fa78 100644 --- a/ccw/frontend/src/types/flow.ts +++ b/ccw/frontend/src/types/flow.ts @@ -197,6 +197,7 @@ export interface FlowActions { // Node operations addNode: (position: { x: number; y: number }) => string; + addNodeFromTemplate: (templateId: string, position: { x: number; y: number }) => string; updateNode: (id: string, data: Partial) => void; removeNode: (id: string) => void; setNodes: (nodes: FlowNode[]) => void; @@ -286,6 +287,30 @@ export interface QuickTemplate { * All use 'prompt-template' type with preset configurations */ export const QUICK_TEMPLATES: QuickTemplate[] = [ + { + id: 'slash-command-main', + label: 'Slash Command', + description: 'Execute /workflow commands (main thread)', + icon: 'Terminal', + color: 'bg-rose-500', + data: { + label: 'Slash Command', + instruction: 'Execute slash command:\n\n/workflow:plan "Implement [feature]"\n\nAvailable commands:\n- /workflow:plan\n- /workflow:lite-plan\n- /workflow:execute\n- /workflow:analyze-with-file\n- /workflow:brainstorm-with-file\n- /workflow:test-fix-gen', + mode: 'mainprocess', + }, + }, + { + id: 'slash-command-async', + label: 'Slash Command (Async)', + description: 'Execute /workflow commands (background)', + icon: 'Terminal', + color: 'bg-rose-400', + data: { + label: 'Slash Command (Async)', + instruction: 'Execute slash command in background:\n\n/workflow:execute --in-memory\n\nThe workflow will run asynchronously via CLI. Continue to next step without waiting.', + mode: 'async', + }, + }, { id: 'analysis', label: 'Analysis',