@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.
- package/dist/components/ConfigForm.svelte +41 -21
- package/dist/components/SchemaForm.svelte +28 -16
- package/dist/components/form/FormAutocomplete.svelte +21 -7
- package/dist/components/nodes/AtomNode.svelte +280 -0
- package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
- package/dist/components/playground/Playground.svelte +10 -5
- package/dist/helpers/proximityConnect.d.ts +4 -1
- package/dist/helpers/proximityConnect.js +17 -1
- package/dist/openapi/v1/openapi.yaml +224 -161
- package/dist/playground/mount.js +2 -2
- package/dist/registry/builtinNodes.d.ts +1 -1
- package/dist/registry/builtinNodes.js +13 -0
- package/dist/schemas/v1/workflow.schema.json +50 -3
- package/dist/stores/playgroundStore.svelte.d.ts +7 -1
- package/dist/stores/playgroundStore.svelte.js +11 -3
- package/dist/types/index.d.ts +38 -3
- package/dist/utils/formMerge.d.ts +36 -0
- package/dist/utils/formMerge.js +70 -0
- package/dist/utils/nodeTypes.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
191
|
-
*
|
|
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
|
|
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]
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
$effect(() => {
|
|
207
|
-
if (schema
|
|
208
|
-
|
|
209
|
-
|
|
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]
|
|
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
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
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] ?? [];
|