Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/components/workflow/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Brain,
Plus,
Bot,
StickyNote,
} from 'lucide-react'
import type { Node } from '@xyflow/react'
import type { AgentNodeData } from './nodes/AgentNode'
Expand Down Expand Up @@ -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 {
Expand All @@ -72,6 +75,7 @@ export function CanvasContextMenu({
onSetAsBrain,
agents,
onInsertAgent,
onAddNote,
}: CanvasContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const [showAgentPicker, setShowAgentPicker] = useState(false)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/components/workflow/KeyboardShortcutsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand Down
107 changes: 103 additions & 4 deletions src/components/workflow/NodeDetailPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'

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

export function NodeDetailPopup({ node, team, agents, executionStates, onDelete, onClose }: NodeDetailPopupProps) {
export function NodeDetailPopup({ node, team, agents, executionStates, runMessages, onDelete, onClose }: NodeDetailPopupProps) {
const popupRef = useRef<HTMLDivElement>(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'
Expand All @@ -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 })
Expand Down Expand Up @@ -211,6 +222,62 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
</>
)}

{/* I/O Inspector */}
{hasIO && (
<>
<hr className="border-white/5 mb-2" />
<div className="space-y-1.5 mb-3">
<p className="text-[10px] font-medium uppercase tracking-wider text-gray-600">I/O Inspector</p>

{/* Input */}
{inputText && (
<div>
<button
type="button"
onClick={() => setShowInput(!showInput)}
className="flex items-center gap-1 text-[10px] text-sky-400 hover:text-sky-300 transition-colors w-full"
>
<ArrowDownToLine className="h-3 w-3" />
<span className="font-medium">Input</span>
{showInput
? <ChevronDown className="h-2.5 w-2.5 ml-auto" />
: <ChevronRight className="h-2.5 w-2.5 ml-auto" />
}
</button>
{showInput && (
<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">
{inputText}
</pre>
)}
</div>
)}

{/* Output */}
{outputText && (
<div>
<button
type="button"
onClick={() => setShowOutput(!showOutput)}
className="flex items-center gap-1 text-[10px] text-emerald-400 hover:text-emerald-300 transition-colors w-full"
>
<ArrowUpFromLine className="h-3 w-3" />
<span className="font-medium">Output</span>
{showOutput
? <ChevronDown className="h-2.5 w-2.5 ml-auto" />
: <ChevronRight className="h-2.5 w-2.5 ml-auto" />
}
</button>
{showOutput && (
<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">
{outputText}
</pre>
)}
</div>
)}
</div>
</>
)}

{/* Brain protection notice */}
{isBrain && team.mode === 'orchestrator' && (
<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">
Expand Down Expand Up @@ -247,3 +314,35 @@ export function NodeDetailPopup({ node, team, agents, executionStates, onDelete,
</div>
)
}

/**
* 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 }
}

90 changes: 68 additions & 22 deletions src/components/workflow/WorkflowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -65,6 +67,7 @@ const NODE_TYPES: NodeTypes = {
agentNode: AgentNode,
startNode: StartNode,
endNode: EndNode,
noteNode: NoteNode,
}

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

// Centralized keyboard shortcuts
useCanvasKeyboard({
onUndo: () => { undo(nodes, edges) },
onRedo: () => { redo(nodes, edges) },
onFitView: () => { void reactFlowInstance.fitView({ duration: 400, padding: 0.3 }) },
onAutoLayout: () => {
pushState(nodes, edges)
const layoutedNodes = applyAutoLayout(nodes, edges)
setNodes(layoutedNodes)
},
onToggleTranscript: () => setShowTranscript((v) => !v),
onToggleShortcuts: () => setShowShortcuts((v) => !v),
onEscape: () => {
setShowPopup(false)
setSelectedNodeId(null)
setContextMenu(null)
setShowShortcuts(false)
},
onSelectAll: () => {
setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
},
})

// ─── Save logic ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -217,6 +198,43 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
}
}, [team, agents, onSaveConfig, onCanvasError, setNodes, setEdges])

// 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)
const layoutedNodes = applyAutoLayout(nodes, edges)
setNodes(layoutedNodes)
},
onToggleTranscript: () => setShowTranscript((v) => !v),
onToggleShortcuts: () => setShowShortcuts((v) => !v),
onEscape: () => {
setShowPopup(false)
setSelectedNodeId(null)
setContextMenu(null)
setShowShortcuts(false)
},
onSelectAll: () => {
setNodes((nds) => nds.map((n) => ({ ...n, selected: true })))
},
})

// ─── Node interactions ───────────────────────────────────────────────────

const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
Expand Down Expand Up @@ -576,6 +594,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)
Expand Down Expand Up @@ -795,6 +839,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
team={team}
agents={agents}
executionStates={executionStates}
runMessages={runMessages}
onDelete={handleDeleteNode}
onClose={() => { setShowPopup(false); setSelectedNodeId(null) }}
/>
Expand All @@ -813,6 +858,7 @@ function WorkflowCanvasInner({ team, agents, onSaveConfig, onCanvasError, active
onGoToAgent={handleGoToAgent}
agents={agents}
onInsertAgent={insertAgentBetween}
onAddNote={handleAddNote}
/>
)}

Expand Down
Loading
Loading