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. 29
      src/components/nodes/Generate3DNode.tsx
  3. 29
      src/components/nodes/GenerateAudioNode.tsx
  4. 27
      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(

29
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,6 +136,19 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
onToggle={handleToggleParams}
nodeId={id}
>
{/* 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">
@ -152,7 +167,7 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
</button>
</div>
{/* External provider parameters - reuse ModelParameters component */}
{/* External provider parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
@ -162,6 +177,18 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>
) : undefined}
>

29
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,6 +253,19 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
onToggle={handleToggleParams}
nodeId={id}
>
{/* 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">
@ -269,7 +284,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
</button>
</div>
{/* External provider parameters - reuse ModelParameters component */}
{/* External provider parameters */}
{nodeData.selectedModel?.modelId && (
<ModelParameters
modelId={nodeData.selectedModel.modelId}
@ -279,6 +294,18 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>
) : undefined}
>

27
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,6 +515,19 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
onToggle={handleToggleParams}
nodeId={id}
>
{/* 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" && (
<>
{/* Gemini-specific controls */}
{isGeminiProvider && currentModelId && (() => {
const controls: React.ReactNode[] = [
@ -621,6 +636,18 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
onInputsLoaded={handleInputsLoaded}
/>
)}
</>
)}
{/* Fallback tab content */}
{settingsTab === "fallback" && nodeData.fallbackModel && (
<ModelParameters
modelId={nodeData.fallbackModel.modelId}
provider={nodeData.fallbackModel.provider}
parameters={nodeData.fallbackParameters || {}}
onParametersChange={(p) => updateNodeData(id, { fallbackParameters: p })}
/>
)}
</InlineParameterPanel>
) : undefined}
>

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