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 5 months ago
parent
commit
5d45d90f84
  1. 15
      src/components/nodes/BaseNode.tsx
  2. 55
      src/components/nodes/ControlPanel.tsx
  3. 26
      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>
</> </>
); );

55
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}
))}
</select>
</div> </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>
) : (
<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 <button
onClick={() => setIsBrowseDialogOpen(true)} 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" 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 Browse
</button> </button>
</div> </div>
{modelsFetchError && (
<p className="text-xs text-red-400 mt-1">{modelsFetchError}</p>
)}
</div> </div>
)}
{/* Gemini-specific controls */} {/* Gemini-specific controls */}
{isGeminiProvider && ( {isGeminiProvider && (

26
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,19 +548,18 @@ 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-contain rounded" className="w-full h-full object-cover"
/> />
{/* Loading overlay for generation */} {/* Loading overlay for generation */}
{nodeData.status === "loading" && ( {nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex items-center justify-center"> <div className="absolute inset-0 bg-neutral-900/70 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"
@ -583,7 +583,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
)} )}
{/* Error overlay when generation failed */} {/* Error overlay when generation failed */}
{nodeData.status === "error" && ( {nodeData.status === "error" && (
<div className="absolute inset-0 bg-red-900/40 rounded flex flex-col items-center justify-center gap-1"> <div className="absolute inset-0 bg-red-900/40 flex flex-col items-center justify-center gap-1">
<svg <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -599,7 +599,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
)} )}
{/* Loading overlay for carousel navigation */} {/* Loading overlay for carousel navigation */}
{isLoadingCarouselImage && ( {isLoadingCarouselImage && (
<div className="absolute inset-0 bg-neutral-900/50 rounded flex items-center justify-center"> <div className="absolute inset-0 bg-neutral-900/50 flex items-center justify-center">
<svg <svg
className="w-4 h-4 animate-spin text-white" className="w-4 h-4 animate-spin text-white"
fill="none" fill="none"
@ -621,6 +621,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</svg> </svg>
</div> </div>
)} )}
{/* Clear button */}
<div className="absolute top-1 right-1"> <div className="absolute top-1 right-1">
<button <button
onClick={handleClearImage} onClick={handleClearImage}
@ -632,28 +633,27 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</svg> </svg>
</button> </button>
</div> </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