From 90d0ea4e9c92cd1b2c8b53c008275f640fd55d6f Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 12 Mar 2026 00:00:21 +1300 Subject: [PATCH] feat: add Browse button to generate node headers in inline parameters mode Wire up a lightweight callback registry so the floating header can trigger each generate node's model browse dialog. Moves Browse into the controls section for consistent vertical alignment and caps title width at 60% to truncate earlier on narrow nodes. Co-Authored-By: Claude Opus 4.6 --- src/components/WorkflowCanvas.tsx | 20 ++++++++ src/components/nodes/FloatingNodeHeader.tsx | 6 ++- src/components/nodes/Generate3DNode.tsx | 7 +++ src/components/nodes/GenerateAudioNode.tsx | 7 +++ src/components/nodes/GenerateImageNode.tsx | 56 +++------------------ src/components/nodes/GenerateVideoNode.tsx | 40 +++------------ src/utils/browseRegistry.ts | 10 ++++ 7 files changed, 62 insertions(+), 84 deletions(-) create mode 100644 src/utils/browseRegistry.ts 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)?.(); }, +};