@flowdrop/flowdrop 1.15.0 → 2.0.0-beta.1

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 (215) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/MIGRATION-2.0.md +472 -0
  3. package/README.md +23 -23
  4. package/dist/adapters/WorkflowAdapter.d.ts +1 -1
  5. package/dist/adapters/WorkflowAdapter.js +14 -8
  6. package/dist/adapters/agentspec/AgentSpecAdapter.js +7 -7
  7. package/dist/chat/batchFeedback.d.ts +39 -0
  8. package/dist/chat/batchFeedback.js +51 -0
  9. package/dist/commands/executor.js +15 -1
  10. package/dist/commands/storeIntegration.svelte.d.ts +4 -1
  11. package/dist/commands/storeIntegration.svelte.js +26 -21
  12. package/dist/commands/types.d.ts +2 -0
  13. package/dist/components/App.svelte +162 -192
  14. package/dist/components/App.svelte.d.ts +47 -8
  15. package/dist/components/ConfigForm.svelte +71 -47
  16. package/dist/components/ConfigModal.svelte +7 -2
  17. package/dist/components/ConnectionLine.svelte +4 -2
  18. package/dist/components/Navbar.svelte +61 -1
  19. package/dist/components/NodeSidebar.svelte +27 -45
  20. package/dist/components/NodeStatusOverlay.svelte +94 -6
  21. package/dist/components/NodeSwapPicker.svelte +10 -8
  22. package/dist/components/PipelineStatus.svelte +16 -67
  23. package/dist/components/PortCoordinateTracker.svelte +5 -6
  24. package/dist/components/SchemaForm.stories.svelte +1 -3
  25. package/dist/components/SchemaForm.svelte +18 -25
  26. package/dist/components/SchemaForm.svelte.d.ts +0 -8
  27. package/dist/components/SettingsModal.svelte +8 -3
  28. package/dist/components/SettingsPanel.svelte +20 -4
  29. package/dist/components/SwapMappingEditor.svelte +67 -49
  30. package/dist/components/SwapMappingEditor.svelte.d.ts +0 -2
  31. package/dist/components/UniversalNode.svelte +9 -7
  32. package/dist/components/WorkflowEditor.svelte +118 -111
  33. package/dist/components/WorkflowEditor.svelte.d.ts +18 -10
  34. package/dist/components/chat/AIChatPanel.svelte +93 -89
  35. package/dist/components/chat/AIChatPanel.svelte.d.ts +0 -4
  36. package/dist/components/chat/CommandPreview.svelte +2 -1
  37. package/dist/components/console/CommandConsole.svelte +7 -5
  38. package/dist/components/console/ConsoleAutocomplete.svelte +10 -11
  39. package/dist/components/console/ConsoleAutocomplete.svelte.d.ts +6 -0
  40. package/dist/components/console/ConsoleInput.svelte +15 -6
  41. package/dist/components/console/ConsoleOutput.svelte +2 -1
  42. package/dist/components/form/FormArray.svelte +5 -9
  43. package/dist/components/form/FormArray.svelte.d.ts +2 -1
  44. package/dist/components/form/FormAutocomplete.svelte +8 -6
  45. package/dist/components/form/FormField.svelte +4 -2
  46. package/dist/components/form/FormFieldLight.svelte +4 -2
  47. package/dist/components/form/FormMarkdownEditor.svelte +9 -4
  48. package/dist/components/form/FormRangeField.svelte +1 -0
  49. package/dist/components/form/FormTemplateEditor.svelte +11 -3
  50. package/dist/components/form/FormToggle.svelte +5 -12
  51. package/dist/components/form/FormToggle.svelte.d.ts +4 -2
  52. package/dist/components/form/templateAutocomplete.js +1 -5
  53. package/dist/components/form/types.d.ts +1 -14
  54. package/dist/components/interrupt/FormPrompt.svelte +3 -2
  55. package/dist/components/interrupt/InterruptBubble.svelte +16 -17
  56. package/dist/components/interrupt/ReviewPrompt.svelte +10 -3
  57. package/dist/components/interrupt/TextInputPrompt.svelte +2 -1
  58. package/dist/components/layouts/MainLayout.svelte +20 -13
  59. package/dist/components/layouts/MainLayout.svelte.d.ts +4 -0
  60. package/dist/components/nodes/AtomNode.svelte +17 -5
  61. package/dist/components/nodes/GatewayNode.svelte +19 -10
  62. package/dist/components/nodes/IdeaNode.svelte +7 -0
  63. package/dist/components/nodes/SimpleNode.svelte +11 -6
  64. package/dist/components/nodes/SquareNode.svelte +15 -8
  65. package/dist/components/nodes/TerminalNode.svelte +9 -4
  66. package/dist/components/nodes/ToolNode.svelte +7 -1
  67. package/dist/components/nodes/WorkflowNode.svelte +16 -7
  68. package/dist/components/playground/ChatInput.svelte +11 -14
  69. package/dist/components/playground/ChatPanel.svelte +6 -49
  70. package/dist/components/playground/ChatPanel.svelte.d.ts +0 -14
  71. package/dist/components/playground/ControlPanel.svelte +134 -123
  72. package/dist/components/playground/ControlPanel.svelte.d.ts +3 -0
  73. package/dist/components/playground/ExecutionLogs.svelte +11 -9
  74. package/dist/components/playground/InputCollector.svelte +11 -9
  75. package/dist/components/playground/MessageStream.svelte +17 -23
  76. package/dist/components/playground/PipelineKanbanView.svelte +65 -6
  77. package/dist/components/playground/PipelinePanel.svelte +11 -5
  78. package/dist/components/playground/PipelineTableView.svelte +186 -44
  79. package/dist/components/playground/Playground.svelte +90 -92
  80. package/dist/components/playground/Playground.svelte.d.ts +2 -0
  81. package/dist/components/playground/PlaygroundApp.svelte +6 -1
  82. package/dist/components/playground/PlaygroundApp.svelte.d.ts +3 -0
  83. package/dist/components/playground/PlaygroundModal.svelte +13 -3
  84. package/dist/components/playground/PlaygroundModal.svelte.d.ts +3 -0
  85. package/dist/components/playground/PlaygroundStudio.svelte +34 -32
  86. package/dist/components/playground/PlaygroundStudio.svelte.d.ts +3 -0
  87. package/dist/components/playground/SessionManager.svelte +9 -12
  88. package/dist/components/playground/pipelineViewUtils.svelte.d.ts +28 -0
  89. package/dist/components/playground/pipelineViewUtils.svelte.js +38 -1
  90. package/dist/config/endpoints.d.ts +0 -7
  91. package/dist/config/endpoints.js +2 -10
  92. package/dist/core/index.d.ts +4 -4
  93. package/dist/core/index.js +6 -6
  94. package/dist/display/index.d.ts +0 -2
  95. package/dist/display/index.js +0 -6
  96. package/dist/editor/index.d.ts +19 -20
  97. package/dist/editor/index.js +25 -35
  98. package/dist/form/code.d.ts +25 -15
  99. package/dist/form/code.js +44 -41
  100. package/dist/form/fieldRegistry.d.ts +17 -13
  101. package/dist/form/fieldRegistry.js +32 -12
  102. package/dist/form/full.d.ts +17 -13
  103. package/dist/form/full.js +22 -27
  104. package/dist/form/index.d.ts +3 -3
  105. package/dist/form/index.js +3 -3
  106. package/dist/form/markdown.d.ts +13 -8
  107. package/dist/form/markdown.js +22 -23
  108. package/dist/helpers/proximityConnect.d.ts +3 -2
  109. package/dist/helpers/proximityConnect.js +2 -5
  110. package/dist/helpers/workflowEditorHelper.d.ts +12 -5
  111. package/dist/helpers/workflowEditorHelper.js +27 -25
  112. package/dist/index.d.ts +28 -24
  113. package/dist/index.js +27 -50
  114. package/dist/messages/defaults.d.ts +2 -5
  115. package/dist/messages/defaults.js +3 -6
  116. package/dist/messages/index.d.ts +0 -1
  117. package/dist/messages/index.js +0 -1
  118. package/dist/mocks/app-forms.d.ts +6 -2
  119. package/dist/mocks/app-forms.js +11 -4
  120. package/dist/openapi/v1/openapi.yaml +3 -3
  121. package/dist/playground/index.d.ts +2 -3
  122. package/dist/playground/index.js +2 -30
  123. package/dist/playground/mount.d.ts +15 -0
  124. package/dist/playground/mount.js +46 -20
  125. package/dist/registry/{BaseRegistry.d.ts → BaseRegistry.svelte.d.ts} +22 -1
  126. package/dist/registry/{BaseRegistry.js → BaseRegistry.svelte.js} +37 -1
  127. package/dist/registry/builtinFormats.d.ts +9 -18
  128. package/dist/registry/builtinFormats.js +9 -39
  129. package/dist/registry/builtinNodes.d.ts +0 -25
  130. package/dist/registry/builtinNodes.js +1 -50
  131. package/dist/registry/index.d.ts +3 -4
  132. package/dist/registry/index.js +4 -6
  133. package/dist/registry/nodeComponentRegistry.d.ts +182 -15
  134. package/dist/registry/nodeComponentRegistry.js +235 -17
  135. package/dist/registry/workflowFormatRegistry.d.ts +14 -9
  136. package/dist/registry/workflowFormatRegistry.js +24 -8
  137. package/dist/{schema → schemas}/index.d.ts +2 -2
  138. package/dist/{schema → schemas}/index.js +2 -2
  139. package/dist/schemas/v1/workflow.schema.json +3 -3
  140. package/dist/services/agentSpecExecutionService.js +0 -1
  141. package/dist/services/apiVariableService.d.ts +2 -1
  142. package/dist/services/apiVariableService.js +5 -22
  143. package/dist/services/autoSaveService.d.ts +7 -0
  144. package/dist/services/autoSaveService.js +6 -4
  145. package/dist/services/chatService.d.ts +8 -4
  146. package/dist/services/chatService.js +15 -15
  147. package/dist/services/draftStorage.d.ts +129 -13
  148. package/dist/services/draftStorage.js +185 -37
  149. package/dist/services/dynamicSchemaService.d.ts +2 -1
  150. package/dist/services/dynamicSchemaService.js +5 -22
  151. package/dist/services/globalSave.d.ts +13 -12
  152. package/dist/services/globalSave.js +29 -51
  153. package/dist/services/historyService.d.ts +9 -3
  154. package/dist/services/historyService.js +9 -3
  155. package/dist/services/interruptService.d.ts +14 -9
  156. package/dist/services/interruptService.js +27 -27
  157. package/dist/services/nodeExecutionService.d.ts +18 -3
  158. package/dist/services/nodeExecutionService.js +71 -45
  159. package/dist/services/playgroundService.d.ts +14 -9
  160. package/dist/services/playgroundService.js +31 -30
  161. package/dist/services/variableService.d.ts +2 -1
  162. package/dist/services/variableService.js +2 -2
  163. package/dist/services/workflowStorage.js +6 -6
  164. package/dist/stores/apiContext.d.ts +45 -0
  165. package/dist/stores/apiContext.js +65 -0
  166. package/dist/stores/categoriesStore.svelte.d.ts +28 -23
  167. package/dist/stores/categoriesStore.svelte.js +70 -64
  168. package/dist/stores/getInstance.svelte.d.ts +39 -0
  169. package/dist/stores/getInstance.svelte.js +65 -0
  170. package/dist/stores/historyStore.svelte.d.ts +77 -93
  171. package/dist/stores/historyStore.svelte.js +134 -160
  172. package/dist/stores/instanceContainer.svelte.d.ts +111 -0
  173. package/dist/stores/instanceContainer.svelte.js +114 -0
  174. package/dist/stores/interruptStore.svelte.d.ts +112 -82
  175. package/dist/stores/interruptStore.svelte.js +253 -226
  176. package/dist/stores/pipelinePanelStore.svelte.d.ts +27 -3
  177. package/dist/stores/pipelinePanelStore.svelte.js +61 -14
  178. package/dist/stores/playgroundStore.svelte.d.ts +169 -222
  179. package/dist/stores/playgroundStore.svelte.js +515 -580
  180. package/dist/stores/portCoordinateStore.svelte.d.ts +57 -51
  181. package/dist/stores/portCoordinateStore.svelte.js +109 -98
  182. package/dist/stores/settingsStore.svelte.d.ts +4 -1
  183. package/dist/stores/settingsStore.svelte.js +47 -12
  184. package/dist/stores/workflowStore.svelte.d.ts +178 -213
  185. package/dist/stores/workflowStore.svelte.js +449 -501
  186. package/dist/stories/EdgeDecorator.svelte +5 -2
  187. package/dist/stories/NodeDecorator.svelte +5 -3
  188. package/dist/svelte-app.d.ts +60 -10
  189. package/dist/svelte-app.js +157 -53
  190. package/dist/types/events.d.ts +6 -3
  191. package/dist/types/index.d.ts +33 -3
  192. package/dist/types/navbar.d.ts +7 -0
  193. package/dist/types/playground.d.ts +18 -3
  194. package/dist/types/settings.d.ts +13 -0
  195. package/dist/types/settings.js +1 -0
  196. package/dist/utils/colors.d.ts +47 -21
  197. package/dist/utils/colors.js +69 -68
  198. package/dist/utils/connections.d.ts +9 -15
  199. package/dist/utils/connections.js +13 -32
  200. package/dist/utils/duration.d.ts +13 -0
  201. package/dist/utils/duration.js +45 -0
  202. package/dist/utils/icons.d.ts +5 -2
  203. package/dist/utils/icons.js +6 -5
  204. package/dist/utils/nodeSwap.d.ts +6 -2
  205. package/dist/utils/nodeSwap.js +62 -126
  206. package/dist/utils/nodeTypes.d.ts +17 -8
  207. package/dist/utils/nodeTypes.js +26 -19
  208. package/dist/utils/performanceUtils.js +7 -0
  209. package/package.json +6 -5
  210. package/dist/messages/deprecation.d.ts +0 -20
  211. package/dist/messages/deprecation.js +0 -33
  212. package/dist/registry/plugin.d.ts +0 -215
  213. package/dist/registry/plugin.js +0 -249
  214. package/dist/services/api.d.ts +0 -129
  215. package/dist/services/api.js +0 -217
