@flowdrop/flowdrop 1.15.0 → 2.0.0-beta.2

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