@flowdrop/flowdrop 1.13.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.
Files changed (51) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ConfigForm.svelte +41 -21
  3. package/dist/components/ConfigPanel.svelte +7 -1
  4. package/dist/components/NodeSwapPicker.svelte +5 -1
  5. package/dist/components/PipelineStatus.svelte +11 -2
  6. package/dist/components/SchemaForm.svelte +28 -16
  7. package/dist/components/SettingsPanel.svelte +5 -1
  8. package/dist/components/WorkflowEditor.svelte +5 -1
  9. package/dist/components/chat/AIChatPanel.svelte +1 -5
  10. package/dist/components/form/FormAutocomplete.svelte +23 -12
  11. package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
  12. package/dist/components/interrupt/InterruptBubble.svelte +4 -5
  13. package/dist/components/nodes/AtomNode.svelte +280 -0
  14. package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
  15. package/dist/components/playground/ChatBubble.svelte +6 -8
  16. package/dist/components/playground/ChatInput.svelte +11 -5
  17. package/dist/components/playground/ControlPanel.svelte +42 -29
  18. package/dist/components/playground/ExecutionConsole.svelte +5 -1
  19. package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
  20. package/dist/components/playground/ExecutionList.svelte +7 -2
  21. package/dist/components/playground/LogRow.svelte +2 -1
  22. package/dist/components/playground/MessageBubble.svelte +1 -4
  23. package/dist/components/playground/MessageCard.svelte +2 -1
  24. package/dist/components/playground/MessageMarkdown.svelte +15 -5
  25. package/dist/components/playground/MessageNotice.svelte +2 -1
  26. package/dist/components/playground/MessageStream.svelte +138 -17
  27. package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
  28. package/dist/components/playground/MessageTagChip.svelte +24 -6
  29. package/dist/components/playground/PipelineKanbanView.svelte +40 -11
  30. package/dist/components/playground/PipelinePanel.svelte +5 -1
  31. package/dist/components/playground/PipelineTableView.svelte +20 -6
  32. package/dist/components/playground/Playground.svelte +94 -27
  33. package/dist/components/playground/PlaygroundStudio.svelte +21 -7
  34. package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
  35. package/dist/helpers/proximityConnect.d.ts +4 -1
  36. package/dist/helpers/proximityConnect.js +17 -1
  37. package/dist/openapi/v1/openapi.yaml +6466 -0
  38. package/dist/playground/mount.js +2 -2
  39. package/dist/registry/builtinNodes.d.ts +1 -1
  40. package/dist/registry/builtinNodes.js +13 -0
  41. package/dist/schemas/v1/workflow.schema.json +86 -3
  42. package/dist/services/playgroundService.d.ts +23 -4
  43. package/dist/services/playgroundService.js +22 -9
  44. package/dist/stores/playgroundStore.svelte.d.ts +29 -2
  45. package/dist/stores/playgroundStore.svelte.js +120 -35
  46. package/dist/types/index.d.ts +38 -3
  47. package/dist/types/playground.d.ts +36 -2
  48. package/dist/utils/formMerge.d.ts +36 -0
  49. package/dist/utils/formMerge.js +70 -0
  50. package/dist/utils/nodeTypes.js +1 -0
  51. package/package.json +7 -1
@@ -2,10 +2,34 @@
2
2
  import type { KanbanColumnDef } from '../../types/index.js';
3
3
 
