1
0
Fork 0

fix: restore small handle labels and make Format node dynamic

- TypedHandle now shows tiny 8px labels next to handles (color-matched)
- Labels positioned outside the handle, no overlap with node content
- Format node dynamically generates input handles from template {0}, {1}, etc.
- NodeHandles accepts dynamicInputs/dynamicOutputs overrides
- isValidConnection treats dynamic handles (arg-*, option-*) as any-type
This commit is contained in:
Jason Fraley 2026-07-28 18:22:25 -04:00
parent 8ec0c194ff
commit 707562faaa
4 changed files with 68 additions and 24 deletions

View file

@ -3,13 +3,30 @@
import { type NodeProps } from "@xyflow/react"; import { type NodeProps } from "@xyflow/react";
import { AlignLeftIcon } from "lucide-react"; import { AlignLeftIcon } from "lucide-react";
import { NodeHandles } from "./NodeHandles"; import { NodeHandles } from "./NodeHandles";
import { NODE_SOCKETS, type SocketDef } from "./types";
function parseTemplateArgs(template: string): SocketDef[] {
const matches = template.match(/\{(\d+)\}/g);
if (!matches) return [];
const unique = [...new Set(matches)].sort();
return unique.map((m) => ({
id: `arg-${m.slice(1, -1)}`,
type: "any" as const,
label: m,
}));
}
export function FormatNode({ data }: NodeProps) { export function FormatNode({ data }: NodeProps) {
const d = data as { template?: string }; const d = data as { template?: string };
const template = d.template ?? "{0}";
const dynamicInputs: SocketDef[] = [
...(NODE_SOCKETS.format?.inputs.filter((s) => s.id === "template") ?? []),
...parseTemplateArgs(template),
];
return ( return (
<div className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40"> <div className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
<NodeHandles nodeType="format" /> <NodeHandles nodeType="format" dynamicInputs={dynamicInputs} />
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30"> <div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" /> <AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-sky-200 flex-1"> <span className="text-sm font-medium text-sky-200 flex-1">
@ -17,7 +34,7 @@ export function FormatNode({ data }: NodeProps) {
</span> </span>
</div> </div>
<div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate"> <div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate">
{d.template || "{0} {1}"} {template}
</div> </div>
</div> </div>
); );

View file

@ -119,10 +119,14 @@ export function NarrativeFlowEditor() {
const sourceSocket = sourceSockets.outputs.find((s) => s.id === connection.sourceHandle); const sourceSocket = sourceSockets.outputs.find((s) => s.id === connection.sourceHandle);
const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle); const targetSocket = targetSockets.inputs.find((s) => s.id === connection.targetHandle);
if (!sourceSocket || !targetSocket) return false;
if (sourceSocket.type === "any" || targetSocket.type === "any") return true; // Dynamic handles (arg-*, option-*) are treated as "any"
return sourceSocket.type === targetSocket.type; const sourceType = sourceSocket?.type ?? (connection.sourceHandle.startsWith("arg-") || connection.sourceHandle.startsWith("option-") ? "any" : null);
const targetType = targetSocket?.type ?? (connection.targetHandle.startsWith("arg-") || connection.targetHandle.startsWith("option-") ? "any" : null);
if (sourceType === null || targetType === null) return false;
if (sourceType === "any" || targetType === "any") return true;
return sourceType === targetType;
}, },
[nodes], [nodes],
); );

View file

@ -1,21 +1,20 @@
"use client"; "use client";
import { TypedHandle } from "./TypedHandle"; import { TypedHandle } from "./TypedHandle";
import { NODE_SOCKETS } from "./types"; import { NODE_SOCKETS, type SocketDef } from "./types";
interface NodeHandlesProps { interface NodeHandlesProps {
nodeType: string; nodeType: string;
dynamicOutputs?: Array<{ id: string }>; dynamicInputs?: SocketDef[];
dynamicOutputs?: SocketDef[];
} }
export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) { export function NodeHandles({ nodeType, dynamicInputs, dynamicOutputs }: NodeHandlesProps) {
const config = NODE_SOCKETS[nodeType]; const config = NODE_SOCKETS[nodeType];
if (!config) return null; if (!config) return null;
const inputs = config.inputs; const inputs = dynamicInputs ?? config.inputs;
const outputs = dynamicOutputs const outputs = dynamicOutputs ?? config.outputs;
? dynamicOutputs.map((o) => ({ ...o, type: "flow" as const }))
: config.outputs;
const maxSlots = Math.max(inputs.length, outputs.length, 1); const maxSlots = Math.max(inputs.length, outputs.length, 1);
@ -28,6 +27,7 @@ export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
key={socket.id} key={socket.id}
id={socket.id} id={socket.id}
socketType={socket.type} socketType={socket.type}
label={socket.label}
isInput={true} isInput={true}
style={{ top: `${topPercent}%` }} style={{ top: `${topPercent}%` }}
/> />
@ -40,6 +40,7 @@ export function NodeHandles({ nodeType, dynamicOutputs }: NodeHandlesProps) {
key={socket.id} key={socket.id}
id={socket.id} id={socket.id}
socketType={socket.type} socketType={socket.type}
label={socket.label}
isInput={false} isInput={false}
style={{ top: `${topPercent}%` }} style={{ top: `${topPercent}%` }}
/> />

View file

@ -7,26 +7,48 @@ interface TypedHandleProps {
id: string; id: string;
socketType: SocketType; socketType: SocketType;
isInput: boolean; isInput: boolean;
label?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
export function TypedHandle({ id, socketType, isInput, style }: TypedHandleProps) { export function TypedHandle({ id, socketType, isInput, label, style }: TypedHandleProps) {
const color = SOCKET_COLORS[socketType]; const color = SOCKET_COLORS[socketType];
const isFlow = socketType === "flow"; const isFlow = socketType === "flow";
return ( return (
<Handle <div
type={isInput ? "target" : "source"} className="absolute flex items-center gap-1"
position={isInput ? Position.Left : Position.Right}
id={id}
style={{ style={{
background: color, [isInput ? "left" : "right"]: -4,
width: isFlow ? 8 : 6, top: style?.top ?? "50%",
height: isFlow ? 8 : 6, transform: "translateY(-50%)",
borderRadius: isFlow ? 2 : "50%", flexDirection: isInput ? "row" : "row-reverse",
border: `1.5px solid ${color}60`,
...style,
}} }}
/> >
<Handle
type={isInput ? "target" : "source"}
position={isInput ? Position.Left : Position.Right}
id={id}
style={{
background: color,
width: isFlow ? 8 : 6,
height: isFlow ? 8 : 6,
borderRadius: isFlow ? 2 : "50%",
border: `1.5px solid ${color}60`,
position: "relative",
left: "auto",
right: "auto",
top: "auto",
}}
/>
{label && (
<span
className="text-[8px] font-mono leading-none whitespace-nowrap pointer-events-none opacity-70"
style={{ color }}
>
{label}
</span>
)}
</div>
); );
} }