@@ -4,273 +4,16 @@
4
4
  * Svelte 5 rune-based state for managing playground state including sessions,
5
5
  * messages, and execution status.
6
6
  *
7
+ * The reactive state lives in the {@link PlaygroundStore} class — one per
8
+ * FlowDrop instance, created by `createFlowDropInstance()` and resolved in
9
+ * components via `getInstance().playground`.
10
+ *
7
11
  * @module stores/playgroundStore
8
12
  */
9
13
  import { isChatInputNode } from '../types/playground.js';
10
14
  import { logger } from '../utils/logger.js';
11
15
  // =========================================================================
12
- // Core State
13
- // =========================================================================
14
- /**
15
- * Currently active playground session
16
- */
17
- let _currentSession = $state(null);
18
- /**
19
- * List of all sessions for the current workflow
20
- */
21
- let _sessions = $state([]);
22
- /**
23
- * Messages in the current session
24
- */
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);
32
- /**
33
- * Whether we are currently loading data
34
- */
35
- let _isLoading = $state(false);
36
- /**
37
- * Current error message, if any
38
- */
39
- let _error = $state(null);
40
- /**
41
- * Current workflow being tested
42
- */
43
- let _currentWorkflow = $state(null);
44
- /**
45
- * Last polling timestamp for incremental message fetching
46
- */
47
- let _lastPollSequenceNumber = $state(null);
48
- /** Execution ID explicitly pinned by the user (null = follow latest) */
49
- let _pinnedExecutionId = $state(null);
50
- /** Incremented on every message batch that should trigger a pipeline re-fetch */
51
- let _pipelineRefreshTrigger = $state(0);
52
- /** Whether log messages are visible in the execution console */
53
- let _showLogs = $state(true);
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);
67
- /** Active execution: pinned if set, otherwise latest */
68
- const _activeExecutionId = $derived(_pinnedExecutionId ?? _latestExecutionId);
69
- // Derived from server status — never manually set.
70
- // Exception: updateSessionStatus('running') in handleSendMessage is an
71
- // acknowledged optimistic write, overwritten by the next server response.
72
- const _isExecuting = $derived(_currentSession?.status === 'running');
73
- // =========================================================================
74
- // Getter Functions (for reactive access in components)
75
- // =========================================================================
76
- /**
77
- * Get the current session
78
- */
79
- export function getCurrentSession() {
80
- return _currentSession;
81
- }
82
- /**
83
- * Get all sessions
84
- */
85
- export function getSessions() {
86
- return _sessions;
87
- }
88
- /**
89
- * Get all messages
90
- */
91
- export function getMessages() {
92
- return _messages;
93
- }
94
- /**
95
- * Get executing state
96
- */
97
- export function getIsExecuting() {
98
- return _isExecuting;
99
- }
100
- /**
101
- * Get loading state
102
- */
103
- export function getIsLoading() {
104
- return _isLoading;
105
- }
106
- /**
107
- * Get error state
108
- */
109
- export function getError() {
110
- return _error;
111
- }
112
- /**
113
- * Get the current workflow
114
- */
115
- export function getCurrentWorkflow() {
116
- return _currentWorkflow;
117
- }
118
- /**
119
- * Get the last poll sequence number cursor
120
- */
121
- export function getLastPollSequenceNumber() {
122
- return _lastPollSequenceNumber;
123
- }
124
- // =========================================================================
125
- // Derived Getters
126
- // =========================================================================
127
- /**
128
- * Get current session status
129
- */
130
- export function getSessionStatus() {
131
- return _currentSession?.status ?? 'idle';
132
- }
133
- /**
134
- * Whether the user can currently send a message.
135
- * False when executing, when awaiting input, or when no session exists.
136
- */
137
- export function getCanSendMessage() {
138
- const status = _currentSession?.status ?? 'idle';
139
- return _currentSession !== null && !_isExecuting && status !== 'awaiting_input';
140
- }
141
- /**
142
- * Get message count
143
- */
144
- export function getMessageCount() {
145
- return _messages.length;
146
- }
147
- /**
148
- * Get chat messages (excludes log messages)
149
- */
150
- export function getChatMessages() {
151
- return _messages.filter((m) => m.role !== 'log');
152
- }
153
- /**
154
- * Get log messages only
155
- */
156
- export function getLogMessages() {
157
- return _messages.filter((m) => m.role === 'log');
158
- }
159
- /**
160
- * Get the latest message
161
- */
162
- export function getLatestMessage() {
163
- return _messages.length > 0 ? _messages[_messages.length - 1] : null;
164
- }
165
- /**
166
- * Get input fields from workflow input nodes
167
- *
168
- * Analyzes the workflow to extract input nodes and their configuration
169
- * schemas for auto-generating input forms.
170
- */
171
- export function getInputFields() {
172
- const workflow = _currentWorkflow;
173
- if (!workflow) {
174
- return [];
175
- }
176
- const fields = [];
177
- // Find input nodes in the workflow
178
- workflow.nodes.forEach((node) => {
179
- const category = node.data.metadata?.category;
180
- const nodeTypeId = node.data.metadata?.id ?? node.type;
181
- // Check if this is an input-type node
182
- // The category can be "inputs" (standard) or variations like "input"
183
- const categoryStr = String(category || '');
184
- const isInputCategory = categoryStr === 'inputs' || categoryStr === 'input';
185
- if (isInputCategory || isChatInputNode(nodeTypeId)) {
186
- // Get output ports that provide data
187
- const outputs = node.data.metadata?.outputs ?? [];
188
- outputs.forEach((output) => {
189
- if (output.type === 'output') {
190
- // Create a field for each output
191
- const field = {
192
- nodeId: node.id,
193
- fieldId: output.id,
194
- label: node.data.label || output.name || nodeTypeId,
195
- type: output.dataType || 'string',
196
- defaultValue: node.data.config?.[output.id],
197
- required: output.required ?? false
198
- };
199
- // Check for schema in configSchema
200
- const configSchema = node.data.metadata?.configSchema;
201
- if (configSchema?.properties?.[output.id]) {
202
- field.schema = configSchema.properties[output.id];
203
- }
204
- fields.push(field);
205
- }
206
- });
207
- // If no outputs defined, create a default field based on node config
208
- if (outputs.length === 0) {
209
- const configSchema = node.data.metadata?.configSchema;
210
- if (configSchema?.properties) {
211
- Object.entries(configSchema.properties).forEach(([key, schema]) => {
212
- const field = {
213
- nodeId: node.id,
214
- fieldId: key,
215
- label: schema.title || key,
216
- type: schema.type || 'string',
217
- defaultValue: node.data.config?.[key] ?? schema.default,
218
- required: configSchema.required?.includes(key) ?? false,
219
- schema
220
- };
221
- fields.push(field);
222
- });
223
- }
224
- }
225
- }
226
- });
227
- return fields;
228
- }
229
- /**
230
- * Check if workflow has a chat input
231
- */
232
- export function getHasChatInput() {
233
- const fields = getInputFields();
234
- return fields.some((field) => isChatInputNode(field.nodeId) || field.type === 'string');
235
- }
236
- /**
237
- * Get session count
238
- */
239
- export function getSessionCount() {
240
- return _sessions.length;
241
- }
242
- export function getPinnedExecutionId() {
243
- return _pinnedExecutionId;
244
- }
245
- export function getLatestExecutionId() {
246
- return _latestExecutionId;
247
- }
248
- export function getActiveExecutionId() {
249
- return _activeExecutionId;
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
- }
258
- /**
259
- * Counter that increments whenever new messages arrive and the pipeline display
260
- * should re-fetch — i.e. when following latest or pinned to the latest execution.
261
- * Pass to PipelinePanel's refreshTrigger prop.
262
- */
263
- export function getPipelineRefreshTrigger() {
264
- return _pipelineRefreshTrigger;
265
- }
266
- /**
267
- * Whether log messages should be shown in the execution console
268
- */
269
- export function getShowLogs() {
270
- return _showLogs;
271
- }
272
- // =========================================================================
273
- // Helper Functions
16
+ // Helper Functions (pure, instance-independent)
274
17
  // =========================================================================
