@mindlogic-ai/logician-ui 4.0.0-alpha.36 → 4.0.0-alpha.38

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 (98) hide show
  1. package/USAGE.md +17 -0
  2. package/dist/components/Form/FormSection/CollapsibleFormSection.js +1 -0
  3. package/dist/components/Form/FormSection/CollapsibleFormSection.js.map +1 -1
  4. package/dist/components/Form/FormSection/CollapsibleFormSection.mjs +1 -0
  5. package/dist/components/Form/FormSection/CollapsibleFormSection.mjs.map +1 -1
  6. package/dist/components/Toast/Toast.d.ts +7 -3
  7. package/dist/components/Toast/Toast.d.ts.map +1 -1
  8. package/dist/components/Toast/Toast.js +7 -3
  9. package/dist/components/Toast/Toast.js.map +1 -1
  10. package/dist/components/Toast/Toast.mjs +8 -4
  11. package/dist/components/Toast/Toast.mjs.map +1 -1
  12. package/dist/components/Toast/Toast.styles.d.ts +8 -0
  13. package/dist/components/Toast/Toast.styles.d.ts.map +1 -1
  14. package/dist/components/Toast/Toast.styles.js +59 -0
  15. package/dist/components/Toast/Toast.styles.js.map +1 -1
  16. package/dist/components/Toast/Toast.styles.mjs +57 -1
  17. package/dist/components/Toast/Toast.styles.mjs.map +1 -1
  18. package/dist/components/Toast/Toast.types.d.ts +25 -1
  19. package/dist/components/Toast/Toast.types.d.ts.map +1 -1
  20. package/dist/components/Toast/Toaster.d.ts.map +1 -1
  21. package/dist/components/Toast/Toaster.js +9 -1
  22. package/dist/components/Toast/Toaster.js.map +1 -1
  23. package/dist/components/Toast/Toaster.mjs +9 -1
  24. package/dist/components/Toast/Toaster.mjs.map +1 -1
  25. package/dist/components/Toast/useToast.d.ts +1 -1
  26. package/dist/components/Toast/useToast.d.ts.map +1 -1
  27. package/dist/components/Toast/useToast.js +9 -1
  28. package/dist/components/Toast/useToast.js.map +1 -1
  29. package/dist/components/Toast/useToast.mjs +10 -2
  30. package/dist/components/Toast/useToast.mjs.map +1 -1
  31. package/dist/components/Workflow/Workflow.d.ts +1 -1
  32. package/dist/components/Workflow/Workflow.d.ts.map +1 -1
  33. package/dist/components/Workflow/Workflow.js +2 -2
  34. package/dist/components/Workflow/Workflow.js.map +1 -1
  35. package/dist/components/Workflow/Workflow.mjs +2 -2
  36. package/dist/components/Workflow/Workflow.mjs.map +1 -1
  37. package/dist/components/Workflow/Workflow.types.d.ts +9 -0
  38. package/dist/components/Workflow/Workflow.types.d.ts.map +1 -1
  39. package/dist/components/Workflow/Workflow.types.js.map +1 -1
  40. package/dist/components/Workflow/Workflow.types.mjs.map +1 -1
  41. package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts +6 -2
  42. package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts.map +1 -1
  43. package/dist/components/Workflow/canvas/Canvas/Canvas.js +29 -23
  44. package/dist/components/Workflow/canvas/Canvas/Canvas.js.map +1 -1
  45. package/dist/components/Workflow/canvas/Canvas/Canvas.mjs +31 -25
  46. package/dist/components/Workflow/canvas/Canvas/Canvas.mjs.map +1 -1
  47. package/dist/components/Workflow/createNode.d.ts +25 -1
  48. package/dist/components/Workflow/createNode.d.ts.map +1 -1
  49. package/dist/components/Workflow/createNode.js +39 -1
  50. package/dist/components/Workflow/createNode.js.map +1 -1
  51. package/dist/components/Workflow/createNode.mjs +38 -2
  52. package/dist/components/Workflow/createNode.mjs.map +1 -1
  53. package/dist/components/Workflow/index.d.ts +2 -0
  54. package/dist/components/Workflow/index.d.ts.map +1 -1
  55. package/dist/components/Workflow/layout/autoLayout.d.ts +10 -0
  56. package/dist/components/Workflow/layout/autoLayout.d.ts.map +1 -1
  57. package/dist/components/Workflow/layout/autoLayout.js +4 -0
  58. package/dist/components/Workflow/layout/autoLayout.js.map +1 -1
  59. package/dist/components/Workflow/layout/autoLayout.mjs +1 -1
  60. package/dist/components/Workflow/layout/autoLayout.mjs.map +1 -1
  61. package/dist/components/Workflow/placement.d.ts +65 -0
  62. package/dist/components/Workflow/placement.d.ts.map +1 -0
  63. package/dist/components/Workflow/placement.js +100 -0
  64. package/dist/components/Workflow/placement.js.map +1 -0
  65. package/dist/components/Workflow/placement.mjs +96 -0
  66. package/dist/components/Workflow/placement.mjs.map +1 -0
  67. package/dist/components/Workflow/useWorkflowActions.d.ts +54 -0
  68. package/dist/components/Workflow/useWorkflowActions.d.ts.map +1 -0
  69. package/dist/components/Workflow/useWorkflowActions.js +105 -0
  70. package/dist/components/Workflow/useWorkflowActions.js.map +1 -0
  71. package/dist/components/Workflow/useWorkflowActions.mjs +103 -0
  72. package/dist/components/Workflow/useWorkflowActions.mjs.map +1 -0
  73. package/dist/index.js +5 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.mjs +2 -1
  76. package/dist/index.mjs.map +1 -1
  77. package/package.json +1 -1
  78. package/src/components/Toast/Toast.action.test.tsx +135 -0
  79. package/src/components/Toast/Toast.stories.tsx +111 -0
  80. package/src/components/Toast/Toast.styles.ts +60 -0
  81. package/src/components/Toast/Toast.tsx +16 -3
  82. package/src/components/Toast/Toast.types.ts +26 -1
  83. package/src/components/Toast/Toaster.tsx +11 -0
  84. package/src/components/Toast/useToast.tsx +17 -2
  85. package/src/components/Workflow/README.md +64 -0
  86. package/src/components/Workflow/Workflow.tsx +2 -0
  87. package/src/components/Workflow/Workflow.types.ts +10 -0
  88. package/src/components/Workflow/canvas/Canvas/Canvas.tsx +57 -53
  89. package/src/components/Workflow/createNode.test.ts +40 -2
  90. package/src/components/Workflow/createNode.ts +70 -2
  91. package/src/components/Workflow/index.ts +8 -0
  92. package/src/components/Workflow/layout/autoLayout.ts +4 -4
  93. package/src/components/Workflow/placement.test.ts +156 -0
  94. package/src/components/Workflow/placement.ts +172 -0
  95. package/src/components/Workflow/stories/Workflow.stories.tsx +79 -0
  96. package/src/components/Workflow/useWorkflowActions.test.tsx +345 -0
  97. package/src/components/Workflow/useWorkflowActions.ts +196 -0
  98. package/src/guidelines/FeedbackActions.mdx +133 -0
