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
1 change: 1 addition & 0 deletions packages/docs-web/src/content/docs/adapters/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ The Workflow Builder at `/workflows/builder` provides a visual editor for creati
- **Command picker** -- Browse available commands when configuring command nodes
- **Validation panel** -- Real-time validation feedback as you build
- **Undo/redo** -- Full undo/redo stack with keyboard shortcuts
- **Delete node** -- Remove a selected node with `Delete` or `Backspace`, the Delete button in the inspector header, or the right-click context menu on any node
- **Save** -- Saves the workflow YAML to your project's `.archon/workflows/` directory

You can also browse existing workflows on the `/workflows` page and open any of them in the builder to edit.
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"test": "bun test src/lib/ && bun test src/stores/",
"test": "bun test src/lib/ && bun test src/stores/ && bun test src/hooks/",
"generate:types": "openapi-typescript http://localhost:3090/api/openapi.json -o src/lib/api.generated.d.ts"
},
"dependencies": {
Expand Down
25 changes: 13 additions & 12 deletions packages/web/src/components/workflows/NodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -642,11 +642,9 @@ function JsonTextareaField({
function AdvancedTab({
node,
onUpdate,
onDelete,
}: {
node: DagNodeData;
onUpdate: (updates: Partial<DagNodeData>) => void;
onDelete: () => void;
}): React.ReactElement {
return (
<div className="flex flex-col gap-3 p-3">
Expand Down Expand Up @@ -696,12 +694,6 @@ function AdvancedTab({
onUpdate({ hooks: v });
}}
/>

<div className="border-t border-border pt-3 mt-2">
<Button variant="destructive" size="sm" onClick={onDelete} className="w-full">
Delete Node
</Button>
</div>
</div>
);
}
Expand All @@ -718,14 +710,23 @@ function DagInspector({
return (
<div key={node.id} className="flex flex-col h-full border-l border-border bg-surface">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-xs font-semibold text-text-primary truncate">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<span className="flex-1 truncate text-xs font-semibold text-text-primary">
{node.label || node.id}
</span>
<Button
variant="destructive"
size="sm"
onClick={onDelete}
className="h-6 shrink-0 px-2 text-[10px]"
aria-label="Delete node"
>
Delete
</Button>
<button
type="button"
onClick={onClose}
className="text-text-tertiary hover:text-text-primary text-sm leading-none px-1"
className="shrink-0 px-1 text-sm leading-none text-text-tertiary hover:text-text-primary"
title="Close inspector"
>
x
Expand Down Expand Up @@ -770,7 +771,7 @@ function DagInspector({

{!isBash && (
<TabsContent value="advanced">
<AdvancedTab key={node.id} node={node} onUpdate={onUpdate} onDelete={onDelete} />
<AdvancedTab key={node.id} node={node} onUpdate={onUpdate} />
</TabsContent>
)}
</ScrollArea>
Expand Down
46 changes: 32 additions & 14 deletions packages/web/src/components/workflows/WorkflowBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ function WorkflowBuilderInner(): React.ReactElement {
setHasUnsavedChanges(true);
}, []);

// Refs mirror the latest nodes/edges so snapshot-taking callbacks don't
// close over stale values when events fire in the same tick as a render.
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
useEffect(() => {
nodesRef.current = nodes;
edgesRef.current = edges;
}, [nodes, edges]);

const pushSnapshotLatest = useCallback((): void => {
pushSnapshot({ nodes: nodesRef.current, edges: edgesRef.current });
}, [pushSnapshot]);

const buildDefinition = useCallback((): WorkflowDefinition => {
const name = workflowName.trim() || 'untitled';
const description = workflowDescription;
Expand Down Expand Up @@ -236,14 +249,21 @@ function WorkflowBuilderInner(): React.ReactElement {
[selectedNodeId, setNodes, markDirty]
);

const handleNodeDeleteById = useCallback(
(nodeId: string): void => {
pushSnapshotLatest();
setNodes(nds => nds.filter(n => n.id !== nodeId));
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId));
setSelectedNodeId(prev => (prev === nodeId ? null : prev));
markDirty();
},
[setNodes, setEdges, markDirty, pushSnapshotLatest]
);

const handleNodeDelete = useCallback((): void => {
if (!selectedNodeId) return;
pushSnapshot({ nodes, edges });
setNodes(nds => nds.filter(n => n.id !== selectedNodeId));
setEdges(eds => eds.filter(e => e.source !== selectedNodeId && e.target !== selectedNodeId));
setSelectedNodeId(null);
markDirty();
}, [selectedNodeId, setNodes, setEdges, markDirty, pushSnapshot, nodes, edges]);
handleNodeDeleteById(selectedNodeId);
}, [selectedNodeId, handleNodeDeleteById]);

// Toolbar action handlers
const handleValidate = useCallback(async (): Promise<void> => {
Expand Down Expand Up @@ -361,7 +381,7 @@ function WorkflowBuilderInner(): React.ReactElement {
position: { x: 200, y: 200 },
data: { id, label: 'Prompt', nodeType: 'prompt' },
};
pushSnapshot({ nodes, edges });
pushSnapshotLatest();
setNodes(nds => [...nds, newNode]);
markDirty();
},
Expand All @@ -373,7 +393,7 @@ function WorkflowBuilderInner(): React.ReactElement {
position: { x: 200, y: 200 },
data: { id, label: 'Shell', nodeType: 'bash' },
};
pushSnapshot({ nodes, edges });
pushSnapshotLatest();
setNodes(nds => [...nds, newNode]);
markDirty();
},
Expand All @@ -393,7 +413,7 @@ function WorkflowBuilderInner(): React.ReactElement {
position: { x: sourceNode.position.x + 30, y: sourceNode.position.y + 30 },
data: { ...sourceNode.data, id },
};
pushSnapshot({ nodes, edges });
pushSnapshotLatest();
setNodes(nds => [...nds, newNode]);
markDirty();
},
Expand All @@ -405,9 +425,8 @@ function WorkflowBuilderInner(): React.ReactElement {
handleToggleValidationPanel,
handleNodeDelete,
nodes,
edges,
selectedNodeId,
pushSnapshot,
pushSnapshotLatest,
setNodes,
markDirty,
]
Expand Down Expand Up @@ -482,10 +501,9 @@ function WorkflowBuilderInner(): React.ReactElement {
setNodes={setNodes}
setEdges={setEdges}
onNodeSelect={setSelectedNodeId}
onNodeDelete={handleNodeDeleteById}
onDirty={markDirty}
onPushSnapshot={(): void => {
pushSnapshot({ nodes, edges });
}}
onPushSnapshot={pushSnapshotLatest}
commands={commandList}
/>
</div>
Expand Down
80 changes: 78 additions & 2 deletions packages/web/src/components/workflows/WorkflowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface WorkflowCanvasProps {
setNodes: React.Dispatch<React.SetStateAction<DagFlowNode[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
onNodeSelect: (nodeId: string | null) => void;
onNodeDelete: (nodeId: string) => void;
onDirty: () => void;
onPushSnapshot?: () => void;
commands: CommandEntry[];
Expand All @@ -100,12 +101,19 @@ export function WorkflowCanvas({
setNodes,
setEdges,
onNodeSelect,
onNodeDelete,
onDirty,
onPushSnapshot,
commands,
}: WorkflowCanvasProps): React.ReactElement {
const { screenToFlowPosition } = useReactFlow();
const [quickAddPosition, setQuickAddPosition] = useState<QuickAddPosition | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
nodeId: string;
} | null>(null);
const contextMenuRef = useRef<HTMLDivElement | null>(null);

const nodeTypes: NodeTypes = useMemo(() => ({ dagNode: dagNodeComponent }), []);

Expand Down Expand Up @@ -164,10 +172,12 @@ export function WorkflowCanvas({
},
};

onPushSnapshot?.();
setNodes(nds => [...nds, newNode]);
onNodeSelect(id);
onDirty();
},
[screenToFlowPosition, setNodes, onDirty]
[screenToFlowPosition, setNodes, onNodeSelect, onDirty, onPushSnapshot]
);

// Track whether we've already pushed a snapshot for the current drag gesture
Expand Down Expand Up @@ -278,17 +288,63 @@ export function WorkflowCanvas({
},
};

onPushSnapshot?.();
setNodes(nds => [...nds, newNode]);
onNodeSelect(id);
onDirty();
setQuickAddPosition(null);
},
[quickAddPosition, setNodes, onDirty]
[quickAddPosition, setNodes, onNodeSelect, onDirty, onPushSnapshot]
);

const handleQuickAddClose = useCallback(() => {
setQuickAddPosition(null);
}, []);

// Approximate menu size used for viewport-edge clamping.
const CONTEXT_MENU_WIDTH = 160;
const CONTEXT_MENU_HEIGHT = 40;

const handleNodeContextMenu = useCallback(
(e: React.MouseEvent, node: DagFlowNode) => {
e.preventDefault();
onNodeSelect(node.id);
const x = Math.min(e.clientX, window.innerWidth - CONTEXT_MENU_WIDTH);
const y = Math.min(e.clientY, window.innerHeight - CONTEXT_MENU_HEIGHT);
setContextMenu({ x, y, nodeId: node.id });
},
[onNodeSelect]
);

// Dismiss the context menu on Escape or any click/contextmenu outside it.
useEffect(() => {
if (!contextMenu) return;

const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setContextMenu(null);
};
const onClickOutside = (e: MouseEvent): void => {
if (
contextMenuRef.current &&
e.target instanceof Node &&
contextMenuRef.current.contains(e.target)
) {
return;
}
setContextMenu(null);
};

window.addEventListener('keydown', onKey);
// Use capture so we beat ReactFlow's own handlers and any stopPropagation.
window.addEventListener('mousedown', onClickOutside, true);
window.addEventListener('contextmenu', onClickOutside, true);
return (): void => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onClickOutside, true);
window.removeEventListener('contextmenu', onClickOutside, true);
};
}, [contextMenu]);

return (
<div className="relative w-full h-full">
<ReactFlow
Expand All @@ -302,6 +358,7 @@ export function WorkflowCanvas({
onNodeClick={(_e, node): void => {
onNodeSelect(node.id);
}}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={handlePaneClick}
nodeTypes={nodeTypes}
panOnDrag
Expand All @@ -324,6 +381,25 @@ export function WorkflowCanvas({
commands={commands}
/>
)}

{contextMenu && (
<div
ref={contextMenuRef}
className="fixed z-50 min-w-[140px] rounded-md border border-border bg-surface-elevated py-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
type="button"
onClick={(): void => {
onNodeDelete(contextMenu.nodeId);
setContextMenu(null);
}}
className="w-full px-3 py-1.5 text-left text-xs text-error hover:bg-surface"
>
Delete node
</button>
</div>
)}
</div>
);
}
Loading
Loading