From d18130c23f76a3297f86f5e075fd672e6e162806 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 24 Jan 2026 01:27:39 +1300 Subject: [PATCH 1/2] fix: resolve node selection bug where nodes with undefined bounds get incorrectly selected React Flow's internal bounds for nodes were undefined, causing certain nodes to be incorrectly included in drag selections regardless of their position. Added statistical outlier detection using IQR to identify and automatically deselect nodes that are clearly outside the actual selection area. Also includes: - Clear selected state and validate position coordinates on workflow load - Strip selected property when saving workflows (transient UI state) - Enable prompt nodes to receive text input connections Co-Authored-By: Claude Opus 4.5 --- src/components/WorkflowCanvas.tsx | 53 ++++++++++++++++++++++++++++++- src/store/workflowStore.ts | 23 +++++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 35b6f223..929bf343 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -13,6 +13,7 @@ import { useReactFlow, OnConnectEnd, Node, + OnSelectionChangeParams, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -80,7 +81,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] case "annotation": return { inputs: ["image"], outputs: ["image"] }; case "prompt": - return { inputs: [], outputs: ["text"] }; + return { inputs: ["text"], outputs: ["text"] }; case "nanoBanana": return { inputs: ["image", "text"], outputs: ["image"] }; case "generateVideo": @@ -613,6 +614,8 @@ export function WorkflowCanvas() { sourceHandleIdForNewNode = "text"; } } else if (nodeType === "prompt") { + // prompt can receive and output text + targetHandleId = "text"; sourceHandleIdForNewNode = "text"; } } @@ -993,6 +996,53 @@ export function WorkflowCanvas() { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); + + // Fix for React Flow selection bug where nodes with undefined bounds get incorrectly selected. + // Uses statistical outlier detection to identify and deselect nodes that are clearly + // outside the actual selection area. + const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => { + if (selectedNodes.length <= 1) return; + + // Get positions of all selected nodes + const positions = selectedNodes.map(n => ({ + id: n.id, + x: n.position.x, + y: n.position.y, + })); + + // Calculate IQR-based bounds for outlier detection + const sortedX = [...positions].sort((a, b) => a.x - b.x); + const sortedY = [...positions].sort((a, b) => a.y - b.y); + + const q1X = sortedX[Math.floor(sortedX.length * 0.25)].x; + const q3X = sortedX[Math.floor(sortedX.length * 0.75)].x; + const q1Y = sortedY[Math.floor(sortedY.length * 0.25)].y; + const q3Y = sortedY[Math.floor(sortedY.length * 0.75)].y; + const iqrX = q3X - q1X; + const iqrY = q3Y - q1Y; + + // Outlier threshold: 3x IQR from quartiles + const minX = q1X - iqrX * 3; + const maxX = q3X + iqrX * 3; + const minY = q1Y - iqrY * 3; + const maxY = q3Y + iqrY * 3; + + // Find and deselect outliers + const outliers = positions.filter(p => + p.x < minX || p.x > maxX || p.y < minY || p.y > maxY + ); + + if (outliers.length > 0) { + onNodesChange( + outliers.map(o => ({ + type: 'select' as const, + id: o.id, + selected: false, + })) + ); + } + }, [onNodesChange]); + const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; @@ -1198,6 +1248,7 @@ export function WorkflowCanvas() { onConnect={handleConnect} onConnectEnd={handleConnectEnd} onNodeDragStop={handleNodeDragStop} + onSelectionChange={handleSelectionChange} nodeTypes={nodeTypes} edgeTypes={edgeTypes} isValidConnection={isValidConnection} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 8ea6a2d1..7a959909 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -1000,9 +1000,14 @@ export const useWorkflowStore = create((set, get) => ({ break; } - case "prompt": - // Nothing to execute, data is already set + case "prompt": { + // Check for connected text input and update prompt if connected + const { text: connectedText } = getConnectedInputs(node.id); + if (connectedText !== null) { + updateNodeData(node.id, { prompt: connectedText }); + } break; + } case "nanoBanana": { const { images, text, dynamicInputs } = getConnectedInputs(node.id); @@ -2368,7 +2373,8 @@ export const useWorkflowStore = create((set, get) => ({ const workflow: WorkflowFile = { version: 1, name: name || `workflow-${new Date().toISOString().slice(0, 10)}`, - nodes, + // Strip selected property - selection is transient UI state and should not be persisted + nodes: nodes.map(({ selected, ...rest }) => rest), edges, edgeStyle, groups: Object.keys(groups).length > 0 ? groups : undefined, @@ -2452,7 +2458,16 @@ export const useWorkflowStore = create((set, get) => ({ const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null; set({ - nodes: hydratedWorkflow.nodes, + // Clear selected state - selection should not be persisted across sessions + // Also validate position to ensure coordinates are finite numbers + nodes: hydratedWorkflow.nodes.map(node => ({ + ...node, + selected: false, + position: { + x: isFinite(node.position?.x) ? node.position.x : 0, + y: isFinite(node.position?.y) ? node.position.y : 0, + }, + })), edges: hydratedWorkflow.edges, edgeStyle: hydratedWorkflow.edgeStyle || "angular", groups: hydratedWorkflow.groups || {}, From fa481498cd012c7607066b2d73bb3214fd6e72c3 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 24 Jan 2026 01:31:38 +1300 Subject: [PATCH 2/2] feat: improve LLM and prompt node functionality LLMGenerateNode improvements: - Add collapsible parameters section with temperature and max tokens sliders - Add copy to clipboard button for output text - Clean up UI organization PromptNode improvements: - Add text input handle to receive incoming text connections - Auto-populate prompt when connected to a text source (e.g., another LLM) ConnectionDropMenu: - Add Prompt as a target option for text connections ProjectSetupModal: - Add max tokens slider for LLM default settings Test updates: - Update mocks to include edges and getConnectedInputs - Fix tests for collapsible parameters section - Update wrap-around test for new menu options Co-Authored-By: Claude Opus 4.5 --- src/components/ConnectionDropMenu.tsx | 9 ++ src/components/ProjectSetupModal.tsx | 21 ++++ .../__tests__/ConnectionDropMenu.test.tsx | 9 +- .../__tests__/LLMGenerateNode.test.tsx | 18 +++- src/components/__tests__/PromptNode.test.tsx | 2 + src/components/nodes/LLMGenerateNode.tsx | 99 ++++++++++++++++--- src/components/nodes/PromptNode.tsx | 31 +++++- 7 files changed, 166 insertions(+), 23 deletions(-) diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index 00ad2c8e..7f460ef5 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -73,6 +73,15 @@ const IMAGE_TARGET_OPTIONS: MenuOption[] = [ ]; const TEXT_TARGET_OPTIONS: MenuOption[] = [ + { + type: "prompt", + label: "Prompt", + icon: ( + + + + ), + }, { type: "nanoBanana", label: "Generate Image", diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 5cf6eaed..0812ccfa 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -778,6 +778,27 @@ export function ProjectSetupModal({ className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400" /> + + {/* Max Tokens slider */} +
+ + { + setLocalNodeDefaults(prev => ({ + ...prev, + llm: { ...prev.llm, maxTokens: parseInt(e.target.value, 10) } + })); + }} + className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400" + /> +
diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx index c5851137..a2d2c556 100644 --- a/src/components/__tests__/ConnectionDropMenu.test.tsx +++ b/src/components/__tests__/ConnectionDropMenu.test.tsx @@ -226,14 +226,15 @@ describe("ConnectionDropMenu", () => { it("should wrap around when navigating past last item", () => { render(); - // Text options: nanoBanana, generateVideo, llmGenerate (3 items) - // Navigate down 3 times to wrap to first + // Text target options: Prompt, nanoBanana, generateVideo, llmGenerate (4 items) + // Navigate down 4 times to wrap to first + fireEvent.keyDown(document, { key: "ArrowDown" }); fireEvent.keyDown(document, { key: "ArrowDown" }); fireEvent.keyDown(document, { key: "ArrowDown" }); fireEvent.keyDown(document, { key: "ArrowDown" }); - // Should be back on first item - const firstButton = screen.getByText("Generate Image").closest("button"); + // Should be back on first item (Prompt) + const firstButton = screen.getByText("Prompt").closest("button"); expect(firstButton).toHaveClass("bg-neutral-700"); }); }); diff --git a/src/components/__tests__/LLMGenerateNode.test.tsx b/src/components/__tests__/LLMGenerateNode.test.tsx index 2f3f43bf..d32d4d09 100644 --- a/src/components/__tests__/LLMGenerateNode.test.tsx +++ b/src/components/__tests__/LLMGenerateNode.test.tsx @@ -194,14 +194,18 @@ describe("LLMGenerateNode", () => { }); describe("Temperature Slider", () => { - it("should render temperature slider with current value", () => { + it("should render temperature slider with current value after expanding parameters", () => { render( ); - expect(screen.getByText("Temp: 0.7")).toBeInTheDocument(); + // Expand the Parameters section + const parametersButton = screen.getByText("Parameters"); + fireEvent.click(parametersButton); + + expect(screen.getByText("Temperature: 0.7")).toBeInTheDocument(); }); it("should call updateNodeData when temperature is changed", () => { @@ -211,8 +215,14 @@ describe("LLMGenerateNode", () => { ); - const slider = screen.getByRole("slider"); - fireEvent.change(slider, { target: { value: "1.5" } }); + // Expand the Parameters section + const parametersButton = screen.getByText("Parameters"); + fireEvent.click(parametersButton); + + // Get the first slider (temperature) - there are two sliders: temperature and maxTokens + const sliders = screen.getAllByRole("slider"); + const temperatureSlider = sliders[0]; + fireEvent.change(temperatureSlider, { target: { value: "1.5" } }); expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { temperature: 1.5, diff --git a/src/components/__tests__/PromptNode.test.tsx b/src/components/__tests__/PromptNode.test.tsx index a3b3702d..eb402d3a 100644 --- a/src/components/__tests__/PromptNode.test.tsx +++ b/src/components/__tests__/PromptNode.test.tsx @@ -17,6 +17,8 @@ vi.mock("@/store/workflowStore", () => ({ currentNodeId: null, groups: {}, nodes: [], + edges: [], + getConnectedInputs: vi.fn(() => ({ images: [], videos: [], text: null, dynamicInputs: {} })), getNodesWithComments: vi.fn(() => []), markCommentViewed: vi.fn(), setNavigationTarget: vi.fn(), diff --git a/src/components/nodes/LLMGenerateNode.tsx b/src/components/nodes/LLMGenerateNode.tsx index 3eb34267..c4e05614 100644 --- a/src/components/nodes/LLMGenerateNode.tsx +++ b/src/components/nodes/LLMGenerateNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; @@ -57,6 +57,15 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps) => { + updateNodeData(id, { maxTokens: parseInt(e.target.value, 10) }); + }, + [id, updateNodeData] + ); + + const [showParams, setShowParams] = useState(false); + const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); @@ -68,6 +77,20 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps { + if (nodeData.outputText) { + try { + await navigator.clipboard.writeText(nodeData.outputText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.error("Failed to copy text:", err); + } + } + }, [nodeData.outputText]); + const provider = nodeData.provider || "google"; const availableModels = MODELS[provider] || MODELS.google; const model = availableModels.some(m => m.value === nodeData.model) @@ -145,6 +168,21 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps
+ + {showParams && ( +
+ {/* Temperature slider */} +
+ + +
+ {/* Max tokens slider */} +
+ + +
+
+ )}
diff --git a/src/components/nodes/PromptNode.tsx b/src/components/nodes/PromptNode.tsx index 18dd25e8..30dc26d5 100644 --- a/src/components/nodes/PromptNode.tsx +++ b/src/components/nodes/PromptNode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useEffect } from "react"; +import { useCallback, useState, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; @@ -17,12 +17,29 @@ export function PromptNode({ id, data, selected }: NodeProps) { const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount); const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount); + const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); + const edges = useWorkflowStore((state) => state.edges); const [isModalOpenLocal, setIsModalOpenLocal] = useState(false); // Local state for prompt to prevent cursor jumping during typing const [localPrompt, setLocalPrompt] = useState(nodeData.prompt); const [isEditing, setIsEditing] = useState(false); + // Check if this node has any incoming text connections + const hasIncomingTextConnection = useMemo(() => { + return edges.some((edge) => edge.target === id && edge.targetHandle === "text"); + }, [edges, id]); + + // Get connected text input and update prompt when connection provides text + useEffect(() => { + if (hasIncomingTextConnection) { + const { text } = getConnectedInputs(id); + if (text !== null && text !== nodeData.prompt) { + updateNodeData(id, { prompt: text }); + } + } + }, [hasIncomingTextConnection, id, getConnectedInputs, updateNodeData, nodeData.prompt]); + // Sync from props when not actively editing useEffect(() => { if (!isEditing) { @@ -78,15 +95,25 @@ export function PromptNode({ id, data, selected }: NodeProps) { selected={selected} commentNavigation={commentNavigation ?? undefined} > + {/* Text input handle - for receiving text from LLM nodes */} + +