@flowdrop/flowdrop 1.14.0 → 1.15.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.
@@ -47,6 +47,7 @@
47
47
  import { logger } from '../utils/logger.js';
48
48
  import { getDataTypeColorToken, getPortBackgroundColor } from '../utils/colors.js';
49
49
  import { applyPortOrder } from '../utils/portUtils.js';
50
+ import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
50
51
 
51
52
  interface Props {
52
53
  /** Optional workflow node (if provided, schema and values are derived from it) */
@@ -187,12 +188,30 @@
187
188
  const initialConfig = $derived(values ?? node?.data.config ?? {});
188
189
 
189
190
  /**
190
- * Create reactive configuration values using $state
191
- * This fixes the Svelte 5 reactivity warnings
191
+ * User edits to config only keys the user has touched since the current
192
+ * schema was loaded. configValues is derived from props + edits, so children
193
+ * mount with the correct values already in place (no parent→child race
194
+ * during the initial flush). Never assign to configValues directly.
192
195
  */
193
- let configValues = $state<Record<string, unknown>>({});
196
+ let edits = $state<Record<string, unknown>>({});
197
+
198
+ const configValues = $derived(mergeWithDefaults(configSchema, initialConfig, edits));
199
+
194
200
  setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => configValues);
195
201
 
202
+ // Drop edits when the schema reference changes — covers dynamic-schema load
203
+ // (configSchema flips from undefined → loaded) and "different node opened"
204
+ // (node prop change → metadata.configSchema reference change). Identity
205
+ // comparison only — value churn in `initialConfig` preserves in-flight edits.
206
+ // svelte-ignore state_referenced_locally — capturing the initial derived reference is intentional; later changes are picked up by the effect below
207
+ let prevSchemaRef = configSchema;
208
+ $effect.pre(() => {
209
+ if (configSchema !== prevSchemaRef) {
210
+ prevSchemaRef = configSchema;
211
+ edits = {};
212
+ }
213
+ });
214
+
196
215
  /**
197
216
  * UI Extension values for display settings
198
217
  * Merges node type defaults with instance overrides
@@ -294,22 +313,6 @@
294
313
  }
295
314
  });
296
315
 
297
- /**
298
- * Initialize config values when node/schema changes
299
- */
300
- $effect(() => {
301
- if (configSchema?.properties) {
302
- const mergedConfig: Record<string, unknown> = {};
303
- Object.entries(configSchema.properties).forEach(([key, field]) => {
304
- const fieldConfig = field as Record<string, unknown>;
305
- // Use existing value if available, otherwise use default
306
- mergedConfig[key] =
307
- initialConfig[key] !== undefined ? initialConfig[key] : fieldConfig.default;
308
- });
309
- configValues = mergedConfig;
310
- }
311
- });
312
-
313
316
  /**
314
317
  * Initialize UI extension values when node changes
315
318
  */
@@ -419,10 +422,22 @@
419
422
  }
420
423
 
421
424
  /**
422
- * Handle field value changes from FormField components
425
+ * Handle field value changes from FormField components.
426
+ *
427
+ * When a field changes, also clear any sibling autocomplete that declared
428
+ * this field in its `autocomplete.params` map — its previous value was
429
+ * computed against the old dependency value and is now stale. The cascade
430
+ * runs only on user-driven edits via this codepath; undo/redo and external
431
+ * config replacement flow through `initialConfig` and don't trigger it (#33).
423
432
  */
424
433
  function handleFieldChange(key: string, value: unknown): void {
425
- configValues[key] = value;
434
+ const previous = configValues[key];
435
+ edits[key] = value;
436
+ if (previous === value) return;
437
+ const dependents = cascadeClearAutocompleteDependents(configSchema, key);
438
+ for (const [depKey, depValue] of Object.entries(dependents)) {
439
+ edits[depKey] = depValue;
440
+ }
426
441
  }
427
442
 
