@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
@@ -106,10 +106,10 @@ function buildMountedPlayground(svelteApp, workflowId, config, onSessionStatusCh
106
106
  startPolling: () => {
107
107
  const session = getCurrentSession();
108
108
  if (session) {
109
- playgroundService.startPolling(session.id, (response) => applyServerResponse(response), pollingInterval, config.shouldStopPolling, playgroundService.getLastSequenceNumber());
109
+ playgroundService.startPolling(session.id, (response) => applyServerResponse(response, session.id), pollingInterval, config.shouldStopPolling, playgroundService.getLastSequenceNumber());
110
110
  }
111
111
  },
112
- pushMessages: (response) => applyServerResponse(response),
112
+ pushMessages: (response) => applyServerResponse(response, null),
113
113
  reset: () => {
114
114
  playgroundService.stopPolling();
115
115
  playgroundActions.reset();
@@ -70,7 +70,7 @@ export declare function getBuiltinTypes(): string[];
70
70
  * Type for built-in node types.
71
71
  * Use this when you specifically need a built-in type.
72
72
  */
73
- export type BuiltinNodeType = 'workflowNode' | 'simple' | 'square' | 'tool' | 'gateway' | 'note' | 'terminal' | 'idea';
73
+ export type BuiltinNodeType = 'workflowNode' | 'simple' | 'square' | 'atom' | 'tool' | 'gateway' | 'note' | 'terminal' | 'idea';
74
74
  /**
75
75
  * Array of built-in type strings for runtime validation.
76
76
  */
@@ -9,6 +9,7 @@ import { nodeComponentRegistry } from './nodeComponentRegistry.js';
9
9
  import WorkflowNode from '../components/nodes/WorkflowNode.svelte';
10
10
  import SimpleNode from '../components/nodes/SimpleNode.svelte';
11
11
  import SquareNode from '../components/nodes/SquareNode.svelte';
12
+ import AtomNode from '../components/nodes/AtomNode.svelte';
12
13
  import ToolNode from '../components/nodes/ToolNode.svelte';
13
14
  import GatewayNode from '../components/nodes/GatewayNode.svelte';
14
15
  import NotesNode from '../components/nodes/NotesNode.svelte';
@@ -56,6 +57,17 @@ export const BUILTIN_NODE_COMPONENTS = [
56
57
  statusPosition: 'top-right',
57
58
  statusSize: 'sm'
58
59
  },
60
+ {
61
+ type: 'atom',
62
+ displayName: 'Atom (Minimal Value/Transform)',
63
+ description: 'Low-chrome label-only node for constants and inline transforms',
64
+ component: AtomNode,
65
+ icon: 'mdi:circle-small',
66
+ category: 'visual',
67
+ source: FLOWDROP_SOURCE,
68
+ statusPosition: 'top-right',
69
+ statusSize: 'sm'
70
+ },
59
71
  {
60
72
  type: 'tool',
61
73
  displayName: 'Tool (Agent Tool)',
@@ -197,6 +209,7 @@ export const BUILTIN_NODE_TYPES = [
197
209
  'workflowNode',
198
210
  'simple',
199
211
  'square',
212
+ 'atom',
200
213
  'tool',
201
214
  'gateway',
202
215
  'note',
@@ -51,6 +51,46 @@
51
51
  "metadata"
52
52
  ],
53
53
  "$defs": {
54
+ "AtomUIConfig": {
55
+ "type": "object",
56
+ "description": "Display/behaviour settings for minimalist `atom` nodes (e.g. Constant, Cast).\nLives under `extensions.ui.atom`. The atom renderer reads these to decide what\nto show, and `valueTypeKey` drives the bound output port's `dataType` from\nconfig so connection validation matches the type the user picked.\n\nAll fields are optional — an empty object renders a label-only pill using the\nnode's `label`.\n",
57
+ "properties": {
58
+ "valueKey": {
59
+ "type": "string",
60
+ "description": "Config key whose value becomes the node body text.\nFalls back to the node `label` when unset or empty.\n"
61
+ },
62
+ "valueTypeKey": {
63
+ "type": "string",
64
+ "description": "Config key holding the selected value's type (a port dataType id).\nThe bound output port adopts this dataType.\n"
65
+ },
66
+ "outputPortId": {
67
+ "type": "string",
68
+ "description": "Output port id driven by `valueTypeKey`.\nDefaults to the first output port when unset.\n"
69
+ },
70
+ "shape": {
71
+ "type": "string",
72
+ "enum": [
73
+ "pill",
74
+ "rectangle"
75
+ ],
76
+ "default": "pill",
77
+ "description": "Body shape. `pill` (default) is fully rounded; `rectangle` is lightly rounded.\n"
78
+ },
79
+ "prefix": {
80
+ "type": "string",
81
+ "description": "Dimmed affordance rendered before the body (e.g. `\"→ \"` to mark a transform).\nStays visible while the body value ellipsizes. Hidden in the empty state.\n"
82
+ },
83
+ "placeholder": {
84
+ "type": "string",
85
+ "description": "Text shown (dimmed) when the resolved body value is empty/unset."
86
+ },
87
+ "maxWidth": {
88
+ "type": "integer",
89
+ "description": "Max body width in px before the label ellipsizes."
90
+ }
91
+ },
92
+ "additionalProperties": false
93
+ },
54
94
  "AutocompleteConfig": {
55
95
  "type": "object",
56
96
  "description": "Configuration for autocomplete fields that fetch suggestions from a callback URL.\nUsed when format is \"autocomplete\".\n",
@@ -279,6 +319,42 @@
279
319
  "description": "Configuration for autocomplete fields (when format is \"autocomplete\")"
280
320
  }
281
321
  ]
322
+ },
323
+ "readOnly": {
324
+ "type": "boolean",
325
+ "description": "JSON Schema `readOnly` keyword. When true, the field is displayed but\ncannot be edited (rendered in a disabled state).\n"
326
+ },
327
+ "height": {
328
+ "type": "string",
329
+ "description": "Editor height as a CSS value (e.g. `200px`).\nApplies to editor fields: `json`/`code`, `markdown`, and `template`.\nDefaults: `200px` (code), `300px` (markdown), `250px` (template).\n"
330
+ },
331
+ "darkTheme": {
332
+ "type": "boolean",
333
+ "description": "Force the editor's dark theme on or off.\nApplies to `json`/`code` and `template` fields.\nWhen omitted, the editor follows the resolved app theme.\n"
334
+ },
335
+ "autoFormat": {
336
+ "type": "boolean",
337
+ "default": true,
338
+ "description": "Whether to auto-format JSON on blur.\nApplies to `json`/`code` editor fields.\n"
339
+ },
340
+ "showToolbar": {
341
+ "type": "boolean",
342
+ "default": true,
343
+ "description": "Whether to show the editor toolbar.\nApplies to `markdown` editor fields.\n"
344
+ },
345
+ "showStatusBar": {
346
+ "type": "boolean",
347
+ "default": true,
348
+ "description": "Whether to show the editor status bar.\nApplies to `markdown` editor fields.\n"
349
+ },
350
+ "spellChecker": {
351
+ "type": "boolean",
352
+ "default": false,
353
+ "description": "Whether to enable spell checking.\nApplies to `markdown` editor fields.\n"
354
+ },
355
+ "placeholderExample": {
356
+ "type": "string",
357
+ "description": "Example template string shown as a placeholder hint.\nApplies to `template` fields.\n"
282
358
  }
283
359
  },
284
360
  "required": [
@@ -378,15 +454,17 @@
378
454
  },
379
455
  "nodeType": {
380
456
  "type": "string",
381
- "description": "Changes how the node is visually rendered. This allows a single node definition\nto support multiple visual representations.\n\nAvailable built-in types:\n- `default`: Standard workflow node with full details\n- `simple`: Compact layout with minimal chrome\n- `square`: Geometric square layout (icon-only)\n- `tool`: Specialized style for agent tools\n- `gateway`: Branching control flow visualization\n- `terminal`: Start/end/exit node styling\n- `note`: Sticky note style for annotations\n\nThe node's `metadata.supportedTypes` defines which types are allowed.\nIf invalid or missing, falls back to `metadata.type` or \"default\".\n",
457
+ "description": "Changes how the node is visually rendered. This allows a single node definition\nto support multiple visual representations.\n\nAvailable built-in types:\n- `default`: Standard workflow node with full details\n- `simple`: Compact layout with minimal chrome\n- `square`: Geometric square layout (icon-only)\n- `atom`: Minimal label-only pill/rectangle (uses extensions.ui.atom)\n- `tool`: Specialized style for agent tools\n- `gateway`: Branching control flow visualization\n- `terminal`: Start/end/exit node styling\n- `note`: Sticky note style for annotations\n- `idea`: Conceptual idea node for BPMN-like flow diagrams\n\nThe node's `metadata.supportedTypes` defines which types are allowed.\nIf invalid or missing, falls back to `metadata.type` or \"default\".\n",
382
458
  "enum": [
383
459
  "default",
384
460
  "simple",
385
461
  "square",
462
+ "atom",
386
463
  "tool",
387
464
  "gateway",
388
465
  "terminal",
389
- "note"
466
+ "note",
467
+ "idea"
390
468
  ]
391
469
  },
392
470
  "dynamicInputs": {
@@ -641,12 +719,14 @@
641
719
  "note",
642
720
  "simple",
643
721
  "square",
722
+ "atom",
644
723
  "tool",
645
724
  "gateway",
646
725
  "terminal",
726
+ "idea",
647
727
  "default"
648
728
  ],
649
- "description": "Visual rendering type for the node.\n\nBuilt-in types:\n- `note` - Sticky note with markdown support\n- `simple` - Compact layout with header and description\n- `square` - Minimal square node with centered icon\n- `tool` - Specialized node for agent tools\n- `gateway` - Branching control flow with dynamic branches (uses config.branches)\n- `terminal` - Circular node for workflow start/end/exit points\n- `default` - Full-featured workflow node with dynamic port support\n\n## Dynamic Port Support\n\nThe `default` and `gateway` node types support dynamic ports:\n\n- **default**: Supports `config.dynamicInputs` and `config.dynamicOutputs`\n for user-defined input/output handles\n- **gateway**: Supports `config.branches` for conditional branching paths\n\n## UI Extensions\n\nAll node types support `extensions.ui.hideUnconnectedHandles` to control\nvisibility of unconnected ports.\n"
729
+ "description": "Visual rendering type for the node.\n\nBuilt-in types:\n- `note` - Sticky note with markdown support\n- `simple` - Compact layout with header and description\n- `square` - Minimal square node with centered icon\n- `atom` - Minimal label-only pill/rectangle for value/transform nodes (uses extensions.ui.atom)\n- `tool` - Specialized node for agent tools\n- `gateway` - Branching control flow with dynamic branches (uses config.branches)\n- `terminal` - Circular node for workflow start/end/exit points\n- `idea` - Conceptual idea node for BPMN-like flow diagrams\n- `default` - Full-featured workflow node with dynamic port support\n\n## Dynamic Port Support\n\nThe `default` and `gateway` node types support dynamic ports:\n\n- **default**: Supports `config.dynamicInputs` and `config.dynamicOutputs`\n for user-defined input/output handles\n- **gateway**: Supports `config.branches` for conditional branching paths\n\n## UI Extensions\n\nAll node types support `extensions.ui.hideUnconnectedHandles` to control\nvisibility of unconnected ports.\n"
650
730
  },