4
4
  const DEFAULT_COLUMNS: KanbanColumnDef[] = [
5
- { key: 'pending', label: 'Pending', statuses: ['idle', 'pending'], icon: 'mdi:clock-outline', color: 'var(--fd-muted-foreground)' },
6
- { key: 'in_progress', label: 'In Progress', statuses: ['running', 'paused', 'interrupted'], icon: 'mdi:play-circle-outline', color: 'var(--fd-warning)' },
7
- { key: 'done', label: 'Done', statuses: ['completed', 'skipped'], icon: 'mdi:check-circle', color: 'var(--fd-success)' },
8
- { key: 'failed', label: 'Failed', statuses: ['failed', 'cancelled'], icon: 'mdi:alert-circle', color: 'var(--fd-error)' },
5
+ {
6
+ key: 'pending',
7
+ label: 'Pending',
8
+ statuses: ['idle', 'pending'],
9
+ icon: 'mdi:clock-outline',
10
+ color: 'var(--fd-muted-foreground)'
11
+ },
12
+ {
13
+ key: 'in_progress',
14
+ label: 'In Progress',
15
+ statuses: ['running', 'paused', 'interrupted'],
16
+ icon: 'mdi:play-circle-outline',
17
+ color: 'var(--fd-warning)'
18
+ },
19
+ {
20
+ key: 'done',
21
+ label: 'Done',
22
+ statuses: ['completed', 'skipped'],
23
+ icon: 'mdi:check-circle',
24
+ color: 'var(--fd-success)'
25
+ },
26
+ {
27
+ key: 'failed',
28
+ label: 'Failed',
29
+ statuses: ['failed', 'cancelled'],
30
+ icon: 'mdi:alert-circle',
31
+ color: 'var(--fd-error)'
32
+ }
9
33
  ];
10
34
  </script>
11
35
 
@@ -13,7 +37,11 @@
13
37
  import { onMount } from 'svelte';
14
38
  import Icon from '@iconify/svelte';
15
39
  import { createPipelineDataFetcher, resolveStatus } from './pipelineViewUtils.svelte.js';
16
- import { getStatusLabel, getStatusTextColor, getStatusBackgroundColor } from '../../utils/nodeStatus.js';
40
+ import {
41
+ getStatusLabel,
42
+ getStatusTextColor,
43
+ getStatusBackgroundColor
44
+ } from '../../utils/nodeStatus.js';
17
45
  import type { NodeStatus } from './pipelineViewUtils.svelte.js';
18
46
  import type { Workflow, WorkflowNode } from '../../types/index.js';
19
47
  import type { EndpointConfig } from '../../config/endpoints.js';
@@ -27,6 +55,7 @@
27
55
 
28
56
  let { pipelineId, workflow, endpointConfig, refreshTrigger = 0 }: Props = $props();
29
57
 
58
+ // svelte-ignore state_referenced_locally — endpointConfig is consumed once to build the API client; it must be stable
30
59
  const fetcher = createPipelineDataFetcher(() => pipelineId, endpointConfig);
31
60
 
