@foresthubai/workflow-builder 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +661 -661
  2. package/NOTICE +16 -16
  3. package/README.md +110 -93
  4. package/dist/components/ui/command.d.ts +2 -2
  5. package/dist/components/ui/input.d.ts +1 -1
  6. package/dist/components/ui/resizable.d.ts +1 -1
  7. package/dist/components/ui/textarea.d.ts +1 -1
  8. package/dist/graph/BaseNode.js +10 -10
  9. package/dist/graph/reactFlowRegistry.d.ts.map +1 -1
  10. package/dist/lib/utils.d.ts +3 -0
  11. package/dist/lib/utils.d.ts.map +1 -0
  12. package/dist/lib/utils.js +6 -0
  13. package/dist/lib/utils.js.map +1 -0
  14. package/dist/toolbars/CanvasTabsToolbar.d.ts +11 -0
  15. package/dist/toolbars/CanvasTabsToolbar.d.ts.map +1 -0
  16. package/dist/toolbars/CanvasTabsToolbar.js +101 -0
  17. package/dist/toolbars/CanvasTabsToolbar.js.map +1 -0
  18. package/package.json +2 -2
  19. package/src/BuilderLayout.tsx +345 -345
  20. package/src/Canvas.tsx +261 -261
  21. package/src/CanvasEditor.tsx +142 -142
  22. package/src/CanvasTabsToolbar.tsx +176 -176
  23. package/src/RightConfigPanel.tsx +266 -266
  24. package/src/WorkflowBuilder.tsx +412 -412
  25. package/src/cn.ts +6 -6
  26. package/src/components/ui/add-button.tsx +39 -39
  27. package/src/components/ui/alert-dialog.tsx +141 -141
  28. package/src/components/ui/alert.tsx +59 -59
  29. package/src/components/ui/badge.tsx +36 -36
  30. package/src/components/ui/button.tsx +85 -85
  31. package/src/components/ui/card.tsx +79 -79
  32. package/src/components/ui/checkbox.tsx +28 -28
  33. package/src/components/ui/collapsible.tsx +9 -9
  34. package/src/components/ui/command.tsx +153 -153
  35. package/src/components/ui/delete-button.tsx +23 -23
  36. package/src/components/ui/dialog.tsx +125 -125
  37. package/src/components/ui/dropdown-menu.tsx +198 -198
  38. package/src/components/ui/input.tsx +55 -55
  39. package/src/components/ui/label.tsx +24 -24
  40. package/src/components/ui/readonly-banner.tsx +15 -15
  41. package/src/components/ui/resizable.tsx +43 -43
  42. package/src/components/ui/scroll-area.tsx +102 -102
  43. package/src/components/ui/select.tsx +160 -160
  44. package/src/components/ui/separator.tsx +29 -29
  45. package/src/components/ui/switch.tsx +27 -27
  46. package/src/components/ui/textarea.tsx +51 -51
  47. package/src/components/ui/toast.tsx +127 -127
  48. package/src/components/ui/toaster.tsx +33 -33
  49. package/src/components/ui/toggle-group.tsx +59 -59
  50. package/src/components/ui/toggle.tsx +43 -43
  51. package/src/components/ui/tooltip.tsx +32 -32
  52. package/src/dialogs/NodePickerDialog.tsx +84 -84
  53. package/src/dialogs/ValidationDialog.tsx +184 -184
  54. package/src/graph/BaseNode.tsx +557 -557
  55. package/src/graph/CustomEdge.tsx +185 -185
  56. package/src/graph/CustomNode.tsx +16 -16
  57. package/src/graph/FunctionCallNode.tsx +30 -30
  58. package/src/graph/PortHandle.tsx +189 -189
  59. package/src/graph/reactFlowRegistry.ts +26 -26
  60. package/src/hooks/use-toast.ts +125 -125
  61. package/src/hooks/useAvailableVariables.ts +20 -20
  62. package/src/hooks/useCanvasHistory.ts +22 -22
  63. package/src/hooks/useCanvasTabs.ts +168 -168
  64. package/src/hooks/useFunctionDiagnosticsSync.ts +40 -40
  65. package/src/hooks/useFunctionRegistry.ts +26 -26
  66. package/src/hooks/useFunctions.ts +44 -44
  67. package/src/hooks/useGraph.ts +161 -161
  68. package/src/hooks/useNodeDefinitions.ts +82 -82
  69. package/src/hooks/useParamErrors.ts +26 -26
  70. package/src/hooks/useResolvedTheme.ts +30 -30
  71. package/src/hooks/useResourceDiagnosticsSync.ts +58 -58
  72. package/src/hooks/useSuppressThemeTransition.ts +79 -79
  73. package/src/hooks/useWorkflowSerialization.ts +127 -127
  74. package/src/i18n/index.ts +53 -53
  75. package/src/i18n/locales/de.json +501 -501
  76. package/src/i18n/locales/en.json +557 -557
  77. package/src/index.ts +27 -27
  78. package/src/inputs/ExpressionInput.tsx +297 -297
  79. package/src/inputs/ParameterEditor.tsx +515 -515
  80. package/src/inputs/PortSection.tsx +144 -144
  81. package/src/panels/BuilderSidebar.tsx +301 -301
  82. package/src/panels/ChannelConfigPanel.tsx +49 -49
  83. package/src/panels/ChannelsPanel.tsx +28 -28
  84. package/src/panels/DebugConsolePanel.tsx +73 -73
  85. package/src/panels/DebugContextPanel.tsx +77 -77
  86. package/src/panels/DebugExternalIOPanel.tsx +180 -180
  87. package/src/panels/DiagnosticsPanel.tsx +170 -170
  88. package/src/panels/EdgeConfigPanel.tsx +104 -104
  89. package/src/panels/FunctionConfigPanel.tsx +179 -179
  90. package/src/panels/FunctionListPanel.tsx +45 -45
  91. package/src/panels/MemoryConfigPanel.tsx +55 -55
  92. package/src/panels/MemoryPanel.tsx +40 -40
  93. package/src/panels/ModelConfigPanel.tsx +41 -41
  94. package/src/panels/ModelsPanel.tsx +36 -36
  95. package/src/panels/NodeConfigPanel.tsx +630 -630
  96. package/src/panels/NodeLibrary.tsx +288 -288
  97. package/src/panels/ResourceConfigPanel.tsx +132 -132
  98. package/src/panels/ResourceListPanel.tsx +113 -113
  99. package/src/panels/VariableConfigPanel.tsx +161 -161
  100. package/src/panels/VariablesPanel.tsx +145 -145
  101. package/src/stores/canvasStore.test.ts +44 -44
  102. package/src/stores/canvasStore.ts +245 -245
  103. package/src/stores/debugStore.ts +74 -74
  104. package/src/stores/diagnosticsStore.ts +130 -130
  105. package/src/stores/editorStore.ts +202 -202
  106. package/src/styles/index.css +526 -526
  107. package/src/utils/categoryConstants.ts +26 -26
  108. package/src/utils/channelOperations.ts +86 -86
  109. package/src/utils/connectionRules.ts +137 -137
  110. package/src/utils/functionOperations.ts +179 -179
  111. package/src/utils/graphOperations.ts +550 -550
  112. package/src/utils/history.ts +207 -207
  113. package/src/utils/memoryOperations.ts +57 -57
  114. package/src/utils/migrateFunctionNodes.ts +107 -107
  115. package/src/utils/modelOperations.ts +55 -55
  116. package/src/utils/paramDisplay.ts +71 -71
  117. package/src/utils/resourceHelpers.ts +32 -32
  118. package/src/utils/translation.ts +28 -28
  119. package/src/utils/variableOperations.ts +75 -75
  120. package/tailwind-preset.ts +166 -166