651
731
  "NodeUIExtensions": {
652
732
  "type": "object",
@@ -656,6 +736,9 @@
656
736
  "type": "boolean",
657
737
  "description": "Show/hide unconnected handles (ports) to reduce visual noise.\nWhen true, only ports with active connections are displayed.\nUseful for nodes with many optional ports.\n"
658
738
  },
739
+ "atom": {
740
+ "$ref": "#/$defs/AtomUIConfig"
741
+ },
659
742
  "style": {
660
743
  "type": "object",
661
744
  "additionalProperties": true,
@@ -7,6 +7,20 @@
7
7
  * @module services/playgroundService
8
8
  */
9
9
  import type { PlaygroundSession, PlaygroundMessage, PlaygroundMessagesApiResponse, PlaygroundSessionStatus } from '../types/playground.js';
10
+ /**
11
+ * Pagination options for {@link PlaygroundService.getMessages}.
12
+ * `since`, `before`, and `latest` are mutually exclusive.
13
+ */
14
+ export interface GetMessagesOptions {
15
+ /** Forward cursor — only messages with sequenceNumber greater than this value */
16
+ since?: number;
17
+ /** Backward cursor — the page of messages immediately older than this sequence number */
18
+ before?: number;
19
+ /** Return the most recent `limit` messages (conversation tail) */
20
+ latest?: boolean;
21
+ /** Maximum number of messages to return */
22
+ limit?: number;
23
+ }
10
24
  /**
11
25
  * Playground Service class
12
26
  *
@@ -75,14 +89,19 @@ export declare class PlaygroundService {
75
89
  */
76
90
  deleteSession(sessionId: string): Promise<void>;
77
91
  /**
78
- * Get messages from a playground session
92
+ * Get messages from a playground session.
93
+ *
94
+ * Three pagination modes (see the OpenAPI spec for the contract):
95
+ * - `since`: forward cursor, returns messages with sequenceNumber > value (polling the live tail)
96
+ * - `before`: backward cursor, returns the page immediately older than the value (scroll-up)
97
+ * - `latest`: returns the most recent `limit` messages (initial load)
98
+ * `since`, `before`, and `latest` are mutually exclusive.
79
99
  *
80
100
  * @param sessionId - The session UUID
81
- * @param afterSequence - Optional sequence number cursor — returns only messages with sequenceNumber > this value
82
- * @param limit - Maximum number of messages to return
101
+ * @param options - Pagination options
83
102
  * @returns Messages and session status
84
103
  */
85
- getMessages(sessionId: string, afterSequence?: number, limit?: number): Promise<PlaygroundMessagesApiResponse>;
104
+ getMessages(sessionId: string, options?: GetMessagesOptions): Promise<PlaygroundMessagesApiResponse>;
86
105
  /**
87
106
  * Send a message to a playground session
88
107
  *
@@ -168,24 +168,35 @@ export class PlaygroundService {
168
168
  // Message Handling
169
169
  // =========================================================================
170
170
  /**
171
- * Get messages from a playground session
171
+ * Get messages from a playground session.
172
+ *
173
+ * Three pagination modes (see the OpenAPI spec for the contract):
174
+ * - `since`: forward cursor, returns messages with sequenceNumber > value (polling the live tail)
175
+ * - `before`: backward cursor, returns the page immediately older than the value (scroll-up)
176
+ * - `latest`: returns the most recent `limit` messages (initial load)
177
+ * `since`, `before`, and `latest` are mutually exclusive.
172
178
  *
173
179
  * @param sessionId - The session UUID
174
- * @param afterSequence - Optional sequence number cursor — returns only messages with sequenceNumber > this value
175
- * @param limit - Maximum number of messages to return
180
+ * @param options - Pagination options
176
181
  * @returns Messages and session status
177
182
  */
178
- async getMessages(sessionId, afterSequence, limit) {
183
+ async getMessages(sessionId, options = {}) {
179
184
  const config = this.getConfig();
180
185
  let url = buildEndpointUrl(config, config.endpoints.playground.getMessages, {
181
186
  sessionId
182
187
  });
183
188
  const params = new URLSearchParams();
184
- if (afterSequence !== undefined) {
185
- params.append('since', afterSequence.toString());
189
+ if (options.since !== undefined) {
190
+ params.append('since', options.since.toString());
191
+ }
192
+ if (options.before !== undefined) {
193
+ params.append('before', options.before.toString());
186
194
  }
187
- if (limit !== undefined) {
188
- params.append('limit', limit.toString());
195
+ if (options.latest) {
196
+ params.append('latest', 'true');
197
+ }
198
+ if (options.limit !== undefined) {
199
+ params.append('limit', options.limit.toString());
189
200
  }
190
201
  const queryString = params.toString();
191
202
  if (queryString) {
@@ -257,7 +268,9 @@ export class PlaygroundService {
257
268
  return;
258
269
  }
259
270
  try {
260
- const response = await this.getMessages(sessionId, this.lastSequenceNumber ?? undefined);
271
+ const response = await this.getMessages(sessionId, {
272
+ since: this.lastSequenceNumber ?? undefined
273
+ });
261
274
  // Update last sequence number cursor
262
275
  if (response.data && response.data.length > 0) {
263
276
  const lastMessage = response.data[response.data.length - 1];
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module stores/playgroundStore
8
8
  */
9
- import type { PlaygroundSession, PlaygroundMessage, PlaygroundInputField, PlaygroundSessionStatus, PlaygroundMessagesApiResponse } from '../types/playground.js';
9
+ import type { PlaygroundSession, PlaygroundMessage, PlaygroundInputField, PlaygroundSessionStatus, PlaygroundMessagesApiResponse, PlaygroundExecution } from '../types/playground.js';
10
10
  import type { Workflow } from '../types/index.js';
11
11
  /**
12
12
  * Get the current session
@@ -83,6 +83,11 @@ export declare function getSessionCount(): number;
83
83
  export declare function getPinnedExecutionId(): string | null;
84
84
  export declare function getLatestExecutionId(): string | null;
85
85
  export declare function getActiveExecutionId(): string | null;
86
+ /**
87
+ * Main pipeline runs for the run-switcher. Excludes sub-flow runs, which can't
88
+ * render their own graph and so aren't user-selectable.
89
+ */
90
+ export declare function getSelectableExecutions(): PlaygroundExecution[];
86
91
  /**
87
92
  * Counter that increments whenever new messages arrive and the pipeline display
88
93
  * should re-fetch — i.e. when following latest or pinned to the latest execution.
@@ -194,8 +199,14 @@ export declare const playgroundActions: {
194
199
  * Apply a server response to the store. All message and status updates from
195
200
  * the server flow through here — polling callback, manual fetches, interrupt
196
201
  * resolution. Nothing updates messages or session status except this function.
202
+ *
203
+ * Pass `sessionId` (the session the response was fetched for) so a response
204
+ * that resolves after the user switched sessions is dropped instead of writing
205
+ * the old session's status/messages onto the new current session. Pass `null`
206
+ * to deliberately opt out of the guard (non-session-scoped callers only) — the
207
+ * argument is required so every new caller has to make that choice explicitly.
197
208
  */
198
- export declare function applyServerResponse(response: PlaygroundMessagesApiResponse): void;
209
+ export declare function applyServerResponse(response: PlaygroundMessagesApiResponse, sessionId: string | null): void;
199
210
  /**
200
211
  * Get the current session ID
201
212
  *
@@ -221,6 +232,22 @@ export declare function getMessagesSnapshot(): PlaygroundMessage[];
221
232
  * @returns Sequence number of the last message, or null
222
233
  */
223
234
  export declare function getLatestSequenceNumber(): number | null;
235
+ /**
236
+ * Get the sequence number of the oldest loaded message, used as the cursor
237
+ * for backward "load older" pagination.
238
+ *
239
+ * @returns Sequence number of the first message, or null
240
+ */
241
+ export declare function getOldestSequenceNumber(): number | null;
242
+ /**
243
+ * Whether older messages exist before the oldest one currently loaded.
244
+ */
245
+ export declare function getHasOlder(): boolean;
246
+ /**
247
+ * Set whether older messages remain to be loaded, derived from a
248
+ * backward-pagination response.
249
+ */
250
+ export declare function setHasOlder(hasOlder: boolean): void;
224
251
  /**
225
252
  * Subscribe to session status changes using $effect.root.
226
253
  * This is designed for use in non-component contexts (e.g., mount.ts).
@@ -23,6 +23,12 @@ let _sessions = $state([]);
23
23
  * Messages in the current session
24
24
  */
25
25
  let _messages = $state([]);
26
+ /**
27
+ * Whether older messages exist before the oldest one currently loaded.
28
+ * Drives the scroll-up "load older" affordance. Reset whenever the message
29
+ * set is replaced (session switch / clear).
30
+ */
31
+ let _hasOlder = $state(false);
26
32
  /**
27
33
  * Whether we are currently loading data
28
34
  */
@@ -45,8 +51,19 @@ let _pinnedExecutionId = $state(null);
45
51
  let _pipelineRefreshTrigger = $state(0);
46
52
  /** Whether log messages are visible in the execution console */
47
53
  let _showLogs = $state(true);
48
- /** Latest execution ID derived from current session's executions list */
49
- const _latestExecutionId = $derived(_currentSession?.executions?.at(-1)?.id ?? null);
54
+ /**
55
+ * The main pipeline runs — the single source of truth for "what's selectable".
56
+ * Sub-flow runs are tracked for classification but excluded here: selecting one
57
+ * can't show its own graph (the panel renders the main pipeline regardless), so
58
+ * listing them would be dead UI. Feeds both the run-switcher and "latest".
59
+ */
60
+ const _selectableExecutions = $derived((_currentSession?.executions ?? []).filter((e) => !e.isSubflow));
61
+ /**
62
+ * Latest execution ID: the most recent main run, so the sidebar keeps the main
63
+ * pipeline in focus and never auto-follows a sub-flow. Null when no main run is
64
+ * known yet — better an empty panel for a poll than the wrong graph.
65
+ */
66
+ const _latestExecutionId = $derived(_selectableExecutions.at(-1)?.id ?? null);
50
67
  /** Active execution: pinned if set, otherwise latest */
51
68
  const _activeExecutionId = $derived(_pinnedExecutionId ?? _latestExecutionId);
52
69
  // Derived from server status — never manually set.
@@ -231,6 +248,13 @@ export function getLatestExecutionId() {
231
248
  export function getActiveExecutionId() {
232
249
  return _activeExecutionId;
233
250
  }
251
+ /**
252
+ * Main pipeline runs for the run-switcher. Excludes sub-flow runs, which can't
253
+ * render their own graph and so aren't user-selectable.
254
+ */
255
+ export function getSelectableExecutions() {
256
+ return _selectableExecutions;
257
+ }
234
258
  /**
235
259
  * Counter that increments whenever new messages arrive and the pipeline display
236
260
  * should re-fetch — i.e. when following latest or pinned to the latest execution.
@@ -279,33 +303,57 @@ function sortMessagesChronologically(messageList) {
279
303
  return a.id.localeCompare(b.id);
280
304
  });
281
305
  }
306
+ /**
307
+ * Whether a message was produced by a nested sub-flow (vs the main pipeline).
308
+ * `parentPipelineId` is the authoritative signal — non-null means a parent run
309
+ * triggered this one. Legacy runs that predate the field carry no nesting info,
310
+ * so they're treated as main runs (by design — we don't reclassify history).
311
+ */
312
+ function isSubflowMessage(msg) {
313
+ return msg.parentPipelineId != null;
314
+ }
282
315
  /**
283
316
  * Syncs the current session's executions list from incoming messages.
284
- * When a message has a new executionId not yet tracked, adds it as a new execution entry.
317
+ *
318
+ * Each message's `executionId` identifies the run that produced it, and
319
+ * `parentPipelineId` says whether that run is the main pipeline or a nested
320
+ * sub-flow (see {@link isSubflowMessage}). A run's classification is fixed by
321
+ * its first sighting — every message from a run reports the same parent — so we
322
+ * only act on executionIds we haven't seen before. Sub-flows are tracked but
323
+ * hidden from the run-switcher; a new *main* run clears the pin so the panel
324
+ * auto-follows it, while sub-flow runs never take focus.
325
+ *
326
+ * Executions are appended in arrival order; new runs land at the tail, which is
327
+ * why "latest" reads the last main run.
285
328
  */
286
329
  function syncExecutionsFromMessages(messages) {
287
330
  if (!_currentSession)
288
331
  return;
289
- const existingIds = new Set((_currentSession.executions ?? []).map((e) => e.id));
290
- const newExecutions = [];
332
+ const executions = [...(_currentSession.executions ?? [])];
333
+ const seenIds = new Set(executions.map((e) => e.id));
334
+ let added = false;
335
+ let gainedMainRun = false;
291
336
  for (const msg of messages) {
292
- if (msg.executionId && !existingIds.has(msg.executionId)) {
293
- existingIds.add(msg.executionId);
294
- newExecutions.push({
295
- id: msg.executionId,
296
- startedAt: msg.timestamp,
297
- status: 'running'
298
- });
299
- }
337
+ if (!msg.executionId || seenIds.has(msg.executionId))
338
+ continue;
339
+ seenIds.add(msg.executionId);
340
+ const isSubflow = isSubflowMessage(msg);
341
+ executions.push({
342
+ id: msg.executionId,
343
+ startedAt: msg.timestamp,
344
+ status: 'running',
345
+ isSubflow
346
+ });
347
+ added = true;
348
+ if (!isSubflow)
349
+ gainedMainRun = true;
300
350
  }
301
- if (newExecutions.length > 0) {
302
- _currentSession = {
303
- ..._currentSession,
304
- executions: [...(_currentSession.executions ?? []), ...newExecutions]
305
- };
306
- // Clear any manual pin so the panel automatically follows the new run.
351
+ if (!added)
352
+ return;
353
+ _currentSession = { ..._currentSession, executions };
354
+ // Auto-follow the new main run by dropping any manual pin.
355
+ if (gainedMainRun)
307
356
  _pinnedExecutionId = null;
308
- }
309
357
  }
310
358
  // =========================================================================
311
359
  // Actions
@@ -348,22 +396,23 @@ export const playgroundActions = {
348
396
  updatedAt: new Date().toISOString()
349
397
  };
350
398
  }
351
- // Update the latest execution status when the session reaches a terminal state.
352
- // Only the last execution can be running at any time (sessions are single-pipeline),
353
- // so we only need to check and update the tail entry.
399
+ // When the session reaches a terminal state, the whole run is finished
400
+ // including any sub-flow executions, which may sit anywhere in the list
401
+ // (not just the tail), so mark every still-running execution terminal.
354
402
  // 'idle' means the run finished normally (server returns 'idle' post-completion,
355
- // not 'completed'), so map it to 'completed' for the execution entry.
403
+ // not 'completed'), so map it to 'completed' for the execution entries.
356
404
  const terminalExecutionStatus = status === 'failed'
357
405
  ? 'failed'
358
406
  : status === 'completed' || status === 'idle'
359
407
  ? 'completed'
360
408
  : null;
361
409
  if (terminalExecutionStatus && _currentSession?.executions?.length) {
362
- const execs = [..._currentSession.executions];
363
- const last = execs[execs.length - 1];
364
- if (last.status === 'running') {
365
- execs[execs.length - 1] = { ...last, status: terminalExecutionStatus };
366
- _currentSession = { ..._currentSession, executions: execs };
410
+ const hasRunning = _currentSession.executions.some((e) => e.status === 'running');
411
+ if (hasRunning) {
412
+ _currentSession = {
413
+ ..._currentSession,
414
+ executions: _currentSession.executions.map((e) => e.status === 'running' ? { ...e, status: terminalExecutionStatus } : e)
415
+ };
367
416
  }
368
417
  }
369
418
  // Also update in sessions list
@@ -417,7 +466,7 @@ export const playgroundActions = {
417
466
  * @param message - The message to add
418
467
  */
419
468
  addMessage: (message) => {
420
- if (_messages.some(m => m.id === message.id))
469
+ if (_messages.some((m) => m.id === message.id))
421
470
  return;
422
471
  const seq = message.sequenceNumber ?? 0;
423
472
  let lo = 0, hi = _messages.length;
@@ -460,6 +509,7 @@ export const playgroundActions = {
460
509
  clearMessages: () => {
461
510
  _messages = [];
462
511
  _lastPollSequenceNumber = null;
512
+ _hasOlder = false;
463
513
  },
464
514
  /**
465
515
  * Set the loading state
@@ -529,12 +579,20 @@ export const playgroundActions = {
529
579
  * Apply a server response to the store. All message and status updates from
530
580
  * the server flow through here — polling callback, manual fetches, interrupt
531
581
  * resolution. Nothing updates messages or session status except this function.
532
- */
533
- export function applyServerResponse(response) {
582
+ *
583
+ * Pass `sessionId` (the session the response was fetched for) so a response
584
+ * that resolves after the user switched sessions is dropped instead of writing
585
+ * the old session's status/messages onto the new current session. Pass `null`
586
+ * to deliberately opt out of the guard (non-session-scoped callers only) — the
587
+ * argument is required so every new caller has to make that choice explicitly.
588
+ */
589
+ export function applyServerResponse(response, sessionId) {
590
+ if (sessionId !== null && _currentSession?.id !== sessionId)
591
+ return;
534
592
  if (response.data && response.data.length > 0) {
535
593
  playgroundActions.addMessages(response.data);
536
- // Refresh pipeline when following latest or pinned to the latest execution.
537
- // Skip only when the user is viewing a historical run.
594
+ // Refresh the pipeline panel when following latest or pinned to the latest
595
+ // run. Skip when pinned to an older run — a historical view that won't change.
538
596
  if (_pinnedExecutionId === null || _pinnedExecutionId === _latestExecutionId) {
539
597
  _pipelineRefreshTrigger++;
540
598
  }
@@ -584,6 +642,33 @@ export function getLatestSequenceNumber() {
584
642
  }
585
643
  return null;
586
644
  }
645
+ /**
646
+ * Get the sequence number of the oldest loaded message, used as the cursor
647
+ * for backward "load older" pagination.
648
+ *
649
+ * @returns Sequence number of the first message, or null
650
+ */
651
+ export function getOldestSequenceNumber() {
652
+ for (let i = 0; i < _messages.length; i++) {
653
+ if (_messages[i].sequenceNumber !== undefined) {
654
+ return _messages[i].sequenceNumber;
655
+ }
656
+ }
657
+ return null;
658
+ }
659
+ /**
660
+ * Whether older messages exist before the oldest one currently loaded.
661
+ */
662
+ export function getHasOlder() {
663
+ return _hasOlder;
664
+ }
665
+ /**
666
+ * Set whether older messages remain to be loaded, derived from a
667
+ * backward-pagination response.
668
+ */
669
+ export function setHasOlder(hasOlder) {
670
+ _hasOlder = hasOlder;
671
+ }
587
672
  /**
588
673
  * Subscribe to session status changes using $effect.root.
589
674
  * This is designed for use in non-component contexts (e.g., mount.ts).
@@ -619,7 +704,7 @@ export async function refreshSessionMessages(fetchMessages) {
619
704
  return;
620
705
  try {
621
706
  const response = await fetchMessages(session.id);
622
- applyServerResponse(response);
707
+ applyServerResponse(response, session.id);
623
708
  }
624
709
  catch (err) {
625
710
  logger.error('[playgroundStore] Failed to refresh messages:', err);