32
61
  $effect(() => {
@@ -88,10 +117,7 @@
88
117
  style="--col-color: {col.color ?? 'var(--fd-muted-foreground)'}"
89
118
  >
90
119
  <div class="pipeline-kanban__column-header">
91
- <Icon
92
- icon={col.icon ?? 'mdi:circle-outline'}
93
- class="pipeline-kanban__col-icon"
94
- />
120
+ <Icon icon={col.icon ?? 'mdi:circle-outline'} class="pipeline-kanban__col-icon" />
95
121
  <span class="pipeline-kanban__col-label">{col.label}</span>
96
122
  <span class="pipeline-kanban__col-count">{items.length}</span>
97
123
  </div>
@@ -104,8 +130,11 @@
104
130
  {#if showStatusPill}
105
131
  <span
106
132
  class="pipeline-kanban__card-status"
107
- style="color: {getStatusTextColor(status)}; background-color: {getStatusBackgroundColor(status)}"
108
- >{getStatusLabel(status)}</span>
133
+ style="color: {getStatusTextColor(
134
+ status
135
+ )}; background-color: {getStatusBackgroundColor(status)}"
136
+ >{getStatusLabel(status)}</span
137
+ >
109
138
  {/if}
110
139
  </div>
111
140
  <span class="pipeline-kanban__card-type">{node.data.metadata.id}</span>
@@ -2,7 +2,7 @@
2
2
  const VIEW_MODE_KEY = 'fd-pipeline-view-mode';
3
3
  const BUILTIN_VIEWS = ['graph', 'kanban', 'table'] as const;
4
4
  // `string & {}` preserves autocomplete for built-in values while still accepting arbitrary strings from extraViews.
5
- type ViewMode = typeof BUILTIN_VIEWS[number] | (string & {});
5
+ type ViewMode = (typeof BUILTIN_VIEWS)[number] | (string & {});
6
6
  </script>
7
7
 
8
8
  <script lang="ts">
@@ -177,6 +177,7 @@
177
177
  class="pipeline-panel__run-popover"
178
178
  bind:this={runPopoverEl}
179
179
  role="menu"
180
+ tabindex="-1"
180
181
  onkeydown={(e) => {
181
182
  if (e.key === 'Escape') {
182
183
  runDropdownOpen = false;
@@ -415,6 +416,9 @@
415
416
  right: 0;
416
417
  z-index: 50;
417
418
  min-width: 160px;
419
+ max-width: 320px;
420
+ max-height: min(60vh, 420px);
421
+ overflow-y: auto;
418
422
  padding: var(--fd-space-xs);
419
423
  background-color: var(--fd-background);
420
424
  border: 1px solid var(--fd-border);
@@ -64,6 +64,7 @@
64
64
  statusData: NodeStatusData | undefined;
65
65
  }
66
66
 
67
+ // svelte-ignore state_referenced_locally — endpointConfig is consumed once to build the API client; it must be stable
67
68
  const fetcher = createPipelineDataFetcher(() => pipelineId, endpointConfig);
68
69
 
69
70
  $effect(() => {
@@ -137,14 +138,24 @@
137
138
  {#if expandable}
138
139
  <Icon
139
140
  icon="mdi:chevron-right"
140
- class="pipeline-table__chevron {expanded ? 'pipeline-table__chevron--open' : ''}"
141
+ class="pipeline-table__chevron {expanded
142
+ ? 'pipeline-table__chevron--open'
143
+ : ''}"
141
144
  />
142
145
  {/if}
143
146
  </td>
144
- <td class="pipeline-table__td pipeline-table__td--label" title={row.node.data.label}>{row.node.data.label}</td>
145
- <td class="pipeline-table__td pipeline-table__td--muted" title={row.node.data.metadata.id}>{row.node.data.metadata.id}</td>
147
+ <td class="pipeline-table__td pipeline-table__td--label" title={row.node.data.label}
148
+ >{row.node.data.label}</td
149
+ >
150
+ <td
151
+ class="pipeline-table__td pipeline-table__td--muted"
152
+ title={row.node.data.metadata.id}>{row.node.data.metadata.id}</td
153
+ >
146
154
  <td class="pipeline-table__td">
147
- <span class="pipeline-table__status" style="color: {getStatusTextColor(row.status)}">
155
+ <span
156
+ class="pipeline-table__status"
157
+ style="color: {getStatusTextColor(row.status)}"
158
+ >
148
159
  <Icon
149
160
  icon={STATUS_ICON[row.status] ?? 'mdi:circle-outline'}
150
161
  class="pipeline-table__status-icon"
@@ -152,7 +163,9 @@
152
163
  {row.status}
153
164
  </span>
154
165
  </td>
155
- <td class="pipeline-table__td pipeline-table__td--id" title={row.node.id}>{row.node.id}</td>
166
+ <td class="pipeline-table__td pipeline-table__td--id" title={row.node.id}
167
+ >{row.node.id}</td
168
+ >
156
169
  </tr>
157
170
  {#if expanded && expandable}
158
171
  <tr class="pipeline-table__detail-row">
@@ -320,7 +333,8 @@
320
333
  }
321
334
 
322
335
  .pipeline-table__detail-cell {
323
- padding: var(--fd-space-sm) var(--fd-space-md) var(--fd-space-sm) calc(1.5rem + var(--fd-space-md));
336
+ padding: var(--fd-space-sm) var(--fd-space-md) var(--fd-space-sm)
337
+ calc(1.5rem + var(--fd-space-md));
324
338
  border-bottom: 1px solid var(--fd-border);
325
339
  }
326
340
 
@@ -33,8 +33,11 @@
33
33
  getError,
34
34
  playgroundActions,
35
35
  applyServerResponse,
36
- getLatestSequenceNumber
36
+ getLatestSequenceNumber,
37
+ getOldestSequenceNumber,
38
+ setHasOlder
37
39
  } from '../../stores/playgroundStore.svelte.js';
40
+ import type { PlaygroundMessagesApiResponse } from '../../types/playground.js';
38
41
  import { interruptActions } from '../../stores/interruptStore.svelte.js';
39
42
  import { logger } from '../../utils/logger.js';
40
43
 
@@ -67,6 +70,11 @@
67
70
  let loadedInitialSessionId = $state<string | undefined>(undefined);
68
71
  let autoRunTriggered = $state(false);
69
72
  let isRefreshing = $state(false);
73
+ // Monotonic token so a slow session load can't overwrite a newer one when the
74
+ // user switches sessions faster than the network responds (last-load wins).
75
+ let loadToken = 0;
76
+
77
+ const messagePageSize = $derived(config.messagePageSize ?? 50);
70
78
 
71
79
  // Vertical resizer state for the ExecutionConsole ↔ ControlPanel split.
72
80
  let playgroundContentEl = $state<HTMLElement | null>(null);
@@ -90,16 +98,15 @@
90
98
  }
91
99
  });
92
100
 
93
- const maxControlPanelHeight = $derived(
94
- containerHeight ? Math.round(containerHeight * 0.6) : 600
95
- );
101
+ const maxControlPanelHeight = $derived(containerHeight ? Math.round(containerHeight * 0.6) : 600);
96
102
 
97
103
  function clampControlPanelHeight(h: number): number {
98
104
  return Math.min(Math.max(h, 140), maxControlPanelHeight);
99
105
  }
100
106
 
101
107
  function handleVerticalResizerPointerDown(e: PointerEvent) {
102
- if (playgroundContentEl) dragContainerBottom = playgroundContentEl.getBoundingClientRect().bottom;
108
+ if (playgroundContentEl)
109
+ dragContainerBottom = playgroundContentEl.getBoundingClientRect().bottom;
103
110
  isVerticalResizing = true;
104
111
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
105
112
  }
@@ -133,8 +140,10 @@
133
140
  const sessionId = getCurrentSession()?.id;
134
141
  if (sessionId) {
135
142
  void playgroundService
136
- .getMessages(sessionId, playgroundService.getLastSequenceNumber() ?? undefined)
137
- .then((response) => applyServerResponse(response))
143
+ .getMessages(sessionId, {
144
+ since: playgroundService.getLastSequenceNumber() ?? undefined
145
+ })
146
+ .then((response) => applyServerResponse(response, sessionId))
138
147
  .catch((err) => logger.error('[Playground] Visibility catchup failed:', err));
139
148
  }
140
149
  }
