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/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/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
+