275
18
  /**
276
19
  * Sort messages chronologically by sequenceNumber
@@ -312,86 +55,352 @@ function sortMessagesChronologically(messageList) {
312
55
  function isSubflowMessage(msg) {
313
56
  return msg.parentPipelineId != null;
314
57
  }
315
- /**
316
- * Syncs the current session's executions list from incoming messages.
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.
328
- */
329
- function syncExecutionsFromMessages(messages) {
330
- if (!_currentSession)
331
- return;
332
- const executions = [...(_currentSession.executions ?? [])];
333
- const seenIds = new Set(executions.map((e) => e.id));
334
- let added = false;
335
- let gainedMainRun = false;
336
- for (const msg of messages) {
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;
350
- }
351
- if (!added)
352
- return;
353
- _currentSession = { ..._currentSession, executions };
354
- // Auto-follow the new main run by dropping any manual pin.
355
- if (gainedMainRun)
356
- _pinnedExecutionId = null;
357
- }
358
58
  // =========================================================================
359
- // Actions
59
+ // PlaygroundStore (per-instance reactive state)
360
60
  // =========================================================================
361
61
  /**
362
- * Playground store actions for modifying state
62
+ * Per-instance playground state: sessions, messages, executions, and the
63
+ * polling/refresh machinery around them.
363
64
  */
