Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
920ea81c54
  1. 72
      src/components/__tests__/GenerateImageNode.test.tsx
  2. 73
      src/components/nodes/Generate3DNode.tsx
  3. 73
      src/components/nodes/GenerateAudioNode.tsx
  4. 229
      src/components/nodes/GenerateImageNode.tsx
  5. 26
      src/components/nodes/GenerateVideoNode.tsx

72
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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "flux/dev", displayName: "FLUX Dev" },
fallbackModel: { provider: "replicate", modelId: "flux-schnell", displayName: "FLUX Schnell" },
parametersExpanded: true,
})} />
</TestWrapper>
);
expect(screen.getByText("FLUX Dev")).toBeInTheDocument();
expect(screen.getByText("FLUX Schnell")).toBeInTheDocument();
});
it("hides tab bar when no fallbackModel", () => {
render(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "flux/dev", displayName: "FLUX Dev" },
parametersExpanded: true,
})} />
</TestWrapper>
);
// 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
selectedModel: { provider: "fal", modelId: "flux/dev", displayName: "FLUX Dev" },
fallbackModel: { provider: "replicate", modelId: "flux-schnell", displayName: "FLUX Schnell" },
parametersExpanded: true,
})} />
</TestWrapper>
);
// 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(

73
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<Generate3DNodeT
const updateNodeData = useWorkflowStore((state) => 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<Generate3DNodeT
onToggle={handleToggleParams}
nodeId={id}
>
{/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[11px] text-neutral-200 truncate">
{displayTitle}
</div>
<div className="text-[9px] text-neutral-500">
{currentProvider}
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{/* Primary tab content */}
{settingsTab === "primary" && (
<>
{/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[11px] text-neutral-200 truncate">
{displayTitle}
</div>
<div className="text-[9px] text-neutral-500">
{currentProvider}
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-2 py-1 text-[10px] bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-2 py-1 text-[10px] bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
{/* External provider parameters - reuse ModelParameters component */}
{nodeData.selectedModel?.modelId && (
{/* External provider parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>

73
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<GenerateAudioNodeData, "generateAudio">;
@ -23,6 +24,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
const generationsPath = useWorkflowStore((state) => 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<GenerateAudi
onToggle={handleToggleParams}
nodeId={id}
>
{/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[11px] text-neutral-200 truncate">
{displayTitle}
</div>
<div className="text-[9px] text-neutral-500">
{currentProvider}
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{/* Primary tab content */}
{settingsTab === "primary" && (
<>
{/* Model selector: Browse button + current model display */}
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[11px] text-neutral-200 truncate">
{displayTitle}
</div>
<div className="text-[9px] text-neutral-500">
{currentProvider}
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-2 py-1 text-[10px] bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
</div>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan shrink-0 px-2 py-1 text-[10px] bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div>
{/* External provider parameters - reuse ModelParameters component */}
{nodeData.selectedModel?.modelId && (
{/* External provider parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>

229
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<NanoBananaNo
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(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<NanoBananaNo
onToggle={handleToggleParams}
nodeId={id}
>
{/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && (() => {
const controls: React.ReactNode[] = [
<div key="model" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Model</label>
<select
value={currentModelId}
onChange={handleModelChange}
data-tutorial="generate-model-selector"
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"
>
{GEMINI_IMAGE_MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>,
<div key="aspect-ratio" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Aspect Ratio</label>
<select
value={nodeData.aspectRatio || "1:1"}
onChange={handleAspectRatioChange}
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"
>
{aspectRatios.map((ratio) => (
<option key={ratio} value={ratio}>
{ratio}
</option>
))}
</select>
</div>,
];
if (supportsResolution) {
controls.push(
<div key="resolution" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Resolution</label>
<select
value={nodeData.resolution || "2K"}
onChange={handleResolutionChange}
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"
>
{resolutions.map((res) => (
<option key={res} value={res}>
{res}
</option>
))}
</select>
</div>
);
}
if (currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") {
controls.push(
<label key="google-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useGoogleSearch || false}
onChange={handleGoogleSearchToggle}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Google Search
</label>
);
}
if (currentModelId === "nano-banana-2") {
controls.push(
<label key="image-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useImageSearch || false}
onChange={handleImageSearchToggle}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Image Search
</label>
);
}
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
const display = useGeminiGrid && geminiColCount > 1
? reorderColumnFirst(controls, geminiColCount)
: controls;
{/* Primary tab content */}
{settingsTab === "primary" && (
<>
{/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && (() => {
const controls: React.ReactNode[] = [
<div key="model" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Model</label>
<select
value={currentModelId}
onChange={handleModelChange}
data-tutorial="generate-model-selector"
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"
>
{GEMINI_IMAGE_MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>,
<div key="aspect-ratio" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Aspect Ratio</label>
<select
value={nodeData.aspectRatio || "1:1"}
onChange={handleAspectRatioChange}
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"
>
{aspectRatios.map((ratio) => (
<option key={ratio} value={ratio}>
{ratio}
</option>
))}
</select>
</div>,
];
if (supportsResolution) {
controls.push(
<div key="resolution" className="flex items-center gap-2">
<label className="text-[11px] text-neutral-400 shrink-0">Resolution</label>
<select
value={nodeData.resolution || "2K"}
onChange={handleResolutionChange}
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"
>
{resolutions.map((res) => (
<option key={res} value={res}>
{res}
</option>
))}
</select>
</div>
);
}
return (
<div
ref={geminiGridRef}
className={useGeminiGrid
? "grid grid-cols-[repeat(auto-fill,minmax(min(180px,100%),1fr))] max-w-[420px] gap-x-6 gap-y-1.5"
: "space-y-1.5 max-w-[280px]"
if (currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2") {
controls.push(
<label key="google-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useGoogleSearch || false}
onChange={handleGoogleSearchToggle}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Google Search
</label>
);
}
>
{display}
</div>
);
})()}
{/* External provider parameters - reuse ModelParameters component */}
{!isGeminiProvider && nodeData.selectedModel?.modelId && (
if (currentModelId === "nano-banana-2") {
controls.push(
<label key="image-search" className="flex items-center gap-1.5 text-[11px] text-neutral-300 cursor-pointer">
<input
type="checkbox"
checked={nodeData.useImageSearch || false}
onChange={handleImageSearchToggle}
className="nodrag nopan w-3 h-3 rounded bg-[#1a1a1a] text-neutral-600 focus:ring-1 focus:ring-neutral-600 focus:ring-offset-0"
/>
Image Search
</label>
);
}
const display = useGeminiGrid && geminiColCount > 1
? reorderColumnFirst(controls, geminiColCount)
: controls;
return (
<div
ref={geminiGridRef}
className={useGeminiGrid
? "grid grid-cols-[repeat(auto-fill,minmax(min(180px,100%),1fr))] max-w-[420px] gap-x-6 gap-y-1.5"
: "space-y-1.5 max-w-[280px]"
}
>
{display}
</div>
);
})()}
{/* External provider parameters - reuse ModelParameters component */}
{!isGeminiProvider && nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
parameters={nodeData.parameters || {}}
onParametersChange={handleParametersChange}
onInputsLoaded={handleInputsLoaded}
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>

26
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<GenerateVide
const [modelsFetchError, setModelsFetchError] = useState<string | null>(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<GenerateVide
onToggle={handleToggleParams}
nodeId={id}
>
{/* External provider parameters - reuse ModelParameters component */}
{nodeData.selectedModel?.modelId && (
{/* Tab bar for primary/fallback settings */}
{nodeData.fallbackModel && (
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.selectedModel?.displayName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
{/* Primary tab: external provider parameters */}
{settingsTab === "primary" && nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
@ -405,6 +417,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
onInputsLoaded={handleInputsLoaded}
/>
)}
{/* Fallback tab: fallback model parameters */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>
) : undefined}
>

Loading…
Cancel
Save