Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
90d0ea4e9c
  1. 20
      src/components/WorkflowCanvas.tsx
  2. 6
      src/components/nodes/FloatingNodeHeader.tsx
  3. 7
      src/components/nodes/Generate3DNode.tsx
  4. 7
      src/components/nodes/GenerateAudioNode.tsx
  5. 56
      src/components/nodes/GenerateImageNode.tsx
  6. 40
      src/components/nodes/GenerateVideoNode.tsx
  7. 10
      src/utils/browseRegistry.ts

20
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 ? (
<button
onClick={() => browseRegistry.open(node.id)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
) : undefined;
return (
<FloatingNodeHeader
key={`header-${node.id}`}
@ -2100,6 +2119,7 @@ export function WorkflowCanvas() {
customTitle={node.data?.customTitle}
comment={node.data?.comment}
provider={(node.data as any)?.selectedModel?.provider}
headerAction={browseAction}
onCustomTitleChange={handleCustomTitleChange}
onCommentChange={handleCommentChange}
onRunNode={handleRunNode}

6
src/components/nodes/FloatingNodeHeader.tsx

@ -325,7 +325,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
onPointerDown={handleHeaderPointerDown}
>
{/* Title Section */}
<div className="flex-1 min-w-0 flex items-center gap-1.5 pl-2">
<div className="flex-1 min-w-0 max-w-[60%] flex items-center gap-1.5 pl-2">
{provider && <ProviderBadge provider={provider} />}
{isEditingTitle ? (
<input
@ -347,11 +347,13 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
{customTitle ? `${customTitle} - ${title}` : title}
</span>
)}
{headerAction}
</div>
{/* Controls - right-aligned, fade in on hover/selected */}
<div className={`shrink-0 flex items-center gap-1 pr-1 transition-opacity duration-200 -translate-y-1 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
{/* Header Action (e.g. Browse button) */}
{headerAction}
{/* Lock Badge for nodes in locked groups */}
{isInLockedGroup && (
<div className="shrink-0 flex items-center" title="This node is in a locked group and will be skipped during execution">

7
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<Generate3DNodeT
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
// Register browse callback for floating header button
useEffect(() => {
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";

7
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<GenerateAudioNodeData, "generateAudio">;
@ -26,6 +27,12 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
// Register browse callback for floating header button
useEffect(() => {
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";

56
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<NanoBananaNo
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
// Register browse callback for floating header button
useEffect(() => {
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<NanoBananaNo
return providers;
}, [replicateEnabled, replicateApiKey, kieEnabled, kieApiKey]);
// Check if external providers (Replicate/Fal) are enabled
// fal.ai is always available (works without key but rate limited)
const hasExternalProviders = useMemo(() => {
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<NanoBananaNo
<ProviderBadge provider={currentProvider} />
), [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 ? (
<a
href={modelPageUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : null;
if (!isGeminiOnly) {
return (
<>
{linkIcon}
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</>
);
}
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";

40
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<GenerateVide
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
// Register browse callback for floating header button
useEffect(() => {
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<GenerateVide
<ProviderBadge provider={currentProvider} />
), [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 && (
<a
href={modelPageUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="nodrag nopan text-neutral-500 hover:text-neutral-300 transition-colors"
title={`View on ${getProviderDisplayName(currentProvider)}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</>
), [modelPageUrl, nodeData.selectedModel?.modelId, currentProvider]);
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
// Track previous status to detect error transitions

10
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<string, () => 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)?.(); },
};
Loading…
Cancel
Save