@@ -231,26 +240,79 @@
231
240
  async function loadSession(sessionId: string): Promise<void> {
232
241
  playgroundActions.setLoading(true);
233
242
  playgroundActions.setError(null);
243
+ const token = ++loadToken;
234
244
 
235
245
  try {
236
246
  const session = await playgroundService.getSession(sessionId);
247
+ if (token !== loadToken) return; // a newer session load superseded us
237
248
  playgroundActions.setCurrentSession(session);
238
249
 
239
- const response = await playgroundService.getMessages(sessionId);
240
- applyServerResponse(response);
250
+ // Load only the most recent page; older messages load on demand when the
251
+ // user scrolls up (loadOlderMessages). Clear right before applying the
252
+ // fresh page — not before the await — so switching sessions doesn't blank
253
+ // the view for the duration of the fetch.
254
+ const response = await playgroundService.getMessages(sessionId, {
255
+ latest: true,
256
+ limit: messagePageSize
257
+ });
258
+ if (token !== loadToken) return;
259
+ playgroundActions.clearMessages();
260
+ applyServerResponse(response, sessionId);
261
+ setHasOlder(deriveHasOlder(response));
241
262
 
242
263
  if (session.status !== 'idle') {
264
+ // Seed polling from the newest loaded message so it tails live updates
265
+ // instead of crawling forward from the start of the conversation.
243
266
  startPolling(sessionId, true);
244
267
  }
245
268
  } catch (err) {
269
+ if (token !== loadToken) return; // don't surface a superseded load's error
246
270
  const errorMessage = err instanceof Error ? err.message : 'Failed to load session';
247
271
  playgroundActions.setError(errorMessage);
248
272
  logger.error('Failed to load session:', err);
249
273
  } finally {
250
- playgroundActions.setLoading(false);
274
+ if (token === loadToken) playgroundActions.setLoading(false);
251
275
  }
252
276
  }
