Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ body {
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
z-index: 5;
}

/* Larger invisible hit area for easier clicking, especially when zoomed out */
Expand Down
2 changes: 1 addition & 1 deletion src/components/__tests__/WelcomeModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe("WelcomeModal", () => {
/>
);

const backdrop = container.querySelector(".bg-black\\/50");
const backdrop = container.querySelector(".bg-black\\/60");
expect(backdrop).toBeInTheDocument();
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/components/nodes/AnnotationNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
aspectFitMedia={nodeData.outputImage}
>
<input
Expand All @@ -122,7 +122,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT

{displayImage ? (
<div
className="relative group cursor-pointer w-full h-full"
className="relative group cursor-pointer w-full h-full overflow-clip rounded-lg"
onClick={handleEdit}
>
<img
Expand Down
52 changes: 43 additions & 9 deletions src/components/nodes/ArrayNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ function arraysEqual(a: string[], b: string[]): boolean {
}

export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const addNode = useWorkflowStore((state) => state.addNode);
const onConnect = useWorkflowStore((state) => state.onConnect);
const nodes = useWorkflowStore((state) => state.nodes);
const edges = useWorkflowStore((state) => state.edges);

// Derive nodeData from the Zustand store (already subscribed via `nodes`)
// rather than React Flow props, so settings changes are reflected immediately.
const nodeData = useMemo(() => {
const n = nodes.find((nd) => nd.id === id);
return (n?.data as ArrayNodeData) ?? data;
}, [nodes, id, data]);
const { setNodes, getNodes } = useReactFlow();
const lastSyncedInputRef = useRef<string | null>(null);
const lastDerivedWriteRef = useRef<string | null>(null);
Expand Down Expand Up @@ -103,11 +109,36 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
});
}, [id, nodeData.error, nodeData.outputItems, nodeData.outputText, parsed.error, parsed.items, updateNodeData]);

// Helper: reparse and update outputs atomically whenever any split setting changes.
// Reads fresh data from the Zustand store (not React Flow props) to avoid stale closures.
const updateSettingsAndReparse = useCallback(
(partialSettings: Partial<Pick<ArrayNodeData, "splitMode" | "delimiter" | "regexPattern" | "trimItems" | "removeEmpty">>) => {
const freshNode = useWorkflowStore.getState().nodes.find((n) => n.id === id);
if (!freshNode) return;
const fresh = freshNode.data as ArrayNodeData;
const merged = {
splitMode: partialSettings.splitMode ?? fresh.splitMode,
delimiter: partialSettings.delimiter ?? fresh.delimiter,
regexPattern: partialSettings.regexPattern ?? fresh.regexPattern,
trimItems: partialSettings.trimItems ?? fresh.trimItems,
removeEmpty: partialSettings.removeEmpty ?? fresh.removeEmpty,
};
const result = parseTextToArray(fresh.inputText, merged);
updateNodeData(id, {
...partialSettings,
outputItems: result.items,
outputText: JSON.stringify(result.items),
error: result.error,
});
},
[id, updateNodeData]
);

const handleBasicModeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateNodeData(id, { splitMode: e.target.value as ArrayNodeData["splitMode"] });
updateSettingsAndReparse({ splitMode: e.target.value as ArrayNodeData["splitMode"] });
},
[id, updateNodeData]
[updateSettingsAndReparse]
);

const previewItems = parsed.items;
Expand Down Expand Up @@ -203,8 +234,11 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
className="nodrag nopan absolute top-2 right-2 z-10 shrink-0 p-1 bg-[#1a1a1a] rounded-md text-neutral-400 hover:text-neutral-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-route to Prompts"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 3v6m0 0a2 2 0 104 0 2 2 0 00-4 0zm0 0v7a3 3 0 003 3h6m0 0a2 2 0 100 4 2 2 0 000-4zm0 0v-6a3 3 0 013-3h0" />
<svg className="w-3.5 h-3.5 rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
</button>

