Skip to content

Commit 01dba3c

Browse files
Merge pull request #346 from CrewForm/feat/canvas-improvements
Feat/canvas improvements
2 parents cb3636b + 50a61d9 commit 01dba3c

12 files changed

Lines changed: 602 additions & 28 deletions

src/components/workflow/CanvasContextMenu.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
Brain,
2222
Plus,
2323
Bot,
24+
StickyNote,
2425
} from 'lucide-react'
2526
import type { Node } from '@xyflow/react'
2627
import type { AgentNodeData } from './nodes/AgentNode'
@@ -49,6 +50,8 @@ interface CanvasContextMenuProps {
4950
/** For edge context menu: insert agent between two nodes */
5051
agents?: Agent[]
5152
onInsertAgent?: (sourceId: string, targetId: string, agent: Agent) => void
53+
/** Add a sticky note at the given canvas position */
54+
onAddNote?: (x: number, y: number) => void
5255
}
5356

5457
interface MenuItem {
@@ -72,6 +75,7 @@ export function CanvasContextMenu({
7275
onSetAsBrain,
7376
agents,
7477
onInsertAgent,
78+
onAddNote,
7579
}: CanvasContextMenuProps) {
7680
const menuRef = useRef<HTMLDivElement>(null)
7781
const [showAgentPicker, setShowAgentPicker] = useState(false)
@@ -178,6 +182,14 @@ export function CanvasContextMenu({
178182
})
179183
} else {
180184
// Pane (canvas background) context menu
185+
if (onAddNote) {
186+
items.push({
187+
label: 'Add Note',
188+
icon: StickyNote,
189+
onClick: () => { onAddNote(state.x, state.y); onClose() },
190+
})
191+
items.push('separator')
192+
}
181193
items.push({
182194
label: 'Fit View',
183195
icon: Maximize2,

src/components/workflow/KeyboardShortcutsOverlay.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
3535
shortcuts: [
3636
{ keys: '⌘ Z', description: 'Undo' },
3737
{ keys: '⌘ ⇧ Z', description: 'Redo' },
38+
{ keys: '⌘ C', description: 'Copy selected nodes' },
39+
{ keys: '⌘ V', description: 'Paste nodes' },
3840
{ keys: '⌘ A', description: 'Select all nodes' },
3941
{ keys: 'Delete', description: 'Remove selected node' },
4042
],

src/components/workflow/NodeDetailPopup.tsx

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
* On-canvas detail popup for agent nodes.
66
*
77
* Appears next to the selected node, showing agent details,
8-
* step config (pipeline), execution state, and quick actions.
8+
* step config (pipeline), execution state, I/O inspector, and quick actions.
99
* Uses glassmorphism styling for a premium floating card look.
1010
*/
1111

12-
import { useEffect, useRef } from 'react'
12+
import { useEffect, useRef, useState } from 'react'
1313
import { useReactFlow } from '@xyflow/react'
1414
import type { Node } from '@xyflow/react'
1515
import {
@@ -22,8 +22,12 @@ import {
2222
Check,
2323
Loader2,
2424
X,
25+
ArrowDownToLine,
26+
ArrowUpFromLine,
27+
ChevronDown,
28+
ChevronRight,
2529
} from 'lucide-react'
26-
import type { Agent, Team, PipelineConfig, PipelineStep } from '@/types'
30+
import type { Agent, Team, PipelineConfig, PipelineStep, TeamMessage } from '@/types'
2731
import type { AgentNodeData } from './nodes/AgentNode'
2832
import type { ExecutionNodeState } from './useExecutionState'
2933

@@ -32,6 +36,7 @@ interface NodeDetailPopupProps {
3236
team: Team
3337
agents: Agent[]
3438
executionStates: Map<string, ExecutionNodeState> | null
39+
runMessages?: TeamMessage[]
3540
onDelete?: (nodeId: string) => void
3641
onClose: () => void
3742
}
@@ -43,9 +48,11 @@ const EXEC_STATUS_CONFIG: Record<ExecutionNodeState, { label: string; className:
4348
failed: { label: 'Failed', className: 'text-red-400', Icon: X },
4449
}
4550

46-
export function NodeDetailPopup({ node, team, agents, executionStates, onDelete, onClose }: NodeDetailPopupProps) {
51+
export function NodeDetailPopup({ node, team, agents, executionStates, runMessages, onDelete, onClose }: NodeDetailPopupProps) {
4752
const popupRef = useRef<HTMLDivElement>(null)
4853
const { getNodesBounds, flowToScreenPosition } = useReactFlow()
54+
const [showInput, setShowInput] = useState(false)
55+
const [showOutput, setShowOutput] = useState(false)
4956

5057
const nodeData = node.data as unknown as AgentNodeData
5158
const isAgentNode = node.type === 'agentNode'
@@ -68,6 +75,10 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
6875
const isBrain = nodeData.role === 'brain' || nodeData.role === 'orchestrator'
6976
const canDelete = isAgentNode && !protectedIds.has(node.id) && !(isBrain && team.mode === 'orchestrator')
7077

78+
// I/O: filter messages for this agent
79+
const { inputText, outputText } = getNodeIO(agentId, runMessages)
80+
const hasIO = inputText || outputText
81+
7182
// Calculate screen position of popup
7283
const bounds = getNodesBounds([node])
7384
const screenPos = flowToScreenPosition({ x: bounds.x + bounds.width + 12, y: bounds.y })
@@ -211,6 +222,62 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
211222
</>
212223
)}
213224

225+
{/* I/O Inspector */}
226+
{hasIO && (
227+
<>
228+
<hr className="border-white/5 mb-2" />
229+
<div className="space-y-1.5 mb-3">
230+
<p className="text-[10px] font-medium uppercase tracking-wider text-gray-600">I/O Inspector</p>
231+
232+
{/* Input */}
233+
{inputText && (
234+
<div>
235+
<button
236+
type="button"
237+
onClick={() => setShowInput(!showInput)}
238+
className="flex items-center gap-1 text-[10px] text-sky-400 hover:text-sky-300 transition-colors w-full"
239+
>
240+
<ArrowDownToLine className="h-3 w-3" />
241+
<span className="font-medium">Input</span>
242+
{showInput
243+
? <ChevronDown className="h-2.5 w-2.5 ml-auto" />
244+
: <ChevronRight className="h-2.5 w-2.5 ml-auto" />
245+
}
246+
</button>
247+
{showInput && (
248+
<pre className="mt-1 rounded-md bg-black/30 border border-white/5 p-2 text-[10px] text-gray-400 leading-relaxed max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
249+
{inputText}
250+
</pre>
251+
)}
252+
</div>
253+
)}
254+
255+
{/* Output */}
256+
{outputText && (
257+
<div>
258+
<button
259+
type="button"
260+
onClick={() => setShowOutput(!showOutput)}
261+
className="flex items-center gap-1 text-[10px] text-emerald-400 hover:text-emerald-300 transition-colors w-full"
262+
>
263+
<ArrowUpFromLine className="h-3 w-3" />
264+
<span className="font-medium">Output</span>
265+
{showOutput
266+
? <ChevronDown className="h-2.5 w-2.5 ml-auto" />
267+
: <ChevronRight className="h-2.5 w-2.5 ml-auto" />
268+
}
269+
</button>
270+
{showOutput && (
271+
<pre className="mt-1 rounded-md bg-black/30 border border-white/5 p-2 text-[10px] text-gray-300 leading-relaxed max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
272+
{outputText}
273+
</pre>
274+
)}
275+
</div>
276+
)}
277+
</div>
278+
</>
279+
)}
280+
214281
{/* Brain protection notice */}
215282
{isBrain && team.mode === 'orchestrator' && (
216283
<div className="flex items-start gap-1.5 rounded-md bg-amber-500/5 border border-amber-500/15 px-2.5 py-2 mb-3">
@@ -247,3 +314,35 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
247314
</div>
248315
)
249316
}
317+
318+
/**
319+
* Extract input/output text for a specific agent from team messages.
320+
* Input = messages received by this agent (receiver_agent_id).
321+
* Output = messages sent by this agent (sender_agent_id).
322+
*/
323+
function getNodeIO(
324+
agentId: string | undefined,
325+
messages?: TeamMessage[],
326+
): { inputText: string; outputText: string } {
327+
if (!agentId || !messages || messages.length === 0) {
328+
return { inputText: '', outputText: '' }
329+
}
330+
331+
const inputMsgs = messages.filter(
332+
(m) => m.receiver_agent_id === agentId && m.content,
333+
)
334+
const outputMsgs = messages.filter(
335+
(m) => m.sender_agent_id === agentId && m.content && m.message_type !== 'delegation',
336+
)
337+
338+
// Use the last input/output message (most recent)
339+
const inputText = inputMsgs.length > 0
340+
? inputMsgs[inputMsgs.length - 1].content
341+
: ''
342+
const outputText = outputMsgs.length > 0
343+
? outputMsgs[outputMsgs.length - 1].content
344+
: ''
345+
346+
return { inputText, outputText }
347+
}
348+

src/components/workflow/WorkflowCanvas.tsx

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import './workflow.css'
2929
import { AgentNode } from './nodes/AgentNode'
3030
import { StartNode } from './nodes/StartNode'
3131
import { EndNode } from './nodes/EndNode'
32+
import { NoteNode } from './nodes/NoteNode'
3233
import { useWorkflowGraph, graphToConfig, validateConfig, getHandleIds, updateEdgeHandles } from './useWorkflowGraph'
3334
import { useCanvasHistory } from './useCanvasHistory'
3435
import { useAutoLayout } from './useAutoLayout'
@@ -37,6 +38,7 @@ import { useCanvasCamera, usePanToNode } from './useCanvasCamera'
3738
import { useCanvasKeyboard } from './useCanvasKeyboard'
3839
import { WorkflowSidebar } from './WorkflowSidebar'
3940
import { NodeDetailPopup } from './NodeDetailPopup'
41+
import { useCanvasClipboard } from './useCanvasClipboard'
4042
import { CanvasContextMenu, type ContextMenuState } from './CanvasContextMenu'
4143
import { ExecutionTimeline } from './ExecutionTimeline'
4244
import { TranscriptPanel } from './TranscriptPanel'
@@ -65,6 +67,7 @@ const NODE_TYPES: NodeTypes = {
6567
agentNode: AgentNode,
6668
startNode: StartNode,
6769
endNode: EndNode,
70+
noteNode: NoteNode,
6871
}
6972

7073
type TeamConfig = PipelineConfig | OrchestratorConfig | CollaborationConfig
@@ -156,28 +159,6 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
156159
)
157160
}, [executionStates, setNodes])
158161

159-
// Centralized keyboard shortcuts
160-
useCanvasKeyboard({
161-
onUndo: () => { undo(nodes, edges) },
162-
onRedo: () => { redo(nodes, edges) },
163-
onFitView: () => { void reactFlowInstance.fitView({ duration: 400, padding: 0.3 }) },
164-
onAutoLayout: () => {
165-
pushState(nodes, edges)
166-
const layoutedNodes = applyAutoLayout(nodes, edges)
167-
setNodes(layoutedNodes)
168-
},
169-
onToggleTranscript: () => setShowTranscript((v) => !v),
170-
onToggleShortcuts: () => setShowShortcuts((v) => !v),
171-
onEscape: () => {
172-
setShowPopup(false)
173-
setSelectedNodeId(null)
174-
setContextMenu(null)
175-
setShowShortcuts(false)
176-
},
177-
onSelectAll: () => {
178-
setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
179-
},
180-
})
181162

182163
// ─── Save logic ──────────────────────────────────────────────────────────
183164

@@ -217,6 +198,43 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
217198
}
218199
}, [team, agents, onSaveConfig, onCanvasError, setNodes, setEdges])
219200