253
277
 
278
+ /**
279
+ * Load the page of messages immediately older than the oldest one currently
280
+ * shown. Triggered by scroll-up in MessageStream, which serializes calls and
281
+ * owns the in-flight/anchoring state. Bypasses applyServerResponse so a
282
+ * historical fetch never disturbs the live polling cursor or pipeline view.
283
+ */
284
+ async function loadOlderMessages(): Promise<void> {
285
+ const sessionId = getCurrentSession()?.id;
286
+ const before = getOldestSequenceNumber();
287
+ if (!sessionId || before === null) return;
288
+
289
+ try {
290
+ const response = await playgroundService.getMessages(sessionId, {
291
+ before,
292
+ limit: messagePageSize
293
+ });
294
+ // The session may have changed while the fetch was in flight — don't
295
+ // splice an old session's page into the new session's store.
296
+ if (getCurrentSession()?.id !== sessionId) return;
297
+ if (response.data && response.data.length > 0) {
298
+ playgroundActions.addMessages(response.data);
299
+ }
300
+ setHasOlder(deriveHasOlder(response));
301
+ } catch (err) {
302
+ logger.error('[Playground] Failed to load older messages:', err);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Whether older messages remain after a backward-pagination response. Prefer
308
+ * the server's explicit `hasOlder` flag; fall back to inferring from page
309
+ * fullness for backends that haven't adopted the field yet.
310
+ */
311
+ function deriveHasOlder(response: PlaygroundMessagesApiResponse): boolean {
312
+ if (typeof response.hasOlder === 'boolean') return response.hasOlder;
313
+ return (response.data?.length ?? 0) >= messagePageSize;
314
+ }
315
+
254
316
  async function handleCreateSession(): Promise<void> {
255
317
  playgroundActions.setLoading(true);
256
318
  playgroundActions.setError(null);
@@ -259,6 +321,11 @@
259
321
  const sessionName = `Session ${getSessions().length + 1}`;
260
322
  const session = await playgroundService.createSession(workflowId, sessionName);
261
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
+
262
329
  if (onSessionNavigate) {
263
330
  onSessionNavigate(session.id);
264
331
  return;
@@ -320,8 +387,10 @@
320
387
  playgroundActions.addMessage(message);
321
388
  // Only start polling if not already active — avoids resetting the cursor
322
389
  // mid-session and re-fetching messages that are already in the store.
390
+ // Seed from the newest loaded message so polling tails live updates
391
+ // rather than crawling forward from the start of the conversation.
323
392
  if (!playgroundService.isPolling()) {
324
- startPolling(sessionId);
393
+ startPolling(sessionId, true);
325
394
  }
326
395
  } catch (err) {
327
396
  const errorMessage = err instanceof Error ? err.message : 'Failed to send message';
@@ -358,7 +427,7 @@
358
427
 
359
428
  playgroundService.startPolling(
360
429
  sessionId,
361
- (response) => applyServerResponse(response),
430
+ (response) => applyServerResponse(response, sessionId),
362
431
  pollingInterval,
363
432
  overrideShouldStopPolling ?? config.shouldStopPolling,
364
433
  initialSequenceNumber
@@ -370,11 +439,10 @@
370
439
  if (!sessionId || isRefreshing) return;
371
440
  isRefreshing = true;
372
441
  try {
373
- const response = await playgroundService.getMessages(
374
- sessionId,
375
- playgroundService.getLastSequenceNumber() ?? undefined
376
- );
377
- applyServerResponse(response);
442
+ const response = await playgroundService.getMessages(sessionId, {
443
+ since: playgroundService.getLastSequenceNumber() ?? undefined
444
+ });
445
+ applyServerResponse(response, sessionId);
378
446
  if (response.sessionStatus === 'running' && !playgroundService.isPolling()) {
379
447
  startPolling(sessionId, true);
380
448
  }
@@ -392,11 +460,10 @@
392
460
  try {
393
461
  // Catch up immediately rather than waiting for the next poll interval.
394
462
  // Use the service's sequence cursor so we only fetch new messages.
395
- const response = await playgroundService.getMessages(
396
- sessionId,
397
- playgroundService.getLastSequenceNumber() ?? undefined
398
- );
399
- applyServerResponse(response);
463
+ const response = await playgroundService.getMessages(sessionId, {
464
+ since: playgroundService.getLastSequenceNumber() ?? undefined
465
+ });
466
+ applyServerResponse(response, sessionId);
400
467
  } catch (err) {
401
468
  logger.error('[Playground] Failed to refresh after interrupt:', err);
402
469
  }
@@ -430,10 +497,7 @@
430
497
  </div>
431
498
  {/if}
432
499
 
433
- <div
434
- class="playground__content"
435
- bind:this={playgroundContentEl}
436
- >
500
+ <div class="playground__content" bind:this={playgroundContentEl}>
437
501
  {#if getIsLoading() && !getCurrentSession()}
438
502
  <div class="playground__loading">
439
503
  <Icon icon="mdi:loading" class="playground__loading-icon" />
@@ -444,11 +508,14 @@
444
508
  showTimestamps={config.showTimestamps ?? true}
445
509
  autoScroll={config.autoScroll ?? true}
446
510
  enableMarkdown={config.enableMarkdown ?? true}
447
- showLogsInline={config.logDisplayMode === 'inline'}
448
511
  onInterruptResolved={handleInterruptResolved}
449
512
  onCreateSession={getSessions().length === 0 ? handleCreateSession : undefined}
513
+ onLoadOlder={loadOlderMessages}
450
514
  />
451
515
 
516
+ <!-- Focusable ARIA splitter: keyboard/pointer handlers drive the resize -->
517
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
518
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
452
519
  <div
453
520
  class="playground__vertical-resizer"
454
521
  class:playground__vertical-resizer--active={isVerticalResizing}
@@ -3,14 +3,17 @@
3
3
  import Icon from '@iconify/svelte';
4
4
  import Playground from './Playground.svelte';
5
5
  import PipelinePanel from './PipelinePanel.svelte';
6
- import { getPipelinePanelOpen, pipelinePanelActions } from '../../stores/pipelinePanelStore.svelte.js';
6
+ import {
7
+ getPipelinePanelOpen,
8
+ pipelinePanelActions
9
+ } from '../../stores/pipelinePanelStore.svelte.js';
7
10
  import {
8
11
  getActiveExecutionId,
9
12
  getPinnedExecutionId,
10
13
  getLatestExecutionId,
11
14
  getPipelineRefreshTrigger,
12
- getCurrentSession,
13
- playgroundActions,
15
+ getSelectableExecutions,
16
+ playgroundActions
14
17
  } from '../../stores/playgroundStore.svelte.js';
15
18
  import { setEndpointConfig, workflowApi } from '../../services/api.js';
16
19
  import { logger } from '../../utils/logger.js';
@@ -57,14 +60,17 @@
57
60
  initialPipelineWidth = 500,
58
61
  onSessionNavigate,
59
62
  onClose,
60
- extraPipelineViews = [],
63
+ extraPipelineViews = []
61
64
  }: Props = $props();
62
65
 
66
+ // svelte-ignore state_referenced_locally — seed mutable state from the prop's initial value; workflow may load asynchronously below
63
67
  let resolvedWorkflow = $state<Workflow | null>(workflowProp ?? null);
68
+ // svelte-ignore state_referenced_locally — initial loading flag derived from whether a workflow was provided up front
64
69
  let workflowLoading = $state(workflowProp === undefined);
65
70
  let workflowError = $state<string | null>(null);
66
71
 
67
72
  let splitEl = $state<HTMLElement | null>(null);
73
+ // svelte-ignore state_referenced_locally — seed mutable width from the initial prop; it changes as the user drags the resizer
68
74
  let pipelineWidth = $state(initialPipelineWidth);
69
75
  let isResizing = $state(false);
70
76
  let containerWidth = $state(0);
@@ -100,7 +106,8 @@
100
106
 
101
107
  async function loadWorkflow(): Promise<void> {
102
108
  if (!endpointConfig) {
103
- workflowError = 'Provide a workflow prop or an endpointConfig so the workflow can be fetched.';
109
+ workflowError =
110
+ 'Provide a workflow prop or an endpointConfig so the workflow can be fetched.';
104
111
  workflowLoading = false;
105
112
  return;
106
113
  }
@@ -149,11 +156,15 @@
149
156
  }
150
157
  </script>
151
158
 
152
- <div class="playground-studio" class:playground-studio--resizing={isResizing} style="--playground-studio-min-chat-width: {minChatWidth}px">
159
+ <div
160
+ class="playground-studio"
161
+ class:playground-studio--resizing={isResizing}
162
+ style="--playground-studio-min-chat-width: {minChatWidth}px"
163
+ >
153
164
  <div class="playground-studio__panes" bind:this={splitEl}>
154
165
  {#if getPipelinePanelOpen() && resolvedWorkflow && endpointConfig}
155
166
  {@const activeId = getActiveExecutionId()}
156
- {@const executions = getCurrentSession()?.executions ?? []}
167
+ {@const executions = getSelectableExecutions()}
157
168
 
158
169
  <div class="playground-studio__pipeline" style="width: {pipelineWidth}px;">
159
170
  <button
@@ -178,6 +189,9 @@
178
189
  />
179
190
  </div>
180
191
 
192
+ <!-- Focusable ARIA splitter: keyboard/pointer handlers drive the resize -->
193
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
194
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
181
195
  <div
182
196
  class="playground-studio__resizer"
183
197
  class:playground-studio__resizer--active={isResizing}
@@ -1,8 +1,15 @@
1
1
  import { EnhancedFlowDropApiClient } from '../../api/enhanced-client.js';
2
2
  import { logger } from '../../utils/logger.js';
3
3
  const KNOWN_STATUSES = new Set([
4
- 'idle', 'pending', 'running', 'paused', 'interrupted',
5
- 'completed', 'skipped', 'failed', 'cancelled'
4
+ 'idle',
5
+ 'pending',
6
+ 'running',
7
+ 'paused',
8
+ 'interrupted',
9
+ 'completed',
10
+ 'skipped',
11
+ 'failed',
12
+ 'cancelled'
6
13
  ]);
7
14
  export function resolveStatus(raw) {
8
15
  if (!raw)
@@ -36,7 +43,7 @@ export function createPipelineDataFetcher(getPipelineId, endpointConfig) {
36
43
  status: info.status,
37
44
  last_executed: info.last_executed,
38
45
  execution_time: info.execution_time,
39
- error: info.error,
46
+ error: info.error
40
47
  };
41
48
  }
42
49
  nodeStatusMap = map;
@@ -48,7 +55,7 @@ export function createPipelineDataFetcher(getPipelineId, endpointConfig) {
48
55
  label: col.label,
49
56
  statuses: col.statuses,
50
57
  icon: col.icon,
51
- color: col.color,
58
+ color: col.color
52
59
  }));
53
60
  }
54
61
  }
@@ -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] ?? [];