From 1d6f5cd6594011ce4da539be0d2ff8ab84b3dfc3 Mon Sep 17 00:00:00 2001 From: Vincent Grobler Date: Fri, 10 Apr 2026 11:25:05 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20canvas=20improvements=20=E2=80=94?= =?UTF-8?q?=20copy/paste,=20sticky=20notes,=20I/O=20inspector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copy/paste nodes: Ctrl+C/V with auto-connection per team mode - Sticky notes: right-click canvas → Add Note, inline editing, 5 color presets - Node I/O Inspector: collapsible input/output sections in the node detail popup showing exact data received and produced during team runs - Updated keyboard shortcuts overlay with new shortcuts --- src/components/workflow/CanvasContextMenu.tsx | 12 ++ .../workflow/KeyboardShortcutsOverlay.tsx | 2 + src/components/workflow/NodeDetailPopup.tsx | 107 +++++++++++- src/components/workflow/WorkflowCanvas.tsx | 45 +++++ src/components/workflow/nodes/NoteNode.tsx | 132 ++++++++++++++ src/components/workflow/useCanvasClipboard.ts | 165 ++++++++++++++++++ src/components/workflow/useCanvasKeyboard.ts | 19 ++ 7 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 src/components/workflow/nodes/NoteNode.tsx create mode 100644 src/components/workflow/useCanvasClipboard.ts diff --git a/src/components/workflow/CanvasContextMenu.tsx b/src/components/workflow/CanvasContextMenu.tsx index 1f627bb..c89de2d 100644 --- a/src/components/workflow/CanvasContextMenu.tsx +++ b/src/components/workflow/CanvasContextMenu.tsx @@ -21,6 +21,7 @@ import { Brain, Plus, Bot, + StickyNote, } from 'lucide-react' import type { Node } from '@xyflow/react' import type { AgentNodeData } from './nodes/AgentNode' @@ -49,6 +50,8 @@ interface CanvasContextMenuProps { /** For edge context menu: insert agent between two nodes */ agents?: Agent[] onInsertAgent?: (sourceId: string, targetId: string, agent: Agent) => void + /** Add a sticky note at the given canvas position */ + onAddNote?: (x: number, y: number) => void } interface MenuItem { @@ -72,6 +75,7 @@ export function CanvasContextMenu({ onSetAsBrain, agents, onInsertAgent, + onAddNote, }: CanvasContextMenuProps) { const menuRef = useRef(null) const [showAgentPicker, setShowAgentPicker] = useState(false) @@ -178,6 +182,14 @@ export function CanvasContextMenu({ }) } else { // Pane (canvas background) context menu + if (onAddNote) { + items.push({ + label: 'Add Note', + icon: StickyNote, + onClick: () => { onAddNote(state.x, state.y); onClose() }, + }) + items.push('separator') + } items.push({ label: 'Fit View', icon: Maximize2, diff --git a/src/components/workflow/KeyboardShortcutsOverlay.tsx b/src/components/workflow/KeyboardShortcutsOverlay.tsx index 07f1087..5928914 100644 --- a/src/components/workflow/KeyboardShortcutsOverlay.tsx +++ b/src/components/workflow/KeyboardShortcutsOverlay.tsx @@ -35,6 +35,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ shortcuts: [ { keys: '⌘ Z', description: 'Undo' }, { keys: '⌘ ⇧ Z', description: 'Redo' }, + { keys: '⌘ C', description: 'Copy selected nodes' }, + { keys: '⌘ V', description: 'Paste nodes' }, { keys: '⌘ A', description: 'Select all nodes' }, { keys: 'Delete', description: 'Remove selected node' }, ], diff --git a/src/components/workflow/NodeDetailPopup.tsx b/src/components/workflow/NodeDetailPopup.tsx index 0a3ffb9..819c9df 100644 --- a/src/components/workflow/NodeDetailPopup.tsx +++ b/src/components/workflow/NodeDetailPopup.tsx @@ -5,11 +5,11 @@ * On-canvas detail popup for agent nodes. * * Appears next to the selected node, showing agent details, - * step config (pipeline), execution state, and quick actions. + * step config (pipeline), execution state, I/O inspector, and quick actions. * Uses glassmorphism styling for a premium floating card look. */ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useReactFlow } from '@xyflow/react' import type { Node } from '@xyflow/react' import { @@ -22,8 +22,12 @@ import { Check, Loader2, X, + ArrowDownToLine, + ArrowUpFromLine, + ChevronDown, + ChevronRight, } from 'lucide-react' -import type { Agent, Team, PipelineConfig, PipelineStep } from '@/types' +import type { Agent, Team, PipelineConfig, PipelineStep, TeamMessage } from '@/types' import type { AgentNodeData } from './nodes/AgentNode' import type { ExecutionNodeState } from './useExecutionState' @@ -32,6 +36,7 @@ interface NodeDetailPopupProps { team: Team agents: Agent[] executionStates: Map | null + runMessages?: TeamMessage[] onDelete?: (nodeId: string) => void onClose: () => void } @@ -43,9 +48,11 @@ const EXEC_STATUS_CONFIG: Record(null) const { getNodesBounds, flowToScreenPosition } = useReactFlow() + const [showInput, setShowInput] = useState(false) + const [showOutput, setShowOutput] = useState(false) const nodeData = node.data as unknown as AgentNodeData const isAgentNode = node.type === 'agentNode' @@ -68,6 +75,10 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete, const isBrain = nodeData.role === 'brain' || nodeData.role === 'orchestrator' const canDelete = isAgentNode && !protectedIds.has(node.id) && !(isBrain && team.mode === 'orchestrator') + // I/O: filter messages for this agent + const { inputText, outputText } = getNodeIO(agentId, runMessages) + const hasIO = inputText || outputText + // Calculate screen position of popup const bounds = getNodesBounds([node]) const screenPos = flowToScreenPosition({ x: bounds.x + bounds.width + 12, y: bounds.y }) @@ -211,6 +222,62 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete, )} + {/* I/O Inspector */} + {hasIO && ( + <> +
+
+

I/O Inspector

+ + {/* Input */} + {inputText && ( +
+ + {showInput && ( +
+                                        {inputText}
+                                    
+ )} +
+ )} + + {/* Output */} + {outputText && ( +
+ + {showOutput && ( +
+                                        {outputText}
+                                    
+ )} +
+ )} +
+ + )} + {/* Brain protection notice */} {isBrain && team.mode === 'orchestrator' && (
@@ -247,3 +314,35 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
) } + +/** + * Extract input/output text for a specific agent from team messages. + * Input = messages received by this agent (receiver_agent_id). + * Output = messages sent by this agent (sender_agent_id). + */ +function getNodeIO( + agentId: string | undefined, + messages?: TeamMessage[], +): { inputText: string; outputText: string } { + if (!agentId || !messages || messages.length === 0) { + return { inputText: '', outputText: '' } + } + + const inputMsgs = messages.filter( + (m) => m.receiver_agent_id === agentId && m.content, + ) + const outputMsgs = messages.filter( + (m) => m.sender_agent_id === agentId && m.content && m.message_type !== 'delegation', + ) + + // Use the last input/output message (most recent) + const inputText = inputMsgs.length > 0 + ? inputMsgs[inputMsgs.length - 1].content + : '' + const outputText = outputMsgs.length > 0 + ? outputMsgs[outputMsgs.length - 1].content + : '' + + return { inputText, outputText } +} + diff --git a/src/components/workflow/WorkflowCanvas.tsx b/src/components/workflow/WorkflowCanvas.tsx index f9f3c1b..9451be0 100644 --- a/src/components/workflow/WorkflowCanvas.tsx +++ b/src/components/workflow/WorkflowCanvas.tsx @@ -29,6 +29,7 @@ import './workflow.css' import { AgentNode } from './nodes/AgentNode' import { StartNode } from './nodes/StartNode' import { EndNode } from './nodes/EndNode' +import { NoteNode } from './nodes/NoteNode' import { useWorkflowGraph, graphToConfig, validateConfig, getHandleIds, updateEdgeHandles } from './useWorkflowGraph' import { useCanvasHistory } from './useCanvasHistory' import { useAutoLayout } from './useAutoLayout' @@ -37,6 +38,7 @@ import { useCanvasCamera, usePanToNode } from './useCanvasCamera' import { useCanvasKeyboard } from './useCanvasKeyboard' import { WorkflowSidebar } from './WorkflowSidebar' import { NodeDetailPopup } from './NodeDetailPopup' +import { useCanvasClipboard } from './useCanvasClipboard' import { CanvasContextMenu, type ContextMenuState } from './CanvasContextMenu' import { ExecutionTimeline } from './ExecutionTimeline' import { TranscriptPanel } from './TranscriptPanel' @@ -65,6 +67,7 @@ const NODE_TYPES: NodeTypes = { agentNode: AgentNode, startNode: StartNode, endNode: EndNode, + noteNode: NoteNode, } type TeamConfig = PipelineConfig | OrchestratorConfig | CollaborationConfig @@ -156,10 +159,24 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active ) }, [executionStates, setNodes]) + // Clipboard + const { copy, paste } = useCanvasClipboard({ + nodes, + edges, + setNodes, + setEdges, + saveGraph, + pushState, + teamMode: team.mode, + layoutDirection, + }) + // Centralized keyboard shortcuts useCanvasKeyboard({ onUndo: () => { undo(nodes, edges) }, onRedo: () => { redo(nodes, edges) }, + onCopy: copy, + onPaste: paste, onFitView: () => { void reactFlowInstance.fitView({ duration: 400, padding: 0.3 }) }, onAutoLayout: () => { pushState(nodes, edges) @@ -576,6 +593,32 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active window.location.href = `/agents/${agentId}` }, []) + // ─── Add sticky note ───────────────────────────────────────────────────── + + const handleAddNote = useCallback((screenX: number, screenY: number) => { + const bounds = document.querySelector('.react-flow')?.getBoundingClientRect() + if (!bounds) return + + const position = reactFlowInstance.screenToFlowPosition({ + x: screenX, + y: screenY, + }) + + const noteId = `note-${Date.now()}` + const newNode: Node = { + id: noteId, + type: 'noteNode', + position, + data: { content: '', color: 'yellow' }, + draggable: true, + } + + setNodes((currentNodes) => { + const updated = [...currentNodes, newNode] + return updated + }) + }, [reactFlowInstance, setNodes]) + // ─── Render ────────────────────────────────────────────────────────────── const selectedNode = nodes.find((n) => n.id === selectedNodeId) @@ -795,6 +838,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active team={team} agents={agents} executionStates={executionStates} + runMessages={runMessages} onDelete={handleDeleteNode} onClose={() => { setShowPopup(false); setSelectedNodeId(null) }} /> @@ -813,6 +857,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active onGoToAgent={handleGoToAgent} agents={agents} onInsertAgent={insertAgentBetween} + onAddNote={handleAddNote} /> )} diff --git a/src/components/workflow/nodes/NoteNode.tsx b/src/components/workflow/nodes/NoteNode.tsx new file mode 100644 index 0000000..e478aed --- /dev/null +++ b/src/components/workflow/nodes/NoteNode.tsx @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 CrewForm + +/** + * Sticky note node for the workflow canvas. + * + * Visual-only annotation — no connection handles. + * Double-click to edit, blur/Escape to save. + * Supports color presets for organisation. + */ + +import { memo, useState, useRef, useEffect, useCallback } from 'react' +import type { NodeProps } from '@xyflow/react' +import { StickyNote } from 'lucide-react' + +export interface NoteNodeData { + content: string + color?: NoteColor + [key: string]: unknown +} + +export type NoteColor = 'yellow' | 'blue' | 'green' | 'pink' | 'purple' + +const COLOR_MAP: Record = { + yellow: { + bg: 'bg-amber-400/10', + border: 'border-amber-400/30', + text: 'text-amber-200', + }, + blue: { + bg: 'bg-sky-400/10', + border: 'border-sky-400/30', + text: 'text-sky-200', + }, + green: { + bg: 'bg-emerald-400/10', + border: 'border-emerald-400/30', + text: 'text-emerald-200', + }, + pink: { + bg: 'bg-pink-400/10', + border: 'border-pink-400/30', + text: 'text-pink-200', + }, + purple: { + bg: 'bg-violet-400/10', + border: 'border-violet-400/30', + text: 'text-violet-200', + }, +} + +function NoteNodeComponent({ data, selected }: NodeProps) { + const noteData = data as unknown as NoteNodeData + const colorKey = noteData.color ?? 'yellow' + const colors = COLOR_MAP[colorKey] + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState(noteData.content) + const textareaRef = useRef(null) + + // Sync external data changes + useEffect(() => { + if (!isEditing) { + setEditContent(noteData.content) + } + }, [noteData.content, isEditing]) + + // Auto-focus on edit + useEffect(() => { + if (isEditing && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.select() + } + }, [isEditing]) + + const handleSave = useCallback(() => { + setIsEditing(false) + // Persist via data update — parent will handle save + if (editContent !== noteData.content) { + noteData.content = editContent + } + }, [editContent, noteData]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setEditContent(noteData.content) // revert + setIsEditing(false) + } + // Stop propagation to prevent canvas keyboard shortcuts while editing + e.stopPropagation() + }, [noteData.content]) + + return ( +
{ + e.stopPropagation() + setIsEditing(true) + }} + > +
+ + + Note + +
+ + {isEditing ? ( +