Browse Source

fix(46-04): ControlPanel layout, carousel overlay, and full-bleed nodes

- ControlPanel: Replace provider/model dropdowns with model name text +
  Browse button. Fixed width, no horizontal scroll.
- GenerateImageNode: Carousel controls now overlay on image bottom with
  semi-transparent dark background for legibility.
- BaseNode: Add fullBleed prop for borderless mode. Image IS the node -
  no visible container, border, or padding.
- GenerateImageNode uses fullBleed mode. Empty state uses subtle fill
  instead of dashed border.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
5d45d90f84
  1. 15
      src/components/nodes/BaseNode.tsx
  2. 69
      src/components/nodes/ControlPanel.tsx
  3. 170
      src/components/nodes/GenerateImageNode.tsx

15
src/components/nodes/BaseNode.tsx

@ -14,6 +14,8 @@ interface BaseNodeProps {
contentClassName?: string; contentClassName?: string;
minWidth?: number; minWidth?: number;
minHeight?: number; minHeight?: number;
/** When true, node has no background/border — content fills the entire node area */
fullBleed?: boolean;
} }
export function BaseNode({ export function BaseNode({
@ -26,6 +28,7 @@ export function BaseNode({
contentClassName, contentClassName,
minWidth = 180, minWidth = 180,
minHeight = 100, minHeight = 100,
fullBleed = false,
}: BaseNodeProps) { }: BaseNodeProps) {
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds); const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds);
const nodes = useWorkflowStore((state) => state.nodes); const nodes = useWorkflowStore((state) => state.nodes);
@ -71,14 +74,16 @@ export function BaseNode({
/> />
<div <div
className={` className={`
bg-neutral-800 rounded-lg shadow-lg border h-full w-full flex flex-col h-full w-full flex flex-col overflow-clip
${isCurrentlyExecuting || isExecuting ? "border-blue-500 ring-1 ring-blue-500/20" : "border-neutral-700/60"} ${fullBleed ? "rounded-lg" : "bg-neutral-800 rounded-lg shadow-lg border"}
${hasError ? "border-red-500" : ""} ${fullBleed ? "" : (isCurrentlyExecuting || isExecuting ? "border-blue-500 ring-1 ring-blue-500/20" : "border-neutral-700/60")}
${selected ? "border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""} ${fullBleed ? "" : (hasError ? "border-red-500" : "")}
${fullBleed && selected ? "ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}
${!fullBleed && selected ? "border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}
${className} ${className}
`} `}
> >
<div className={contentClassName ?? "px-3 pb-4 flex-1 min-h-0 overflow-hidden flex flex-col"}>{children}</div> <div className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-hidden flex flex-col")}>{children}</div>
</div> </div>
</> </>
); );

69
src/components/nodes/ControlPanel.tsx

@ -353,61 +353,26 @@ function GenerateImageControls({ node }: { node: Node }) {
return ( return (
<> <>
<div className="space-y-3"> <div className="space-y-3">
{/* Provider Selector */} {/* Model display + Browse */}
<div> <div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Provider</label> <label className="block text-xs font-medium text-neutral-400 mb-1">Model</label>
<select <div className="flex items-center gap-2">
value={currentProvider} <div className="flex-1 min-w-0">
onChange={handleProviderChange} <div className="text-sm text-neutral-100 truncate">
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500" {nodeData.selectedModel?.displayName || GEMINI_IMAGE_MODELS.find(m => m.value === nodeData.model)?.label || "Select model..."}
> </div>
{enabledProviders.map(p => ( <div className="text-[10px] text-neutral-500 truncate">
<option key={p.id} value={p.id}>{p.name}</option> {enabledProviders.find(p => p.id === currentProvider)?.name || currentProvider}
))} </div>
</select>
</div>
{/* Model Selector */}
{isGeminiProvider ? (
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Model</label>
<select
value={nodeData.model || "nano-banana-pro"}
onChange={handleModelChange}
className="nodrag nopan w-full px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{GEMINI_IMAGE_MODELS.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Model</label>
<div className="flex gap-1">
<select
value={nodeData.selectedModel?.modelId || ""}
onChange={handleExternalModelChange}
className="nodrag nopan flex-1 px-2 py-1 text-xs bg-neutral-700 border border-neutral-600 rounded text-neutral-200 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isLoadingModels}
>
<option value="">Select model...</option>
{externalModels.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<button
onClick={() => setIsBrowseDialogOpen(true)}
className="nodrag nopan px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div> </div>
{modelsFetchError && ( <button
<p className="text-xs text-red-400 mt-1">{modelsFetchError}</p> onClick={() => setIsBrowseDialogOpen(true)}
)} className="nodrag nopan shrink-0 px-3 py-1.5 text-xs bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors"
>
Browse
</button>
</div> </div>
)} </div>
{/* Gemini-specific controls */} {/* Gemini-specific controls */}
{isGeminiProvider && ( {isGeminiProvider && (

170
src/components/nodes/GenerateImageNode.tsx

@ -486,6 +486,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
selected={selected} selected={selected}
isExecuting={isRunning} isExecuting={isRunning}
hasError={nodeData.status === "error"} hasError={nodeData.status === "error"}
fullBleed
> >
{/* Input handles - ALWAYS use same IDs and positions for connection stability */} {/* Input handles - ALWAYS use same IDs and positions for connection stability */}
{/* Image input at 35%, Text input at 65% - never changes regardless of model */} {/* Image input at 35%, Text input at 65% - never changes regardless of model */}
@ -547,113 +548,112 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
Image Image
</div> </div>
<div className="flex-1 flex flex-col min-h-0 gap-2"> <div className="relative w-full h-full min-h-0">
{/* Preview area */} {/* Preview area */}
{nodeData.outputImage ? ( {nodeData.outputImage ? (
<> <>
<div className="relative w-full flex-1 min-h-0"> <img
<img src={nodeData.outputImage}
src={nodeData.outputImage} alt="Generated"
alt="Generated" className="w-full h-full object-cover"
className="w-full h-full object-contain rounded" />
/> {/* Loading overlay for generation */}
{/* Loading overlay for generation */} {nodeData.status === "loading" && (
{nodeData.status === "loading" && ( <div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center">
<div className="absolute inset-0 bg-neutral-900/70 rounded flex items-center justify-center"> <svg
<svg className="w-6 h-6 animate-spin text-white"
className="w-6 h-6 animate-spin text-white" fill="none"
fill="none" viewBox="0 0 24 24"
viewBox="0 0 24 24" >
> <circle
<circle className="opacity-25"
className="opacity-25" cx="12"
cx="12" cy="12"
cy="12" r="10"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{/* Error overlay when generation failed */}
{nodeData.status === "error" && (
<div className="absolute inset-0 bg-red-900/40 rounded flex flex-col items-center justify-center gap-1">
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth="3"
> />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
</svg> className="opacity-75"
<span className="text-white text-xs font-medium">Generation failed</span> fill="currentColor"
<span className="text-white/70 text-[10px]">See toast for details</span> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
</div> />
)} </svg>
{/* Loading overlay for carousel navigation */} </div>
{isLoadingCarouselImage && ( )}
<div className="absolute inset-0 bg-neutral-900/50 rounded flex items-center justify-center"> {/* Error overlay when generation failed */}
<svg {nodeData.status === "error" && (
className="w-4 h-4 animate-spin text-white" <div className="absolute inset-0 bg-red-900/40 flex flex-col items-center justify-center gap-1">
fill="none" <svg
viewBox="0 0 24 24" className="w-6 h-6 text-white"
> fill="none"
<circle viewBox="0 0 24 24"
className="opacity-25" stroke="currentColor"
cx="12" strokeWidth={2}
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<div className="absolute top-1 right-1">
<button
onClick={handleClearImage}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear image"
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg>
</svg> <span className="text-white text-xs font-medium">Generation failed</span>
</button> <span className="text-white/70 text-[10px]">See toast for details</span>
</div>
)}
{/* Loading overlay for carousel navigation */}
{isLoadingCarouselImage && (
<div className="absolute inset-0 bg-neutral-900/50 flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div> </div>
)}
{/* Clear button */}
<div className="absolute top-1 right-1">
<button
onClick={handleClearImage}
className="w-5 h-5 bg-neutral-900/80 hover:bg-red-600/80 rounded flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Clear image"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
{/* Carousel controls - only show if there are multiple images */} {/* Carousel controls - overlaid on image bottom */}
{hasCarouselImages && ( {hasCarouselImages && (
<div className="flex items-center justify-center gap-2 shrink-0"> <div className="absolute bottom-0 left-0 right-0 flex items-center justify-center gap-2 py-1.5 bg-neutral-900/60 backdrop-blur-sm">
<button <button
onClick={handleCarouselPrevious} onClick={handleCarouselPrevious}
disabled={isLoadingCarouselImage} disabled={isLoadingCarouselImage}
className="w-5 h-5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-neutral-400 hover:text-white transition-colors" className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 hover:text-white transition-colors"
title="Previous image" title="Previous image"
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<span className="text-[10px] text-neutral-400 min-w-[32px] text-center"> <span className="text-[10px] text-white/70 min-w-[32px] text-center">
{(nodeData.selectedHistoryIndex || 0) + 1} / {(nodeData.imageHistory || []).length} {(nodeData.selectedHistoryIndex || 0) + 1} / {(nodeData.imageHistory || []).length}
</span> </span>
<button <button
onClick={handleCarouselNext} onClick={handleCarouselNext}
disabled={isLoadingCarouselImage} disabled={isLoadingCarouselImage}
className="w-5 h-5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-neutral-400 hover:text-white transition-colors" className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 hover:text-white transition-colors"
title="Next image" title="Next image"
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@ -664,7 +664,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
)} )}
</> </>
) : ( ) : (
<div className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center"> <div className="w-full h-full min-h-[112px] bg-neutral-900/40 flex flex-col items-center justify-center">
{nodeData.status === "loading" ? ( {nodeData.status === "loading" ? (
<svg <svg
className="w-4 h-4 animate-spin text-neutral-400" className="w-4 h-4 animate-spin text-neutral-400"

Loading…
Cancel
Save