201+
// Clipboard
202+
const { copy, paste } = useCanvasClipboard({
203+
nodes,
204+
edges,
205+
setNodes,
206+
setEdges,
207+
saveGraph,
208+
pushState,
209+
teamMode: team.mode,
210+
layoutDirection,
211+
})
212+
213+
// Centralized keyboard shortcuts
214+
useCanvasKeyboard({
215+
onUndo: () => { undo(nodes, edges) },
216+
onRedo: () => { redo(nodes, edges) },
217+
onCopy: copy,
218+
onPaste: paste,
219+
onFitView: () => { void reactFlowInstance.fitView({ duration: 400, padding: 0.3 }) },
220+
onAutoLayout: () => {
221+
pushState(nodes, edges)
222+
const layoutedNodes = applyAutoLayout(nodes, edges)
223+
setNodes(layoutedNodes)
224+
},
225+
onToggleTranscript: () => setShowTranscript((v) => !v),
226+
onToggleShortcuts: () => setShowShortcuts((v) => !v),
227+
onEscape: () => {
228+
setShowPopup(false)
229+
setSelectedNodeId(null)
230+
setContextMenu(null)
231+
setShowShortcuts(false)
232+
},
233+
onSelectAll: () => {
234+
setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
235+
},
236+
})
237+
220238
// ─── Node interactions ───────────────────────────────────────────────────
221239