@@ -1,630 +1,630 @@
1
- import { Button } from "../components/ui/button";
2
- import { AddButton } from "../components/ui/add-button";
3
- import { ReadOnlyBanner } from "../components/ui/readonly-banner";
4
- import { DeleteButton } from "../components/ui/delete-button";
5
- import { Checkbox } from "../components/ui/checkbox";
6
- import { Input } from "../components/ui/input";
7
- import { Separator } from "../components/ui/separator";
8
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
9
- import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
10
- import {
11
- NodeDefinition,
12
- NodeData,
13
- OutputBinding,
14
- FunctionCallNode,
15
- getArguments,
16
- getNodeAvailableOutput,
17
- getOutputBinding,
18
- } from "@foresthubai/workflow-core/node";
19
- import type { DataType, Reference } from "@foresthubai/workflow-core";
20
- import type { StaticOutput, OutputList, OutputDeclaration } from "@foresthubai/workflow-core/parameter";
21
- import { isParameterActive, Parameter } from "@foresthubai/workflow-core/parameter";
22
- import { generateId } from "@foresthubai/workflow-core/id";
23
- import { ArrowRight, ChevronRight, Plus, RefreshCw, Trash2 } from "lucide-react";
24
- import { getOrCreateCanvasStore } from "../stores/canvasStore";
25
- import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
26
- import { varKey, refToLookupKey } from "@foresthubai/workflow-core/variable";
27
- import type { Diagnostic } from "@foresthubai/workflow-core/diagnostics";
28
- import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
29
- import { useTranslation } from "react-i18next";
30
- import ParameterEditor from "../inputs/ParameterEditor";
31
- import { useDiagnosticsStore } from "../stores/diagnosticsStore";
32
- import { useFunctionRegistry } from "../hooks/useFunctionRegistry";
33
- import { useParamErrors } from "../hooks/useParamErrors";
34
- import { buildFunctionNodeDef } from "../hooks/useNodeDefinitions";
35
- import { useEditorStore } from "../stores/editorStore";
36
- import { isReadOnly } from "../WorkflowBuilder";
37
- import { migrateFunctionCallNodes } from "../utils/migrateFunctionNodes";
38
- import { getNodeDescription } from "../utils/translation";
39
- import { useAvailableVariables } from "../hooks/useAvailableVariables";
40
-
41
- interface NodeConfigPanelProps {
42
- canvasId: string;
43
- selectedNode: NodeData;
44
- onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown>; label?: string }) => void;
45
- onNodeDelete: (nodeId: string) => void;
46
- onClose: () => void;
47
- onOpenTest: (nodeId: string) => void;
48
- getNodeDef: (node: NodeData) => NodeDefinition | undefined;
49
- }
50
-
51
- export const NodeConfigPanel = ({
52
- canvasId,
53
- selectedNode,
54
- onNodeUpdate,
55
- onNodeDelete,
56
- onClose,
57
- onOpenTest,
58
- getNodeDef,
59
- }: NodeConfigPanelProps) => {
60
- const { t } = useTranslation();
61
- const readOnly = useEditorStore((s) => isReadOnly(s.builderMode));
62
-
63
- // Local state for label input to preserve cursor position
64
- const [localLabel, setLocalLabel] = useState(selectedNode.label || "");
65
- const [isFocused, setIsFocused] = useState(false);
66
- useEffect(() => {
67
- setLocalLabel(selectedNode.label || "");
68
- }, [selectedNode.id, selectedNode.label]);
69
-
70
- // Check if FunctionCall node is stale (e.g. after undo reverted migration)
71
- const functionId = selectedNode.type === "FunctionCall" ? (selectedNode as FunctionCallNode).functionInfo.id : null;
72
- const { getFunction } = useFunctionRegistry();
73
- const functionInfo = functionId ? getFunction(functionId) : undefined;
74
- const isStaleFunction = (() => {
75
- if (selectedNode.type !== "FunctionCall" || !functionInfo) return false;
76
- const functionNode = selectedNode as FunctionCallNode;
77
- return functionNode.functionInfo.version !== functionInfo.version;
78
- })();
79
-
80
- // Get node definition - for FunctionCallNodes, build from node's stored functionInfo
81
- const nodeDefinition =
82
- selectedNode.type === "FunctionCall"
83
- ? buildFunctionNodeDef({
84
- ...(selectedNode as FunctionCallNode).functionInfo,
85
- name: functionInfo?.name ?? (selectedNode as FunctionCallNode).functionInfo.name,
86
- })
87
- : getNodeDef(selectedNode);
88
- const cannotDelete = nodeDefinition?.isUnremovable ?? false;
89
-
90
- // Detect whether this node is currently used as a tool input
91
- const edges = getOrCreateCanvasStore(canvasId)((s) => s.edges);
92
- const usedAsToolInput = useMemo(() => isNodeUsedAsTool(selectedNode.id, selectedNode, edges), [selectedNode, edges]);
93
-
94
- // Read per-parameter error state from diagnostics store
95
- const nodeDiags = useDiagnosticsStore((s) => s.byNodeId[selectedNode.id]);
96
- const paramErrors = useParamErrors(nodeDiags);
97
-
98
- if (!nodeDefinition) {
99
- return (
100
- <div className="p-4">
101
- <p className="text-sm text-muted-foreground">{t("unknownNodeType", { type: selectedNode.type })}</p>
102
- </div>
103
- );
104
- }
105
- const allArguments = getArguments(selectedNode);
106
- const parameters = nodeDefinition.parameters.filter((p) => isParameterActive(p, allArguments, usedAsToolInput));
107
- // OutputsSection self-hides when there's nothing to render; gate on "has any output defined".
108
- const hasAnyOutputs = (nodeDefinition.outputs ?? []).length > 0;
109
-
110
- return (
111
- <div className="p-4">
112
- <div className="space-y-4">
113
- <div className="flex items-center justify-between gap-2">
114
- <div className="flex-1 min-w-0">
115
- <div className="group flex items-center gap-1.5 rounded-md border border-transparent px-1.5 -mx-1.5 hover:border-input focus-within:border-input transition-colors">
116
- <input
117
- type="text"
118
- title={t("nodeLabel")}
119
- className="font-semibold text-lg bg-transparent w-full outline-none cursor-text py-0.5"
120
- value={isFocused ? localLabel : localLabel || nodeDefinition.label}
121
- readOnly={readOnly}
122
- onFocus={() => {
123
- if (readOnly) return;
124
- setIsFocused(true);
125
- if (!localLabel) {
126
- setLocalLabel(nodeDefinition.label);
127
- }
128
- }}
129
- onBlur={() => {
130
- setIsFocused(false);
131
- if (!localLabel || localLabel === nodeDefinition.label) {
132
- setLocalLabel("");
133
- onNodeUpdate(selectedNode.id, { label: undefined });
134
- }
135
- }}
136
- onChange={(e) => {
137
- setLocalLabel(e.target.value);
138
- onNodeUpdate(selectedNode.id, { label: e.target.value });
139
- }}
140
- />
141
- </div>
142
- <p className="text-sm text-muted-foreground">{getNodeDescription(t, nodeDefinition)}</p>
143
- </div>
144
- <Button variant="ghost" size="icon" className="shrink-0" onClick={onClose}>
145
- <ChevronRight className="h-4 w-4" />
146
- </Button>
147
- </div>
148
-
149
- {readOnly && <ReadOnlyBanner />}
150
-
151
- {parameters.length > 0 && (
152
- <>
153
- <Separator />
154
- <div className={`space-y-3 ${readOnly ? "pointer-events-none opacity-60" : ""}`}>
155
- {parameters.map((param: Parameter) => (
156
- <ParameterEditor
157
- canvasId={canvasId}
158
- key={param.id}
159
- parameter={param}
160
- value={allArguments[param.id]}
161
- allArguments={allArguments}
162
- onChange={(value) => onNodeUpdate(selectedNode.id, { arguments: { [param.id]: value } })}
163
- errors={paramErrors.get(param.id)}
164
- translationPrefix={`nodes.${selectedNode.type}`}
165
- />
166
- ))}
167
- </div>
168
- </>
169
- )}
170
-
171
- {!readOnly && isStaleFunction && (
172
- <>
173
- <Separator />
174
- <Button
175
- variant="outline"
176
- className="w-full border-warning text-warning hover:bg-warning/10"
177
- onClick={() => migrateFunctionCallNodes()}
178
- >
179
- <RefreshCw className="w-4 h-4 mr-2" />
180
- {t("updateToLatestDefinition")}
181
- </Button>
182
- </>
183
- )}
184
-
185
- {!readOnly && hasAnyOutputs && !usedAsToolInput && (
186
- <>
187
- <Separator />
188
- <OutputsSection
189
- canvasId={canvasId}
190
- node={selectedNode}
191
- nodeDefinition={nodeDefinition}
192
- onNodeUpdate={onNodeUpdate}
193
- nodeDiags={nodeDiags}
194
- />
195
- </>
196
- )}
197
-
198
- {!readOnly && selectedNode.type === "Agent" && (
199
- <>
200
- <Separator />
201
- <Button variant="outline" className="w-full" onClick={() => onOpenTest(selectedNode.id)}>
202
- {t("testAgent")}
203
- </Button>
204
- </>
205
- )}
206
-
207
- {!readOnly && !cannotDelete && (
208
- <>
209
- <Separator />
210
- <DeleteButton onClick={() => onNodeDelete(selectedNode.id)}>{t("deleteNode")}</DeleteButton>
211
- </>
212
- )}
213
- </div>
214
- </div>
215
- );
216
- };
217
-
218
- // ============================================================================
219
- // Outputs Section
220
- // ============================================================================
221
- //
222
- // Single unified section for all of a node's outputs. Renders under one "OUTPUTS"
223
- // header with sequential rows — no sub-sections, no per-list separator. Two row
224
- // shapes live side by side here:
225
- //
226
- // 1. Static output row — rows for NodeDefinition.outputs.type === "static"
227
- // (FunctionCall returns are just StaticOutputs — they
228
- // flow through this same path). Active checkbox toggles
229
- // binding.active; mode toggle picks emit vs. assign.
230
- // 2. List declaration row — rows for each entry in an OutputList's backing array
231
- // (mode: emit/assign, own dataType selector, own delete button)
232
- //
233
- // List outputs also render an "+ Add" button after their entries so new declarations
234
- // can be appended. Errors on any row (static or list) apply a destructive ring using
235
- // the same `outputId` key diagnostics.ts produces.
236
-
237
- const DATA_TYPES: DataType[] = ["int", "float", "bool", "string"];
238
-
239
- const DATA_TYPE_LABELS: Record<DataType, string> = {
240
- int: "int",
241
- float: "float",
242
- bool: "bool",
243
- string: "string",
244
- };
245
-
246
- /** Stable synthetic outputId used to key list-entry diagnostics (matches diagnostics.ts). */
247
- function listEntryOutputId(listId: string, index: number): string {
248
- return `${listId}[${index}]`;
249
- }
250
-
251
- /** Convert an AvailableVariable into a canonical Reference (srcId + varId). */
252
- function availableVarToRef(
253
- v:
254
- | { kind: "node"; nodeId: string; outputId: string }
255
- | { kind: "declared"; uid: string }
256
- | { kind: "fnarg"; uid: string },
257
- ): Reference {
258
- if (v.kind === "node") return { srcId: v.nodeId, varId: v.outputId };
259
- if (v.kind === "declared") return { srcId: "declared", varId: v.uid };
260
- return { srcId: "fnarg", varId: v.uid };
261
- }
262
-
263
- function OutputsSection({
264
- canvasId,
265
- node,
266
- nodeDefinition,
267
- onNodeUpdate,
268
- nodeDiags,
269
- }: {
270
- canvasId: string;
271
- node: NodeData;
272
- nodeDefinition: NodeDefinition;
273
- onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown> }) => void;
274
- nodeDiags: Diagnostic[] | undefined;
275
- }) {
276
- const { t } = useTranslation();
277
- const { list: availableVars } = useAvailableVariables(canvasId);
278
- const availableOutput = useMemo(() => getNodeAvailableOutput(node), [node]);
279
-
280
- const staticOutputs = useMemo(
281
- () => (nodeDefinition.outputs ?? []).filter((o): o is StaticOutput => o.type === "static"),
282
- [nodeDefinition],
283
- );
284
- const listOutputs = useMemo(
285
- () => (nodeDefinition.outputs ?? []).filter((o): o is OutputList => o.type === "list"),
286
- [nodeDefinition],
287
- );
288
-
289
- // Collect diagnostics keyed by outputId so rows can look themselves up.
290
- const outputErrors = useMemo(() => {
291
- const map = new Map<string, string[]>();
292
- if (!nodeDiags) return map;
293
- for (const d of nodeDiags) {
294
- if (d.outputId && d.severity === "error") {
295
- const arr = map.get(d.outputId);
296
- if (arr) arr.push(d.message);
297
- else map.set(d.outputId, [d.message]);
298
- }
299
- }
300
- return map;
301
- }, [nodeDiags]);
302
-
303
- /**
304
- * Write a binding for a static output (or FunctionCall return). List entries
305
- * have their own update path via `replaceListEntry`.
306
- */
307
- const updateStaticBinding = useCallback(
308
- (key: string, binding: OutputBinding) => {
309
- onNodeUpdate(node.id, { arguments: { [key]: binding } });
310
- },
311
- [node.id, onNodeUpdate],
312
- );
313
-
314
- const writeListEntries = useCallback(
315
- (listId: string, next: OutputDeclaration[]) => onNodeUpdate(node.id, { arguments: { [listId]: next } }),
316
- [node.id, onNodeUpdate],
317
- );
318
-
319
- // Filter available vars to matching dataType, excluding this node's own outputs
320
- // (a node can't assign its own output back into itself).
321
- const filterCompatible = useCallback(
322
- (dataType: DataType) =>
323
- availableVars.filter((v) => v.dataType === dataType && !("nodeId" in v && v.nodeId === node.id)),
324
- [availableVars, node.id],
325
- );
326
-
327
- // Any rows to show? Early-exit so the parent panel doesn't render an empty section.
328
- if (staticOutputs.length === 0 && listOutputs.length === 0) return null;
329
-
330
- // --- Row renderer: static output / FunctionCall return ------------------------------
331
- // UX shape:
332
- // ┌─ card ────────────────────────────────────────┐
333
- // │ [☑] label dataType │
334
- // │ [emit | assign] [ name OR variable picker ] │
335
- // └────────────────────────────────────────────────┘
336
- // Checkbox sits inline with the label (so the card width matches list-entry rows
337
- // which have no checkbox). Toggles `binding.active`; mode/name/target are kept as
338
- // draft state when inactive so off→on round-trips identically. Body is dimmed and
339
- // pointer-event-disabled while inactive.
340
- const renderStaticRow = (key: string, output: { name: string; dataType: DataType }, displayLabel: string) => {
341
- const binding = getOutputBinding(node, key) ?? ({ active: true, mode: "emit", name: output.name } as OutputBinding);
342
- const compatibleVars = filterCompatible(output.dataType);
343
- const errors = outputErrors.get(key);
344
- const hasError = !!errors?.length;
345
- const enabled = binding.active;
346
-
347
- const setEnabled = (next: boolean) => {
348
- if (next === enabled) return;
349
- updateStaticBinding(key, { ...binding, active: next });
350
- };
351
- const setMode = (mode: "emit" | "assign") => {
352
- if (mode === binding.mode) return;
353
- if (mode === "emit") updateStaticBinding(key, { active: binding.active, mode: "emit", name: output.name });
354
- else updateStaticBinding(key, { active: binding.active, mode: "assign", target: { srcId: "", varId: "" } });
355
- };
356
-
357
- return (
358
- <div
359
- key={key}
360
- className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
361
- >
362
- <div className="flex items-center gap-2">
363
- <Checkbox
364
- checked={enabled}
365
- onCheckedChange={(c) => setEnabled(c === true)}
366
- aria-label={t("outputBinding.enable", "Enable output")}
367
- className="shrink-0"
368
- />
369
- <span className={`text-xs font-medium flex-1 truncate ${enabled ? "" : "opacity-50"}`}>{displayLabel}</span>
370
- <span className={`text-xs text-muted-foreground ${enabled ? "" : "opacity-50"}`}>
371
- {DATA_TYPE_LABELS[output.dataType]}
372
- </span>
373
- </div>
374
- <div className={enabled ? "" : "opacity-50"}>
375
- <div className={`flex items-center gap-2 ${enabled ? "" : "pointer-events-none"}`}>
376
- <ToggleGroup
377
- type="single"
378
- value={binding.mode}
379
- onValueChange={(v) => v && setMode(v as "emit" | "assign")}
380
- className="gap-0"
381
- >
382
- <ToggleGroupItem
383
- value="emit"
384
- size="sm"
385
- variant="outline"
386
- className="h-7 w-7 p-0 rounded-r-none"
387
- aria-label={t("outputBinding.emit", "Emit")}
388
- title={t("outputBinding.emit", "Emit")}
389
- >
390
- <Plus className="w-3.5 h-3.5" />
391
- </ToggleGroupItem>
392
- <ToggleGroupItem
393
- value="assign"
394
- size="sm"
395
- variant="outline"
396
- className="h-7 w-7 p-0 rounded-l-none -ml-px"
397
- aria-label={t("outputBinding.assign", "Assign")}
398
- title={t("outputBinding.assign", "Assign")}
399
- >
400
- <ArrowRight className="w-3.5 h-3.5" />
401
- </ToggleGroupItem>
402
- </ToggleGroup>
403
-
404
- {binding.mode === "emit" ? (
405
- <Input
406
- className="h-7 text-xs flex-1"
407
- value={binding.name}
408
- disabled={!enabled}
409
- onChange={(e) =>
410
- updateStaticBinding(key, { active: binding.active, mode: "emit", name: e.target.value })
411
- }
412
- />
413
- ) : compatibleVars.length > 0 ? (
414
- <Select
415
- value={binding.target.srcId ? refToLookupKey(binding.target) : ""}
416
- onValueChange={(lookupKey) => {
417
- const v = availableVars.find((av) => varKey(av) === lookupKey);
418
- if (!v) return;
419
- updateStaticBinding(key, { active: binding.active, mode: "assign", target: availableVarToRef(v) });
420
- }}
421
- disabled={!enabled}
422
- >
423
- <SelectTrigger className="h-7 text-xs flex-1">
424
- <SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
425
- </SelectTrigger>
426
- <SelectContent>
427
- {compatibleVars.map((v) => (
428
- <SelectItem key={varKey(v)} value={varKey(v)}>
429
- {v.name}
430
- </SelectItem>
431
- ))}
432
- </SelectContent>
433
- </Select>
434
- ) : (
435
- <span className="text-xs text-muted-foreground italic flex-1">
436
- {t("outputBinding.noCompatibleVariables", "No compatible variables")}
437
- </span>
438
- )}
439
- </div>
440
- {hasError && (
441
- <div className="space-y-0.5">
442
- {errors.map((msg, i) => (
443
- <p key={i} className="text-xs text-destructive">
444
- {msg}
445
- </p>
446
- ))}
447
- </div>
448
- )}
449
- </div>
450
- </div>
451
- );
452
- };
453
-
454
- // --- Row renderer: list declaration entry ------------------------------------------
455
- const renderListEntryRow = (listId: string, entries: OutputDeclaration[], index: number) => {
456
- const entry = entries[index];
457
- if (!entry) return null;
458
- const compatibleVars = filterCompatible(entry.dataType);
459
- const outputId = listEntryOutputId(listId, index);
460
- const errors = outputErrors.get(outputId);
461
- const hasError = !!errors?.length;
462
-
463
- const replace = (next: OutputDeclaration) =>
464
- writeListEntries(
465
- listId,
466
- entries.map((e, i) => (i === index ? next : e)),
467
- );
468
- const remove = () =>
469
- writeListEntries(
470
- listId,
471
- entries.filter((_, i) => i !== index),
472
- );
473
- // Mode flip preserves name + dataType — the user already typed/picked them.
474
- // emit↔assign only swaps the trailing payload (uid vs target).
475
- const changeMode = (newMode: "emit" | "assign") => {
476
- if (entry.mode === newMode) return;
477
- if (newMode === "emit") {
478
- replace({ mode: "emit", uid: generateId(), name: entry.name, dataType: entry.dataType });
479
- } else {
480
- replace({ mode: "assign", name: entry.name, dataType: entry.dataType, target: { srcId: "", varId: "" } });
481
- }
482
- };
483
- const changeName = (name: string) => replace({ ...entry, name });
484
- const changeDataType = (dt: DataType) => replace({ ...entry, dataType: dt });
485
-
486
- return (
487
- <div
488
- key={`${listId}-${entry.mode === "emit" ? entry.uid : `assign-${index}`}`}
489
- className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
490
- >
491
- <div className="flex items-center gap-2">
492
- {/* Row 1 mirrors OutputBinding's header: leading control (trash↔checkbox),
493
- name (editable here, label-only there), dataType (editable here, RO there). */}
494
- <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={remove}>
495
- <Trash2 className="w-3.5 h-3.5" />
496
- </Button>
497
- <Input
498
- className="h-7 text-xs flex-1"
499
- value={entry.name}
500
- placeholder={t("outputBinding.name", "Name")}
501
- onChange={(e) => changeName(e.target.value)}
502
- />
503
- <Select value={entry.dataType} onValueChange={(dt: DataType) => changeDataType(dt)}>
504
- <SelectTrigger className="h-7 text-xs w-20">
505
- <SelectValue />
506
- </SelectTrigger>
507
- <SelectContent>
508
- {DATA_TYPES.map((dt) => (
509
- <SelectItem key={dt} value={dt}>
510
- {dt}
511
- </SelectItem>
512
- ))}
513
- </SelectContent>
514
- </Select>
515
- </div>
516
- <div className="flex items-center gap-2">
517
- <ToggleGroup
518
- type="single"
519
- value={entry.mode}
520
- onValueChange={(v) => v && changeMode(v as "emit" | "assign")}
521
- className="gap-0"
522
- >
523
- <ToggleGroupItem
524
- value="emit"
525
- size="sm"
526
- variant="outline"
527
- className="h-7 w-7 p-0 rounded-r-none"
528
- aria-label={t("outputBinding.emit", "Emit")}
529
- title={t("outputBinding.emit", "Emit")}
530
- >
531
- <Plus className="w-3.5 h-3.5" />
532
- </ToggleGroupItem>
533
- <ToggleGroupItem
534
- value="assign"
535
- size="sm"
536
- variant="outline"
537
- className="h-7 w-7 p-0 rounded-l-none -ml-px"
538
- aria-label={t("outputBinding.assign", "Assign")}
539
- title={t("outputBinding.assign", "Assign")}
540
- >
541
- <ArrowRight className="w-3.5 h-3.5" />
542
- </ToggleGroupItem>
543
- </ToggleGroup>
544
- {entry.mode === "emit" ? (
545
- <span className="text-xs text-muted-foreground italic flex-1">
546
- {t("outputBinding.emitHint", "creates new variable in scope")}
547
- </span>
548
- ) : compatibleVars.length > 0 ? (
549
- <Select
550
- value={entry.target.srcId ? refToLookupKey(entry.target) : ""}
551
- onValueChange={(lookupKey) => {
552
- const v = availableVars.find((av) => varKey(av) === lookupKey);
553
- if (!v) return;
554
- replace({ ...entry, target: availableVarToRef(v) });
555
- }}
556
- >
557
- <SelectTrigger className="h-7 text-xs flex-1">
558
- <SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
559
- </SelectTrigger>
560
- <SelectContent>
561
- {compatibleVars.map((v) => (
562
- <SelectItem key={varKey(v)} value={varKey(v)}>
563
- {v.name}
564
- </SelectItem>
565
- ))}
566
- </SelectContent>
567
- </Select>
568
- ) : (
569
- <span className="text-xs text-muted-foreground italic flex-1">
570
- {t("outputBinding.noCompatibleVariables", "No compatible variables")}
571
- </span>
572
- )}
573
- </div>
574
- {hasError && (
575
- <div className="space-y-0.5">
576
- {errors.map((msg, i) => (
577
- <p key={i} className="text-xs text-destructive">
578
- {msg}
579
- </p>
580
- ))}
581
- </div>
582
- )}
583
- </div>
584
- );
585
- };
586
-
587
- const addListEntry = (listId: string, entries: OutputDeclaration[]) => {
588
- const fresh: OutputDeclaration = {
589
- mode: "emit",
590
- uid: generateId(),
591
- name: `output${entries.length + 1}`,
592
- dataType: "string",
593
- };
594
- writeListEntries(listId, [...entries, fresh]);
595
- };
596
-
597
- return (
598
- <div className="space-y-2">
599
- <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t("outputs", "Outputs")}</p>
600
- <div className="space-y-2">
601
- {/* Static outputs from the node definition (FunctionCall returns flow here too). */}
602
- {staticOutputs.map((out) => {
603
- const output = availableOutput[out.id];
604
- if (!output) return null;
605
- return renderStaticRow(out.id, output, out.label);
606
- })}
607
-
608
- {/* List outputs — each list gets a minor subheader (its label), then its entries,
609
- then an "Add" button. The subheader also serves as the visual boundary between
610
- static outputs above and list entries below. */}
611
- {listOutputs.map((out) => {
612
- const entries =
613
- ((node.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
614
- return (
615
- <Fragment key={out.id}>
616
- <div className="flex items-center gap-2 pt-1">
617
- <span className="text-[11px] font-medium text-muted-foreground">{out.label}</span>
618
- <div className="flex-1 h-px bg-border/60" />
619
- </div>
620
- {entries.map((_, index) => renderListEntryRow(out.id, entries, index))}
621
- <AddButton onClick={() => addListEntry(out.id, entries)}>
622
- {t("addOutput", { label: out.label, defaultValue: `Add ${out.label.toLowerCase()}` })}
623
- </AddButton>
624
- </Fragment>
625
- );
626
- })}
627
- </div>
628
- </div>
629
- );
630
- }
1
+ import { Button } from "../components/ui/button";
2
+ import { AddButton } from "../components/ui/add-button";
3
+ import { ReadOnlyBanner } from "../components/ui/readonly-banner";
4
+ import { DeleteButton } from "../components/ui/delete-button";
5
+ import { Checkbox } from "../components/ui/checkbox";
6
+ import { Input } from "../components/ui/input";
7
+ import { Separator } from "../components/ui/separator";
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
9
+ import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
10
+ import {
11
+ NodeDefinition,
12
+ NodeData,
13
+ OutputBinding,
14
+ FunctionCallNode,
15
+ getArguments,
16
+ getNodeAvailableOutput,
17
+ getOutputBinding,
18
+ } from "@foresthubai/workflow-core/node";
19
+ import type { DataType, Reference } from "@foresthubai/workflow-core";
20
+ import type { StaticOutput, OutputList, OutputDeclaration } from "@foresthubai/workflow-core/parameter";
21
+ import { isParameterActive, Parameter } from "@foresthubai/workflow-core/parameter";
22
+ import { generateId } from "@foresthubai/workflow-core/id";
23
+ import { ArrowRight, ChevronRight, Plus, RefreshCw, Trash2 } from "lucide-react";
24
+ import { getOrCreateCanvasStore } from "../stores/canvasStore";
25
+ import { isNodeUsedAsTool } from "@foresthubai/workflow-core/node";
26
+ import { varKey, refToLookupKey } from "@foresthubai/workflow-core/variable";
27
+ import type { Diagnostic } from "@foresthubai/workflow-core/diagnostics";
28
+ import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
29
+ import { useTranslation } from "react-i18next";
30
+ import ParameterEditor from "../inputs/ParameterEditor";
31
+ import { useDiagnosticsStore } from "../stores/diagnosticsStore";
32
+ import { useFunctionRegistry } from "../hooks/useFunctionRegistry";
33
+ import { useParamErrors } from "../hooks/useParamErrors";
34
+ import { buildFunctionNodeDef } from "../hooks/useNodeDefinitions";
35
+ import { useEditorStore } from "../stores/editorStore";
36
+ import { isReadOnly } from "../WorkflowBuilder";
37
+ import { migrateFunctionCallNodes } from "../utils/migrateFunctionNodes";
38
+ import { getNodeDescription } from "../utils/translation";
39
+ import { useAvailableVariables } from "../hooks/useAvailableVariables";
40
+
41
+ interface NodeConfigPanelProps {
42
+ canvasId: string;
43
+ selectedNode: NodeData;
44
+ onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown>; label?: string }) => void;
45
+ onNodeDelete: (nodeId: string) => void;
46
+ onClose: () => void;
47
+ onOpenTest: (nodeId: string) => void;
48
+ getNodeDef: (node: NodeData) => NodeDefinition | undefined;
49
+ }
50
+
51
+ export const NodeConfigPanel = ({
52
+ canvasId,
53
+ selectedNode,
54
+ onNodeUpdate,
55
+ onNodeDelete,
56
+ onClose,
57
+ onOpenTest,
58
+ getNodeDef,
59
+ }: NodeConfigPanelProps) => {
60
+ const { t } = useTranslation();
61
+ const readOnly = useEditorStore((s) => isReadOnly(s.builderMode));
62
+
63
+ // Local state for label input to preserve cursor position
64
+ const [localLabel, setLocalLabel] = useState(selectedNode.label || "");
65
+ const [isFocused, setIsFocused] = useState(false);
66
+ useEffect(() => {
67
+ setLocalLabel(selectedNode.label || "");
68
+ }, [selectedNode.id, selectedNode.label]);
69
+
70
+ // Check if FunctionCall node is stale (e.g. after undo reverted migration)
71
+ const functionId = selectedNode.type === "FunctionCall" ? (selectedNode as FunctionCallNode).functionInfo.id : null;
72
+ const { getFunction } = useFunctionRegistry();
73
+ const functionInfo = functionId ? getFunction(functionId) : undefined;
74
+ const isStaleFunction = (() => {
75
+ if (selectedNode.type !== "FunctionCall" || !functionInfo) return false;
76
+ const functionNode = selectedNode as FunctionCallNode;
77
+ return functionNode.functionInfo.version !== functionInfo.version;
78
+ })();
79
+
80
+ // Get node definition - for FunctionCallNodes, build from node's stored functionInfo
81
+ const nodeDefinition =
82
+ selectedNode.type === "FunctionCall"
83
+ ? buildFunctionNodeDef({
84
+ ...(selectedNode as FunctionCallNode).functionInfo,
85
+ name: functionInfo?.name ?? (selectedNode as FunctionCallNode).functionInfo.name,
86
+ })
87
+ : getNodeDef(selectedNode);
88
+ const cannotDelete = nodeDefinition?.isUnremovable ?? false;
89
+
90
+ // Detect whether this node is currently used as a tool input
91
+ const edges = getOrCreateCanvasStore(canvasId)((s) => s.edges);
92
+ const usedAsToolInput = useMemo(() => isNodeUsedAsTool(selectedNode.id, selectedNode, edges), [selectedNode, edges]);
93
+
94
+ // Read per-parameter error state from diagnostics store
95
+ const nodeDiags = useDiagnosticsStore((s) => s.byNodeId[selectedNode.id]);
96
+ const paramErrors = useParamErrors(nodeDiags);
97
+
98
+ if (!nodeDefinition) {
99
+ return (
100
+ <div className="p-4">
101
+ <p className="text-sm text-muted-foreground">{t("unknownNodeType", { type: selectedNode.type })}</p>
102
+ </div>
103
+ );
104
+ }
105
+ const allArguments = getArguments(selectedNode);
106
+ const parameters = nodeDefinition.parameters.filter((p) => isParameterActive(p, allArguments, usedAsToolInput));
107
+ // OutputsSection self-hides when there's nothing to render; gate on "has any output defined".
108
+ const hasAnyOutputs = (nodeDefinition.outputs ?? []).length > 0;
109
+
110
+ return (
111
+ <div className="p-4">
112
+ <div className="space-y-4">
113
+ <div className="flex items-center justify-between gap-2">
114
+ <div className="flex-1 min-w-0">
115
+ <div className="group flex items-center gap-1.5 rounded-md border border-transparent px-1.5 -mx-1.5 hover:border-input focus-within:border-input transition-colors">
116
+ <input
117
+ type="text"
118
+ title={t("nodeLabel")}
119
+ className="font-semibold text-lg bg-transparent w-full outline-none cursor-text py-0.5"
120
+ value={isFocused ? localLabel : localLabel || nodeDefinition.label}
121
+ readOnly={readOnly}
122
+ onFocus={() => {
123
+ if (readOnly) return;
124
+ setIsFocused(true);
125
+ if (!localLabel) {
126
+ setLocalLabel(nodeDefinition.label);
127
+ }
128
+ }}
129
+ onBlur={() => {
130
+ setIsFocused(false);
131
+ if (!localLabel || localLabel === nodeDefinition.label) {
132
+ setLocalLabel("");
133
+ onNodeUpdate(selectedNode.id, { label: undefined });
134
+ }
135
+ }}
136
+ onChange={(e) => {
137
+ setLocalLabel(e.target.value);
138
+ onNodeUpdate(selectedNode.id, { label: e.target.value });
139
+ }}
140
+ />
141
+ </div>
142
+ <p className="text-sm text-muted-foreground">{getNodeDescription(t, nodeDefinition)}</p>
143
+ </div>
144
+ <Button variant="ghost" size="icon" className="shrink-0" onClick={onClose}>
145
+ <ChevronRight className="h-4 w-4" />
146
+ </Button>
147
+ </div>
148
+
149
+ {readOnly && <ReadOnlyBanner />}
150
+
151
+ {parameters.length > 0 && (
152
+ <>
153
+ <Separator />
154
+ <div className={`space-y-3 ${readOnly ? "pointer-events-none opacity-60" : ""}`}>
155
+ {parameters.map((param: Parameter) => (
156
+ <ParameterEditor
157
+ canvasId={canvasId}
158
+ key={param.id}
159
+ parameter={param}
160
+ value={allArguments[param.id]}
161
+ allArguments={allArguments}
162
+ onChange={(value) => onNodeUpdate(selectedNode.id, { arguments: { [param.id]: value } })}
163
+ errors={paramErrors.get(param.id)}
164
+ translationPrefix={`nodes.${selectedNode.type}`}
165
+ />
166
+ ))}
167
+ </div>
168
+ </>
169
+ )}
170
+
171
+ {!readOnly && isStaleFunction && (
172
+ <>
173
+ <Separator />
174
+ <Button
175
+ variant="outline"
176
+ className="w-full border-warning text-warning hover:bg-warning/10"
177
+ onClick={() => migrateFunctionCallNodes()}
178
+ >
179
+ <RefreshCw className="w-4 h-4 mr-2" />
180
+ {t("updateToLatestDefinition")}
181
+ </Button>
182
+ </>
183
+ )}
184
+
185
+ {!readOnly && hasAnyOutputs && !usedAsToolInput && (
186
+ <>
187
+ <Separator />
188
+ <OutputsSection
189
+ canvasId={canvasId}
190
+ node={selectedNode}
191
+ nodeDefinition={nodeDefinition}
192
+ onNodeUpdate={onNodeUpdate}
193
+ nodeDiags={nodeDiags}
194
+ />
195
+ </>
196
+ )}
197
+
198
+ {!readOnly && selectedNode.type === "Agent" && (
199
+ <>
200
+ <Separator />
201
+ <Button variant="outline" className="w-full" onClick={() => onOpenTest(selectedNode.id)}>
202
+ {t("testAgent")}
203
+ </Button>
204
+ </>
205
+ )}
206
+
207
+ {!readOnly && !cannotDelete && (
208
+ <>
209
+ <Separator />
210
+ <DeleteButton onClick={() => onNodeDelete(selectedNode.id)}>{t("deleteNode")}</DeleteButton>
211
+ </>
212
+ )}
213
+ </div>
214
+ </div>
215
+ );
216
+ };
217
+
218
+ // ============================================================================
219
+ // Outputs Section
220
+ // ============================================================================
221
+ //
222
+ // Single unified section for all of a node's outputs. Renders under one "OUTPUTS"
223
+ // header with sequential rows — no sub-sections, no per-list separator. Two row
224
+ // shapes live side by side here:
225
+ //
226
+ // 1. Static output row — rows for NodeDefinition.outputs.type === "static"
227
+ // (FunctionCall returns are just StaticOutputs — they
228
+ // flow through this same path). Active checkbox toggles
229
+ // binding.active; mode toggle picks emit vs. assign.
230
+ // 2. List declaration row — rows for each entry in an OutputList's backing array
231
+ // (mode: emit/assign, own dataType selector, own delete button)
232
+ //
233
+ // List outputs also render an "+ Add" button after their entries so new declarations
234
+ // can be appended. Errors on any row (static or list) apply a destructive ring using
235
+ // the same `outputId` key diagnostics.ts produces.
236
+
237
+ const DATA_TYPES: DataType[] = ["int", "float", "bool", "string"];
238
+
239
+ const DATA_TYPE_LABELS: Record<DataType, string> = {
240
+ int: "int",
241
+ float: "float",
242
+ bool: "bool",
243
+ string: "string",
244
+ };
245
+
246
+ /** Stable synthetic outputId used to key list-entry diagnostics (matches diagnostics.ts). */
247
+ function listEntryOutputId(listId: string, index: number): string {
248
+ return `${listId}[${index}]`;
249
+ }
250
+
251
+ /** Convert an AvailableVariable into a canonical Reference (srcId + varId). */
252
+ function availableVarToRef(
253
+ v:
254
+ | { kind: "node"; nodeId: string; outputId: string }
255
+ | { kind: "declared"; uid: string }
256
+ | { kind: "fnarg"; uid: string },
257
+ ): Reference {
258
+ if (v.kind === "node") return { srcId: v.nodeId, varId: v.outputId };
259
+ if (v.kind === "declared") return { srcId: "declared", varId: v.uid };
260
+ return { srcId: "fnarg", varId: v.uid };
261
+ }
262
+
263
+ function OutputsSection({
264
+ canvasId,
265
+ node,
266
+ nodeDefinition,
267
+ onNodeUpdate,
268
+ nodeDiags,
269
+ }: {
270
+ canvasId: string;
271
+ node: NodeData;
272
+ nodeDefinition: NodeDefinition;
273
+ onNodeUpdate: (nodeId: string, updates: { arguments?: Record<string, unknown> }) => void;
274
+ nodeDiags: Diagnostic[] | undefined;
275
+ }) {
276
+ const { t } = useTranslation();
277
+ const { list: availableVars } = useAvailableVariables(canvasId);
278
+ const availableOutput = useMemo(() => getNodeAvailableOutput(node), [node]);
279
+
280
+ const staticOutputs = useMemo(
281
+ () => (nodeDefinition.outputs ?? []).filter((o): o is StaticOutput => o.type === "static"),
282
+ [nodeDefinition],
283
+ );
284
+ const listOutputs = useMemo(
285
+ () => (nodeDefinition.outputs ?? []).filter((o): o is OutputList => o.type === "list"),
286
+ [nodeDefinition],
287
+ );
288
+
289
+ // Collect diagnostics keyed by outputId so rows can look themselves up.
290
+ const outputErrors = useMemo(() => {
291
+ const map = new Map<string, string[]>();
292
+ if (!nodeDiags) return map;
293
+ for (const d of nodeDiags) {
294
+ if (d.outputId && d.severity === "error") {
295
+ const arr = map.get(d.outputId);
296
+ if (arr) arr.push(d.message);
297
+ else map.set(d.outputId, [d.message]);
298
+ }
299
+ }
300
+ return map;
301
+ }, [nodeDiags]);
302
+
303
+ /**
304
+ * Write a binding for a static output (or FunctionCall return). List entries
305
+ * have their own update path via `replaceListEntry`.
306
+ */
307
+ const updateStaticBinding = useCallback(
308
+ (key: string, binding: OutputBinding) => {
309
+ onNodeUpdate(node.id, { arguments: { [key]: binding } });
310
+ },
311
+ [node.id, onNodeUpdate],
312
+ );
313
+
314
+ const writeListEntries = useCallback(
315
+ (listId: string, next: OutputDeclaration[]) => onNodeUpdate(node.id, { arguments: { [listId]: next } }),
316
+ [node.id, onNodeUpdate],
317
+ );
318
+
319
+ // Filter available vars to matching dataType, excluding this node's own outputs
320
+ // (a node can't assign its own output back into itself).
321
+ const filterCompatible = useCallback(
322
+ (dataType: DataType) =>
323
+ availableVars.filter((v) => v.dataType === dataType && !("nodeId" in v && v.nodeId === node.id)),
324
+ [availableVars, node.id],
325
+ );
326
+
327
+ // Any rows to show? Early-exit so the parent panel doesn't render an empty section.
328
+ if (staticOutputs.length === 0 && listOutputs.length === 0) return null;
329
+
330
+ // --- Row renderer: static output / FunctionCall return ------------------------------
331
+ // UX shape:
332
+ // ┌─ card ────────────────────────────────────────┐
333
+ // │ [☑] label dataType │
334
+ // │ [emit | assign] [ name OR variable picker ] │
335
+ // └────────────────────────────────────────────────┘
336
+ // Checkbox sits inline with the label (so the card width matches list-entry rows
337
+ // which have no checkbox). Toggles `binding.active`; mode/name/target are kept as
338
+ // draft state when inactive so off→on round-trips identically. Body is dimmed and
339
+ // pointer-event-disabled while inactive.
340
+ const renderStaticRow = (key: string, output: { name: string; dataType: DataType }, displayLabel: string) => {
341
+ const binding = getOutputBinding(node, key) ?? ({ active: true, mode: "emit", name: output.name } as OutputBinding);
342
+ const compatibleVars = filterCompatible(output.dataType);
343
+ const errors = outputErrors.get(key);
344
+ const hasError = !!errors?.length;
345
+ const enabled = binding.active;
346
+
347
+ const setEnabled = (next: boolean) => {
348
+ if (next === enabled) return;
349
+ updateStaticBinding(key, { ...binding, active: next });
350
+ };
351
+ const setMode = (mode: "emit" | "assign") => {
352
+ if (mode === binding.mode) return;
353
+ if (mode === "emit") updateStaticBinding(key, { active: binding.active, mode: "emit", name: output.name });
354
+ else updateStaticBinding(key, { active: binding.active, mode: "assign", target: { srcId: "", varId: "" } });
355
+ };
356
+
357
+ return (
358
+ <div
359
+ key={key}
360
+ className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
361
+ >
362
+ <div className="flex items-center gap-2">
363
+ <Checkbox
364
+ checked={enabled}
365
+ onCheckedChange={(c) => setEnabled(c === true)}
366
+ aria-label={t("outputBinding.enable", "Enable output")}
367
+ className="shrink-0"
368
+ />
369
+ <span className={`text-xs font-medium flex-1 truncate ${enabled ? "" : "opacity-50"}`}>{displayLabel}</span>
370
+ <span className={`text-xs text-muted-foreground ${enabled ? "" : "opacity-50"}`}>
371
+ {DATA_TYPE_LABELS[output.dataType]}
372
+ </span>
373
+ </div>
374
+ <div className={enabled ? "" : "opacity-50"}>
375
+ <div className={`flex items-center gap-2 ${enabled ? "" : "pointer-events-none"}`}>
376
+ <ToggleGroup
377
+ type="single"
378
+ value={binding.mode}
379
+ onValueChange={(v) => v && setMode(v as "emit" | "assign")}
380
+ className="gap-0"
381
+ >
382
+ <ToggleGroupItem
383
+ value="emit"
384
+ size="sm"
385
+ variant="outline"
386
+ className="h-7 w-7 p-0 rounded-r-none"
387
+ aria-label={t("outputBinding.emit", "Emit")}
388
+ title={t("outputBinding.emit", "Emit")}
389
+ >
390
+ <Plus className="w-3.5 h-3.5" />
391
+ </ToggleGroupItem>
392
+ <ToggleGroupItem
393
+ value="assign"
394
+ size="sm"
395
+ variant="outline"
396
+ className="h-7 w-7 p-0 rounded-l-none -ml-px"
397
+ aria-label={t("outputBinding.assign", "Assign")}
398
+ title={t("outputBinding.assign", "Assign")}
399
+ >
400
+ <ArrowRight className="w-3.5 h-3.5" />
401
+ </ToggleGroupItem>
402
+ </ToggleGroup>
403
+
404
+ {binding.mode === "emit" ? (
405
+ <Input
406
+ className="h-7 text-xs flex-1"
407
+ value={binding.name}
408
+ disabled={!enabled}
409
+ onChange={(e) =>
410
+ updateStaticBinding(key, { active: binding.active, mode: "emit", name: e.target.value })
411
+ }
412
+ />
413
+ ) : compatibleVars.length > 0 ? (
414
+ <Select
415
+ value={binding.target.srcId ? refToLookupKey(binding.target) : ""}
416
+ onValueChange={(lookupKey) => {
417
+ const v = availableVars.find((av) => varKey(av) === lookupKey);
418
+ if (!v) return;
419
+ updateStaticBinding(key, { active: binding.active, mode: "assign", target: availableVarToRef(v) });
420
+ }}
421
+ disabled={!enabled}
422
+ >
423
+ <SelectTrigger className="h-7 text-xs flex-1">
424
+ <SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
425
+ </SelectTrigger>
426
+ <SelectContent>
427
+ {compatibleVars.map((v) => (
428
+ <SelectItem key={varKey(v)} value={varKey(v)}>
429
+ {v.name}
430
+ </SelectItem>
431
+ ))}
432
+ </SelectContent>
433
+ </Select>
434
+ ) : (
435
+ <span className="text-xs text-muted-foreground italic flex-1">
436
+ {t("outputBinding.noCompatibleVariables", "No compatible variables")}
437
+ </span>
438
+ )}
439
+ </div>
440
+ {hasError && (
441
+ <div className="space-y-0.5">
442
+ {errors.map((msg, i) => (
443
+ <p key={i} className="text-xs text-destructive">
444
+ {msg}
445
+ </p>
446
+ ))}
447
+ </div>
448
+ )}
449
+ </div>
450
+ </div>
451
+ );
452
+ };
453
+
454
+ // --- Row renderer: list declaration entry ------------------------------------------
455
+ const renderListEntryRow = (listId: string, entries: OutputDeclaration[], index: number) => {
456
+ const entry = entries[index];
457
+ if (!entry) return null;
458
+ const compatibleVars = filterCompatible(entry.dataType);
459
+ const outputId = listEntryOutputId(listId, index);
460
+ const errors = outputErrors.get(outputId);
461
+ const hasError = !!errors?.length;
462
+
463
+ const replace = (next: OutputDeclaration) =>
464
+ writeListEntries(
465
+ listId,
466
+ entries.map((e, i) => (i === index ? next : e)),
467
+ );
468
+ const remove = () =>
469
+ writeListEntries(
470
+ listId,
471
+ entries.filter((_, i) => i !== index),
472
+ );
473
+ // Mode flip preserves name + dataType — the user already typed/picked them.
474
+ // emit↔assign only swaps the trailing payload (uid vs target).
475
+ const changeMode = (newMode: "emit" | "assign") => {
476
+ if (entry.mode === newMode) return;
477
+ if (newMode === "emit") {
478
+ replace({ mode: "emit", uid: generateId(), name: entry.name, dataType: entry.dataType });
479
+ } else {
480
+ replace({ mode: "assign", name: entry.name, dataType: entry.dataType, target: { srcId: "", varId: "" } });
481
+ }
482
+ };
483
+ const changeName = (name: string) => replace({ ...entry, name });
484
+ const changeDataType = (dt: DataType) => replace({ ...entry, dataType: dt });
485
+
486
+ return (
487
+ <div
488
+ key={`${listId}-${entry.mode === "emit" ? entry.uid : `assign-${index}`}`}
489
+ className={`rounded-lg bg-card shadow-sm border p-2 space-y-2 transition-all hover:shadow-md ${hasError ? "border-destructive ring-1 ring-destructive" : "border-border"}`}
490
+ >
491
+ <div className="flex items-center gap-2">
492
+ {/* Row 1 mirrors OutputBinding's header: leading control (trash↔checkbox),
493
+ name (editable here, label-only there), dataType (editable here, RO there). */}
494
+ <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={remove}>
495
+ <Trash2 className="w-3.5 h-3.5" />
496
+ </Button>
497
+ <Input
498
+ className="h-7 text-xs flex-1"
499
+ value={entry.name}
500
+ placeholder={t("outputBinding.name", "Name")}
501
+ onChange={(e) => changeName(e.target.value)}
502
+ />
503
+ <Select value={entry.dataType} onValueChange={(dt: DataType) => changeDataType(dt)}>
504
+ <SelectTrigger className="h-7 text-xs w-20">
505
+ <SelectValue />
506
+ </SelectTrigger>
507
+ <SelectContent>
508
+ {DATA_TYPES.map((dt) => (
509
+ <SelectItem key={dt} value={dt}>
510
+ {dt}
511
+ </SelectItem>
512
+ ))}
513
+ </SelectContent>
514
+ </Select>
515
+ </div>
516
+ <div className="flex items-center gap-2">
517
+ <ToggleGroup
518
+ type="single"
519
+ value={entry.mode}
520
+ onValueChange={(v) => v && changeMode(v as "emit" | "assign")}
521
+ className="gap-0"
522
+ >
523
+ <ToggleGroupItem
524
+ value="emit"
525
+ size="sm"
526
+ variant="outline"
527
+ className="h-7 w-7 p-0 rounded-r-none"
528
+ aria-label={t("outputBinding.emit", "Emit")}
529
+ title={t("outputBinding.emit", "Emit")}
530
+ >
531
+ <Plus className="w-3.5 h-3.5" />
532
+ </ToggleGroupItem>
533
+ <ToggleGroupItem
534
+ value="assign"
535
+ size="sm"
536
+ variant="outline"
537
+ className="h-7 w-7 p-0 rounded-l-none -ml-px"
538
+ aria-label={t("outputBinding.assign", "Assign")}
539
+ title={t("outputBinding.assign", "Assign")}
540
+ >
541
+ <ArrowRight className="w-3.5 h-3.5" />
542
+ </ToggleGroupItem>
543
+ </ToggleGroup>
544
+ {entry.mode === "emit" ? (
545
+ <span className="text-xs text-muted-foreground italic flex-1">
546
+ {t("outputBinding.emitHint", "creates new variable in scope")}
547
+ </span>
548
+ ) : compatibleVars.length > 0 ? (
549
+ <Select
550
+ value={entry.target.srcId ? refToLookupKey(entry.target) : ""}
551
+ onValueChange={(lookupKey) => {
552
+ const v = availableVars.find((av) => varKey(av) === lookupKey);
553
+ if (!v) return;
554
+ replace({ ...entry, target: availableVarToRef(v) });
555
+ }}
556
+ >
557
+ <SelectTrigger className="h-7 text-xs flex-1">
558
+ <SelectValue placeholder={t("outputBinding.selectVariable", "Select variable...")} />
559
+ </SelectTrigger>
560
+ <SelectContent>
561
+ {compatibleVars.map((v) => (
562
+ <SelectItem key={varKey(v)} value={varKey(v)}>
563
+ {v.name}
564
+ </SelectItem>
565
+ ))}
566
+ </SelectContent>
567
+ </Select>
568
+ ) : (
569
+ <span className="text-xs text-muted-foreground italic flex-1">
570
+ {t("outputBinding.noCompatibleVariables", "No compatible variables")}
571
+ </span>
572
+ )}
573
+ </div>
574
+ {hasError && (
575
+ <div className="space-y-0.5">
576
+ {errors.map((msg, i) => (
577
+ <p key={i} className="text-xs text-destructive">
578
+ {msg}
579
+ </p>
580
+ ))}
581
+ </div>
582
+ )}
583
+ </div>
584
+ );
585
+ };
586
+
587
+ const addListEntry = (listId: string, entries: OutputDeclaration[]) => {
588
+ const fresh: OutputDeclaration = {
589
+ mode: "emit",
590
+ uid: generateId(),
591
+ name: `output${entries.length + 1}`,
592
+ dataType: "string",
593
+ };
594
+ writeListEntries(listId, [...entries, fresh]);
595
+ };
596
+
597
+ return (
598
+ <div className="space-y-2">
599
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t("outputs", "Outputs")}</p>
600
+ <div className="space-y-2">
601
+ {/* Static outputs from the node definition (FunctionCall returns flow here too). */}
602
+ {staticOutputs.map((out) => {
603
+ const output = availableOutput[out.id];
604
+ if (!output) return null;
605
+ return renderStaticRow(out.id, output, out.label);
606
+ })}
607
+
608
+ {/* List outputs — each list gets a minor subheader (its label), then its entries,
609
+ then an "Add" button. The subheader also serves as the visual boundary between
610
+ static outputs above and list entries below. */}
611
+ {listOutputs.map((out) => {
612
+ const entries =
613
+ ((node.arguments as Record<string, unknown>)[out.id] as OutputDeclaration[] | undefined) ?? [];
614
+ return (
615
+ <Fragment key={out.id}>
616
+ <div className="flex items-center gap-2 pt-1">
617
+ <span className="text-[11px] font-medium text-muted-foreground">{out.label}</span>
618
+ <div className="flex-1 h-px bg-border/60" />
619
+ </div>
620
+ {entries.map((_, index) => renderListEntryRow(out.id, entries, index))}
621
+ <AddButton onClick={() => addListEntry(out.id, entries)}>
622
+ {t("addOutput", { label: out.label, defaultValue: `Add ${out.label.toLowerCase()}` })}
623
+ </AddButton>
624
+ </Fragment>
625
+ );
626
+ })}
627
+ </div>
628
+ </div>
629
+ );
630
+ }