diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 0536fcdc..52ec031c 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -68,6 +68,8 @@ import { EditOperation } from "@/lib/chat/editOperations"; import { stripBinaryData } from "@/lib/chat/contextBuilder"; import { PromptEditorModal } from "./modals/PromptEditorModal"; import { AnnotationModal } from "./AnnotationModal"; +import { browseRegistry } from "@/utils/browseRegistry"; +import { useInlineParameters } from "@/hooks/useInlineParameters"; import { SplitGridSettingsModal } from "./SplitGridSettingsModal"; import { createPortal } from "react-dom"; import { useAnnotationStore } from "@/store/annotationStore"; @@ -412,6 +414,9 @@ export function WorkflowCanvas() { regenerateNode(nodeId); }, [regenerateNode]); + // Inline parameters mode (for showing Browse in header) + const { inlineParametersEnabled } = useInlineParameters(); + // Stable callback for expanding a node from its header const handleExpandNode = useCallback((nodeId: string, nodeType: string) => { if (nodeType === 'annotation') { @@ -2085,6 +2090,20 @@ export function WorkflowCanvas() { const defaultWidth = defaultNodeDimensions[node.type as NodeType]?.width ?? 250; const headerWidth = node.measured?.width || (node.style?.width as number) || defaultWidth; + // Browse button for generate nodes in inline-parameters mode + const showBrowse = inlineParametersEnabled && ( + node.type === "nanoBanana" || node.type === "generateVideo" || + node.type === "generate3d" || node.type === "generateAudio" + ); + const browseAction = showBrowse ? ( + + ) : undefined; + return ( {/* Title Section */} -
+
{provider && } {isEditingTitle ? ( )} - {headerAction}
{/* Controls - right-aligned, fade in on hover/selected */}
+ {/* 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)?.(); }, +};