Expand All @@ -227,7 +261,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<label className="shrink-0 text-[11px] text-neutral-400">By</label>
<input
value={nodeData.delimiter}
onChange={(e) => updateNodeData(id, { delimiter: e.target.value })}
onChange={(e) => updateSettingsAndReparse({ delimiter: e.target.value })}
placeholder="*"
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
/>
Expand All @@ -239,7 +273,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<label className="shrink-0 text-[11px] text-neutral-400">By</label>
<input
value={nodeData.regexPattern}
onChange={(e) => updateNodeData(id, { regexPattern: e.target.value })}
onChange={(e) => updateSettingsAndReparse({ regexPattern: e.target.value })}
placeholder="/\\n+/"
className="nodrag nopan flex-1 min-w-0 text-[11px] py-1 px-2 bg-[#1a1a1a] rounded-md focus:outline-none focus:ring-1 focus:ring-neutral-600 text-white"
/>
Expand All @@ -264,7 +298,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<input
type="checkbox"
checked={nodeData.trimItems}
onChange={(e) => updateNodeData(id, { trimItems: e.target.checked })}
onChange={(e) => updateSettingsAndReparse({ trimItems: e.target.checked })}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Trim
Expand All @@ -273,7 +307,7 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
<input
type="checkbox"
checked={nodeData.removeEmpty}
onChange={(e) => updateNodeData(id, { removeEmpty: e.target.checked })}
onChange={(e) => updateSettingsAndReparse({ removeEmpty: e.target.checked })}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Remove empty
Expand Down
25 changes: 20 additions & 5 deletions src/components/nodes/BaseNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export function BaseNode({
if (node.id !== id) return node;
const currentHeight = getNodeDimension(node, "height");
const newHeight = Math.max(minHeight, currentHeight - heightToRemove);
return applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight),
data: { ...node.data, _settingsPanelHeight: 0 },
};
Comment on lines +111 to +114
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Persist _settingsPanelHeight through the Zustand store, not setNodes.

These branches now mutate node.data through React Flow while the repo treats workflowStore.ts as the source of truth for node state. Since components in this PR already read node data back from the store, _settingsPanelHeight should go through updateNodeData() (or a small helper that updates dimensions and data together) so persistence and other subscribers stay in sync. As per coding guidelines, "All application state lives in workflowStore.ts using Zustand; access via useWorkflowStore() hook" and "Update node state using updateNodeData(nodeId, partialData) instead of direct mutations".