@@ -182,6 +182,69 @@ to keep the built-in drawer but also highlight the selection elsewhere.
182
182
 
183
183
  ---
184
184
 
185
+ ## Adding nodes from your own UI (`useWorkflowActions`)
186
+
187
+ The palette is drag-and-drop. For anything else — a tap-to-add sheet on touch
188
+ devices, a "+" beside a node, a command menu — call `useWorkflowActions()` from a
189
+ component rendered inside `<Workflow>` (its `children` or
190
+ `renderSelectedNodeToolbar`). It uses the same node builder as a palette drop
191
+ (`{kind}_{N}` id; `defaultConfig` → `localizeDefaults` → `hostDefaults`), so you
192
+ never rebuild ids, configs or placement, and you never import `@xyflow/react`.
193
+
194
+ ```tsx
195
+ import {
196
+ useWorkflow,
197
+ useWorkflowActions,
198
+ Workflow,
199
+ } from '@mindlogic-ai/logician-ui';
200
+
201
+ function AddAgentButton() {
202
+ const { editor } = useWorkflow();
203
+ const { addNode, getAddAfterSource } = useWorkflowActions();
204
+ const after = getAddAfterSource(editor.selectedNodeId);
205
+ return (
206
+ <button
207
+ onClick={() =>
208
+ addNode(
209
+ 'agent',
210
+ after ? { after: after.nodeId } : { at: 'viewportCenter' }
211
+ )
212
+ }
213
+ >
214
+ {after ? `Add after ${after.label}` : 'Add at centre'}
215
+ </button>
216
+ );
217
+ }
218
+
219
+ <Workflow
220
+ nodeTypes={NODE_TYPES}
221
+ renderSelectedNodeToolbar={(node) => <PlusAfter nodeId={node.id} />}
222
+ >
223
+ <AddAgentButton />
224
+ </Workflow>;
225
+ ```
226
+
227
+ - `addNode(kind, target, { inspector? })` → `{ nodeId, edgeId | null } | null`.
228
+ - `{ after: nodeId }` places the node to that node's right and wires it from the
229
+ first free exit. No edge when the new kind has no entry (Note) or a
230
+ `canConnect` hook / connection rule rejects it. A node with no free exit (End,
231
+ Note, every exit taken) falls back to the viewport centre.
232
+ - `{ at: 'viewportCenter' }` places it unconnected in the middle of the view.
233
+ - Either way the node moves down past anything already there, becomes the
234
+ selection, and the view pans to it. Node + edge are **one undo step**.
235
+ - `inspector`: `'follow'` (default — an open inspector moves to the new node),
236
+ `'open'`, or `'close'`.
237
+ - Returns `null` without changing anything when read-only, the kind is not
238
+ registered, or its id is taken (a second `start`).
239
+ - `getAddAfterSource(nodeId)` → `{ nodeId, sourceHandle, label } | null` — the
240
+ exit an add-after would use; `null` means the add would land at the centre.
241
+ `canAddAfter(nodeId)` is the boolean form.
242
+ - `renderSelectedNodeToolbar(node)` (a `<Workflow>` prop) renders your content
243
+ beside the selected node's exit side, in screen space (constant size at any
244
+ zoom). Return `null` to show nothing.
245
+
246
+ ---
247
+
185
248
  ## Controlled vs uncontrolled graph