364
- export const playgroundActions = {
65
+ export class PlaygroundStore {
66
+ /** Currently active playground session */
67
+ #currentSession = $state(null);
68
+ /** List of all sessions for the current workflow */
69
+ #sessions = $state([]);
70
+ /** Messages in the current session */
71
+ #messages = $state([]);
365
72
  /**
366
- * Set the current workflow
367
- *
368
- * @param workflow - The workflow to test
73
+ * Whether older messages exist before the oldest one currently loaded.
74
+ * Drives the scroll-up "load older" affordance. Reset whenever the message
75
+ * set is replaced (session switch / clear).
76
+ */
77
+ #hasOlder = $state(false);
78
+ /** Whether we are currently loading data */
79
+ #isLoading = $state(false);
80
+ /** Current error message, if any */
81
+ #error = $state(null);
82
+ /** Current workflow being tested */
83
+ #currentWorkflow = $state(null);
84
+ /** Last polling cursor for incremental message fetching */
85
+ #lastPollSequenceNumber = $state(null);
86
+ /** Execution ID explicitly pinned by the user (null = follow latest) */
87
+ #pinnedExecutionId = $state(null);
88
+ /** Incremented on every message batch that should trigger a pipeline re-fetch */
89
+ #pipelineRefreshTrigger = $state(0);
90
+ /** Whether log messages are visible in the execution console */
91
+ #showLogs = $state(true);
92
+ /**
93
+ * The main pipeline runs — the single source of truth for "what's selectable".
94
+ * Sub-flow runs are tracked for classification but excluded here: selecting one
95
+ * can't show its own graph (the panel renders the main pipeline regardless), so
96
+ * listing them would be dead UI. Feeds both the run-switcher and "latest".
97
+ */
98
+ #selectableExecutions = $derived((this.#currentSession?.executions ?? []).filter((e) => !e.isSubflow));
99
+ /**
100
+ * Latest execution ID: the most recent main run, so the sidebar keeps the main
101
+ * pipeline in focus and never auto-follows a sub-flow. Null when no main run is
102
+ * known yet — better an empty panel for a poll than the wrong graph.
103
+ */
104
+ #latestExecutionId = $derived(this.#selectableExecutions.at(-1)?.id ?? null);
105
+ /** Active execution: pinned if set, otherwise latest */
106
+ #activeExecutionId = $derived(this.#pinnedExecutionId ?? this.#latestExecutionId);
107
+ // Derived from server status — never manually set.
108
+ // Exception: updateSessionStatus('running') in handleSendMessage is an
109
+ // acknowledged optimistic write, overwritten by the next server response.
110
+ #isExecuting = $derived(this.#currentSession?.status === 'running');
111
+ /** Cleanups for active subscribeToSessionStatus effect roots. */
112
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- non-reactive bookkeeping registry; nothing renders from it
113
+ #statusSubscriptions = new Set();
114
+ /** Bound mutation facade — see {@link PlaygroundStoreActions}. */
115
+ actions;
116
+ constructor() {
117
+ this.actions = Object.freeze({
118
+ setWorkflow: this.setWorkflow.bind(this),
119
+ setCurrentSession: this.setCurrentSession.bind(this),
120
+ updateSessionStatus: this.updateSessionStatus.bind(this),
121
+ setSessions: this.setSessions.bind(this),
122
+ addSession: this.addSession.bind(this),
123
+ removeSession: this.removeSession.bind(this),
124
+ setMessages: this.setMessages.bind(this),
125
+ addMessage: this.addMessage.bind(this),
126
+ addMessages: this.addMessages.bind(this),
127
+ clearMessages: this.clearMessages.bind(this),
128
+ setLoading: this.setLoading.bind(this),
129
+ setError: this.setError.bind(this),
130
+ updateLastPollSequenceNumber: this.updateLastPollSequenceNumber.bind(this),
131
+ reset: this.reset.bind(this),
132
+ switchSession: this.switchSession.bind(this),
133
+ pinExecution: this.pinExecution.bind(this),
134
+ setShowLogs: this.setShowLogs.bind(this),
135
+ toggleShowLogs: this.toggleShowLogs.bind(this)
136
+ });
137
+ }
138
+ // -----------------------------------------------------------------------
139
+ // Reactive getters
140
+ // -----------------------------------------------------------------------
141
+ /** The current session. */
142
+ get currentSession() {
143
+ return this.#currentSession;
144
+ }
145
+ /** All sessions. */
146
+ get sessions() {
147
+ return this.#sessions;
148
+ }
149
+ /** All messages (chronological). */
150
+ get messages() {
151
+ return this.#messages;
152
+ }
153
+ /** Executing state (derived from server status). */
154
+ get isExecuting() {
155
+ return this.#isExecuting;
156
+ }
157
+ /** Loading state. */
158
+ get isLoading() {
159
+ return this.#isLoading;
160
+ }
161
+ /** Error state. */
162
+ get error() {
163
+ return this.#error;
164
+ }
165
+ /** The current workflow. */
166
+ get currentWorkflow() {
167
+ return this.#currentWorkflow;
168
+ }
169
+ /** The last poll sequence number cursor. */
170
+ get lastPollSequenceNumber() {
171
+ return this.#lastPollSequenceNumber;
172
+ }
173
+ /** Current session status. */
174
+ get sessionStatus() {
175
+ return this.#currentSession?.status ?? 'idle';
176
+ }
177
+ /**
178
+ * Whether the user can currently send a message.
179
+ * False when executing, when awaiting input, or when no session exists.
369
180
  */
370
- setWorkflow: (workflow) => {
371
- _currentWorkflow = workflow;
372
- },
181
+ get canSendMessage() {
182
+ const status = this.#currentSession?.status ?? 'idle';
183
+ return this.#currentSession !== null && !this.#isExecuting && status !== 'awaiting_input';
184
+ }
185
+ /** Message count. */
186
+ get messageCount() {
187
+ return this.#messages.length;
188
+ }
189
+ /** Chat messages (excludes log messages). */
190
+ get chatMessages() {
191
+ return this.#messages.filter((m) => m.role !== 'log');
192
+ }
193
+ /** Log messages only. */
194
+ get logMessages() {
195
+ return this.#messages.filter((m) => m.role === 'log');
196
+ }
197
+ /** The latest message, or null. */
198
+ get latestMessage() {
199
+ return this.#messages.length > 0 ? this.#messages[this.#messages.length - 1] : null;
200
+ }
373
201
  /**
374
- * Set the current session
202
+ * Input fields from workflow input nodes.
375
203
  *
376
- * @param session - The session to set as active
204
+ * Analyzes the workflow to extract input nodes and their configuration
205
+ * schemas for auto-generating input forms.
377
206
  */
378
- setCurrentSession: (session) => {
379
- _pinnedExecutionId = null;
380
- _currentSession = session;
381
- if (session) {
382
- // Update session in the list
383
- _sessions = _sessions.map((s) => (s.id === session.id ? session : s));
207
+ get inputFields() {
208
+ const workflow = this.#currentWorkflow;
209
+ if (!workflow) {
210
+ return [];
211
+ }
212
+ const fields = [];
213
+ // Find input nodes in the workflow
214
+ workflow.nodes.forEach((node) => {
215
+ const category = node.data.metadata?.category;
216
+ const nodeTypeId = node.data.metadata?.id ?? node.type;
217
+ // Check if this is an input-type node
218
+ // The category can be "inputs" (standard) or variations like "input"
219
+ const categoryStr = String(category || '');
220
+ const isInputCategory = categoryStr === 'inputs' || categoryStr === 'input';
221
+ if (isInputCategory || isChatInputNode(nodeTypeId)) {
222
+ // Get output ports that provide data
223
+ const outputs = node.data.metadata?.outputs ?? [];
224
+ outputs.forEach((output) => {
225
+ if (output.type === 'output') {
226
+ // Create a field for each output
227
+ const field = {
228
+ nodeId: node.id,
229
+ fieldId: output.id,
230
+ label: node.data.label || output.name || nodeTypeId,
231
+ type: output.dataType || 'string',
232
+ defaultValue: node.data.config?.[output.id],
233
+ required: output.required ?? false
234
+ };
235
+ // Check for schema in configSchema
236
+ const configSchema = node.data.metadata?.configSchema;
237
+ if (configSchema?.properties?.[output.id]) {
238
+ field.schema = configSchema.properties[output.id];
239
+ }
240
+ fields.push(field);
241
+ }
242
+ });
243
+ // If no outputs defined, create a default field based on node config
244
+ if (outputs.length === 0) {
245
+ const configSchema = node.data.metadata?.configSchema;
246
+ if (configSchema?.properties) {
247
+ Object.entries(configSchema.properties).forEach(([key, schema]) => {
248
+ const field = {
249
+ nodeId: node.id,
250
+ fieldId: key,
251
+ label: schema.title || key,
252
+ type: schema.type || 'string',
253
+ defaultValue: node.data.config?.[key] ?? schema.default,
254
+ required: configSchema.required?.includes(key) ?? false,
255
+ schema
256
+ };
257
+ fields.push(field);
258
+ });
259
+ }
260
+ }
261
+ }
262
+ });
263
+ return fields;
264
+ }
265
+ /** Whether the workflow has a chat input. */
266
+ get hasChatInput() {
267
+ const fields = this.inputFields;
268
+ return fields.some((field) => isChatInputNode(field.nodeId) || field.type === 'string');
269
+ }
270
+ /** Session count. */
271
+ get sessionCount() {
272
+ return this.#sessions.length;
273
+ }
274
+ /** Execution ID explicitly pinned by the user (null = follow latest). */
275
+ get pinnedExecutionId() {
276
+ return this.#pinnedExecutionId;
277
+ }
278
+ /** Latest main-run execution ID. */
279
+ get latestExecutionId() {
280
+ return this.#latestExecutionId;
281
+ }
282
+ /** Active execution: pinned if set, otherwise latest. */
283
+ get activeExecutionId() {
284
+ return this.#activeExecutionId;
285
+ }
286
+ /**
287
+ * Main pipeline runs for the run-switcher. Excludes sub-flow runs, which can't
288
+ * render their own graph and so aren't user-selectable.
289
+ */
290
+ get selectableExecutions() {
291
+ return this.#selectableExecutions;
292
+ }
293
+ /**
294
+ * Counter that increments whenever new messages arrive and the pipeline display
295
+ * should re-fetch — i.e. when following latest or pinned to the latest execution.
296
+ * Pass to PipelinePanel's refreshTrigger prop.
297
+ */
298
+ get pipelineRefreshTrigger() {
299
+ return this.#pipelineRefreshTrigger;
300
+ }
301
+ /** Whether log messages should be shown in the execution console. */
302
+ get showLogs() {
303
+ return this.#showLogs;
304
+ }
305
+ /** The current session ID, or null. */
306
+ get currentSessionId() {
307
+ return this.#currentSession?.id ?? null;
308
+ }
309
+ /** Whether older messages exist before the oldest one currently loaded. */
310
+ get hasOlder() {
311
+ return this.#hasOlder;
312
+ }
313
+ /**
314
+ * The sequence number of the latest message, used to seed incremental polling.
315
+ */
316
+ get latestSequenceNumber() {
317
+ for (let i = this.#messages.length - 1; i >= 0; i--) {
318
+ if (this.#messages[i].sequenceNumber !== undefined) {
319
+ return this.#messages[i].sequenceNumber;
320
+ }
384
321
  }
385
- },
322
+ return null;
323
+ }
386
324
  /**
387
- * Update session status
325
+ * The sequence number of the oldest loaded message, used as the cursor
326
+ * for backward "load older" pagination.
327
+ */
328
+ get oldestSequenceNumber() {
329
+ for (let i = 0; i < this.#messages.length; i++) {
330
+ if (this.#messages[i].sequenceNumber !== undefined) {
331
+ return this.#messages[i].sequenceNumber;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+ // -----------------------------------------------------------------------
337
+ // Internal helpers
338
+ // -----------------------------------------------------------------------
339
+ /**
340
+ * Syncs the current session's executions list from incoming messages.
388
341
  *
389
- * @param status - The new status
342
+ * Each message's `executionId` identifies the run that produced it, and
343
+ * `parentPipelineId` says whether that run is the main pipeline or a nested
344
+ * sub-flow (see {@link isSubflowMessage}). A run's classification is fixed by
345
+ * its first sighting — every message from a run reports the same parent — so we
346
+ * only act on executionIds we haven't seen before. Sub-flows are tracked but
347
+ * hidden from the run-switcher; a new *main* run clears the pin so the panel
348
+ * auto-follows it, while sub-flow runs never take focus.
349
+ *
350
+ * Executions are appended in arrival order; new runs land at the tail, which is
351
+ * why "latest" reads the last main run.
390
352
  */
391
- updateSessionStatus: (status) => {
392
- if (_currentSession) {
393
- _currentSession = {
394
- ..._currentSession,
353
+ #syncExecutionsFromMessages(messages) {
354
+ if (!this.#currentSession)
355
+ return;
356
+ const executions = [...(this.#currentSession.executions ?? [])];
357
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local for dedup within this call
358
+ const seenIds = new Set(executions.map((e) => e.id));
359
+ let added = false;
360
+ let gainedMainRun = false;
361
+ for (const msg of messages) {
362
+ if (!msg.executionId || seenIds.has(msg.executionId))
363
+ continue;
364
+ seenIds.add(msg.executionId);
365
+ const isSubflow = isSubflowMessage(msg);
366
+ executions.push({
367
+ id: msg.executionId,
368
+ startedAt: msg.timestamp,
369
+ status: 'running',
370
+ isSubflow
371
+ });
372
+ added = true;
373
+ if (!isSubflow)
374
+ gainedMainRun = true;
375
+ }
376
+ if (!added)
377
+ return;
378
+ this.#currentSession = { ...this.#currentSession, executions };
379
+ // Auto-follow the new main run by dropping any manual pin.
380
+ if (gainedMainRun)
381
+ this.#pinnedExecutionId = null;
382
+ }
383
+ // -----------------------------------------------------------------------
384
+ // Mutation actions
385
+ // -----------------------------------------------------------------------
386
+ /** Set the current workflow. */
387
+ setWorkflow(workflow) {
388
+ this.#currentWorkflow = workflow;
389
+ }
390
+ /** Set the current session. */
391
+ setCurrentSession(session) {
392
+ this.#pinnedExecutionId = null;
393
+ this.#currentSession = session;
394
+ if (session) {
395
+ // Update session in the list
396
+ this.#sessions = this.#sessions.map((s) => (s.id === session.id ? session : s));
397
+ }
398
+ }
399
+ /** Update session status. */
400
+ updateSessionStatus(status) {
401
+ if (this.#currentSession) {
402
+ this.#currentSession = {
403
+ ...this.#currentSession,
395
404
  status,
396
405
  updatedAt: new Date().toISOString()
397
406
  };
@@ -406,93 +415,75 @@ export const playgroundActions = {
406
415
  : status === 'completed' || status === 'idle'
407
416
  ? 'completed'
408
417
  : null;
409
- if (terminalExecutionStatus && _currentSession?.executions?.length) {
410
- const hasRunning = _currentSession.executions.some((e) => e.status === 'running');
418
+ if (terminalExecutionStatus && this.#currentSession?.executions?.length) {
419
+ const hasRunning = this.#currentSession.executions.some((e) => e.status === 'running');
411
420
  if (hasRunning) {
412
- _currentSession = {
413
- ..._currentSession,
414
- executions: _currentSession.executions.map((e) => e.status === 'running' ? { ...e, status: terminalExecutionStatus } : e)
421
+ this.#currentSession = {
422
+ ...this.#currentSession,
423
+ executions: this.#currentSession.executions.map((e) => e.status === 'running' ? { ...e, status: terminalExecutionStatus } : e)
415
424
  };
416
425
  }
417
426
  }
418
427
  // Also update in sessions list
419
- const session = _currentSession;
428
+ const session = this.#currentSession;
420
429
  if (session) {
421
- _sessions = _sessions.map((s) => (s.id === session.id ? { ...s, status } : s));
430
+ this.#sessions = this.#sessions.map((s) => (s.id === session.id ? { ...s, status } : s));
422
431
  }
423
- },
424
- /**
425
- * Set the sessions list
426
- *
427
- * @param sessionList - Array of sessions
428
- */
429
- setSessions: (sessionList) => {
430
- _sessions = sessionList;
431
- },
432
- /**
433
- * Add a new session to the list
434
- *
435
- * @param session - The session to add
436
- */
437
- addSession: (session) => {
438
- _sessions = [session, ..._sessions];
439
- },
440
- /**
441
- * Remove a session from the list
442
- *
443
- * @param sessionId - The session ID to remove
444
- */
445
- removeSession: (sessionId) => {
446
- _sessions = _sessions.filter((s) => s.id !== sessionId);
432
+ }
433
+ /** Set the sessions list. */
434
+ setSessions(sessionList) {
435
+ this.#sessions = sessionList;
436
+ }
437
+ /** Add a new session to the list. */
438
+ addSession(session) {
439
+ this.#sessions = [session, ...this.#sessions];
440
+ }
441
+ /** Remove a session from the list. */
442
+ removeSession(sessionId) {
443
+ this.#sessions = this.#sessions.filter((s) => s.id !== sessionId);
447
444
  // Clear current session if it was removed
448
- if (_currentSession?.id === sessionId) {
449
- _currentSession = null;
450
- _messages = [];
445
+ if (this.#currentSession?.id === sessionId) {
446
+ this.#currentSession = null;
447
+ this.#messages = [];
451
448
  }
452
- },
449
+ }
453
450
  /**
454
- * Set messages for the current session
455
- * Messages are automatically sorted chronologically
456
- *
457
- * @param messageList - Array of messages
451
+ * Set messages for the current session.
452
+ * Messages are automatically sorted chronologically.
458
453
  */
459
- setMessages: (messageList) => {
460
- _messages = sortMessagesChronologically(messageList);
461
- },
454
+ setMessages(messageList) {
455
+ this.#messages = sortMessagesChronologically(messageList);
456
+ }
462
457
  /**
463
- * Add a message to the current session
458
+ * Add a message to the current session.
464
459
  * Uses binary search insertion for O(log n) instead of full sort.
465
- *
466
- * @param message - The message to add
467
460
  */
468
- addMessage: (message) => {
469
- if (_messages.some((m) => m.id === message.id))
461
+ addMessage(message) {
462
+ if (this.#messages.some((m) => m.id === message.id))
470
463
  return;
471
464
  const seq = message.sequenceNumber ?? 0;
472
- let lo = 0, hi = _messages.length;
465
+ let lo = 0, hi = this.#messages.length;
473
466
  while (lo < hi) {
474
467
  const mid = (lo + hi) >>> 1;
475
- if ((_messages[mid].sequenceNumber ?? 0) <= seq)
468
+ if ((this.#messages[mid].sequenceNumber ?? 0) <= seq)
476
469
  lo = mid + 1;
477
470
  else
478
471
  hi = mid;
479
472
  }
480
- _messages = [..._messages.slice(0, lo), message, ..._messages.slice(lo)];
481
- },
473
+ this.#messages = [...this.#messages.slice(0, lo), message, ...this.#messages.slice(lo)];
474
+ }
482
475
  /**
483
- * Add multiple messages to the current session
484
- * Messages are deduplicated and automatically sorted chronologically
485
- *
486
- * @param newMessages - Array of messages to add
476
+ * Add multiple messages to the current session.
477
+ * Messages are deduplicated and automatically sorted chronologically.
487
478
  */
488
- addMessages: (newMessages) => {
479
+ addMessages(newMessages) {
489
480
  if (newMessages.length === 0)
490
481
  return;
491
482
  // Deduplicate against existing messages AND within the incoming batch itself.
492
483
  // The latter matters when the backend returns the same page twice (e.g. broken
493
- // offset pagination), which would otherwise create duplicate IDs in _messages
484
+ // offset pagination), which would otherwise create duplicate IDs in #messages
494
485
  // and trigger Svelte's each_key_duplicate error.
495
- const existingIds = new Set(_messages.map((m) => m.id));
486
+ const existingIds = new Set(this.#messages.map((m) => m.id));
496
487
  const seenInBatch = new Set();
497
488
  const uniqueNewMessages = newMessages.filter((m) => {
498
489
  if (existingIds.has(m.id) || seenInBatch.has(m.id))
@@ -500,213 +491,157 @@ export const playgroundActions = {
500
491
  seenInBatch.add(m.id);
501
492
  return true;
502
493
  });
503
- _messages = sortMessagesChronologically([..._messages, ...uniqueNewMessages]);
504
- syncExecutionsFromMessages(uniqueNewMessages);
505
- },
506
- /**
507
- * Clear all messages
508
- */
509
- clearMessages: () => {
510
- _messages = [];
511
- _lastPollSequenceNumber = null;
512
- _hasOlder = false;
513
- },
494
+ this.#messages = sortMessagesChronologically([...this.#messages, ...uniqueNewMessages]);
495
+ this.#syncExecutionsFromMessages(uniqueNewMessages);
496
+ }
497
+ /** Clear all messages. */
498
+ clearMessages() {
499
+ this.#messages = [];
500
+ this.#lastPollSequenceNumber = null;
501
+ this.#hasOlder = false;
502
+ }
503
+ /** Set the loading state. */
504
+ setLoading(loading) {
505
+ this.#isLoading = loading;
506
+ }
507
+ /** Set an error message (or null to clear). */
508
+ setError(errorMessage) {
509
+ this.#error = errorMessage;
510
+ }
511
+ /** Update the last poll cursor. */
512
+ updateLastPollSequenceNumber(seq) {
513
+ this.#lastPollSequenceNumber = seq;
514
+ }
515
+ /** Reset all playground state. */
516
+ reset() {
517
+ this.#currentSession = null;
518
+ this.#sessions = [];
519
+ this.#messages = [];
520
+ this.#isLoading = false;
521
+ this.#error = null;
522
+ this.#currentWorkflow = null;
523
+ this.#lastPollSequenceNumber = null;
524
+ this.#pipelineRefreshTrigger = 0;
525
+ }
526
+ /** Switch to a different session. */
527
+ switchSession(sessionId) {
528
+ this.#pinnedExecutionId = null;
529
+ const session = this.#sessions.find((s) => s.id === sessionId);
530
+ if (session) {
531
+ this.#currentSession = session;
532
+ this.#messages = [];
533
+ this.#lastPollSequenceNumber = null;
534
+ }
535
+ }
536
+ /** Pin an execution (null = follow latest). */
537
+ pinExecution(executionId) {
538
+ this.#pinnedExecutionId = executionId;
539
+ }
540
+ /** Set log message visibility. */
541
+ setShowLogs(value) {
542
+ this.#showLogs = value;
543
+ }
544
+ /** Toggle log message visibility. */
545
+ toggleShowLogs() {
546
+ this.#showLogs = !this.#showLogs;
547
+ }
548
+ // -----------------------------------------------------------------------
549
+ // Server response application & utilities
550
+ // -----------------------------------------------------------------------
514
551
  /**
515
- * Set the loading state
552
+ * Apply a server response to the store. All message and status updates from
553
+ * the server flow through here — polling callback, manual fetches, interrupt
554
+ * resolution. Nothing updates messages or session status except this function.
516
555
  *
517
- * @param loading - Whether loading is in progress
556
+ * Pass `sessionId` (the session the response was fetched for) so a response
557
+ * that resolves after the user switched sessions is dropped instead of writing
558
+ * the old session's status/messages onto the new current session. Pass `null`
559
+ * to deliberately opt out of the guard (non-session-scoped callers only) — the
560
+ * argument is required so every new caller has to make that choice explicitly.
518
561
  */
519
- setLoading: (loading) => {
520
- _isLoading = loading;
521
- },
562
+ applyServerResponse(response, sessionId) {
563
+ if (sessionId !== null && this.#currentSession?.id !== sessionId)
564
+ return;
565
+ if (response.data && response.data.length > 0) {
566
+ this.addMessages(response.data);
567
+ // Refresh the pipeline panel when following latest or pinned to the latest
568
+ // run. Skip when pinned to an older run — a historical view that won't change.
569
+ if (this.#pinnedExecutionId === null || this.#pinnedExecutionId === this.#latestExecutionId) {
570
+ this.#pipelineRefreshTrigger++;
571
+ }
572
+ }
573
+ if (response.sessionStatus) {
574
+ this.updateSessionStatus(response.sessionStatus);
575
+ }
576
+ }
577
+ /** Check if a specific session is selected. */
578
+ isSessionSelected(sessionId) {
579
+ return this.#currentSession?.id === sessionId;
580
+ }
522
581
  /**
523
- * Set an error message
524
- *
525
- * @param errorMessage - The error message or null to clear
582
+ * Set whether older messages remain to be loaded, derived from a
583
+ * backward-pagination response.
526
584
  */
527
- setError: (errorMessage) => {
528
- _error = errorMessage;
529
- },
585
+ setHasOlder(hasOlder) {
586
+ this.#hasOlder = hasOlder;
587
+ }
530
588
  /**
531
- * Update the last poll timestamp
589
+ * Subscribe to session status changes using $effect.root.
590
+ * This is designed for use in non-component contexts (e.g., mount.ts).
532
591
  *
533
- * @param timestamp - ISO 8601 timestamp
592
+ * The effect root is tracked by the store and also disposed by
593
+ * {@link dispose} (via the owning instance's `destroy()`), so a forgotten
594
+ * unsubscribe can't outlive the instance.
595
+ *
596
+ * @param callback - Called when session status changes
597
+ * @returns Cleanup function to stop the subscription
534
598
  */
535
- updateLastPollSequenceNumber: (seq) => {
536
- _lastPollSequenceNumber = seq;
537
- },
599
+ subscribeToSessionStatus(callback) {
600
+ let previousStatus = this.sessionStatus;
601
+ const cleanup = $effect.root(() => {
602
+ $effect(() => {
603
+ const status = this.sessionStatus;
604
+ if (status !== previousStatus) {
605
+ callback(status, previousStatus);
606
+ previousStatus = status;
607
+ }
608
+ });
609
+ });
610
+ const tracked = () => {
611
+ this.#statusSubscriptions.delete(tracked);
612
+ cleanup();
613
+ };
614
+ this.#statusSubscriptions.add(tracked);
615
+ return tracked;
616
+ }
538
617
  /**
539
- * Reset all playground state
618
+ * Dispose all active session-status effect roots.
619
+ * Called by the owning instance's destroy(); safe to call repeatedly.
540
620
  */
541
- reset: () => {
542
- _currentSession = null;
543
- _sessions = [];
544
- _messages = [];
545
- _isLoading = false;
546
- _error = null;
547
- _currentWorkflow = null;
548
- _lastPollSequenceNumber = null;
549
- _pipelineRefreshTrigger = 0;
550
- },
621
+ dispose() {
622
+ for (const cleanup of [...this.#statusSubscriptions]) {
623
+ cleanup();
624
+ }
625
+ }
551
626
  /**
552
- * Switch to a different session
627
+ * Refresh messages for the current session.
553
628
  *
554
- * @param sessionId - The session ID to switch to
629
+ * This function is useful after interrupt resolution when polling
630
+ * has stopped but new messages may exist on the server.
631
+ *
632
+ * @param fetchMessages - Async function to fetch messages from the API
633
+ * @returns Promise that resolves when messages are refreshed
555
634
  */
556
- switchSession: (sessionId) => {
557
- _pinnedExecutionId = null;
558
- const session = _sessions.find((s) => s.id === sessionId);
559
- if (session) {
560
- _currentSession = session;
561
- _messages = [];
562
- _lastPollSequenceNumber = null;
563
- }
564
- },
565
- pinExecution(executionId) {
566
- _pinnedExecutionId = executionId;
567
- },
568
- setShowLogs(value) {
569
- _showLogs = value;
570
- },
571
- toggleShowLogs() {
572
- _showLogs = !_showLogs;
573
- }
574
- };
575
- // =========================================================================
576
- // Server Response Application
577
- // =========================================================================
578
- /**
579
- * Apply a server response to the store. All message and status updates from
580
- * the server flow through here — polling callback, manual fetches, interrupt
581
- * resolution. Nothing updates messages or session status except this function.
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;
592
- if (response.data && response.data.length > 0) {
593
- playgroundActions.addMessages(response.data);
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.
596
- if (_pinnedExecutionId === null || _pinnedExecutionId === _latestExecutionId) {
597
- _pipelineRefreshTrigger++;
598
- }
599
- }
600
- if (response.sessionStatus) {
601
- playgroundActions.updateSessionStatus(response.sessionStatus);
602
- }
603
- }
604
- // =========================================================================
605
- // Utilities
606
- // =========================================================================
607
- /**
608
- * Get the current session ID
609
- *
610
- * @returns The current session ID or null
611
- */
612
- export function getCurrentSessionId() {
613
- return _currentSession?.id ?? null;
614
- }
615
- /**
616
- * Check if a specific session is selected
617
- *
618
- * @param sessionId - The session ID to check
619
- * @returns True if the session is currently selected
620
- */
621
- export function isSessionSelected(sessionId) {
622
- return _currentSession?.id === sessionId;
623
- }
624
- /**
625
- * Get all messages as a snapshot
626
- *
627
- * @returns Array of all messages
628
- */
629
- export function getMessagesSnapshot() {
630
- return _messages;
631
- }
632
- /**
633
- * Get the sequence number of the latest message, used to seed incremental polling.
634
- *
635
- * @returns Sequence number of the last message, or null
636
- */
637
- export function getLatestSequenceNumber() {
638
- for (let i = _messages.length - 1; i >= 0; i--) {
639
- if (_messages[i].sequenceNumber !== undefined) {
640
- return _messages[i].sequenceNumber;
635
+ async refreshSessionMessages(fetchMessages) {
636
+ const session = this.#currentSession;
637
+ if (!session)
638
+ return;
639
+ try {
640
+ const response = await fetchMessages(session.id);
641
+ this.applyServerResponse(response, session.id);
641
642
  }
642
- }
643
- return null;
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;
643
+ catch (err) {
644
+ logger.error('[playgroundStore] Failed to refresh messages:', err);
655
645
  }
656
646
  }
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
- }
672
- /**
673
- * Subscribe to session status changes using $effect.root.
674
- * This is designed for use in non-component contexts (e.g., mount.ts).
675
- *
676
- * @param callback - Called when session status changes
677
- * @returns Cleanup function to stop the subscription
678
- */
679
- export function subscribeToSessionStatus(callback) {
680
- let previousStatus = getSessionStatus();
681
- const cleanup = $effect.root(() => {
682
- $effect(() => {
683
- const status = getSessionStatus();
684
- if (status !== previousStatus) {
685
- callback(status, previousStatus);
686
- previousStatus = status;
687
- }
688
- });
689
- });
690
- return cleanup;
691
- }
692
- /**
693
- * Refresh messages for the current session
694
- *
695
- * This function is useful after interrupt resolution when polling
696
- * has stopped but new messages may exist on the server.
697
- *
698
- * @param fetchMessages - Async function to fetch messages from the API
699
- * @returns Promise that resolves when messages are refreshed
700
- */
701
- export async function refreshSessionMessages(fetchMessages) {
702
- const session = _currentSession;
703
- if (!session)
704
- return;
705
- try {
706
- const response = await fetchMessages(session.id);
707
- applyServerResponse(response, session.id);
708
- }
709
- catch (err) {
710
- logger.error('[playgroundStore] Failed to refresh messages:', err);
711
- }
712
647
  }