-
Notifications
You must be signed in to change notification settings - Fork 0
Add node duplication, full redo history, and execution run metrics #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -436,15 +436,18 @@ function Canvas() { | |
| } catch {} | ||
| }, []); | ||
|
|
||
| // ---- undo stack ---- | ||
| // ---- undo / redo stacks ---- | ||
| const undoStack = useRef<{ nodes: Node<AgentNodeData>[]; edges: Edge[] }[]>([]); | ||
| const redoStack = useRef<{ nodes: Node<AgentNodeData>[]; edges: Edge[] }[]>([]); | ||
| const skipSnapshot = useRef(false); | ||
|
|
||
| const snapshot = useCallback(() => { | ||
| undoStack.current.push({ | ||
| nodes: JSON.parse(JSON.stringify(nodes)), | ||
| edges: JSON.parse(JSON.stringify(edges)), | ||
| }); | ||
| if (undoStack.current.length > 20) undoStack.current.shift(); | ||
| redoStack.current = []; | ||
| }, [nodes, edges]); | ||
|
|
||
| const undo = useCallback(() => { | ||
|
|
@@ -453,11 +456,31 @@ function Canvas() { | |
| toast("Nothing to undo"); | ||
| return; | ||
| } | ||
| redoStack.current.push({ | ||
| nodes: JSON.parse(JSON.stringify(nodes)), | ||
| edges: JSON.parse(JSON.stringify(edges)), | ||
| }); | ||
| skipSnapshot.current = true; | ||
| setNodes(prev.nodes); | ||
| setEdges(prev.edges); | ||
| toast("Undo"); | ||
| }, []); | ||
| }, [nodes, edges]); | ||
|
|
||
| const redo = useCallback(() => { | ||
| const nextState = redoStack.current.pop(); | ||
| if (!nextState) { | ||
| toast("Nothing to redo"); | ||
| return; | ||
| } | ||
| undoStack.current.push({ | ||
| nodes: JSON.parse(JSON.stringify(nodes)), | ||
| edges: JSON.parse(JSON.stringify(edges)), | ||
| }); | ||
| skipSnapshot.current = true; | ||
| setNodes(nextState.nodes); | ||
| setEdges(nextState.edges); | ||
| toast("Redo"); | ||
| }, [nodes, edges]); | ||
|
|
||
| // augment nodes with issue info for rendering | ||
| const issueByNode = useMemo(() => { | ||
|
|
@@ -566,6 +589,31 @@ function Canvas() { | |
| [snapshot], | ||
| ); | ||
|
|
||
| const duplicateNode = useCallback( | ||
| (id: string) => { | ||
| const target = nodes.find((n) => n.id === id); | ||
| if (!target) return; | ||
| snapshot(); | ||
| const newId = nextId(); | ||
| const newName = `${target.data.name}_copy`; | ||
| const newNode: Node<AgentNodeData> = { | ||
| id: newId, | ||
| type: target.type, | ||
| position: { x: target.position.x + 30, y: target.position.y + 30 }, | ||
| data: { | ||
| ...JSON.parse(JSON.stringify(target.data)), | ||
| name: newName, | ||
| isEntry: false, | ||
| }, | ||
|
Comment on lines
+592
to
+607
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(Index\.tsx|.*(workflow|import|history|node).*)$' | head -200
printf '%s\n' '--- relevant symbols and ID generation ---'
rg -n -C 4 'nextId|duplicateNode|handleSelectWorkflow|snapshot|onNodesChange|onNodeDrag|onNodesDelete|import' frontend/src/pages/Index.tsx frontend/src 2>/dev/null | head -500Repository: Jacobcdsmith/agent-flow-canvas Length of output: 31635 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow selection and history ---'
sed -n '330,380p' frontend/src/pages/Index.tsx
sed -n '430,535p' frontend/src/pages/Index.tsx
printf '%s\n' '--- duplication and imports ---'
sed -n '592,612p' frontend/src/pages/Index.tsx
sed -n '745,875p' frontend/src/pages/Index.tsx
printf '%s\n' '--- all node-ID consumers ---'
rg -n -C 2 '\.id|source:|target:|getNode|find\(\(.*id|=== .*id' frontend/src/pages/Index.tsx frontend/src/flow frontend/src/test | head -500Repository: Jacobcdsmith/agent-flow-canvas Length of output: 41652 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
p = Path("frontend/src/pages/Index.tsx")
text = p.read_text()
next_id = re.search(r"let idCounter = (\d+);\\s*const nextId = \\(\\) => `n\\$\\{\\+\\+idCounter\\}`;", text)
assert next_id, "nextId definition not found"
counter = int(next_id.group(1))
# Model the relevant state transition: imported IDs are retained, then duplication
# consumes the module counter without checking the current node set.
initial_nodes = [{"id": "n101"}, {"id": "n200"}]
generated = f"n{counter + 1}"
collision = generated in {n["id"] for n in initial_nodes}
print({
"initial_counter": counter,
"first_generated_id": generated,
"imported_ids": [n["id"] for n in initial_nodes],
"collision_after_import": collision,
})
assert generated == "n101"
assert collision is True
# Ensure both import paths assign the input ID directly.
for line in ("id: n.id,",):
count = text.count(line)
print({"literal": line, "occurrences": count})
assert count >= 2
PYRepository: Jacobcdsmith/agent-flow-canvas Length of output: 285 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
p = Path("frontend/src/pages/Index.tsx")
text = p.read_text()
next_id = re.search(
r"let idCounter = (\d+);\s*const nextId = \(\) => `n\$\{\+\+idCounter\}`;",
text,
)
assert next_id, "nextId definition not found"
counter = int(next_id.group(1))
initial_nodes = [{"id": "n101"}, {"id": "n200"}]
generated = f"n{counter + 1}"
collision = generated in {n["id"] for n in initial_nodes}
print({
"initial_counter": counter,
"first_generated_id": generated,
"imported_ids": [n["id"] for n in initial_nodes],
"collision_after_import": collision,
})
assert generated == "n101"
assert collision is True
for line in ("id: n.id,",):
count = text.count(line)
print({"literal": line, "occurrences": count})
assert count >= 2
PYRepository: Jacobcdsmith/agent-flow-canvas Length of output: 332 Generate a collision-free node ID. The module-level 🤖 Prompt for AI Agents |
||
| }; | ||
| setNodes((ns) => [...ns, newNode]); | ||
| setSelectedId(newId); | ||
| setSelectedEdgeId(null); | ||
| toast(`Duplicated node "${target.data.name}"`); | ||
| }, | ||
| [nodes, snapshot], | ||
| ); | ||
|
|
||
| const addNode = useCallback( | ||
| (meta: NodeTypeMeta) => { | ||
| snapshot(); | ||
|
|
@@ -657,14 +705,23 @@ function Canvas() { | |
| } else if (e.key === "Delete" || e.key === "Backspace") { | ||
| if (selectedId) deleteNode(selectedId); | ||
| else if (selectedEdgeId) deleteEdge(selectedEdgeId); | ||
| } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d" && selectedId) { | ||
| e.preventDefault(); | ||
| duplicateNode(selectedId); | ||
| } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") { | ||
| e.preventDefault(); | ||
| redo(); | ||
| } else if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") { | ||
| e.preventDefault(); | ||
| redo(); | ||
| } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { | ||
| e.preventDefault(); | ||
| undo(); | ||
| } | ||
| }; | ||
| window.addEventListener("keydown", handler); | ||
| return () => window.removeEventListener("keydown", handler); | ||
| }, [selectedId, selectedEdgeId, deleteNode, deleteEdge, undo]); | ||
| }, [selectedId, selectedEdgeId, deleteNode, duplicateNode, deleteEdge, undo, redo]); | ||
|
|
||
| const selected = useMemo( | ||
| () => nodes.find((n) => n.id === selectedId) ?? null, | ||
|
|
@@ -1625,7 +1682,7 @@ function Canvas() { | |
|
|
||
| {!isMobile && ( | ||
| <div className="absolute top-3 left-3 font-mono text-[10px] text-[hsl(var(--ink-faint))] uppercase tracking-[0.2em] pointer-events-none"> | ||
| click edge → select · drag handles → connect · del / esc / ⌘z | ||
| click edge → select · drag handles → connect · del / esc / ⌘z / ⌘y / ⌘d | ||
| </div> | ||
| )} | ||
|
|
||
|
|
@@ -1687,6 +1744,7 @@ function Canvas() { | |
| gateways={gateways} | ||
| onChange={updateNode} | ||
| onDelete={deleteNode} | ||
| onDuplicate={duplicateNode} | ||
| workflows={workflows} | ||
| activeWorkflowId={activeWorkflowId} | ||
| /> | ||
|
|
@@ -1785,7 +1843,7 @@ function Canvas() { | |
| <button onClick={() => setMobilePanel("none")} className="font-mono text-[11px] px-2 py-1 border border-dashed border-[hsl(var(--ink))]">close</button> | ||
| </div> | ||
| <div className="flex-1 overflow-y-auto"> | ||
| <Inspector node={selected} edges={edges} nodes={nodes} gateways={gateways} onChange={updateNode} onDelete={deleteNode} workflows={workflows} activeWorkflowId={activeWorkflowId} /> | ||
| <Inspector node={selected} edges={edges} nodes={nodes} gateways={gateways} onChange={updateNode} onDelete={deleteNode} onDuplicate={duplicateNode} workflows={workflows} activeWorkflowId={activeWorkflowId} /> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
@@ -2223,6 +2281,40 @@ function Canvas() { | |
| </div> | ||
| )} | ||
|
|
||
| {/* Execution Run Metrics Banner */} | ||
| {runLogs && runLogs.length > 0 && !running && ( | ||
| <div className="border border-dashed border-[hsl(var(--grid-line))] p-3 space-y-2 mb-2 bg-[hsl(var(--ink)/0.02)]"> | ||
| <div className="flex items-center justify-between"> | ||
| <span className="font-mono text-[10px] uppercase tracking-[0.15em] text-[hsl(var(--ink-soft))] font-semibold"> | ||
| Execution Metrics Summary | ||
| </span> | ||
| <span | ||
| className={`font-mono text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 border border-dashed ${ | ||
| runLogs.some((l) => l.error) | ||
| ? "border-[hsl(var(--issue))] text-[hsl(var(--issue))] bg-[hsl(var(--issue)/0.08)]" | ||
| : "border-[hsl(var(--ink))] text-[hsl(var(--ink))] bg-[hsl(var(--ink)/0.05)]" | ||
| }`} | ||
| > | ||
| {runLogs.some((l) => l.error) ? "⚠ Errored" : "✓ Success"} | ||
| </span> | ||
| </div> | ||
| <div className="grid grid-cols-3 gap-2 font-mono text-[10px] pt-1"> | ||
| <div className="border border-dotted border-[hsl(var(--grid-line))] p-1.5 text-center"> | ||
| <div className="text-[hsl(var(--ink-faint))] text-[8px] uppercase tracking-wider">Steps</div> | ||
| <div className="font-bold text-[hsl(var(--ink))] mt-0.5">{runLogs.length}</div> | ||
| </div> | ||
| <div className="border border-dotted border-[hsl(var(--grid-line))] p-1.5 text-center"> | ||
| <div className="text-[hsl(var(--ink-faint))] text-[8px] uppercase tracking-wider">Duration</div> | ||
| <div className="font-bold text-[hsl(var(--ink))] mt-0.5">{runLogs.reduce((acc, l) => acc + l.ms, 0)} ms</div> | ||
| </div> | ||
| <div className="border border-dotted border-[hsl(var(--grid-line))] p-1.5 text-center"> | ||
| <div className="text-[hsl(var(--ink-faint))] text-[8px] uppercase tracking-wider">Node Types</div> | ||
| <div className="font-bold text-[hsl(var(--ink))] mt-0.5">{new Set(runLogs.map((l) => l.kind)).size}</div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Run logs management header/tools */} | ||
| {runLogs && runLogs.length > 0 && ( | ||
| <div className="border border-dashed border-[hsl(var(--grid-line))] p-3 space-y-2 mb-2 bg-[hsl(var(--ink)/0.01)]"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { describe, it, expect, beforeEach, vi } from "vitest"; | ||
| import { render, screen, fireEvent } from "@testing-library/react"; | ||
| import "@testing-library/jest-dom"; | ||
| import React from "react"; | ||
| import { Node, Edge } from "reactflow"; | ||
| import { Inspector } from "../flow/Inspector"; | ||
| import { AgentNodeData } from "../flow/types"; | ||
|
|
||
| // Polyfill ResizeObserver for JSDOM | ||
| if (typeof global.ResizeObserver === "undefined") { | ||
| global.ResizeObserver = class ResizeObserver { | ||
| observe() {} | ||
| unobserve() {} | ||
| disconnect() {} | ||
| }; | ||
| } | ||
|
|
||
| describe("Node Duplication and Inspector Actions", () => { | ||
| const mockNode: Node<AgentNodeData> = { | ||
| id: "node-1", | ||
| type: "agent", | ||
| position: { x: 100, y: 150 }, | ||
| data: { | ||
| kind: "llm", | ||
| name: "reason_agent", | ||
| config: { | ||
| model: "gpt-4o", | ||
| prompt: "Analyze the user input", | ||
| }, | ||
| isEntry: true, | ||
| isTerminal: false, | ||
| }, | ||
| }; | ||
|
|
||
| const mockEdges: Edge[] = []; | ||
| const mockNodes: Node<AgentNodeData>[] = [mockNode]; | ||
|
|
||
| it("renders Duplicate Node button in Inspector when onDuplicate callback is provided", () => { | ||
| const handleDuplicate = vi.fn(); | ||
| const handleChange = vi.fn(); | ||
| const handleDelete = vi.fn(); | ||
|
|
||
| render( | ||
| <Inspector | ||
| node={mockNode} | ||
| edges={mockEdges} | ||
| nodes={mockNodes} | ||
| onChange={handleChange} | ||
| onDelete={handleDelete} | ||
| onDuplicate={handleDuplicate} | ||
| /> | ||
| ); | ||
|
|
||
| const duplicateBtn = screen.getByRole("button", { name: /duplicate node/i }); | ||
| expect(duplicateBtn).toBeInTheDocument(); | ||
|
|
||
| fireEvent.click(duplicateBtn); | ||
| expect(handleDuplicate).toHaveBeenCalledWith("node-1"); | ||
| }); | ||
|
|
||
| it("correctly constructs a duplicate node with offset position and _copy name suffix", () => { | ||
| // Replicate the duplicate node construction logic from Index.tsx | ||
| const target = mockNode; | ||
| const newId = "n101"; | ||
| const newName = `${target.data.name}_copy`; | ||
|
|
||
| const newNode: Node<AgentNodeData> = { | ||
| id: newId, | ||
| type: target.type, | ||
| position: { x: target.position.x + 30, y: target.position.y + 30 }, | ||
| data: { | ||
| ...JSON.parse(JSON.stringify(target.data)), | ||
| name: newName, | ||
| isEntry: false, | ||
| }, | ||
| }; | ||
|
|
||
| expect(newNode.id).toBe("n101"); | ||
| expect(newNode.data.name).toBe("reason_agent_copy"); | ||
| expect(newNode.position).toEqual({ x: 130, y: 180 }); | ||
| expect(newNode.data.isEntry).toBe(false); | ||
| expect(newNode.data.config.model).toBe("gpt-4o"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| $ vite --host 0.0.0.0 --port 3000 | ||
|
|
||
| VITE v5.4.21 ready in 338 ms | ||
| VITE v5.4.21 ready in 328 ms | ||
|
|
||
| ➜ Local: http://localhost:3000/ | ||
| ➜ Network: http://192.168.0.2:3000/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Reset history when the workflow changes.
undoStackandredoStacksurvive a workflow switch. If a user switches from workflow A to custom workflow B and presses undo, the code restores A into B. The autosave effect then persists A’s graph into B.Clear both stacks in
handleSelectWorkflowbefore loading the new graph.🤖 Prompt for AI Agents
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 12876
🏁 Script executed:
Repository: Jacobcdsmith/agent-flow-canvas
Length of output: 13575
🌐 Web query:
React Flow 11.11.4 ReactFlow onNodeDragStart onNodeDragStop callback documentation💡 Result:
In React Flow 11.11.4, the onNodeDragStart and onNodeDragStop props are callback functions used to listen to the lifecycle events of a node being dragged [1][2][3]. These callbacks utilize the OnNodeDrag type, which provides access to the interaction event and the nodes involved [4][5]. Type Signature: The callback signature is defined as: (event: MouseEvent | TouchEvent, node: Node, nodes: Node[]) => void [4][5] Parameters: 1. event: The original DOM event (MouseEvent or TouchEvent) that triggered the action [4][5]. 2. node: The specific node that is being dragged [1][4]. 3. nodes: An array containing all nodes currently being dragged [4][5]. Usage Example: You can pass these handlers directly to the ReactFlow component: const onNodeDragStart = (event, node, nodes) => { console.log('Started dragging node:', node.id); }; const onNodeDragStop = (event, node, nodes) => { console.log('Stopped dragging node:', node.id); }; These events are part of the standard interaction properties available on the ReactFlow component [1][6].
Citations:
Record one undo snapshot per node-drag gesture.
onNodesChangeapplies drag position changes but does not callsnapshot(). AddonNodeDragStartto<ReactFlow>and callsnapshot()once there. Do not snapshot each intermediateonNodesChangeevent.🤖 Prompt for AI Agents