Browse Source

fix(24-01): fix node UX issues - error display, accessibility, button states, dead code

- LLM error view now shows nodeData.error message below "Generation failed"
- ImageInputNode remove button has aria-label and keyboard focus styles
- ImageInputNode empty state has role=button, tabIndex, keyboard handler
- SplitGrid split button disabled when no source image connected
- PromptNode dead modal code removed, variable dialog has clickable @var trigger
- PromptConstructorNode dead modal code and unused imports removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
621d7e06ba
  1. 12
      src/components/nodes/ImageInputNode.tsx
  2. 3
      src/components/nodes/LLMGenerateNode.tsx
  3. 34
      src/components/nodes/PromptConstructorNode.tsx
  4. 44
      src/components/nodes/PromptNode.tsx
  5. 4
      src/components/nodes/SplitGridNode.tsx

12
src/components/nodes/ImageInputNode.tsx

@ -114,7 +114,8 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-all flex items-center justify-center"
aria-label="Remove image"
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 focus:ring-1 focus:ring-red-400 transition-all flex items-center justify-center"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@ -123,7 +124,16 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
</div>
) : (
<div
role="button"
tabIndex={0}
aria-label="Upload image"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center cursor-pointer hover:bg-neutral-900/60 transition-colors"

3
src/components/nodes/LLMGenerateNode.tsx

@ -253,6 +253,9 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
<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>
{nodeData.error && (
<span className="text-red-200 text-[10px] text-center px-3 mt-1 line-clamp-3">{nodeData.error}</span>
)}
</div>
) : nodeData.outputText ? (
<div className="group/text relative w-full h-full bg-neutral-900/40 p-2 overflow-auto nowheel">

34
src/components/nodes/PromptConstructorNode.tsx

@ -1,13 +1,11 @@
"use client";
import { useCallback, useState, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { usePromptAutocomplete } from "@/hooks/usePromptAutocomplete";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptConstructorNodeData, PromptNodeData, LLMGenerateNodeData, AvailableVariable } from "@/types";
import { PromptConstructorEditorModal } from "@/components/modals/PromptConstructorEditorModal";
import { parseVarTags } from "@/utils/parseVarTags";
type PromptConstructorNodeType = Node<PromptConstructorNodeData, "promptConstructor">;
@ -17,13 +15,10 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges);
const nodes = useWorkflowStore((state) => state.nodes);
const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount);
const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount);
// Local state for template to prevent cursor jumping
const [localTemplate, setLocalTemplate] = useState(nodeData.template);
const [isEditing, setIsEditing] = useState(false);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -158,23 +153,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
setTimeout(() => closeAutocomplete(), 200);
}, [id, localTemplate, nodeData.template, updateNodeData, closeAutocomplete]);
const handleOpenModal = useCallback(() => {
setIsModalOpenLocal(true);
incrementModalCount();
}, [incrementModalCount]);
const handleCloseModal = useCallback(() => {
setIsModalOpenLocal(false);
decrementModalCount();
}, [decrementModalCount]);
const handleSubmitModal = useCallback(
(template: string) => {
updateNodeData(id, { template });
},
[id, updateNodeData]
);
return (
<>
<BaseNode
@ -260,18 +238,6 @@ export function PromptConstructorNode({ id, data, selected }: NodeProps<PromptCo
style={{ zIndex: 10 }}
/>
</BaseNode>
{/* Prompt Constructor Editor Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptConstructorEditorModal
isOpen={isModalOpenLocal}
initialTemplate={nodeData.template}
availableVariables={availableVariables}
onSubmit={handleSubmitModal}
onClose={handleCloseModal}
/>,
document.body
)}
</>
);
}

44
src/components/nodes/PromptNode.tsx

@ -6,18 +6,14 @@ import { Handle, Position, NodeProps, Node } from "@xyflow/react";
import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types";
import { PromptEditorModal } from "@/components/modals/PromptEditorModal";
type PromptNodeType = Node<PromptNodeData, "prompt">;
export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const incrementModalCount = useWorkflowStore((state) => state.incrementModalCount);
const decrementModalCount = useWorkflowStore((state) => state.decrementModalCount);
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges);
const [isModalOpenLocal, setIsModalOpenLocal] = useState(false);
// Local state for prompt to prevent cursor jumping during typing
const [localPrompt, setLocalPrompt] = useState(nodeData.prompt);
@ -75,23 +71,6 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
}
}, [id, localPrompt, nodeData.prompt, updateNodeData]);
const handleOpenModal = useCallback(() => {
setIsModalOpenLocal(true);
incrementModalCount();
}, [incrementModalCount]);
const handleCloseModal = useCallback(() => {
setIsModalOpenLocal(false);
decrementModalCount();
}, [decrementModalCount]);
const handleSubmitModal = useCallback(
(prompt: string) => {
updateNodeData(id, { prompt });
},
[id, updateNodeData]
);
const handleSaveVariableName = useCallback(() => {
updateNodeData(id, { variableName: varNameInput || undefined });
setShowVarDialog(false);
@ -133,11 +112,13 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
placeholder={hasIncomingTextConnection ? "Text from connected node (editable)..." : "Describe what to generate..."}
className="nodrag nopan nowheel w-full h-full p-3 text-xs leading-relaxed text-neutral-100 bg-neutral-800 rounded-lg resize-none focus:outline-none placeholder:text-neutral-500"
/>
{nodeData.variableName && (
<div className="absolute bottom-2 left-3 z-10 text-[10px] text-blue-400 pointer-events-none">
@{nodeData.variableName}
</div>
)}
<button
onClick={() => setShowVarDialog(true)}
className="nodrag nopan absolute bottom-2 left-3 z-10 text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
title="Set variable name"
>
@{nodeData.variableName || "var"}
</button>
{/* Text output handle */}
<Handle
@ -149,17 +130,6 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
/>
</BaseNode>
{/* Prompt Editor Modal - rendered via portal to escape React Flow stacking context */}
{isModalOpenLocal && createPortal(
<PromptEditorModal
isOpen={isModalOpenLocal}
initialPrompt={nodeData.prompt}
onSubmit={handleSubmitModal}
onClose={handleCloseModal}
/>,
document.body
)}
{/* Variable Naming Dialog - rendered via portal */}
{showVarDialog && createPortal(
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999]">

4
src/components/nodes/SplitGridNode.tsx

@ -148,9 +148,9 @@ export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeTyp
{/* Split button */}
<button
onClick={handleSplit}
disabled={isRunning || !nodeData.isConfigured}
disabled={isRunning || !nodeData.isConfigured || !nodeData.sourceImage}
className="nodrag nopan px-2 py-0.5 text-[10px] border border-white hover:bg-white hover:text-neutral-900 disabled:border-neutral-600 disabled:text-neutral-600 disabled:cursor-not-allowed text-white rounded transition-colors"
title={!nodeData.isConfigured ? "Configure node first" : "Split grid"}
title={!nodeData.isConfigured ? "Configure node first" : !nodeData.sourceImage ? "Connect an image first" : "Split grid"}
>
Split
</button>

Loading…
Cancel
Save