+ {/* Header Action (e.g. Browse button) */}
+ {headerAction}
+
{/* Lock Badge for nodes in locked groups */}
{isInLockedGroup && (
diff --git a/src/components/nodes/Generate3DNode.tsx b/src/components/nodes/Generate3DNode.tsx
index 864708e1..0f16cbb3 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 { browseRegistry } from "@/utils/browseRegistry";
// 3D generation capabilities
const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"];
@@ -28,6 +29,12 @@ export function Generate3DNode({ id, data, selected }: NodeProps
{
+ browseRegistry.register(id, () => setIsBrowseDialogOpen(true));
+ return () => { browseRegistry.unregister(id); };
+ }, [id]);
+
// Get the current selected provider (default to fal since most 3D models are there)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx
index f9f9c579..f71cf5de 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 { browseRegistry } from "@/utils/browseRegistry";
type GenerateAudioNodeType = Node;
@@ -26,6 +27,12 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps {
+ browseRegistry.register(id, () => setIsBrowseDialogOpen(true));
+ return () => { browseRegistry.unregister(id); };
+ }, [id]);
+
// Get the current selected provider (default to fal)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx
index b61308af..ecf46775 100644
--- a/src/components/nodes/GenerateImageNode.tsx
+++ b/src/components/nodes/GenerateImageNode.tsx
@@ -12,9 +12,9 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge";
-import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
+import { browseRegistry } from "@/utils/browseRegistry";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols → [1,5,2,6,3,7,4,8] */
@@ -67,6 +67,12 @@ export function GenerateImageNode({ id, data, selected }: NodeProps {
+ browseRegistry.register(id, () => setIsBrowseDialogOpen(true));
+ return () => { browseRegistry.unregister(id); };
+ }, [id]);
+
// Get the current selected provider (default to gemini)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "gemini";
@@ -88,16 +94,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps {
- const hasReplicate = replicateEnabled && replicateApiKey;
- // fal.ai is always available
- return !!(hasReplicate || true);
- }, [replicateEnabled, replicateApiKey]);
-
- const isGeminiOnly = !hasExternalProviders;
-
// Migrate legacy data: derive selectedModel from model field if missing
useEffect(() => {
if (nodeData.model && !nodeData.selectedModel) {
@@ -414,44 +410,6 @@ export function GenerateImageNode({ id, data, selected }: NodeProps
), [currentProvider]);
- // Compute model page URL for external link
- const modelPageUrl = useMemo(() => {
- if (!nodeData.selectedModel?.modelId) return null;
- return getModelPageUrl(currentProvider, nodeData.selectedModel.modelId);
- }, [currentProvider, nodeData.selectedModel?.modelId]);
-
- // Header action element based on provider mode
- const headerAction = useMemo(() => {
- const linkIcon = modelPageUrl && nodeData.selectedModel?.modelId ? (
- e.stopPropagation()}
- className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
- title={`View on ${getProviderDisplayName(currentProvider)}`}
- >
-
-
- ) : null;
-
- if (!isGeminiOnly) {
- return (
- <>
- {linkIcon}
-
- >
- );
- }
- return linkIcon;
- }, [isGeminiOnly, modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
// Use selectedModel.modelId for Gemini models, fallback to legacy model field
const currentModelId = isGeminiProvider ? (nodeData.selectedModel?.modelId || nodeData.model) : null;
const supportsResolution = currentModelId === "nano-banana-pro" || currentModelId === "nano-banana-2";
diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx
index d64d06b6..b7b2bf1c 100644
--- a/src/components/nodes/GenerateVideoNode.tsx
+++ b/src/components/nodes/GenerateVideoNode.tsx
@@ -14,9 +14,9 @@ import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/n
import { ProviderBadge } from "./ProviderBadge";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
-import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
+import { browseRegistry } from "@/utils/browseRegistry";
// Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"];
@@ -60,6 +60,12 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps {
+ browseRegistry.register(id, () => setIsBrowseDialogOpen(true));
+ return () => { browseRegistry.unregister(id); };
+ }, [id]);
+
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
// Get enabled providers
@@ -321,38 +327,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps
), [currentProvider]);
- // Compute model page URL for external link
- const modelPageUrl = useMemo(() => {
- if (!nodeData.selectedModel?.modelId) return null;
- return getModelPageUrl(currentProvider, nodeData.selectedModel.modelId);
- }, [currentProvider, nodeData.selectedModel?.modelId]);
-
- // Header action element - external link + browse button
- const headerAction = useMemo(() => (
- <>
- {modelPageUrl && nodeData.selectedModel?.modelId && (
- e.stopPropagation()}
- className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
- title={`View on ${getProviderDisplayName(currentProvider)}`}
- >
-
-
- )}
-
- >
- ), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
-
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
// Track previous status to detect error transitions
diff --git a/src/utils/browseRegistry.ts b/src/utils/browseRegistry.ts
new file mode 100644
index 00000000..df22f9de
--- /dev/null
+++ b/src/utils/browseRegistry.ts
@@ -0,0 +1,10 @@
+/** Lightweight registry for browse-dialog callbacks.
+ * Generate-node components register their open-browse handler;
+ * WorkflowCanvas invokes it via the floating header Browse button. */
+const callbacks = new Map void>();
+
+export const browseRegistry = {
+ register: (id: string, cb: () => void) => { callbacks.set(id, cb); },
+ unregister: (id: string) => { callbacks.delete(id); },
+ open: (id: string) => { callbacks.get(id)?.(); },
+};