428
443
  /**
@@ -434,6 +449,11 @@
434
449
  if (onChange) {
435
450
  const extensions = showUIExtensions && node ? uiExtensionValues : undefined;
436
451
  onChange({ ...configValues }, extensions);
452
+ // Discharge the edits buffer at the commit boundary. Subsequent prop
453
+ // changes (parent absorbing the commit, undo/redo, collaboration) then
454
+ // flow through `initialConfig` cleanly instead of being shadowed by a
455
+ // stale local edit.
456
+ edits = {};
437
457
  }
438
458
  }
439
459
 
@@ -62,6 +62,7 @@
62
62
  import FormUISchemaRenderer from './form/FormUISchemaRenderer.svelte';
63
63
  import type { FieldSchema } from './form/index.js';
64
64
  import { m, warnDeprecatedProp } from '../messages/index.js';
65
+ import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
65
66
 
66
67
  /**
67
68
  * Props interface for SchemaForm component
@@ -194,24 +195,24 @@
194
195
  let formRef: HTMLFormElement | undefined = $state();
195
196
 
196
197
  /**
197
- * Internal reactive state for form values
198
+ * User edits only keys the user has changed since the current schema
199
+ * was loaded. formValues is derived from props + edits, never synchronised
200
+ * via an effect, so children mount with the correct values already in place.
198
201
  */
199
- let formValues = $state<Record<string, unknown>>({});
202
+ let edits = $state<Record<string, unknown>>({});
203
+
204
+ const formValues = $derived(mergeWithDefaults(schema, values, edits));
205
+
200
206
  setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => formValues);
201
207
 
202
- /**
203
- * Initialize form values when schema or values change
204
- * Merges default values from schema with provided values
205
- */
206
- $effect(() => {
207
- if (schema?.properties) {
208
- const mergedValues: Record<string, unknown> = {};
209
- Object.entries(schema.properties).forEach(([key, field]) => {
210
- const fieldConfig = field as Record<string, unknown>;
211
- // Use provided value if available, otherwise use schema default
212
- mergedValues[key] = values[key] !== undefined ? values[key] : fieldConfig.default;
213
- });
214
- formValues = mergedValues;
208
+ // Drop edits when the schema reference changes (different form mounted).
209
+ // Identity comparison only value churn in `values` preserves in-flight edits.
210
+ // svelte-ignore state_referenced_locally capturing the initial prop reference is intentional; later changes are picked up by the effect below
211
+ let prevSchemaRef = schema;
212
+ $effect.pre(() => {
213
+ if (schema !== prevSchemaRef) {
214
+ prevSchemaRef = schema;
215
+ edits = {};
215
216
  }
216
217
  });
217
218
 
@@ -234,7 +235,18 @@
234
235
  * @param value - New field value
235
236
  */
236
237
  function handleFieldChange(key: string, value: unknown): void {
237
- formValues[key] = value;
238
+ const previous = formValues[key];
239
+ edits[key] = value;
240
+
241
+ // Cascade-clear any autocomplete whose `params` references this field.
242
+ // Only runs on user-driven edits; undo/redo and external value-prop
243
+ // replacement flow through `values` and bypass this path (#33).
244
+ if (previous !== value) {
245
+ const dependents = cascadeClearAutocompleteDependents(schema, key);
246
+ for (const [depKey, depValue] of Object.entries(dependents)) {
247
+ edits[depKey] = depValue;
248
+ }
249
+ }
238
250
 
239
251
  // Notify parent of the change
240
252
  if (onChange) {
@@ -309,10 +309,9 @@
309
309
  // Open dropdown
310
310
  showDropdown();
311
311
 
312
- // If allowFreeText and single mode, update the value immediately
313
- if (allowFreeText && !multiple) {
314
- onChange(inputValue);
315
- }
312
+ // Free-text single mode commits on intent signals (Enter, blur, option select),
313
+ // NOT per keystroke — see issue #32. Treating the search-box text as the field's
314
+ // committed value pollutes the parent form's edits buffer and history.
316
315
 
317
316
  // Fetch suggestions with debounce
318
317
  debouncedFetch(inputValue);
@@ -353,6 +352,19 @@
353
352
  inputValue = '';
354
353
  }
355
354
  }
355
+
356
+ // Free-text single mode — commit on blur if the user typed something
357
+ // that differs from the current value. An empty inputValue is just the
358
+ // resting state of the search box (the value lives in the chip), so it
359
+ // is NOT an intent signal; clearing happens via the X button or
360
+ // Backspace-on-empty. Replaces the per-keystroke commit dropped from
361
+ // handleInput; see issue #32.
362
+ if (allowFreeText && !multiple && inputValue !== '') {
363
+ const currentSingle = selectedValues[0] ?? '';
364
+ if (inputValue !== currentSingle) {
365
+ onChange(inputValue);
366
+ }
367
+ }
356
368
  }, 200);
357
369
  }