Also applies to: 153-156, 203-206

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/nodes/BaseNode.tsx` around lines 111 - 114, The code mutates
node.data (_settingsPanelHeight) via React Flow's setNodes (in the
applyNodeDimensions/getNodeDimension branches) but the app treats workflowStore
(useWorkflowStore) as the source of truth; change these updates to call the
store helper updateNodeData(node.id, { _settingsPanelHeight: 0 }) (or a small
helper that applies dimensions and then calls updateNodeData) instead of writing
into node.data directly so subscribers and persistence remain in sync; locate
the occurrences around applyNodeDimensions/getNodeDimension (and the similar
blocks at lines noted) and replace direct node.data changes with calls to
updateNodeData while preserving the width/height dimension logic.

})
);

Expand All @@ -134,14 +137,23 @@ export function BaseNode({
animationTimeoutRef.current = setTimeout(() => {
isAnimatingRef.current = false;

// Apply the final panel height in one shot, then unlock the wrapper
// Apply the final panel height in one shot, then unlock the wrapper.
// Subtract any previously saved panel height to avoid double-counting
// on workflow reload (saved node height already includes panel).
const finalHeight = trackedSettingsHeightRef.current;
if (finalHeight > 0) {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id !== id) return node;
const savedPanelHeight = typeof (node.data as Record<string, unknown>)?._settingsPanelHeight === "number"
? (node.data as Record<string, unknown>)._settingsPanelHeight as number
: 0;
const heightToAdd = finalHeight - savedPanelHeight;
const currentHeight = getNodeDimension(node, "height");
return applyNodeDimensions(node, getNodeDimension(node, "width"), currentHeight + finalHeight);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), currentHeight + heightToAdd),
data: { ...node.data, _settingsPanelHeight: finalHeight },
};
})
);
}
Expand Down Expand Up @@ -188,7 +200,10 @@ export function BaseNode({
if (node.id !== id) return node;
const currentHeight = getNodeDimension(node, "height");
const newHeight = Math.max(minHeight, currentHeight + delta);
return applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight);
return {
...applyNodeDimensions(node, getNodeDimension(node, "width"), newHeight),
data: { ...node.data, _settingsPanelHeight: newPanelHeight },
};
})
);

Expand Down Expand Up @@ -305,7 +320,7 @@ export function BaseNode({
setHoveredNodeId(null);
}}
>
<div ref={contentRef} style={{ contain: "layout style" }} className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-hidden flex flex-col")}>{children}</div>
<div ref={contentRef} style={{ contain: "layout style" }} className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-visible flex flex-col")}>{children}</div>
</div>
{settingsPanel && (
<div ref={settingsPanelRef}>
Expand Down
2 changes: 1 addition & 1 deletion src/components/nodes/GLBViewerNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
<BaseNode
id={id}
selected={selected}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 overflow-hidden flex flex-col" : undefined}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 flex flex-col" : undefined}
aspectFitMedia={nodeData.capturedImage}
>
<input
Expand Down
21 changes: 10 additions & 11 deletions src/components/nodes/ImageInputNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,10 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
aspectFitMedia={nodeData.image}
fullBleed
>
{/* Reference input handle for visual links from Split Grid node */}
<Handle
type="target"
position={Position.Left}
id="reference"
data-handletype="reference"
className="!bg-gray-500"
/>

<input
ref={fileInputRef}
type="file"
Expand All @@ -109,7 +100,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/>

{nodeData.image ? (
<div className="relative group w-full h-full">
<div className="relative group w-full h-full overflow-clip rounded-lg">
<img
src={adaptiveImage ?? undefined}
alt={nodeData.filename || "Uploaded image"}
Expand Down Expand Up @@ -148,6 +139,14 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
</div>
)}

{/* Handles rendered after visual content so they paint on top */}
<Handle
type="target"
position={Position.Left}
id="reference"
data-handletype="reference"
className="!bg-gray-500"
/>
Comment on lines +142 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize target handle metadata to image conventions.

At Line 146-147, id="reference" and data-handletype="reference" deviate from the node handle conventions for image payloads. Please use image-aligned handle metadata.

Proposed fix
       <Handle
         type="target"
         position={Position.Left}
-        id="reference"
-        data-handletype="reference"
+        id="image"
+        data-handletype="image"
         className="!bg-gray-500"
       />

As per coding guidelines: "Use descriptive handle IDs matching the data type: id="image" for image data and id="text" for text data" and "Use image handle type for Base64 data URLs, text for strings, and audio for Base64 data URLs in node connections".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{/* Handles rendered after visual content so they paint on top */}
<Handle
type="target"
position={Position.Left}
id="reference"
data-handletype="reference"
className="!bg-gray-500"
/>
{/* Handles rendered after visual content so they paint on top */}
<Handle
type="target"
position={Position.Left}
id="image"
data-handletype="image"
className="!bg-gray-500"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/nodes/ImageInputNode.tsx` around lines 142 - 149, The target
Handle metadata uses generic "reference" values; update the Handle in
ImageInputNode so its id and data-handletype follow image conventions (change
id="reference" to id="image" and data-handletype="reference" to
data-handletype="image") so the component Handle (Handle, Position.Left)
correctly identifies Base64 image payloads for node connections.

<Handle
type="source"
position={Position.Right}
Expand Down
4 changes: 3 additions & 1 deletion src/components/nodes/PromptConstructorNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode";
import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { parseVarTags } from "@/utils/parseVarTags";

type PromptConstructorNodeType = Node<PromptConstructorNodeData, "promptConstructor">;
Expand All @@ -31,10 +32,11 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo

// Get available variables from connected prompt nodes (named variables + inline <var> tags)
const availableVariables = useMemo((): AvailableVariable[] => {
const connectedTextNodes = edges
const directTextNodes = edges
.filter((e) => e.target === id && e.targetHandle === "text")
.map((e) => nodes.find((n) => n.id === e.source))
.filter((n): n is typeof nodes[0] => n !== undefined);
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges);

const vars: AvailableVariable[] = [];
const usedNames = new Set<string>();
Expand Down
2 changes: 1 addition & 1 deletion src/components/nodes/VideoTrimNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
<BaseNode
id={id}
selected={selected}
contentClassName="flex-1 min-h-0 overflow-clip"
contentClassName="flex-1 min-h-0"
minWidth={360}
minHeight={360}
>
Expand Down
6 changes: 4 additions & 2 deletions src/store/execution/simpleNodeExecutors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
WorkflowNode,
} from "@/types";
import type { NodeExecutionContext } from "./types";
import { resolveTextSourcesThroughRouters } from "@/store/utils/connectedInputs";
import { parseTextToArray } from "@/utils/arrayParser";
import { parseVarTags } from "@/utils/parseVarTags";

