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

69
src/components/nodes/ControlPanel.tsx

@ -353,61 +353,26 @@ function GenerateImageControls({ node }: { node: Node }) {
return (
<>
<div className="space-y-3">
{/* Provider Selector */}
{/* Model display + Browse */}
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Provider</label>
<select
value={currentProvider}
onChange={handleProviderChange}
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"
>
{enabledProviders.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</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>
<label className="block text-xs font-medium text-neutral-400 mb-1">Model</label>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-100 truncate">
{nodeData.selectedModel?.displayName || GEMINI_IMAGE_MODELS.find(m => m.value === nodeData.model)?.label || "Select model..."}
</div>
<div className="text-[10px] text-neutral-500 truncate">
{enabledProviders.find(p => p.id === currentProvider)?.name || currentProvider}
</div>
</div>
{modelsFetchError && (
<p className="text-xs text-red-400 mt-1">{modelsFetchError}</p>
)}
<button
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>
{/* Gemini-specific controls */}
{isGeminiProvider && (

170
src/components/nodes/GenerateImageNode.tsx

@ -486,6 +486,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
selected={selected}
isExecuting={isRunning}
hasError={nodeData.status === "error"}
fullBleed
>
{/* Input handles - ALWAYS use same IDs and positions for connection stability */}
{/* 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
</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 */}
{nodeData.outputImage ? (
<>
<div className="relative w-full flex-1 min-h-0">
<img
src={nodeData.outputImage}
alt="Generated"
className="w-full h-full object-contain rounded"
/>
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex items-center justify-center">
<svg
className="w-6 h-6 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>
)}
{/* 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"
<img
src={nodeData.outputImage}
alt="Generated"
className="w-full h-full object-cover"
/>
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
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" />
</svg>
<span className="text-white text-xs font-medium">Generation failed</span>
<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 rounded 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 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"
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 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"
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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-white text-xs font-medium">Generation failed</span>
<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>
)}
{/* 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>
{/* Carousel controls - only show if there are multiple images */}
{/* Carousel controls - overlaid on image bottom */}
{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
onClick={handleCarouselPrevious}
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"
>
<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" />
</svg>
</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}
</span>
<button
onClick={handleCarouselNext}
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"
>
<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" ? (
<svg
className="w-4 h-4 animate-spin text-neutral-400"

Loading…
Cancel
Save