358
370
 
@@ -592,12 +604,14 @@
592
604
  const current = depFingerprint;
593
605
  if (current === prevDepFingerprint) return;
594
606
  prevDepFingerprint = current;
595
- // A dependency field changed — abort any in-flight fetch, clear state
607
+ // A dependency field changed — abort any in-flight fetch and invalidate
608
+ // cached suggestions/labels (they were keyed by the previous dependency
609
+ // values). Do NOT clear this field's value here: that policy lives in the
610
+ // parent form's handleFieldChange so it only fires on user-driven changes,
611
+ // not on undo/redo, programmatic resets, or collaborative edits (#33).
596
612
  abortController?.abort();
597
613
  suggestions = [];
598
614
  labelCache = new Map();
599
- if (multiple) onChange([]);
600
- else onChange('');
601
615
  });
602
616
 
603
617
  /**
@@ -0,0 +1,280 @@
1
+ <!--
2
+ Atom Node Component
3
+ Minimalist, label-only node for "supplies a value" atoms (Constant now, Cast later).
4
+ Renders as a pill that hugs its content with handles driven by the node's ports.
5
+
6
+ The body text and the output port's dataType are both driven by config via
7
+ `extensions.ui.atom` (see AtomUIConfig). This component owns no domain semantics —
8
+ meaning comes entirely from the node's NodeMetadata.
9
+ -->
10
+
11
+ <script lang="ts">
12
+ import { Position, Handle } from '@xyflow/svelte';
13
+ import type {
14
+ ConfigValues,
15
+ ConfigSchema,
16
+ NodeMetadata,
17
+ NodeExtensions,
18
+ NodePort,
19
+ AtomUIConfig,
20
+ WorkflowNode as WorkflowNodeType
21
+ } from '../../types/index.js';
22
+ import { getDataTypeColor } from '../../utils/colors.js';
23
+ import { getConnectedHandles } from '../../stores/workflowStore.svelte.js';
24
+ import { applyPortOrder, isPortVisible } from '../../utils/portUtils.js';
25
+ import { ProximityConnectHelper } from '../../helpers/proximityConnect.js';
26
+
27
+ interface AtomNodeData {
28
+ label: string;
29
+ config: ConfigValues;
30
+ metadata: NodeMetadata;
31
+ nodeId?: string;
32
+ extensions?: NodeExtensions;
33
+ onConfigOpen?: (node: {
34
+ id: string;
35
+ type: string;
36
+ data: { label: string; config: ConfigValues; metadata: NodeMetadata };
37
+ }) => void;
38
+ }
39
+
40
+ interface Props {
41
+ data: AtomNodeData;
42
+ selected?: boolean;
43
+ isProcessing?: boolean;
44
+ isError?: boolean;
45
+ }
46
+
47
+ let { data, selected, isProcessing, isError }: Props = $props();
48
+
49
+ const nodeId = $derived(data.nodeId ?? 'unknown');
50
+ const nodeType = $derived(data.metadata?.type ?? 'atom');
51
+
52
+ // Instance extensions override node-type defaults.
53
+ const atomCfg = $derived<AtomUIConfig>(
54
+ data.extensions?.ui?.atom ?? data.metadata?.extensions?.ui?.atom ?? {}
55
+ );
56
+ const hideUnconnectedHandles = $derived(
57
+ data.extensions?.ui?.hideUnconnectedHandles ??
58
+ data.metadata?.extensions?.ui?.hideUnconnectedHandles ??
59
+ false
60
+ );
61
+ const hiddenPorts = $derived(
62
+ data.extensions?.ui?.hiddenPorts ?? data.metadata?.extensions?.ui?.hiddenPorts ?? {}
63
+ );
64
+ const portOrder = $derived(
65
+ data.extensions?.ui?.portOrder ?? data.metadata?.extensions?.ui?.portOrder ?? {}
66
+ );
67
+
68
+ // Optional, server-driven accent. When unset the inline custom property is
69
+ // omitted, so CSS falls back to the neutral border — uncolored atoms are
70
+ // unchanged. Mirrors ToolNode: node definition (metadata) wins over instance.
71
+ const atomColor = $derived(data.metadata?.color ?? (data.config?.color as string | undefined));
72
+ const isRect = $derived(atomCfg.shape === 'rectangle');
73
+
74
+ // Only the dynamic bits live inline; max-width and accent both optional.
75
+ const nodeStyle = $derived(
76
+ [
77
+ atomCfg.maxWidth ? `max-width: ${atomCfg.maxWidth}px` : '',
78
+ atomColor ? `--fd-atom-node-color: ${atomColor}` : ''
79
+ ]
80
+ .filter(Boolean)
81
+ .join('; ')
82
+ );
83
+
84
+ // The node slice getAllPorts needs — keeps port resolution in one place,
85
+ // shared with proximity-connect and coordinate/validation logic. No cast: the
86
+ // helper's signature documents that `type` + `data` are all it reads.
87
+ const nodeLike = $derived<Pick<WorkflowNodeType, 'type' | 'data'>>({ type: nodeType, data });
88
+
89
+ const inPorts = $derived(
90
+ applyPortOrder(ProximityConnectHelper.getAllPorts(nodeLike, 'input'), portOrder.inputs).filter(
91
+ (p: NodePort) =>
92
+ isPortVisible(
93
+ p,
94
+ 'input',
95
+ hiddenPorts,
96
+ hideUnconnectedHandles,
97
+ getConnectedHandles(),
98
+ nodeId
99
+ )
100
+ )
101
+ );
102
+ const outPorts = $derived(
103
+ applyPortOrder(
104
+ ProximityConnectHelper.getAllPorts(nodeLike, 'output'),
105
+ portOrder.outputs
106
+ ).filter((p: NodePort) =>
107
+ isPortVisible(p, 'output', hiddenPorts, hideUnconnectedHandles, getConnectedHandles(), nodeId)
108
+ )
109
+ );
110
+
111
+ /** Friendly label for a value, using the field's oneOf titles when present. */
112
+ function resolveDisplay(schema: ConfigSchema | undefined, key: string, raw: unknown): string {
113
+ const prop = schema?.properties?.[key] as
114
+ | { oneOf?: Array<{ const: unknown; title?: string }> }
115
+ | undefined;
116
+ const match = prop?.oneOf?.find((o) => o.const === raw);
117
+ if (match?.title) return match.title;
118
+ if (typeof raw === 'boolean') return raw ? 'true' : 'false';
119
+ return String(raw);
120
+ }
121
+
122
+ // Body text: config[valueKey] (label-resolved), else node label. When neither
123
+ // resolves, fall back to the placeholder and render it dimmed.
124
+ const display = $derived.by(() => {
125
+ const key = atomCfg.valueKey;
126
+ const raw = key ? data.config?.[key] : undefined;
127
+ if (key && raw !== undefined && raw !== null && raw !== '') {
128
+ return { text: resolveDisplay(data.metadata?.configSchema, key, raw), empty: false };
129
+ }
130
+ if (data.label) return { text: data.label, empty: false };
131
+ return { text: atomCfg.placeholder ?? '', empty: true };
132
+ });
133
+
134
+ // Pill height is content-driven (~28px), so fixed px offsets don't fit.
135
+ // Distribute handles as a % of node height: 50% for one port, evenly otherwise.
136
+ function portTopPct(index: number, count: number): number {
137
+ return ((index + 1) / (count + 1)) * 100;
138
+ }
139
+
140
+ function openConfig(): void {
141
+ data.onConfigOpen?.({ id: nodeId, type: nodeType, data });
142
+ }
143
+ function handleKeydown(event: KeyboardEvent): void {
144
+ if (event.key === 'Enter' || event.key === ' ') {
145
+ event.preventDefault();
146
+ openConfig();
147
+ }
148
+ }
149
+ </script>
150
+
151
+ {#each inPorts as port, index}
152
+ <Handle
153
+ type="target"
154
+ position={Position.Left}
155
+ id={`${nodeId}-input-${port.id}`}
156
+ style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
157
+ port.dataType
158
+ )}); top: {portTopPct(index, inPorts.length)}%;"
159
+ />
160
+ {/each}
161
+
162
+ <div
163
+ class="flowdrop-atom-node"
164
+ class:flowdrop-atom-node--selected={selected}
165
+ class:flowdrop-atom-node--processing={isProcessing}
166
+ class:flowdrop-atom-node--error={isError}
167
+ class:flowdrop-atom-node--empty={display.empty}
168
+ class:flowdrop-atom-node--rect={isRect}
169
+ style={nodeStyle}
170
+ onclick={openConfig}
171
+ onkeydown={handleKeydown}
172
+ role="button"
173
+ tabindex="0"
174
+ >
175
+ {#if atomCfg.prefix && !display.empty}
176
+ <span class="flowdrop-atom-node__prefix" aria-hidden="true">{atomCfg.prefix}</span>
177
+ {/if}
178
+ <span class="flowdrop-atom-node__body" title={display.text}>{display.text}</span>
179
+ </div>
180
+
181
+ {#each outPorts as port, index}
182
+ <Handle
183
+ type="source"
184
+ position={Position.Right}
185
+ id={`${nodeId}-output-${port.id}`}
186
+ style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
187
+ port.dataType
188
+ )}); top: {portTopPct(index, outPorts.length)}%;"
189
+ />
190
+ {/each}
191
+
192
+ <style>
193
+ .flowdrop-atom-node {
194
+ position: relative;
195
+ display: inline-flex;
196
+ align-items: center;
197
+ width: fit-content;
198
+ min-width: 2rem;
199
+ min-height: 28px;
200
+ padding: 2px var(--fd-space-sm);
201
+ background-color: var(--fd-card);
202
+ /* --fd-atom-node-color is set inline only when the server provides a color;
203
+ otherwise it falls back to the neutral border token. */
204
+ border: 1.5px solid var(--fd-atom-node-color, var(--fd-node-border));
205
+ border-radius: 999px;
206
+ box-shadow: var(--fd-shadow-sm);
207
+ color: var(--fd-foreground);
208
+ cursor: pointer;
209
+ transition:
210
+ box-shadow var(--fd-transition-fast),
211
+ border-color var(--fd-transition-fast);
212
+ z-index: 10;
213
+ }
214
+
215
+ .flowdrop-atom-node--rect {
216
+ border-radius: var(--fd-radius-md);
217
+ }
218
+
219
+ .flowdrop-atom-node:hover {
220
+ box-shadow: var(--fd-shadow-md);
221
+ border-color: var(--fd-atom-node-color, var(--fd-node-border-hover));
222
+ }
223
+
224
+ .flowdrop-atom-node--selected {
225
+ box-shadow:
226
+ 0 0 0 2px color-mix(in srgb, var(--fd-atom-node-color, var(--fd-primary)) 30%, transparent),
227
+ var(--fd-shadow-md);
228
+ border-color: var(--fd-atom-node-color, var(--fd-primary));
229
+ }
230
+
231
+ .flowdrop-atom-node:focus-visible {
232
+ outline: 2px solid var(--fd-ring);
233
+ outline-offset: 2px;
234
+ }
235
+
236
+ .flowdrop-atom-node--processing {
237
+ opacity: 0.7;
238
+ }
239
+
240
+ .flowdrop-atom-node--error {
241
+ border-color: var(--fd-error) !important;
242
+ background-color: var(--fd-error-muted) !important;
243
+ }
244
+
245
+ .flowdrop-atom-node--empty .flowdrop-atom-node__body {
246
+ color: var(--fd-muted-foreground);
247
+ font-style: italic;
248
+ }
249
+
250
+ .flowdrop-atom-node__prefix {
251
+ flex-shrink: 0;
252
+ margin-right: 2px;
253
+ color: var(--fd-muted-foreground);
254
+ font-size: var(--fd-text-sm);
255
+ line-height: 1.2;
256
+ }
257
+
258
+ .flowdrop-atom-node__body {
259
+ /* min-width:0 lets the body ellipsize as a flex sibling of the prefix */
260
+ min-width: 0;
261
+ font-size: var(--fd-text-sm);
262
+ line-height: 1.2;
263
+ white-space: nowrap;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ }
267
+
268
+ /* `top` is set inline (dynamic, must beat svelte-flow defaults); transform and
269
+ stacking live here so the hover rule can compose instead of fighting !important. */
270
+ :global(.svelte-flow__node-atom .svelte-flow__handle) {
271
+ --fd-handle-border-color: var(--fd-handle-border);
272
+ transform: translateY(-50%);
273
+ z-index: 20 !important;
274
+ pointer-events: auto !important;
275
+ }
276
+
277
+ :global(.svelte-flow__node-atom .svelte-flow__handle:hover) {
278
+ transform: translateY(-50%) scale(1.2);
279
+ }
280
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { ConfigValues, NodeMetadata, NodeExtensions } from '../../types/index.js';
2
+ interface AtomNodeData {
3
+ label: string;
4
+ config: ConfigValues;
5
+ metadata: NodeMetadata;
6
+ nodeId?: string;
7
+ extensions?: NodeExtensions;
8
+ onConfigOpen?: (node: {
9
+ id: string;
10
+ type: string;
11
+ data: {
12
+ label: string;
13
+ config: ConfigValues;
14
+ metadata: NodeMetadata;
15
+ };
16
+ }) => void;
17
+ }
18
+ interface Props {
19
+ data: AtomNodeData;
20
+ selected?: boolean;
21
+ isProcessing?: boolean;
22
+ isError?: boolean;
23
+ }
24
+ declare const AtomNode: import("svelte").Component<Props, {}, "">;
25
+ type AtomNode = ReturnType<typeof AtomNode>;
26
+ export default AtomNode;
@@ -143,7 +143,7 @@
143
143
  .getMessages(sessionId, {
144
144
  since: playgroundService.getLastSequenceNumber() ?? undefined
145
145
  })
