From 920ea81c546634adc030ed87754db181d0c1ea4a Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 11 Apr 2026 20:39:01 +1200 Subject: [PATCH] feat: integrate fallback settings tabs into generation nodes Adds SettingsTabBar to all four generation nodes (image, video, audio, 3D). When a fallback model is set, a tab bar appears to switch between primary and fallback model parameter controls. Fallback tab renders ModelParameters bound to nodeData.fallbackParameters. Co-Authored-By: Claude Sonnet 4.5 --- .../__tests__/GenerateImageNode.test.tsx | 72 ++++++ src/components/nodes/Generate3DNode.tsx | 73 ++++-- src/components/nodes/GenerateAudioNode.tsx | 73 ++++-- src/components/nodes/GenerateImageNode.tsx | 229 ++++++++++-------- src/components/nodes/GenerateVideoNode.tsx | 26 +- 5 files changed, 324 insertions(+), 149 deletions(-) diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx index d2fa611e..40689faa 100644 --- a/src/components/__tests__/GenerateImageNode.test.tsx +++ b/src/components/__tests__/GenerateImageNode.test.tsx @@ -568,6 +568,78 @@ describe("GenerateImageNode", () => { }); }); + describe("Fallback Settings Tab", () => { + beforeEach(() => { + // Enable inline parameters for tab bar tests + localStorage.setItem("node-banana-inline-parameters", "true"); + }); + afterEach(() => { + localStorage.removeItem("node-banana-inline-parameters"); + }); + + it("shows tab bar when fallbackModel is set", () => { + render( + + + + ); + + expect(screen.getByText("FLUX Dev")).toBeInTheDocument(); + expect(screen.getByText("FLUX Schnell")).toBeInTheDocument(); + }); + + it("hides tab bar when no fallbackModel", () => { + render( + + + + ); + + // Should not find the fallback label + expect(screen.queryByText("FLUX Schnell")).not.toBeInTheDocument(); + }); + + it("switches to fallback ModelParameters when fallback tab clicked", async () => { + // Track which modelId is being fetched for schema + const schemaFetchCalls: string[] = []; + mockFetch.mockImplementation((url: string) => { + if (typeof url === "string" && url.includes("/api/models/")) { + schemaFetchCalls.push(url); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ parameters: [], inputs: [], success: true }), + }); + }); + + render( + + + + ); + + // Click the fallback tab + fireEvent.click(screen.getByText("FLUX Schnell")); + + // After clicking fallback tab, ModelParameters should fetch schema for fallback model + await waitFor(() => { + const fallbackFetches = schemaFetchCalls.filter(url => url.includes("flux-schnell")); + expect(fallbackFetches.length).toBeGreaterThan(0); + }); + }); + }); + describe("Fetch Models on Provider Change", () => { it("should fetch models when provider is fal", async () => { render( diff --git a/src/components/nodes/Generate3DNode.tsx b/src/components/nodes/Generate3DNode.tsx index b0ad5adf..63d95562 100644 --- a/src/components/nodes/Generate3DNode.tsx +++ b/src/components/nodes/Generate3DNode.tsx @@ -13,6 +13,7 @@ import { ProviderBadge } from "./ProviderBadge"; import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; +import { SettingsTabBar } from "./SettingsTabBar"; import { browseRegistry } from "@/utils/browseRegistry"; // 3D generation capabilities @@ -25,6 +26,7 @@ export function Generate3DNode({ id, data, selected }: NodeProps state.updateNodeData); const { replicateApiKey, falApiKey, kieApiKey } = useProviderApiKeys(); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); // Inline parameters infrastructure const { inlineParametersEnabled } = useInlineParameters(); @@ -134,32 +136,57 @@ export function Generate3DNode({ id, data, selected }: NodeProps - {/* Model selector: Browse button + current model display */} -
-
-
- {displayTitle} -
-
- {currentProvider} + {/* Tab bar for primary/fallback settings */} + {nodeData.fallbackModel && ( + + )} + + {/* Primary tab content */} + {settingsTab === "primary" && ( + <> + {/* Model selector: Browse button + current model display */} +
+
+
+ {displayTitle} +
+
+ {currentProvider} +
+
+
-
- -
- {/* External provider parameters - reuse ModelParameters component */} - {nodeData.selectedModel?.modelId && ( + {/* External provider parameters */} + {nodeData.selectedModel?.modelId && ( + + )} + + )} + + {/* Fallback tab content */} + {settingsTab === "fallback" && nodeData.fallbackModel && ( updateNodeData(id, { fallbackParameters: p })} /> )} diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index 1254ffde..cc960126 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -13,6 +13,7 @@ import { useAudioVisualization } from "@/hooks/useAudioVisualization"; import { useAudioPlayback } from "@/hooks/useAudioPlayback"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; +import { SettingsTabBar } from "./SettingsTabBar"; import { browseRegistry } from "@/utils/browseRegistry"; type GenerateAudioNodeType = Node; @@ -23,6 +24,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps state.generationsPath); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false); + const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); // Inline parameters infrastructure const { inlineParametersEnabled } = useInlineParameters(); @@ -251,32 +253,57 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps - {/* Model selector: Browse button + current model display */} -
-
-
- {displayTitle} -
-
- {currentProvider} + {/* Tab bar for primary/fallback settings */} + {nodeData.fallbackModel && ( + + )} + + {/* Primary tab content */} + {settingsTab === "primary" && ( + <> + {/* Model selector: Browse button + current model display */} +
+
+
+ {displayTitle} +
+
+ {currentProvider} +
+
+
-
- -
- {/* External provider parameters - reuse ModelParameters component */} - {nodeData.selectedModel?.modelId && ( + {/* External provider parameters */} + {nodeData.selectedModel?.modelId && ( + + )} + + )} + + {/* Fallback tab content */} + {settingsTab === "fallback" && nodeData.fallbackModel && ( updateNodeData(id, { fallbackParameters: p })} /> )} diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 65b6a166..cf19d1d6 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -14,6 +14,7 @@ import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/n import { ProviderBadge } from "./ProviderBadge"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; +import { SettingsTabBar } from "./SettingsTabBar"; import { browseRegistry } from "@/utils/browseRegistry"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; @@ -65,6 +66,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps(null); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); // Inline parameters infrastructure const { inlineParametersEnabled } = useInlineParameters(); @@ -513,112 +515,137 @@ export function GenerateImageNode({ id, data, selected }: NodeProps - {/* Gemini-specific controls */} - {isGeminiProvider && currentModelId && (() => { - const controls: React.ReactNode[] = [ -
- - -
, -
- - -
, - ]; - - if (supportsResolution) { - controls.push( -
- - -
- ); - } - - if (currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") { - controls.push( - - ); - } - - if (currentModelId === "nano-banana-2") { - controls.push( - - ); - } + {/* Tab bar for primary/fallback settings */} + {nodeData.fallbackModel && ( + + )} - const display = useGeminiGrid && geminiColCount > 1 - ? reorderColumnFirst(controls, geminiColCount) - : controls; + {/* Primary tab content */} + {settingsTab === "primary" && ( + <> + {/* Gemini-specific controls */} + {isGeminiProvider && currentModelId && (() => { + const controls: React.ReactNode[] = [ +
+ + +
, +
+ + +
, + ]; + + if (supportsResolution) { + controls.push( +
+ + +
+ ); + } - return ( -
+ + Google Search + + ); } - > - {display} -
- ); - })()} - {/* External provider parameters - reuse ModelParameters component */} - {!isGeminiProvider && nodeData.selectedModel?.modelId && ( + if (currentModelId === "nano-banana-2") { + controls.push( + + ); + } + + const display = useGeminiGrid && geminiColCount > 1 + ? reorderColumnFirst(controls, geminiColCount) + : controls; + + return ( +
+ {display} +
+ ); + })()} + + {/* External provider parameters - reuse ModelParameters component */} + {!isGeminiProvider && nodeData.selectedModel?.modelId && ( + + )} + + )} + + {/* Fallback tab content */} + {settingsTab === "fallback" && nodeData.fallbackModel && ( updateNodeData(id, { fallbackParameters: p })} /> )} diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 603e0336..9dbf4944 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -16,6 +16,7 @@ import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoAutoplay } from "@/hooks/useVideoAutoplay"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; +import { SettingsTabBar } from "./SettingsTabBar"; import { browseRegistry } from "@/utils/browseRegistry"; // Video generation capabilities @@ -54,6 +55,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps(null); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isLoadingCarouselVideo, setIsLoadingCarouselVideo] = useState(false); + const [settingsTab, setSettingsTab] = useState<"primary" | "fallback">("primary"); const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null); const videoAutoplayRef = useVideoAutoplay(id, selected); @@ -395,8 +397,18 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps - {/* External provider parameters - reuse ModelParameters component */} - {nodeData.selectedModel?.modelId && ( + {/* Tab bar for primary/fallback settings */} + {nodeData.fallbackModel && ( + + )} + + {/* Primary tab: external provider parameters */} + {settingsTab === "primary" && nodeData.selectedModel?.modelId && ( )} + + {/* Fallback tab: fallback model parameters */} + {settingsTab === "fallback" && nodeData.fallbackModel && ( + updateNodeData(id, { fallbackParameters: p })} + /> + )} ) : undefined} >