From 90e5f5f60a2e10b5971104b079512c74f3a82ab4 Mon Sep 17 00:00:00 2001 From: Jacobcdsmith <88069592+Jacobcdsmith@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:00:32 +0000 Subject: [PATCH] feat: add node duplication, full redo history, and execution run metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement node duplication with ⌘D / Ctrl+D keyboard shortcuts and Inspector UI action - Implement full Redo support with ⌘Y / ⌘Shift+Z / Ctrl+Y / Ctrl+Shift+Z and redoStack tracking - Add Execution Metrics Summary banner in the Execution Run Drawer UI showing steps, total duration, node types count, and pass/error badge - Add comprehensive unit tests in duplicationAndUndoRedo.test.tsx --- frontend/src/flow/Inspector.tsx | 12 ++- frontend/src/pages/Index.tsx | 102 +++++++++++++++++- .../src/test/duplicationAndUndoRedo.test.tsx | 84 +++++++++++++++ server.log | 2 +- 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 frontend/src/test/duplicationAndUndoRedo.test.tsx diff --git a/frontend/src/flow/Inspector.tsx b/frontend/src/flow/Inspector.tsx index faea35b..390aea8 100644 --- a/frontend/src/flow/Inspector.tsx +++ b/frontend/src/flow/Inspector.tsx @@ -11,6 +11,7 @@ interface Props { gateways?: Gateway[]; onChange: (id: string, data: Partial) => void; onDelete: (id: string) => void; + onDuplicate?: (id: string) => void; workflows?: Workflow[]; activeWorkflowId?: string | null; } @@ -22,6 +23,7 @@ export function Inspector({ gateways = [], onChange, onDelete, + onDuplicate, workflows, activeWorkflowId, }: Props) { @@ -198,7 +200,15 @@ export function Inspector({ ))} -
+
+ {onDuplicate && ( + + )} {!confirming ? (
- +
)} @@ -2223,6 +2281,40 @@ function Canvas() { )} + {/* Execution Run Metrics Banner */} + {runLogs && runLogs.length > 0 && !running && ( +
+
+ + Execution Metrics Summary + + 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"} + +
+
+
+
Steps
+
{runLogs.length}
+
+
+
Duration
+
{runLogs.reduce((acc, l) => acc + l.ms, 0)} ms
+
+
+
Node Types
+
{new Set(runLogs.map((l) => l.kind)).size}
+
+
+
+ )} + {/* Run logs management header/tools */} {runLogs && runLogs.length > 0 && (
diff --git a/frontend/src/test/duplicationAndUndoRedo.test.tsx b/frontend/src/test/duplicationAndUndoRedo.test.tsx new file mode 100644 index 0000000..d3aec9d --- /dev/null +++ b/frontend/src/test/duplicationAndUndoRedo.test.tsx @@ -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 = { + 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[] = [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( + + ); + + 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 = { + 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"); + }); +}); diff --git a/server.log b/server.log index 9a4850b..ac7a8ac 100644 --- a/server.log +++ b/server.log @@ -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/