146
- .then((response) => applyServerResponse(response))
146
+ .then((response) => applyServerResponse(response, sessionId))
147
147
  .catch((err) => logger.error('[Playground] Visibility catchup failed:', err));
148
148
  }
149
149
  }
@@ -257,7 +257,7 @@
257
257
  });
258
258
  if (token !== loadToken) return;
259
259
  playgroundActions.clearMessages();
260
- applyServerResponse(response);
260
+ applyServerResponse(response, sessionId);
261
261
  setHasOlder(deriveHasOlder(response));
262
262
 
263
263
  if (session.status !== 'idle') {
@@ -321,6 +321,11 @@
321
321
  const sessionName = `Session ${getSessions().length + 1}`;
322
322
  const session = await playgroundService.createSession(workflowId, sessionName);
323
323
 
324
+ // Stop polling the previous (possibly running) session before switching,
325
+ // mirroring handleSelectSession. Otherwise its next poll keeps the old
326
+ // 'running' status alive and the new session's chat input stays disabled.
327
+ playgroundService.stopPolling();
328
+
324
329
  if (onSessionNavigate) {
325
330
  onSessionNavigate(session.id);
326
331
  return;
@@ -422,7 +427,7 @@
422
427
 
423
428
  playgroundService.startPolling(
424
429
  sessionId,
425
- (response) => applyServerResponse(response),
430
+ (response) => applyServerResponse(response, sessionId),
426
431
  pollingInterval,
427
432
  overrideShouldStopPolling ?? config.shouldStopPolling,
428
433
  initialSequenceNumber
@@ -437,7 +442,7 @@
437
442
  const response = await playgroundService.getMessages(sessionId, {
438
443
  since: playgroundService.getLastSequenceNumber() ?? undefined
439
444
  });
440
- applyServerResponse(response);
445
+ applyServerResponse(response, sessionId);
441
446
  if (response.sessionStatus === 'running' && !playgroundService.isPolling()) {
442
447
  startPolling(sessionId, true);
443
448
  }
@@ -458,7 +463,7 @@
458
463
  const response = await playgroundService.getMessages(sessionId, {
459
464
  since: playgroundService.getLastSequenceNumber() ?? undefined
460
465
  });
461
- applyServerResponse(response);
466
+ applyServerResponse(response, sessionId);
462
467
  } catch (err) {
463
468
  logger.error('[Playground] Failed to refresh after interrupt:', err);
464
469
  }
@@ -19,8 +19,11 @@ export interface ProximityEdgeCandidate {
19
19
  export declare class ProximityConnectHelper {
20
20
  /**
21
21
  * Get ALL ports (static + dynamic + gateway branches) for a node.
22
+ *
23
+ * Only reads `type` and `data`, so callers can pass a full node or a lighter
24
+ * slice (e.g. a renderer that has metadata + config but no position/measured).
22
25
  */
23
- static getAllPorts(node: WorkflowNodeType, direction: 'input' | 'output'): NodePort[];
26
+ static getAllPorts(node: Pick<WorkflowNodeType, 'type' | 'data'>, direction: 'input' | 'output'): NodePort[];
24
27
  /**
25
28
  * Build handle ID in the standard format.
26
29
  */
@@ -13,12 +13,28 @@ const PROXIMITY_EDGE_CLASS = 'flowdrop--edge--proximity-preview';
13
13
  export class ProximityConnectHelper {
14
14
  /**
15
15
  * Get ALL ports (static + dynamic + gateway branches) for a node.
16
+ *
17
+ * Only reads `type` and `data`, so callers can pass a full node or a lighter
18
+ * slice (e.g. a renderer that has metadata + config but no position/measured).
16
19
  */
17
20
  static getAllPorts(node, direction) {
18
21
  // Static ports from metadata
19
- const staticPorts = direction === 'output'
22
+ let staticPorts = direction === 'output'
20
23
  ? (node.data?.metadata?.outputs ?? [])
21
24
  : (node.data?.metadata?.inputs ?? []);
25
+ // Atom value-type binding: the bound output port's dataType follows a config
26
+ // field (e.g. Constant's `valueType`), so connection validation matches the
27
+ // type the user actually picked. Derived on read — never stored redundantly.
28
+ if (direction === 'output') {
29
+ const atom = node.data?.metadata?.extensions?.ui?.atom;
30
+ const boundType = atom?.valueTypeKey
31
+ ? node.data?.config?.[atom.valueTypeKey]
32
+ : undefined;
33
+ if (boundType) {
34
+ const portId = atom?.outputPortId ?? staticPorts[0]?.id;
35
+ staticPorts = staticPorts.map((p) => (p.id === portId ? { ...p, dataType: boundType } : p));
36
+ }
37
+ }
22
38
  // Dynamic ports from config
23
39
  const dynamicKey = direction === 'output' ? 'dynamicOutputs' : 'dynamicInputs';
24
40
  const rawDynamic = node.data?.config?.[dynamicKey] ?? [];