From 325bbc0a65f5d9f488ff1ac441e540f8ab8b5ee5 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 1 Feb 2026 13:41:28 +1300 Subject: [PATCH] feat(40-04): build PromptConstructorNode component with autocomplete and preview - Template textarea with @ autocomplete dropdown - Detects @ typing and shows matching connected variable names - Autocomplete dropdown shows @name and prompt value preview - Arrow keys navigate, Enter/Tab select, Escape closes - Client-side computation of unresolvedVars from @(\w+) patterns - Warning badge displays unresolved @tags in amber - Live preview tooltip on hover showing resolved template - availableVariables computed from connected prompt nodes with variableName - Text input handle (target, left) and text output handle (source, right) - BaseNode wrapper with title 'Prompt Constructor' - Sync template to store on blur (same pattern as PromptNode) - Local state prevents cursor jumping during typing - Filtered autocomplete based on typed characters after @ - Position autocomplete relative to textarea with line height calculation --- .../nodes/PromptConstructorNode.tsx | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/components/nodes/PromptConstructorNode.tsx diff --git a/src/components/nodes/PromptConstructorNode.tsx b/src/components/nodes/PromptConstructorNode.tsx new file mode 100644 index 00000000..5de17cf0 --- /dev/null +++ b/src/components/nodes/PromptConstructorNode.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useCallback, useState, useEffect, useMemo, useRef } from "react"; +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useCommentNavigation } from "@/hooks/useCommentNavigation"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { PromptConstructorNodeData, PromptNodeData } from "@/types"; + +type PromptConstructorNodeType = Node; + +interface AvailableVariable { + name: string; + value: string; + nodeId: string; +} + +export function PromptConstructorNode({ id, data, selected }: NodeProps) { + const nodeData = data; + const commentNavigation = useCommentNavigation(id); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const edges = useWorkflowStore((state) => state.edges); + const nodes = useWorkflowStore((state) => state.nodes); + + // Local state for template to prevent cursor jumping + const [localTemplate, setLocalTemplate] = useState(nodeData.template); + const [isEditing, setIsEditing] = useState(false); + + // Autocomplete state + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }); + const [autocompleteFilter, setAutocompleteFilter] = useState(""); + const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(0); + const textareaRef = useRef(null); + + // Sync from props when not actively editing + useEffect(() => { + if (!isEditing) { + setLocalTemplate(nodeData.template); + } + }, [nodeData.template, isEditing]); + + // Get available variables from connected prompt nodes + const availableVariables = useMemo((): AvailableVariable[] => { + const connectedPromptNodes = edges + .filter((e) => e.target === id && e.targetHandle === "text") + .map((e) => nodes.find((n) => n.id === e.source)) + .filter((n): n is typeof nodes[0] => n !== undefined && n.type === "prompt"); + + const vars: AvailableVariable[] = []; + connectedPromptNodes.forEach((promptNode) => { + const promptData = promptNode.data as PromptNodeData; + if (promptData.variableName) { + vars.push({ + name: promptData.variableName, + value: promptData.prompt || "", + nodeId: promptNode.id, + }); + } + }); + + return vars; + }, [edges, nodes, id]); + + // Compute unresolved variables client-side + const unresolvedVars = useMemo(() => { + const varPattern = /@(\w+)/g; + const unresolved: string[] = []; + const matches = localTemplate.matchAll(varPattern); + const availableNames = new Set(availableVariables.map(v => v.name)); + + for (const match of matches) { + const varName = match[1]; + if (!availableNames.has(varName) && !unresolved.includes(varName)) { + unresolved.push(varName); + } + } + + return unresolved; + }, [localTemplate, availableVariables]); + + // Compute resolved text client-side for preview + const resolvedPreview = useMemo(() => { + let resolved = localTemplate; + availableVariables.forEach((v) => { + resolved = resolved.replace(new RegExp(`@${v.name}`, 'g'), v.value); + }); + return resolved; + }, [localTemplate, availableVariables]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalTemplate(newValue); + + // Check if @ was just typed + const cursorPos = e.target.selectionStart; + const textBeforeCursor = newValue.slice(0, cursorPos); + const match = textBeforeCursor.match(/@(\w*)$/); + + if (match && textareaRef.current) { + // Show autocomplete + setAutocompleteFilter(match[1] || ""); + setSelectedAutocompleteIndex(0); + + // Calculate position relative to textarea + const lineHeight = 20; // Approximate line height + const lines = textBeforeCursor.split('\n'); + const currentLine = lines.length - 1; + const top = currentLine * lineHeight + 30; // Offset from top of textarea + const left = 10; + + setAutocompletePosition({ top, left }); + setShowAutocomplete(true); + } else { + setShowAutocomplete(false); + } + }, + [] + ); + + const handleFocus = useCallback(() => { + setIsEditing(true); + }, []); + + const handleBlur = useCallback(() => { + setIsEditing(false); + if (localTemplate !== nodeData.template) { + updateNodeData(id, { template: localTemplate }); + } + // Close autocomplete on blur + setTimeout(() => setShowAutocomplete(false), 200); + }, [id, localTemplate, nodeData.template, updateNodeData]); + + const handleAutocompleteSelect = useCallback((varName: string) => { + if (!textareaRef.current) return; + + const cursorPos = textareaRef.current.selectionStart; + const textBeforeCursor = localTemplate.slice(0, cursorPos); + const textAfterCursor = localTemplate.slice(cursorPos); + + // Find the @ position + const match = textBeforeCursor.match(/@(\w*)$/); + if (!match) return; + + const atPosition = cursorPos - match[0].length; + const newTemplate = localTemplate.slice(0, atPosition) + `@${varName}` + textAfterCursor; + + setLocalTemplate(newTemplate); + updateNodeData(id, { template: newTemplate }); + setShowAutocomplete(false); + + // Set cursor after inserted variable + const newCursorPos = atPosition + varName.length + 1; + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }, [localTemplate, id, updateNodeData]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!showAutocomplete) return; + + const filteredVars = availableVariables.filter(v => + v.name.toLowerCase().includes(autocompleteFilter.toLowerCase()) + ); + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedAutocompleteIndex((prev) => (prev + 1) % filteredVars.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedAutocompleteIndex((prev) => (prev - 1 + filteredVars.length) % filteredVars.length); + } else if (e.key === "Enter" || e.key === "Tab") { + if (filteredVars.length > 0) { + e.preventDefault(); + handleAutocompleteSelect(filteredVars[selectedAutocompleteIndex].name); + } + } else if (e.key === "Escape") { + setShowAutocomplete(false); + } + }, [showAutocomplete, availableVariables, autocompleteFilter, selectedAutocompleteIndex, handleAutocompleteSelect]); + + const filteredAutocompleteVars = useMemo(() => { + return availableVariables.filter(v => + v.name.toLowerCase().includes(autocompleteFilter.toLowerCase()) + ); + }, [availableVariables, autocompleteFilter]); + + return ( + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + commentNavigation={commentNavigation ?? undefined} + > + {/* Text input handle */} + + +
+ {/* Warning badge for unresolved variables */} + {unresolvedVars.length > 0 && ( +
+ Unresolved: {unresolvedVars.map(v => `@${v}`).join(', ')} +
+ )} + + {/* Template textarea with autocomplete */} +
+