Expand Down Expand Up @@ -109,11 +110,12 @@ export async function executePromptConstructor(ctx: NodeExecutionContext): Promi
const edges = getEdges();
const nodes = getNodes();

// Find all connected text nodes
const connectedTextNodes = edges
// Find all connected text nodes (resolving through routers)
const directTextNodes = edges
.filter((e) => e.target === node.id && e.targetHandle === "text")
.map((e) => nodes.find((n) => n.id === e.source))
.filter((n): n is WorkflowNode => n !== undefined);
const connectedTextNodes = resolveTextSourcesThroughRouters(directTextNodes, nodes, edges);

// Build variable map: named variables from Prompt nodes take precedence
const variableMap: Record<string, string> = {};
Expand Down
35 changes: 35 additions & 0 deletions src/store/utils/connectedInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,41 @@ function getSourceOutput(
return { type: "image", value: null };
}

/**
* Resolves text source nodes through router (passthrough) nodes.
* Given a list of directly-connected source nodes, expands any router nodes
* by recursively following their upstream text connections to find the actual
* text-producing source nodes.
*/
export function resolveTextSourcesThroughRouters(
sourceNodes: WorkflowNode[],
allNodes: WorkflowNode[],
edges: { source: string; target: string; targetHandle?: string | null }[],
visited?: Set<string>
): WorkflowNode[] {
const seen = visited ?? new Set<string>();
const resolved: WorkflowNode[] = [];

for (const node of sourceNodes) {
if (seen.has(node.id)) continue;
seen.add(node.id);

if (node.type === "router") {
const upstreamNodes = edges
.filter((e) => e.target === node.id && e.targetHandle === "text")
.map((e) => allNodes.find((n) => n.id === e.source))
.filter((n): n is WorkflowNode => n !== undefined);
resolved.push(
...resolveTextSourcesThroughRouters(upstreamNodes, allNodes, edges, seen)
);
} else {
resolved.push(node);
}
}

return resolved;
}

/**
* Get all connected inputs for a node.
* Pure function version of workflowStore.getConnectedInputs.
Expand Down
5 changes: 5 additions & 0 deletions src/types/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
parameters?: Record<string, unknown>; // Model-specific parameters for external providers
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parametersExpanded?: boolean; // Collapse state for inline parameter display
_settingsPanelHeight?: number; // Measured settings panel height for reload correction
status: NodeStatus;
error: string | null;
imageHistory: CarouselImageItem[]; // Carousel history (IDs only)
Expand All @@ -197,6 +198,7 @@ export interface GenerateVideoNodeData extends BaseNodeData {
parameters?: Record<string, unknown>; // Model-specific parameters
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parametersExpanded?: boolean; // Collapse state for inline parameter display
_settingsPanelHeight?: number; // Measured settings panel height for reload correction
status: NodeStatus;
error: string | null;
videoHistory: CarouselVideoItem[]; // Carousel history (IDs only)
Expand All @@ -217,6 +219,7 @@ export interface Generate3DNodeData extends BaseNodeData {
parameters?: Record<string, unknown>;
inputSchema?: ModelInputDef[];
parametersExpanded?: boolean; // Collapse state for inline parameter display
_settingsPanelHeight?: number; // Measured settings panel height for reload correction
status: NodeStatus;
error: string | null;
}
Expand All @@ -242,6 +245,7 @@ export interface GenerateAudioNodeData extends BaseNodeData {
parameters?: Record<string, unknown>; // Model-specific parameters (voice, speed, etc.)
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
parametersExpanded?: boolean; // Collapse state for inline parameter display
_settingsPanelHeight?: number; // Measured settings panel height for reload correction
status: NodeStatus;
error: string | null;
audioHistory: CarouselAudioItem[]; // Carousel history (IDs only)
Expand All @@ -263,6 +267,7 @@ export interface LLMGenerateNodeData extends BaseNodeData {
temperature: number;
maxTokens: number;
parametersExpanded?: boolean; // Collapse state for inline parameter display
_settingsPanelHeight?: number; // Measured settings panel height for reload correction
status: NodeStatus;
error: string | null;
}
Expand Down