186
249
 
187
250
  - **Controlled:** pass `graph` and `onGraphChange`. You own the source of truth.
@@ -335,6 +398,7 @@ surfaces.
335
398
  | `onArrange` | `(g: Graph) => void` | After one-click auto-arrange. |
336
399
  | `onHistoryNavigate` | `(g: Graph) => void` | After undo/redo. |
337
400
  | `children` | `ReactNode` | Canvas overlay slot (mount `<NodeInspector>` here). |
401
+ | `renderSelectedNodeToolbar` | `(node) => ReactNode` | Content beside the selected node (e.g. a "+"). |
338
402
  | `footer` | `ReactNode` | Slot below the canvas. |
339
403
  | `categoryTokens` | `CategoryTokenMap` | Override category → color tokens. |
340
404
  | `hostBridge` | `unknown` | Opaque host data for inspectors. |
@@ -36,6 +36,7 @@ export function Workflow({
36
36
  onEdgeClick,
37
37
  onSelectionChange,
38
38
  onIssuesChange,
39
+ renderSelectedNodeToolbar,
39
40
  footer,
40
41
  children,
41
42
  categoryTokens,
@@ -169,6 +170,7 @@ export function Workflow({
169
170
  showPalette={showPalette && !readOnly}
170
171
  onNodeClick={onNodeClick}
171
172
  onEdgeClick={onEdgeClick}
173
+ renderSelectedNodeToolbar={renderSelectedNodeToolbar}
172
174
  >
173
175
  {children}
174
176
  </Canvas>
@@ -443,6 +443,16 @@ export type WorkflowProps = {
443
443
  */
444
444
  children?: ReactNode;
445
445
 
446
+ /**
447
+ * Content rendered beside the selected node, on its exit (right) side — e.g.
448
+ * a "+" that adds a node after it via `useWorkflowActions().addNode`. Called
449
+ * with the selected node whenever one is selected; return `null` to show
450
+ * nothing. The library positions it in screen space (it keeps its size at
451
+ * any zoom) and renders it inside the editor, so the content may call
452
+ * `useWorkflow` / `useWorkflowActions`. Hosts never touch React Flow.
453
+ */
454
+ renderSelectedNodeToolbar?: (node: GraphNode) => ReactNode;
455
+
446
456
  /**
447
457
  * Notified whenever the issues array identity changes. Mostly a Storybook
448
458
  * convenience now that validation is owned entirely by the backend — prod
@@ -21,7 +21,9 @@ import {
21
21
  IsValidConnection,
22
22
  MiniMap,
23
23
  NodeChange,
24
+ NodeToolbar,
24
25
  NodeTypes,
26
+ Position as HandleSide,
25
27
  ReactFlow,
26
28
  ReactFlowProvider,
27
29
  useReactFlow,
@@ -34,7 +36,11 @@ import {
34
36
  isValidConnection as isValidWorkflowConnection,
35
37
  soleEdgeToHandle,
36
38
  } from '../../connectionRules';
37
- import { genId } from '../../createNode';
39
+ import {
40
+ createNodeFromType,
41
+ genId,
42
+ toDefaultsTranslate,
43
+ } from '../../createNode';
38
44
  import {
39
45
  autoLayout,
40
46
  estimateLabelOverhang,
@@ -47,7 +53,6 @@ import type {
47
53
  GraphNode,
48
54
  Position,
49
55
  } from '../../Workflow.types';
50
- import { resolveDefaultConfig } from '../../Workflow.types';
51
56
  import { useWorkflow, useWorkflowTranslate } from '../../WorkflowContext';
52
57
  import type { WorkflowReactFlowNode } from '../GenericNode';
53
58
  import { GenericNode } from '../GenericNode';
@@ -107,17 +112,22 @@ function graphToReactFlow(
107
112
  };
108
113
  }
109
114
 
115
+ type CanvasProps = {
116
+ showPalette?: boolean;
117
+ onNodeClick?: (node: GraphNode) => void;
118
+ onEdgeClick?: (edge: GraphEdge) => void;
119
+ /** See `WorkflowProps.renderSelectedNodeToolbar`. */
120
+ renderSelectedNodeToolbar?: (node: GraphNode) => ReactNode;
121
+ children?: ReactNode;
122
+ };
123
+
110
124
  function CanvasInner({
111
- showPalette,
125
+ showPalette = true,
112
126
  onNodeClick: onNodeClickHost,
113
127
  onEdgeClick: onEdgeClickHost,
128
+ renderSelectedNodeToolbar,
114
129
  children,
115
- }: {
116
- showPalette: boolean;
117
- onNodeClick?: (node: GraphNode) => void;
118
- onEdgeClick?: (edge: GraphEdge) => void;
119
- children?: ReactNode;
120
- }) {
130
+ }: CanvasProps) {
121
131
  const {
122
132
  graph,
123
133
  dispatch,
@@ -144,13 +154,6 @@ function CanvasInner({
144
154
  const [isPaletteOpen, setIsPaletteOpen] = useState(true);
145
155
 
146
156
  const translate = useWorkflowTranslate();
147
- // Tear off only the string lookup so `localizeDefaults` doesn't depend
148
- // on the React-fragment interpolation path.
149
- const t = useCallback(
150
- (key: string, params?: Record<string, string>) =>
151
- translate(key, params) as string,
152
- [translate]
153
- );
154
157
 
155
158
  useWorkflowKeyboard();
156
159
 
@@ -606,32 +609,37 @@ function CanvasInner({
606
609
  if (!kind) return;
607
610
  const def = getNodeType(kind);
608
611
  if (!def) return;
609
- const position = screenToFlowPosition({ x: e.clientX, y: e.clientY });
610
- const base = resolveDefaultConfig(def) as Record<string, unknown>;
611
- const overlay = def.localizeDefaults?.(t) as
612
- | Record<string, unknown>
613
- | undefined;
614
- // Host-data overlay (e.g. a starting LLM picked from the tenant's live
615
- // model list) — applied last so it wins over any static placeholder in
616
- // `defaultConfig`. Keyed on the opaque `hostBridge`; the node type narrows
617
- // it. Absent on a generic host, leaving the static defaults in place.
618
- const bridged = def.hostDefaults?.(hostBridge) as
619
- | Record<string, unknown>
620
- | undefined;
621
- const node = {
622
- id: genId(kind),
623
- kind,
624
- position,
625
- config: { ...base, ...overlay, ...bridged },
626
- };
612
+ // Same builder `useWorkflowActions().addNode` uses, so a drop and a
613
+ // programmatic add get the same `{kind}_{N}` id and starting config.
614
+ const node = createNodeFromType(def, {
615
+ position: screenToFlowPosition({ x: e.clientX, y: e.clientY }),
616
+ nodes: graph.nodes,
617
+ translate: toDefaultsTranslate(translate),
618
+ hostBridge,
619
+ });
627
620
 
628
621
  // Dropped nodes land unconnected — authors wire edges explicitly by
629
622
  // dragging from an exit point.
630
623
  dispatch({ type: 'addNode', node });
631
624
  },
632
- [dispatch, getNodeType, screenToFlowPosition, t, hostBridge]
625
+ [
626
+ dispatch,
627
+ getNodeType,
628
+ screenToFlowPosition,
629
+ graph.nodes,
630
+ translate,
631
+ hostBridge,
632
+ ]
633
633
  );
634
634
 
635
+ const selectedNode = selectedNodeId
636
+ ? graph.nodes.find((n) => n.id === selectedNodeId)
637
+ : undefined;
638
+ const selectedNodeToolbar =
639
+ selectedNode && renderSelectedNodeToolbar
640
+ ? renderSelectedNodeToolbar(selectedNode)
641
+ : null;
642
+
635
643
  // Does the canvas top-left corner actually hold overlay chrome the zoom
636
644
  // controls should tuck under? The palette toggle docks there when the palette
637
645
  // is collapsed, and the error banner when the (editable) graph has issues. If
@@ -716,6 +724,18 @@ function CanvasInner({
716
724
  proOptions={{ hideAttribution: true }}
717
725
  >
718
726
  <Background />
727
+ {selectedNode && selectedNodeToolbar ? (
728
+ // Positioned in screen space beside the node's exit side, so the
729
+ // host's content keeps its size at any zoom.
730
+ <NodeToolbar
731
+ nodeId={selectedNode.id}
732
+ isVisible
733
+ position={HandleSide.Right}
734
+ offset={4}
735
+ >
736
+ {selectedNodeToolbar}
737
+ </NodeToolbar>
738
+ ) : null}
719
739
  <CanvasControls
720
740
  readOnly={readOnly}
721
741
  onAutoArrange={onAutoArrange}
@@ -748,26 +768,10 @@ function CanvasInner({
748
768
  );
749
769
  }
750
770
 
751
- export function Canvas({
752
- showPalette = true,
753
- onNodeClick,
754
- onEdgeClick,
755
- children,
756
- }: {
757
- showPalette?: boolean;
758
- onNodeClick?: (node: GraphNode) => void;
759
- onEdgeClick?: (edge: GraphEdge) => void;
760
- children?: ReactNode;
761
- }) {
771
+ export function Canvas(props: CanvasProps) {
762
772
  return (
763
773
  <ReactFlowProvider>
764
- <CanvasInner
765
- showPalette={showPalette}
766
- onNodeClick={onNodeClick}
767
- onEdgeClick={onEdgeClick}
768
- >
769
- {children}
770
- </CanvasInner>
774
+ <CanvasInner {...props} />
771
775
  </ReactFlowProvider>
772
776
  );
773
777
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { nextNodeId } from './createNode';
4
- import type { GraphNode } from './Workflow.types';
3
+ import { createNodeFromType, nextNodeId } from './createNode';
4
+ import type { GraphNode, NodeTypeDef } from './Workflow.types';
5
5
 
6
6
  const node = (id: string, kind: string): GraphNode => ({
7
7
  id,
@@ -59,3 +59,41 @@ describe('nextNodeId', () => {
59
59
  expect(nextNodeId('agent', nodes)).toBe('agent_4');
60
60
  });
61
61
  });
62
+
63
+ describe('nextNodeId with namespaced kinds', () => {
64
+ it('treats the dot in a kind literally', () => {
65
+ // Unescaped, `math.add` would match `mathXadd_7` and jump to _8.
66
+ const nodes = [
67
+ node('mathXadd_7', 'math.add'),
68
+ node('math.add_2', 'math.add'),
69
+ ];
70
+ expect(nextNodeId('math.add', nodes)).toBe('math.add_3');
71
+ });
72
+ });
73
+
74
+ describe('createNodeFromType', () => {
75
+ const def = {
76
+ kind: 'agent',
77
+ defaultConfig: () => ({ name: 'Agent', model: 'static', temp: 1 }),
78
+ localizeDefaults: (t: (k: string) => string) => ({ name: t('agent_name') }),
79
+ hostDefaults: (bridge: unknown) => ({
80
+ model: (bridge as { model: string }).model,
81
+ }),
82
+ handles: () => ({ inputs: [], outputs: [] }),
83
+ } as unknown as NodeTypeDef;
84
+
85
+ it('layers defaultConfig, then localizeDefaults, then hostDefaults', () => {
86
+ const created = createNodeFromType(def, {
87
+ position: { x: 1, y: 2 },
88
+ nodes: [node('agent_4', 'agent')],
89
+ translate: (key) => `t:${key}`,
90
+ hostBridge: { model: 'live' },
91
+ });
92
+ expect(created).toEqual({
93
+ id: 'agent_5',
94
+ kind: 'agent',
95
+ position: { x: 1, y: 2 },
96
+ config: { name: 't:agent_name', model: 'live', temp: 1 },
97
+ });
98
+ });
99
+ });
@@ -1,4 +1,10 @@
1
- import type { GraphNode } from './Workflow.types';
1
+ import type {
2
+ GraphNode,
3
+ NodeTypeDef,
4
+ Position,
5
+ WorkflowTranslate,
6
+ } from './Workflow.types';
7
+ import { resolveDefaultConfig } from './Workflow.types';
2
8
 
3
9
  /**
4
10
  * Unique id for edges and other internal graph elements that aren't referenced
@@ -29,7 +35,10 @@ export function nextNodeId(
29
35
  nodes: ReadonlyArray<GraphNode>
30
36
  ): string {
31
37
  if (kind === 'start') return 'start';
32
- const re = new RegExp(`^${kind}_(\\d+)$`);
38
+ // Escape the kind: namespaced kinds (`math.add`) would otherwise read `.` as
39
+ // a wildcard and count unrelated ids toward the counter.
40
+ const escaped = kind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ const re = new RegExp(`^${escaped}_(\\d+)$`);
33
42
  let max = 0;
34
43
  for (const n of nodes) {
35
44
  const m = re.exec(n.id);
@@ -65,3 +74,62 @@ export function cloneNode(
65
74
  config: structuredClone(node.config),
66
75
  };
67
76
  }
77
+
78
+ /** String-only translator handed to `NodeTypeDef.localizeDefaults`. */
79
+ export type DefaultsTranslate = (
80
+ key: string,
81
+ params?: Record<string, string>
82
+ ) => string;
83
+
84
+ /**
85
+ * Tear the string lookup off the host translator so `localizeDefaults` doesn't
86
+ * depend on the React-fragment interpolation path.
87
+ */
88
+ export function toDefaultsTranslate(
89
+ translate: WorkflowTranslate
90
+ ): DefaultsTranslate {
91
+ return (key, params) => translate(key, params) as string;
92
+ }
93
+
94
+ /**
95
+ * Build a fresh node of `def`'s kind — the ONE place a node is created from a
96
+ * node type, shared by the palette drop and `useWorkflowActions().addNode`, so
97
+ * both paths produce the same id and starting config:
98
+ *
99
+ * - id: `nextNodeId` (`{kind}_{N}`), so authors can type it into `{{...}}`
100
+ * references — the same scheme duplicate/paste use;
101
+ * - config: `defaultConfig`, then `localizeDefaults(translate)`, then
102
+ * `hostDefaults(hostBridge)`, each shallow-merged over the previous.
103
+ */
104
+ export function createNodeFromType(
105
+ def: NodeTypeDef,
106
+ {
107
+ position,
108
+ nodes,
109
+ translate,
110
+ hostBridge,
111
+ }: {
112
+ position: Position;
113
+ /** Current node list — continues the per-kind id counter. */
114
+ nodes: ReadonlyArray<GraphNode>;
115
+ translate: DefaultsTranslate;
116
+ hostBridge?: unknown;
117
+ }
118
+ ): GraphNode {
119
+ const base = resolveDefaultConfig(def) as Record<string, unknown>;
120
+ const overlay = def.localizeDefaults?.(translate) as
121
+ | Record<string, unknown>
122
+ | undefined;
123
+ // Host-data overlay (e.g. a starting LLM picked from the tenant's live model
124
+ // list) — applied last so it wins over any static placeholder in
125
+ // `defaultConfig`. Absent on a generic host, leaving the static defaults.
126
+ const bridged = def.hostDefaults?.(hostBridge) as
127
+ | Record<string, unknown>
128
+ | undefined;
129
+ return {
130
+ id: nextNodeId(def.kind, nodes),
131
+ kind: def.kind,
132
+ position,
133
+ config: { ...base, ...overlay, ...bridged },
134
+ };
135
+ }
@@ -35,6 +35,14 @@ export {
35
35
  } from './graphObserver';
36
36
  export type { GraphAction } from './graphReducer';
37
37
  export { emptyGraph, graphReducer } from './graphReducer';
38
+ export type {
39
+ AddAfterSource,
40
+ AddNodeOptions,
41
+ AddNodeResult,
42
+ AddNodeTarget,
43
+ WorkflowActions,
44
+ } from './useWorkflowActions';
45
+ export { useWorkflowActions } from './useWorkflowActions';
38
46
  export { Workflow } from './Workflow';
39
47
  export type {
40
48
  ConnectionCtx,
@@ -122,11 +122,11 @@ function orderEdgesForDagre(
122
122
  * height is a typical two-row card. Callers should pass real measured sizes
123
123
  * when available so ranks pack tightly.
124
124
  */
125
- const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
126
- const FALLBACK_HEIGHT = 76;
125
+ export const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
126
+ export const FALLBACK_HEIGHT = 76;
127
127
 
128
- const DEFAULT_RANK_GAP = 80;
129
- const DEFAULT_NODE_GAP = 40;
128
+ export const DEFAULT_RANK_GAP = 80;
129
+ export const DEFAULT_NODE_GAP = 40;
130
130
 
131
131
  /**
132
132
  * Compute a tidy, hierarchical layout for a workflow graph and return a new
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { findFreeExit, planNodePlacement } from './placement';
4
+ import type { Graph, GraphNode, NodeTypeDef } from './Workflow.types';
5
+
6
+ // Handle shapes of FactChat's start / agent / end / note / classify types.
7
+ const HANDLES: Record<string, NodeTypeDef['handles']> = {
8
+ start: () => ({ inputs: [], outputs: [{ id: 'out' }] }),
9
+ agent: () => ({ inputs: [{ id: 'in' }], outputs: [{ id: 'out' }] }),
10
+ end: () => ({ inputs: [{ id: 'in' }], outputs: [] }),
11
+ note: () => ({ inputs: [], outputs: [] }),
12
+ classify: () => ({
13
+ inputs: [{ id: 'in' }],
14
+ outputs: [{ id: 'cat_a' }, { id: 'cat_b' }],
15
+ }),
16
+ };
17
+ const getNodeType = (kind: string) =>
18
+ HANDLES[kind] ? ({ kind, handles: HANDLES[kind] } as NodeTypeDef) : undefined;
19
+
20
+ const node = (id: string, kind: string, x = 0, y = 0): GraphNode => ({
21
+ id,
22
+ kind,
23
+ position: { x, y },
24
+ config: {},
25
+ });
26
+
27
+ const plan = (
28
+ graph: Graph,
29
+ kind: string,
30
+ after: string | null,
31
+ types: typeof getNodeType = getNodeType
32
+ ) =>
33
+ planNodePlacement({
34
+ graph,
35
+ getNodeType: types,
36
+ node: node(`${kind}_9`, kind),
37
+ target: after ? { after } : { at: 'viewportCenter' },
38
+ viewportCenter: { x: 500, y: 300 },
39
+ edgeId: 'e_new',
40
+ });
41
+
42
+ describe('planNodePlacement', () => {
43
+ const start = node('start', 'start');
44
+
45
+ it('places the node right of the source and connects its free exit', () => {
46
+ const result = plan({ nodes: [start], edges: [] }, 'agent', 'start');
47
+
48
+ // 174 (card width) + 80 (rank gap).
49
+ expect(result.position).toEqual({ x: 254, y: 0 });
50
+ expect(result.edge).toEqual({
51
+ id: 'e_new',
52
+ source: 'start',
53
+ target: 'agent_9',
54
+ sourceHandle: 'out',
55
+ targetHandle: 'in',
56
+ });
57
+ });
58
+
59
+ it('uses the measured source width when React Flow has one', () => {
60
+ const result = planNodePlacement({
61
+ graph: { nodes: [start], edges: [] },
62
+ getNodeType,
63
+ node: node('agent_9', 'agent'),
64
+ target: { after: 'start' },
65
+ viewportCenter: { x: 0, y: 0 },
66
+ edgeId: 'e_new',
67
+ getNodeSize: (id) =>
68
+ id === 'start' ? { width: 300, height: 90 } : undefined,
69
+ });
70
+ expect(result.position).toEqual({ x: 380, y: 0 });
71
+ });
72
+
73
+ it('moves the node down when the spot to the right is taken', () => {
74
+ const blocker = node('agent_1', 'agent', 254, 0);
75
+ const result = plan({ nodes: [start, blocker], edges: [] }, 'end', 'start');
76
+
77
+ // 0 + 76 (fallback height) + 40 (node gap).
78
+ expect(result.position).toEqual({ x: 254, y: 116 });
79
+ });
80
+
81
+ it('centres an unconnected node in the viewport for { at: viewportCenter }', () => {
82
+ const result = plan({ nodes: [start], edges: [] }, 'agent', null);
83
+
84
+ expect(result.position).toEqual({ x: 413, y: 262 });
85
+ expect(result.edge).toBeNull();
86
+ });
87
+
88
+ it('falls back to the viewport centre when the node has no free exit', () => {
89
+ const end = node('end_1', 'end', 400, 0);
90
+ const taken: Graph = {
91
+ nodes: [start, end],
92
+ edges: [{ id: 'e_1', source: 'start', target: 'end_1' }],
93
+ };
94
+
95
+ // From End (no exits), and from Start whose only exit is taken.
96
+ for (const after of ['end_1', 'start', 'missing']) {
97
+ const result = plan(taken, 'agent', after);
98
+ expect(result.edge).toBeNull();
99
+ expect(result.position).toEqual({ x: 413, y: 262 });
100
+ }
101
+ });
102
+
103
+ it('places a Note right of the source but adds no edge into it', () => {
104
+ const result = plan({ nodes: [start], edges: [] }, 'note', 'start');
105
+
106
+ expect(result.position).toEqual({ x: 254, y: 0 });
107
+ expect(result.edge).toBeNull();
108
+ });
109
+
110
+ it('wires the next free exit of a multi-exit node', () => {
111
+ const classify = node('classify_1', 'classify');
112
+ const graph: Graph = {
113
+ nodes: [classify],
114
+ edges: [
115
+ { id: 'e_1', source: 'classify_1', target: 'x', sourceHandle: 'cat_a' },
116
+ ],
117
+ };
118
+ expect(plan(graph, 'agent', 'classify_1').edge?.sourceHandle).toBe('cat_b');
119
+ });
120
+
121
+ it('respects canConnect on either endpoint', () => {
122
+ const rejectingTarget = (kind: string) => {
123
+ const def = getNodeType(kind);
124
+ return def && kind === 'agent'
125
+ ? { ...def, canConnect: () => 'agents cannot follow start' }
126
+ : def;
127
+ };
128
+ const result = plan(
129
+ { nodes: [start], edges: [] },
130
+ 'agent',
131
+ 'start',
132
+ rejectingTarget
133
+ );
134
+ // Still placed after the source, just not wired.
135
+ expect(result.position).toEqual({ x: 254, y: 0 });
136
+ expect(result.edge).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe('findFreeExit', () => {
141
+ it('treats an edge without sourceHandle as sitting on the first exit', () => {
142
+ const graph: Graph = {
143
+ nodes: [node('classify_1', 'classify')],
144
+ edges: [{ id: 'e_1', source: 'classify_1', target: 'x' }],
145
+ };
146
+ expect(findFreeExit(graph, getNodeType, 'classify_1')?.sourceHandle).toBe(
147
+ 'cat_b'
148
+ );
149
+ });
150
+
151
+ it('returns null for no node id', () => {
152
+ expect(findFreeExit({ nodes: [], edges: [] }, getNodeType, null)).toBe(
153
+ null
154
+ );
155
+ });
156
+ });