222240
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
@@ -576,6 +594,32 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
576594
window.location.href = `/agents/${agentId}`
577595
}, [])
578596

597+
// ─── Add sticky note ─────────────────────────────────────────────────────
598+
599+
const handleAddNote = useCallback((screenX: number, screenY: number) => {
600+
const bounds = document.querySelector('.react-flow')?.getBoundingClientRect()
601+
if (!bounds) return
602+
603+
const position = reactFlowInstance.screenToFlowPosition({
604+
x: screenX,
605+
y: screenY,
606+
})
607+
608+
const noteId = `note-${Date.now()}`
609+
const newNode: Node = {
610+
id: noteId,
611+
type: 'noteNode',
612+
position,
613+
data: { content: '', color: 'yellow' },
614+
draggable: true,
615+
}
616+
617+
setNodes((currentNodes) => {
618+
const updated = [...currentNodes, newNode]
619+
return updated
620+
})
621+
}, [reactFlowInstance, setNodes])
622+
579623
// ─── Render ──────────────────────────────────────────────────────────────
580624

581625
const selectedNode = nodes.find((n) => n.id === selectedNodeId)
@@ -795,6 +839,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
795839
team={team}
796840
agents={agents}
797841
executionStates={executionStates}
842+
runMessages={runMessages}
798843
onDelete={handleDeleteNode}
799844
onClose={() => { setShowPopup(false); setSelectedNodeId(null) }}
800845
/>
@@ -813,6 +858,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
813858
onGoToAgent={handleGoToAgent}
814859
agents={agents}
815860
onInsertAgent={insertAgentBetween}
861+
onAddNote={handleAddNote}
816862
/>
817863
)}
818864

0 commit comments

Comments
 (0)