@flowdrop/flowdrop 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/api/enhanced-client.js +5 -1
  2. package/dist/components/PipelineStatus.svelte +31 -8
  3. package/dist/components/PipelineStatus.svelte.d.ts +5 -0
  4. package/dist/components/WorkflowEditor.svelte +26 -0
  5. package/dist/components/chat/AIChatPanel.svelte +16 -5
  6. package/dist/components/playground/ChatPanel.svelte +31 -108
  7. package/dist/components/playground/ChatPanel.svelte.d.ts +3 -1
  8. package/dist/components/playground/ExecutionList.svelte +138 -0
  9. package/dist/components/playground/ExecutionList.svelte.d.ts +10 -0
  10. package/dist/components/playground/MessageBubble.svelte +281 -156
  11. package/dist/components/playground/PipelinePanel.svelte +382 -0
  12. package/dist/components/playground/PipelinePanel.svelte.d.ts +20 -0
  13. package/dist/components/playground/Playground.svelte +707 -174
  14. package/dist/components/playground/Playground.svelte.d.ts +6 -0
  15. package/dist/components/playground/PlaygroundStudio.svelte +404 -0
  16. package/dist/components/playground/PlaygroundStudio.svelte.d.ts +30 -0
  17. package/dist/editor/index.d.ts +1 -1
  18. package/dist/editor/index.js +1 -1
  19. package/dist/playground/index.d.ts +7 -3
  20. package/dist/playground/index.js +14 -5
  21. package/dist/playground/mount.d.ts +7 -0
  22. package/dist/playground/mount.js +78 -81
  23. package/dist/services/globalSave.d.ts +7 -0
  24. package/dist/services/globalSave.js +5 -1
  25. package/dist/services/nodeExecutionService.js +4 -2
  26. package/dist/services/playgroundService.d.ts +11 -4
  27. package/dist/services/playgroundService.js +22 -12
  28. package/dist/stores/pipelinePanelStore.svelte.d.ts +6 -0
  29. package/dist/stores/pipelinePanelStore.svelte.js +24 -0
  30. package/dist/stores/playgroundStore.svelte.d.ts +26 -21
  31. package/dist/stores/playgroundStore.svelte.js +134 -55
  32. package/dist/svelte-app.js +25 -2
  33. package/dist/types/playground.d.ts +15 -5
  34. package/package.json +1 -1
@@ -11,6 +11,7 @@
11
11
  import { onMount, onDestroy } from 'svelte';
12
12
  import Icon from '@iconify/svelte';
13
13
  import ChatPanel from './ChatPanel.svelte';
14
+ import ExecutionList from './ExecutionList.svelte';
14
15
  import type { Workflow } from '../../types/index.js';
15
16
  import type { EndpointConfig } from '../../config/endpoints.js';
16
17
  import type { PlaygroundMode, PlaygroundConfig } from '../../types/playground.js';
@@ -25,7 +26,12 @@
25
26
  getError,
26
27
  playgroundActions,
27
28
  getInputFields,
28
- createPollingCallback
29
+ applyServerResponse,
30
+ getCanSendMessage,
31
+ getLatestSequenceNumber,
32
+ getActiveExecutionId,
33
+ getLatestExecutionId,
34
+ getPinnedExecutionId,
29
35
  } from '../../stores/playgroundStore.svelte.js';
30
36
  import { interruptActions } from '../../stores/interruptStore.svelte.js';
31
37
  import { logger } from '../../utils/logger.js';
@@ -49,6 +55,12 @@
49
55
  config?: PlaygroundConfig;
50
56
  /** Callback when playground is closed (for embedded mode) */
51
57
  onClose?: () => void;
58
+ /** Callback to toggle the pipeline panel (if undefined, toggle button is hidden) */
59
+ onTogglePanel?: () => void;
60
+ /** Whether the pipeline panel is currently open (for toggle button active state) */
61
+ isPipelinePanelOpen?: boolean;
62
+ /** When provided, session switches and creation navigate to a URL instead of mutating store state */
63
+ onSessionNavigate?: (sessionId: string) => void;
52
64
  }
53
65
 
54
66
  let {
@@ -58,7 +70,10 @@
58
70
  initialSessionId,
59
71
  endpointConfig,
60
72
  config = {},
61
- onClose
73
+ onClose,
74
+ onTogglePanel,
75
+ isPipelinePanelOpen = false,
76
+ onSessionNavigate,
62
77
  }: Props = $props();
63
78
 
64
79
  /** Current input values from InputCollector */
@@ -70,6 +85,9 @@
70
85
  /** Track which session's dropdown menu is open */
71
86
  let openMenuId = $state<string | null>(null);
72
87
 
88
+ /** Whether the runs sub-section is expanded under the active session */
89
+ let runsExpanded = $state(false);
90
+
73
91
  /** Track if initial session has been loaded to prevent duplicate loads */
74
92
  let initialSessionLoaded = $state(false);
75
93
 
@@ -79,45 +97,56 @@
79
97
  /** Track if auto-run has already been triggered to prevent duplicate executions */
80
98
  let autoRunTriggered = $state(false);
81
99
 
82
- /**
83
- * Initialize the playground on mount
84
- */
85
- onMount(() => {
86
- // Set endpoint config if provided
87
- if (endpointConfig) {
88
- setEndpointConfig(endpointConfig);
89
- }
100
+ /** Whether log messages are visible in the chat panel */
101
+ let showLogs = $state(true);
90
102
 
91
- // Set workflow in store
92
- if (workflow) {
93
- playgroundActions.setWorkflow(workflow);
94
- }
103
+ /** Whether a manual refresh is in flight */
104
+ let isRefreshing = $state(false);
95
105
 
96
- // Async initialization
97
- const initializePlayground = async (): Promise<void> => {
98
- try {
99
- // Load sessions
100
- await loadSessions();
106
+ /** Whether the session switcher popover is open (standalone mode) */
107
+ let sessionDropdownOpen = $state(false);
101
108
 
102
- // Resume initial session if provided
103
- if (initialSessionId) {
104
- await loadInitialSession(initialSessionId);
105
- }
109
+ // Close session popover on outside click
110
+ $effect(() => {
111
+ if (!sessionDropdownOpen) return;
112
+ function handleOutside(e: MouseEvent) {
113
+ if (!(e.target as HTMLElement).closest('.playground__session-chip-wrap')) {
114
+ sessionDropdownOpen = false;
115
+ }
116
+ }
117
+ document.addEventListener('click', handleOutside);
118
+ return () => document.removeEventListener('click', handleOutside);
119
+ });
106
120
 
107
- // Handle auto-run after initialization is complete
108
- if (config.autoRun && !autoRunTriggered) {
109
- autoRunTriggered = true;
110
- const predefinedMessage = config.predefinedMessage ?? 'Run workflow';
111
- logger.debug('[Playground] Auto-run triggered with message:', predefinedMessage);
112
- await handleSendMessage(predefinedMessage);
121
+ /**
122
+ * Initialize the playground on mount
123
+ */
124
+ onMount(() => {
125
+ if (endpointConfig) setEndpointConfig(endpointConfig);
126
+ if (workflow) playgroundActions.setWorkflow(workflow);
127
+
128
+ const handleVisibility = () => {
129
+ if (document.visibilityState === 'visible' && playgroundService.isPolling()) {
130
+ const sessionId = getCurrentSession()?.id;
131
+ if (sessionId) {
132
+ void playgroundService
133
+ .getMessages(sessionId, playgroundService.getLastSequenceNumber() ?? undefined)
134
+ .then((response) => applyServerResponse(response))
135
+ .catch((err) => logger.error('[Playground] Visibility catchup failed:', err));
113
136
  }
114
- } catch (err) {
115
- logger.error('[Playground] Initialization error:', err);
116
137
  }
117
138
  };
139
+ document.addEventListener('visibilitychange', handleVisibility);
140
+
141
+ const handleRefreshStatus = () => void refreshFromServer();
142
+ document.addEventListener('flowdrop:refresh-status', handleRefreshStatus);
118
143
 
119
- // Execute initialization
120
144
  void initializePlayground();
145
+
146
+ return () => {
147
+ document.removeEventListener('visibilitychange', handleVisibility);
148
+ document.removeEventListener('flowdrop:refresh-status', handleRefreshStatus);
149
+ };
121
150
  });
122
151
 
123
152
  /**
@@ -130,8 +159,10 @@
130
159
  return;
131
160
  }
132
161
 
133
- // Skip if this session was already loaded
134
- if (initialSessionLoaded && loadedInitialSessionId === initialSessionId) {
162
+ // Skip if this session was already loaded or is currently loading.
163
+ // loadedInitialSessionId is set synchronously at the start of loadInitialSession,
164
+ // so this prevents the effect from spawning concurrent loads when isLoading changes.
165
+ if (loadedInitialSessionId === initialSessionId) {
135
166
  return;
136
167
  }
137
168
 
@@ -142,18 +173,42 @@
142
173
  }
143
174
 
144
175
  // Load the initial session if sessions are available
145
- if (sessionList.length > 0 && !initialSessionLoaded) {
176
+ if (sessionList.length > 0) {
146
177
  void loadInitialSession(initialSessionId);
147
178
  }
148
179
  });
149
180
 
181
+ /**
182
+ * Initialize the playground: load sessions, load initial session, handle auto-run
183
+ */
184
+ async function initializePlayground(): Promise<void> {
185
+ try {
186
+ await loadSessions();
187
+
188
+ if (initialSessionId) {
189
+ await loadInitialSession(initialSessionId);
190
+ }
191
+
192
+ if (config.autoRun && !autoRunTriggered) {
193
+ autoRunTriggered = true;
194
+ const predefinedMessage = config.predefinedMessage ?? 'Run workflow';
195
+ logger.debug('[Playground] Auto-run triggered with message:', predefinedMessage);
196
+ await handleSendMessage(predefinedMessage);
197
+ }
198
+ } catch (err) {
199
+ logger.error('[Playground] Initialization error:', err);
200
+ }
201
+ }
202
+
150
203
  /**
151
204
  * Load the initial session with validation and error handling
152
205
  *
153
206
  * @param sessionId - The session ID to load
154
207
  */
155
208
  async function loadInitialSession(sessionId: string): Promise<void> {
156
- // Validate session exists in loaded sessions
209
+ // Validate session exists in loaded sessions before setting the guard.
210
+ // If not found yet, we skip setting loadedInitialSessionId so the $effect
211
+ // can retry when _sessions updates (e.g. after a new session is created).
157
212
  const sessionList = getSessions();
158
213
  const sessionExists = sessionList.some((s) => s.id === sessionId);
159
214
 
@@ -162,21 +217,19 @@
162
217
  `[Playground] Initial session "${sessionId}" not found in available sessions. ` +
163
218
  `Available sessions: ${sessionList.map((s) => s.id).join(', ') || 'none'}`
164
219
  );
165
- // Don't set error - just log warning and let user pick a session
166
220
  initialSessionLoaded = true;
167
- loadedInitialSessionId = sessionId;
168
221
  return;
169
222
  }
170
223
 
224
+ // Set guard BEFORE the first await to prevent concurrent loads.
225
+ loadedInitialSessionId = sessionId;
226
+
171
227
  try {
172
228
  await loadSession(sessionId);
173
229
  initialSessionLoaded = true;
174
- loadedInitialSessionId = sessionId;
175
230
  } catch (err) {
176
231
  logger.error('[Playground] Failed to load initial session:', err);
177
- // Mark as attempted to prevent retry loops
178
232
  initialSessionLoaded = true;
179
- loadedInitialSessionId = sessionId;
180
233
  }
181
234
  }
182
235
 
@@ -231,17 +284,14 @@
231
284
  playgroundActions.setError(null);
232
285
 
233
286
  try {
234
- // Get session details
235
287
  const session = await playgroundService.getSession(sessionId);
236
288
  playgroundActions.setCurrentSession(session);
237
289
 
238
- // Get messages
239
290
  const response = await playgroundService.getMessages(sessionId);
240
- playgroundActions.setMessages(response.data ?? []);
291
+ applyServerResponse(response);
241
292
 
242
- // Start polling if session is running
243
293
  if (session.status === 'running') {
244
- startPolling(sessionId);
294
+ startPolling(sessionId, true);
245
295
  }
246
296
  } catch (err) {
247
297
  const errorMessage = err instanceof Error ? err.message : 'Failed to load session';
@@ -262,6 +312,13 @@
262
312
  try {
263
313
  const sessionName = `Session ${getSessions().length + 1}`;
264
314
  const session = await playgroundService.createSession(workflowId, sessionName);
315
+
316
+ if (onSessionNavigate) {
317
+ // URL-based routing: navigate to the new session; page remount handles store init
318
+ onSessionNavigate(session.id);
319
+ return;
320
+ }
321
+
265
322
  playgroundActions.addSession(session);
266
323
  playgroundActions.setCurrentSession(session);
267
324
  playgroundActions.clearMessages();
@@ -278,14 +335,15 @@
278
335
  * Select a session
279
336
  */
280
337
  async function handleSelectSession(sessionId: string): Promise<void> {
338
+ playgroundActions.pinExecution(null);
339
+ runsExpanded = false;
281
340
  const currentSessionId = getCurrentSession()?.id;
282
341
  if (currentSessionId === sessionId) {
283
342
  return;
284
343
  }
285
344
 
286
- // Stop polling for current session
287
345
  playgroundService.stopPolling();
288
-
346
+ playgroundActions.updateSessionStatus('idle');
289
347
  await loadSession(sessionId);
290
348
  }
291
349
 
@@ -344,9 +402,10 @@
344
402
  * Send a message
345
403
  */
346
404
  async function handleSendMessage(content: string): Promise<void> {
405
+ if (getIsExecuting()) return;
406
+
347
407
  const session = getCurrentSession();
348
408
  if (!session) {
349
- // Create a session first if none exists
350
409
  await handleCreateSession();
351
410
  const newSession = getCurrentSession();
352
411
  if (!newSession) {
@@ -354,23 +413,19 @@
354
413
  }
355
414
  }
356
415
 
357
- const sessionId = getCurrentSession()?.id;
358
- if (!sessionId) {
359
- return;
360
- }
416
+ const sessionId = getCurrentSession()!.id;
361
417
 
362
- playgroundActions.setExecuting(true);
418
+ playgroundActions.updateSessionStatus('running');
419
+ playgroundActions.pinExecution(null);
363
420
  playgroundActions.setError(null);
364
421
 
365
422
  try {
366
- // Prepare inputs from the input collector
367
423
  const inputs: Record<string, unknown> = {};
368
424
  const fields = getInputFields();
369
425
 
370
426
  fields.forEach((field) => {
371
427
  const key = `${field.nodeId}:${field.fieldId}`;
372
428
  if (inputValues[key] !== undefined) {
373
- // Map to node ID and field ID for the backend
374
429
  if (!inputs[field.nodeId]) {
375
430
  inputs[field.nodeId] = {};
376
431
  }
@@ -378,19 +433,13 @@
378
433
  }
379
434
  });
380
435
 
381
- // Send message
382
436
  const message = await playgroundService.sendMessage(sessionId, content, inputs);
383
437
  playgroundActions.addMessage(message);
384
-
385
- // Update session status
386
- playgroundActions.updateSessionStatus('running');
387
-
388
- // Start polling for responses
389
438
  startPolling(sessionId);
390
439
  } catch (err) {
391
440
  const errorMessage = err instanceof Error ? err.message : 'Failed to send message';
392
441
  playgroundActions.setError(errorMessage);
393
- playgroundActions.setExecuting(false);
442
+ playgroundActions.updateSessionStatus('idle');
394
443
  logger.error('Failed to send message:', err);
395
444
  }
396
445
  }
@@ -407,33 +456,56 @@
407
456
  try {
408
457
  await playgroundService.stopExecution(sessionId);
409
458
  playgroundService.stopPolling();
410
- playgroundActions.setExecuting(false);
411
459
  playgroundActions.updateSessionStatus('idle');
412
460
  } catch (err) {
413
461
  const errorMessage = err instanceof Error ? err.message : 'Failed to stop execution';
414
462
  playgroundActions.setError(errorMessage);
463
+ playgroundService.stopPolling();
464
+ playgroundActions.updateSessionStatus('idle');
415
465
  logger.error('Failed to stop execution:', err);
416
466
  }
417
467
  }
418
468
 
419
- /** Shared polling callback created from config lifecycle hooks */
420
- // svelte-ignore state_referenced_locally — config is static
421
- const pollingCallback = createPollingCallback(config.isTerminalStatus);
422
-
423
469
  /**
424
470
  * Start polling for messages
425
471
  */
426
- function startPolling(sessionId: string): void {
472
+ function startPolling(sessionId: string, seedSequence = false): void {
427
473
  const pollingInterval = config.pollingInterval ?? 1500;
474
+ const initialSequenceNumber = seedSequence ? getLatestSequenceNumber() : null;
428
475
 
429
476
  playgroundService.startPolling(
430
477
  sessionId,
431
- pollingCallback,
478
+ (response) => applyServerResponse(response),
432
479
  pollingInterval,
433
- config.shouldStopPolling
480
+ config.shouldStopPolling,
481
+ initialSequenceNumber
434
482
  );
435
483
  }
436
484
 
485
+ /**
486
+ * Fetch the latest messages and session status from the server.
487
+ * Resumes polling if the session is running but polling had stopped.
488
+ */
489
+ async function refreshFromServer(): Promise<void> {
490
+ const sessionId = getCurrentSession()?.id;
491
+ if (!sessionId || isRefreshing) return;
492
+ isRefreshing = true;
493
+ try {
494
+ const response = await playgroundService.getMessages(
495
+ sessionId,
496
+ playgroundService.getLastSequenceNumber() ?? undefined
497
+ );
498
+ applyServerResponse(response);
499
+ if (response.sessionStatus === 'running' && !playgroundService.isPolling()) {
500
+ startPolling(sessionId, true);
501
+ }
502
+ } catch (err) {
503
+ logger.error('[Playground] Status refresh failed:', err);
504
+ } finally {
505
+ isRefreshing = false;
506
+ }
507
+ }
508
+
437
509
  /**
438
510
  * Refresh messages for the current session
439
511
  * Called after interrupt resolution when polling has stopped
@@ -444,9 +516,13 @@
444
516
 
445
517
  try {
446
518
  const response = await playgroundService.getMessages(sessionId);
447
- pollingCallback(response);
519
+ applyServerResponse(response);
520
+
521
+ if (response.sessionStatus === 'running') {
522
+ startPolling(sessionId, true);
523
+ }
448
524
  } catch (err) {
449
- logger.error('[Playground] Failed to refresh messages after interrupt:', err);
525
+ logger.error('[Playground] Failed to refresh after interrupt:', err);
450
526
  }
451
527
  }
452
528
 
@@ -488,8 +564,8 @@
488
564
  class:playground--no-sidebar={config.showSidebar === false}
489
565
  >
490
566
  <div class="playground__container">
491
- <!-- Sidebar (conditionally rendered based on config.showSidebar) -->
492
- {#if config.showSidebar !== false}
567
+ <!-- Sidebar — hidden in standalone mode (session switcher lives in the header chip instead) -->
568
+ {#if config.showSidebar === true || (config.showSidebar !== false && mode !== 'standalone')}
493
569
  <aside
494
570
  class="playground__sidebar"
495
571
  style={config.sidebarWidth ? `--fd-playground-sidebar-width: ${config.sidebarWidth}` : ''}
@@ -499,6 +575,13 @@
499
575
  <div class="playground__sidebar-title">
500
576
  <span>Playground</span>
501
577
  </div>
578
+ <a
579
+ href="/workflow/{workflowId}/edit"
580
+ class="playground__edit-link"
581
+ title="Edit workflow"
582
+ >
583
+ <Icon icon="mdi:pencil-outline" />
584
+ </a>
502
585
  {#if (mode === 'embedded' || mode === 'modal') && onClose}
503
586
  <button
504
587
  type="button"
@@ -515,24 +598,24 @@
515
598
  {/if}
516
599
  </div>
517
600
 
518
- <!-- New Session Section -->
601
+ <!-- Sessions Section -->
519
602
  <div class="playground__section">
520
- <button
521
- type="button"
522
- class="playground__new-session-btn"
523
- onclick={handleCreateSession}
524
- disabled={getIsLoading()}
525
- title="Start a new session"
526
- >
527
- <Icon icon="mdi:plus" />
528
- <span>New Session</span>
529
- </button>
603
+ <!-- Section header with inline add button -->
604
+ <div class="playground__section-header">
605
+ <span class="playground__section-label">Sessions</span>
606
+ <button
607
+ type="button"
608
+ class="playground__section-add"
609
+ onclick={handleCreateSession}
610
+ disabled={getIsLoading()}
611
+ title="New session"
612
+ >
613
+ <Icon icon="mdi:plus" />
614
+ </button>
615
+ </div>
530
616
 
531
- <!-- Sessions List - click a session to load it -->
617
+ <!-- Sessions List -->
532
618
  <div class="playground__sessions-wrap">
533
- {#if getSessions().length > 0}
534
- <p class="playground__sessions-hint">Click a session to load it</p>
535
- {/if}
536
619
  <div class="playground__sessions">
537
620
  {#if getSessions().length === 0 && !getIsLoading()}
538
621
  <div class="playground__sessions-empty">
@@ -540,42 +623,75 @@
540
623
  </div>
541
624
  {:else}
542
625
  {#each getSessions() as session (session.id)}
543
- <div
544
- class="playground__session"
545
- class:playground__session--active={getCurrentSession()?.id === session.id}
546
- role="button"
547
- tabindex="0"
548
- title="Click to load this session"
549
- aria-label={m().layout.loadSession({ name: session.name })}
550
- onclick={() => handleSelectSession(session.id)}
551
- onkeydown={(e) => e.key === 'Enter' && handleSelectSession(session.id)}
552
- >
553
- <span class="playground__session-name" title={session.name}>
554
- {session.name}
555
- </span>
556
- <div class="playground__session-actions">
557
- <button
558
- type="button"
559
- class="playground__session-menu"
560
- class:playground__session-menu--open={openMenuId === session.id}
561
- onclick={(e) => handleMenuToggle(e, session.id)}
562
- title="Session options"
563
- >
564
- <Icon icon="mdi:dots-vertical" />
565
- </button>
566
- {#if openMenuId === session.id}
567
- <div class="playground__session-dropdown">
568
- <button
569
- type="button"
570
- class="playground__session-dropdown-item playground__session-dropdown-item--danger"
571
- onclick={(e) => handleMenuDelete(e, session.id)}
572
- >
573
- <Icon icon="mdi:delete-outline" />
574
- <span>Delete</span>
575
- </button>
576
- </div>
577
- {/if}
626
+ {@const isActive = getCurrentSession()?.id === session.id}
627
+ <div class="playground__session-group">
628
+ <div
629
+ class="playground__session"
630
+ class:playground__session--active={isActive}
631
+ role="button"
632
+ tabindex="0"
633
+ title="Click to load this session"
634
+ aria-label={m().layout.loadSession({ name: session.name })}
635
+ onclick={() => handleSelectSession(session.id)}
636
+ onkeydown={(e) => e.key === 'Enter' && handleSelectSession(session.id)}
637
+ >
638
+ <span class="playground__session-name" title={session.name}>
639
+ {session.name}
640
+ </span>
641
+ <div class="playground__session-actions">
642
+ <button
643
+ type="button"
644
+ class="playground__session-menu"
645
+ class:playground__session-menu--open={openMenuId === session.id}
646
+ onclick={(e) => handleMenuToggle(e, session.id)}
647
+ title="Session options"
648
+ >
649
+ <Icon icon="mdi:dots-vertical" />
650
+ </button>
651
+ {#if openMenuId === session.id}
652
+ <div class="playground__session-dropdown">
653
+ <button
654
+ type="button"
655
+ class="playground__session-dropdown-item playground__session-dropdown-item--danger"
656
+ onclick={(e) => handleMenuDelete(e, session.id)}
657
+ >
658
+ <Icon icon="mdi:delete-outline" />
659
+ <span>Delete</span>
660
+ </button>
661
+ </div>
662
+ {/if}
663
+ </div>
578
664
  </div>
665
+ <!-- Collapsible runs sub-section under active session -->
666
+ {#if isActive && getCurrentSession()?.executions?.length}
667
+ <div class="playground__runs-section">
668
+ <button
669
+ type="button"
670
+ class="playground__runs-toggle"
671
+ onclick={() => (runsExpanded = !runsExpanded)}
672
+ >
673
+ <Icon icon={runsExpanded ? 'mdi:chevron-down' : 'mdi:chevron-right'} />
674
+ <span>Runs</span>
675
+ <span class="playground__runs-count">{getCurrentSession()!.executions!.length}</span>
676
+ </button>
677
+ {#if runsExpanded}
678
+ <div class="playground__executions-inline">
679
+ <ExecutionList
680
+ executions={getCurrentSession()!.executions!}
681
+ activeExecutionId={getActiveExecutionId()}
682
+ latestExecutionId={getLatestExecutionId()}
683
+ onSelect={(id) => {
684
+ if (id === getLatestExecutionId()) {
685
+ playgroundActions.pinExecution(null);
686
+ } else {
687
+ playgroundActions.pinExecution(id);
688
+ }
689
+ }}
690
+ />
691
+ </div>
692
+ {/if}
693
+ </div>
694
+ {/if}
579
695
  </div>
580
696
  {/each}
581
697
  {/if}
@@ -587,18 +703,131 @@
587
703
 
588
704
  <!-- Main Content -->
589
705
  <main class="playground__main">
590
- <!-- Session Header (conditionally rendered based on config.showSessionHeader) -->
591
- {#if getCurrentSession() && config.showSessionHeader !== false}
706
+ <!-- Session Header -->
707
+ {#if mode === 'standalone' || (getCurrentSession() && config.showSessionHeader !== false)}
592
708
  <header class="playground__header">
593
- <h2 class="playground__header-title">{getCurrentSession()?.name}</h2>
594
- <button
595
- type="button"
596
- class="playground__header-close"
597
- onclick={handleCloseSession}
598
- title="Close session"
599
- >
600
- <Icon icon="mdi:close" />
601
- </button>
709
+ {#if mode === 'standalone'}
710
+ <!-- Panel icon + label (mirrors PipelinePanel header) -->
711
+ <Icon icon="mdi:message-text-outline" class="playground__header-icon" />
712
+ <span class="playground__header-label">Sessions</span>
713
+
714
+ <!-- Session chip — switches sessions via popover -->
715
+ <div class="playground__session-chip-wrap">
716
+ <button
717
+ type="button"
718
+ class="playground__session-chip"
719
+ class:playground__session-chip--open={sessionDropdownOpen}
720
+ onclick={() => (sessionDropdownOpen = !sessionDropdownOpen)}
721
+ title="Switch session"
722
+ >
723
+ <span class="playground__session-chip-name">
724
+ {getCurrentSession()?.name ?? 'No session'}
725
+ </span>
726
+ <Icon icon={sessionDropdownOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'} class="playground__session-chip-chevron" />
727
+ </button>
728
+
729
+ {#if sessionDropdownOpen}
730
+ <div class="playground__session-popover">
731
+ <button
732
+ type="button"
733
+ class="playground__session-popover-item playground__session-popover-item--new"
734
+ disabled={getIsLoading()}
735
+ onclick={() => { sessionDropdownOpen = false; void handleCreateSession(); }}
736
+ >
737
+ <Icon icon="mdi:plus" />
738
+ <span>New session</span>
739
+ </button>
740
+ {#if getSessions().length > 0}
741
+ <div class="playground__session-popover-divider"></div>
742
+ {#each getSessions() as session (session.id)}
743
+ {@const isActive = getCurrentSession()?.id === session.id}
744
+ <div class="playground__session-popover-row">
745
+ <button
746
+ type="button"
747
+ class="playground__session-popover-item"
748
+ class:playground__session-popover-item--active={isActive}
749
+ onclick={() => {
750
+ sessionDropdownOpen = false;
751
+ if (onSessionNavigate) {
752
+ onSessionNavigate(session.id);
753
+ } else {
754
+ void handleSelectSession(session.id);
755
+ }
756
+ }}
757
+ >
758
+ {#if isActive}
759
+ <Icon icon="mdi:check" class="playground__session-popover-check" />
760
+ {:else}
761
+ <Icon icon="mdi:message-outline" />
762
+ {/if}
763
+ <span>{session.name}</span>
764
+ </button>
765
+ <button
766
+ type="button"
767
+ class="playground__session-popover-delete"
768
+ onclick={(e) => { handleMenuDelete(e, session.id); sessionDropdownOpen = false; }}
769
+ title="Delete session"
770
+ >
771
+ <Icon icon="mdi:delete-outline" />
772
+ </button>
773
+ </div>
774
+ {/each}
775
+ {/if}
776
+ </div>
777
+ {/if}
778
+ </div>
779
+ {:else}
780
+ <!-- Embedded / modal: original title + close -->
781
+ <div class="playground__header-group">
782
+ <h2 class="playground__header-title">{getCurrentSession()?.name}</h2>
783
+ <button
784
+ type="button"
785
+ class="playground__header-close"
786
+ onclick={handleCloseSession}
787
+ title="Close session"
788
+ >
789
+ <Icon icon="mdi:close" />
790
+ </button>
791
+ </div>
792
+ {/if}
793
+
794
+ <div class="playground__header-actions">
795
+ {#if mode === 'standalone' && onTogglePanel}
796
+ <button
797
+ type="button"
798
+ class="playground__log-toggle"
799
+ class:playground__log-toggle--active={isPipelinePanelOpen}
800
+ onclick={onTogglePanel}
801
+ title={isPipelinePanelOpen ? 'Hide pipeline' : 'Show pipeline'}
802
+ >
803
+ <Icon icon="mdi:source-branch" />
804
+ Pipeline
805
+ </button>
806
+ {/if}
807
+ {#if getCurrentSession()}
808
+ <button
809
+ type="button"
810
+ class="playground__log-toggle"
811
+ class:playground__refresh--spinning={isRefreshing}
812
+ onclick={() => void refreshFromServer()}
813
+ disabled={isRefreshing}
814
+ title="Refresh status"
815
+ >
816
+ <Icon icon="mdi:refresh" />
817
+ Refresh
818
+ </button>
819
+ {/if}
820
+ <button
821
+ type="button"
822
+ class="playground__log-toggle"
823
+ class:playground__log-toggle--active={showLogs}
824
+ onclick={() => (showLogs = !showLogs)}
825
+ title={showLogs ? 'Hide log messages' : 'Show log messages'}
826
+ >
827
+ <Icon icon="mdi:console" />
828
+ Logs
829
+ </button>
830
+ </div>
602
831
  </header>
603
832
  {/if}
604
833
 
@@ -636,6 +865,7 @@
636
865
  onSendMessage={handleSendMessage}
637
866
  onStopExecution={handleStopExecution}
638
867
  onInterruptResolved={handleInterruptResolved}
868
+ bind:showLogs
639
869
  />
640
870
  {/if}
641
871
  </div>
@@ -691,6 +921,7 @@
691
921
  width: var(--fd-playground-sidebar-width);
692
922
  background-color: var(--fd-background);
693
923
  border-right: 1px solid var(--fd-border);
924
+ box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
694
925
  display: flex;
695
926
  flex-direction: column;
696
927
  }
@@ -717,6 +948,24 @@
717
948
  color: var(--fd-foreground);
718
949
  }
719
950
 
951
+ .playground__edit-link {
952
+ display: flex;
953
+ align-items: center;
954
+ justify-content: center;
955
+ width: var(--fd-playground-icon-btn-size);
956
+ height: var(--fd-playground-icon-btn-size);
957
+ border-radius: var(--fd-radius-md);
958
+ color: var(--fd-muted-foreground);
959
+ text-decoration: none;
960
+ transition: all var(--fd-transition-fast);
961
+ flex-shrink: 0;
962
+ }
963
+
964
+ .playground__edit-link:hover {
965
+ background-color: var(--fd-muted);
966
+ color: var(--fd-foreground);
967
+ }
968
+
720
969
  .playground__sidebar-close {
721
970
  display: flex;
722
971
  align-items: center;
@@ -742,51 +991,119 @@
742
991
  display: flex;
743
992
  flex-direction: column;
744
993
  min-height: 0;
745
- padding: var(--fd-space-md) var(--fd-space-xs) 0;
994
+ padding: 0 var(--fd-space-md);
995
+ }
996
+
997
+ /* Section header: label + add icon */
998
+ .playground__section-header {
999
+ display: flex;
1000
+ align-items: center;
1001
+ justify-content: space-between;
1002
+ padding: var(--fd-space-md) var(--fd-space-xs) var(--fd-space-xs);
1003
+ }
1004
+
1005
+ .playground__section-label {
1006
+ font-size: var(--fd-text-xs);
1007
+ font-weight: 600;
1008
+ color: var(--fd-muted-foreground);
1009
+ text-transform: uppercase;
1010
+ letter-spacing: 0.06em;
746
1011
  }
747
1012
 
748
- /* New Session – neutral full-width button with icon */
749
- .playground__new-session-btn {
1013
+ .playground__section-add {
750
1014
  display: flex;
751
1015
  align-items: center;
752
1016
  justify-content: center;
753
- gap: var(--fd-space-xs);
754
- width: 100%;
755
- padding: var(--fd-space-sm) var(--fd-space-xl);
756
- border: 1px solid var(--fd-border);
1017
+ width: var(--fd-playground-icon-btn-size);
1018
+ height: var(--fd-playground-icon-btn-size);
1019
+ border: none;
757
1020
  border-radius: var(--fd-radius-md);
758
- background-color: var(--fd-background);
1021
+ background: transparent;
1022
+ color: var(--fd-muted-foreground);
1023
+ cursor: pointer;
1024
+ transition: all var(--fd-transition-fast);
1025
+ }
1026
+
1027
+ .playground__section-add:hover:not(:disabled) {
1028
+ background-color: var(--fd-muted);
759
1029
  color: var(--fd-foreground);
760
- font-size: var(--fd-text-sm);
1030
+ }
1031
+
1032
+ .playground__section-add:disabled {
1033
+ opacity: 0.4;
1034
+ cursor: not-allowed;
1035
+ }
1036
+
1037
+ /* Session group wraps session row + its inline runs */
1038
+ .playground__session-group {
1039
+ margin-bottom: var(--fd-space-3xs);
1040
+ }
1041
+
1042
+ /* Collapsible runs sub-section under active session */
1043
+ .playground__runs-section {
1044
+ margin-bottom: var(--fd-space-3xs);
1045
+ }
1046
+
1047
+ .playground__runs-toggle {
1048
+ display: flex;
1049
+ align-items: center;
1050
+ gap: var(--fd-space-3xs);
1051
+ width: 100%;
1052
+ padding: var(--fd-space-3xs) var(--fd-space-sm);
1053
+ padding-left: calc(var(--fd-space-md) + var(--fd-space-3xs));
1054
+ border: none;
1055
+ border-radius: var(--fd-radius-sm);
1056
+ background: transparent;
1057
+ color: var(--fd-muted-foreground);
1058
+ font-size: var(--fd-text-xs);
761
1059
  font-weight: 500;
762
1060
  cursor: pointer;
763
- transition:
764
- background-color var(--fd-transition-fast),
765
- border-color var(--fd-transition-fast),
766
- transform 0.1s ease;
767
- box-sizing: border-box;
1061
+ text-align: left;
1062
+ transition: all var(--fd-transition-fast);
768
1063
  }
769
1064
 
770
- .playground__new-session-btn:hover:not(:disabled) {
1065
+ .playground__runs-toggle:hover {
771
1066
  background-color: var(--fd-muted);
772
- border-color: var(--fd-border);
773
- transform: translateY(-1px);
1067
+ color: var(--fd-foreground);
774
1068
  }
775
1069
 
776
- .playground__new-session-btn:focus {
777
- outline: none;
778
- box-shadow: 0 0 0 2px var(--fd-ring);
1070
+ .playground__runs-toggle :global(svg) {
1071
+ width: 0.875rem;
1072
+ height: 0.875rem;
1073
+ flex-shrink: 0;
779
1074
  }
780
1075
 
781
- .playground__new-session-btn:disabled {
782
- opacity: 0.5;
783
- cursor: not-allowed;
784
- transform: none;
1076
+ .playground__runs-count {
1077
+ margin-left: auto;
1078
+ font-size: var(--fd-text-2xs);
1079
+ font-weight: 600;
1080
+ color: var(--fd-muted-foreground);
1081
+ background: var(--fd-muted);
1082
+ border-radius: 999px;
1083
+ padding: 1px var(--fd-space-xs);
1084
+ min-width: 1.4em;
1085
+ text-align: center;
1086
+ line-height: 1.4;
1087
+ }
1088
+
1089
+ /* Inline runs tree under active session */
1090
+ .playground__executions-inline {
1091
+ margin-left: calc(var(--fd-space-md) + var(--fd-space-xs));
1092
+ margin-bottom: var(--fd-space-xs);
1093
+ border-left: 2px solid var(--fd-border);
1094
+ padding-left: var(--fd-space-xs);
785
1095
  }
786
1096
 
787
- .playground__new-session-btn :global(svg) {
788
- width: 1.125rem;
789
- height: 1.125rem;
1097
+ .playground__executions-inline :global(.execution-list__item) {
1098
+ padding: var(--fd-space-xs) var(--fd-space-sm);
1099
+ font-size: var(--fd-text-xs);
1100
+ border-radius: var(--fd-radius-sm);
1101
+ border-left-width: 2px;
1102
+ }
1103
+
1104
+ .playground__executions-inline :global(.execution-list) {
1105
+ gap: 1px;
1106
+ padding: var(--fd-space-3xs) 0;
790
1107
  }
791
1108
 
792
1109
  /* Sessions */
@@ -797,17 +1114,10 @@
797
1114
  min-height: 0;
798
1115
  }
799
1116
 
800
- .playground__sessions-hint {
801
- font-size: var(--fd-text-2xs);
802
- color: var(--fd-muted-foreground);
803
- margin: var(--fd-space-md) 0 var(--fd-space-2xs) var(--fd-space-md);
804
- line-height: 1.3;
805
- }
806
-
807
1117
  .playground__sessions {
808
1118
  flex: 1;
809
1119
  overflow-y: auto;
810
- padding: 0 var(--fd-space-xs) var(--fd-space-xl);
1120
+ padding: 0 var(--fd-space-3xs) var(--fd-space-xl);
811
1121
  min-height: 0;
812
1122
  }
813
1123
 
@@ -824,7 +1134,6 @@
824
1134
  align-items: center;
825
1135
  justify-content: space-between;
826
1136
  padding: var(--fd-space-sm) var(--fd-space-md);
827
- margin-bottom: var(--fd-space-3xs);
828
1137
  border-radius: var(--fd-radius-md);
829
1138
  border-left: 3px solid transparent;
830
1139
  cursor: pointer;
@@ -956,15 +1265,34 @@
956
1265
  .playground__header {
957
1266
  display: flex;
958
1267
  align-items: center;
959
- justify-content: space-between;
1268
+ gap: var(--fd-space-xs);
960
1269
  height: var(--fd-playground-header-height);
961
- padding: 0 var(--fd-space-2xl);
1270
+ padding: 0 var(--fd-space-xl);
962
1271
  border-bottom: 1px solid var(--fd-border);
963
1272
  background-color: var(--fd-background);
964
1273
  box-sizing: border-box;
965
1274
  flex-shrink: 0;
966
1275
  }
967
1276
 
1277
+ :global(.playground__header-icon) {
1278
+ font-size: var(--fd-text-base);
1279
+ color: var(--fd-muted-foreground);
1280
+ flex-shrink: 0;
1281
+ }
1282
+
1283
+ .playground__header-label {
1284
+ font-size: var(--fd-text-sm);
1285
+ font-weight: 600;
1286
+ color: var(--fd-foreground);
1287
+ flex-shrink: 0;
1288
+ }
1289
+
1290
+ .playground__header-group {
1291
+ display: flex;
1292
+ align-items: center;
1293
+ gap: var(--fd-space-xs);
1294
+ }
1295
+
968
1296
  .playground__header-title {
969
1297
  font-size: var(--fd-text-md);
970
1298
  font-weight: 600;
@@ -992,6 +1320,211 @@
992
1320
  color: var(--fd-foreground);
993
1321
  }
994
1322
 
1323
+ .playground__header-actions {
1324
+ display: flex;
1325
+ align-items: center;
1326
+ gap: var(--fd-space-xs);
1327
+ margin-left: auto;
1328
+ }
1329
+
1330
+ .playground__log-toggle {
1331
+ display: inline-flex;
1332
+ align-items: center;
1333
+ gap: var(--fd-space-3xs);
1334
+ padding: var(--fd-space-3xs) var(--fd-space-sm);
1335
+ border: 1px solid var(--fd-border);
1336
+ border-radius: var(--fd-radius-md);
1337
+ background: transparent;
1338
+ color: var(--fd-muted-foreground);
1339
+ font-size: var(--fd-text-xs);
1340
+ font-weight: 500;
1341
+ cursor: pointer;
1342
+ transition: all var(--fd-transition-fast);
1343
+ line-height: 1;
1344
+ }
1345
+
1346
+ .playground__log-toggle :global(svg) {
1347
+ font-size: var(--fd-text-xs);
1348
+ }
1349
+
1350
+ .playground__log-toggle:hover {
1351
+ background-color: var(--fd-muted);
1352
+ color: var(--fd-foreground);
1353
+ border-color: var(--fd-border-strong);
1354
+ }
1355
+
1356
+ .playground__log-toggle--active {
1357
+ background-color: var(--fd-primary-muted);
1358
+ border-color: var(--fd-primary);
1359
+ color: var(--fd-primary);
1360
+ }
1361
+
1362
+ .playground__refresh--spinning :global(svg) {
1363
+ animation: spin 0.8s linear infinite;
1364
+ }
1365
+
1366
+ /* Session chip (standalone mode) */
1367
+ .playground__session-chip-wrap {
1368
+ position: relative;
1369
+ flex-shrink: 0;
1370
+ }
1371
+
1372
+ .playground__session-chip {
1373
+ display: inline-flex;
1374
+ align-items: center;
1375
+ gap: var(--fd-space-xs);
1376
+ padding: var(--fd-space-3xs) var(--fd-space-sm) var(--fd-space-3xs) var(--fd-space-xs);
1377
+ border: 1px solid var(--fd-border);
1378
+ border-radius: var(--fd-radius-md);
1379
+ background: var(--fd-background);
1380
+ color: var(--fd-foreground);
1381
+ font-size: var(--fd-text-sm);
1382
+ font-weight: 500;
1383
+ cursor: pointer;
1384
+ transition: all var(--fd-transition-fast);
1385
+ max-width: 220px;
1386
+ line-height: 1;
1387
+ }
1388
+
1389
+ .playground__session-chip :global(svg) {
1390
+ flex-shrink: 0;
1391
+ font-size: var(--fd-text-sm);
1392
+ color: var(--fd-muted-foreground);
1393
+ }
1394
+
1395
+ .playground__session-chip:hover {
1396
+ background-color: var(--fd-muted);
1397
+ border-color: var(--fd-border-strong);
1398
+ }
1399
+
1400
+ .playground__session-chip--open {
1401
+ background-color: var(--fd-muted);
1402
+ border-color: var(--fd-border-strong);
1403
+ }
1404
+
1405
+ .playground__session-chip-name {
1406
+ flex: 1;
1407
+ white-space: nowrap;
1408
+ overflow: hidden;
1409
+ text-overflow: ellipsis;
1410
+ min-width: 0;
1411
+ }
1412
+
1413
+ :global(.playground__session-chip-chevron) {
1414
+ color: var(--fd-muted-foreground);
1415
+ flex-shrink: 0;
1416
+ }
1417
+
1418
+ /* Session switcher popover */
1419
+ .playground__session-popover {
1420
+ position: absolute;
1421
+ top: calc(100% + var(--fd-space-xs));
1422
+ left: 0;
1423
+ z-index: 50;
1424
+ min-width: 220px;
1425
+ max-width: 300px;
1426
+ padding: var(--fd-space-xs);
1427
+ background-color: var(--fd-background);
1428
+ border: 1px solid var(--fd-border);
1429
+ border-radius: var(--fd-radius-lg);
1430
+ box-shadow: var(--fd-shadow-lg);
1431
+ }
1432
+
1433
+ .playground__session-popover-divider {
1434
+ height: 1px;
1435
+ background-color: var(--fd-border-muted);
1436
+ margin: var(--fd-space-xs) 0;
1437
+ }
1438
+
1439
+ .playground__session-popover-row {
1440
+ display: flex;
1441
+ align-items: center;
1442
+ gap: 2px;
1443
+ }
1444
+
1445
+ .playground__session-popover-item {
1446
+ display: flex;
1447
+ align-items: center;
1448
+ gap: var(--fd-space-sm);
1449
+ flex: 1;
1450
+ min-width: 0;
1451
+ padding: var(--fd-space-sm) var(--fd-space-sm);
1452
+ border: none;
1453
+ border-radius: var(--fd-radius-sm);
1454
+ background: transparent;
1455
+ color: var(--fd-foreground);
1456
+ font-size: var(--fd-text-sm);
1457
+ text-align: left;
1458
+ cursor: pointer;
1459
+ transition: background-color var(--fd-transition-fast);
1460
+ white-space: nowrap;
1461
+ overflow: hidden;
1462
+ text-overflow: ellipsis;
1463
+ }
1464
+
1465
+ .playground__session-popover-item :global(svg) {
1466
+ flex-shrink: 0;
1467
+ color: var(--fd-muted-foreground);
1468
+ font-size: var(--fd-text-sm);
1469
+ }
1470
+
1471
+ .playground__session-popover-item span {
1472
+ overflow: hidden;
1473
+ text-overflow: ellipsis;
1474
+ }
1475
+
1476
+ .playground__session-popover-item:hover {
1477
+ background-color: var(--fd-muted);
1478
+ }
1479
+
1480
+ .playground__session-popover-item:disabled {
1481
+ opacity: 0.4;
1482
+ cursor: not-allowed;
1483
+ }
1484
+
1485
+ .playground__session-popover-item--new {
1486
+ color: var(--fd-primary);
1487
+ font-weight: 500;
1488
+ }
1489
+
1490
+ .playground__session-popover-item--new :global(svg) {
1491
+ color: var(--fd-primary);
1492
+ }
1493
+
1494
+ .playground__session-popover-item--active {
1495
+ font-weight: 500;
1496
+ }
1497
+
1498
+ :global(.playground__session-popover-check) {
1499
+ color: var(--fd-primary) !important;
1500
+ }
1501
+
1502
+ .playground__session-popover-delete {
1503
+ display: flex;
1504
+ align-items: center;
1505
+ justify-content: center;
1506
+ flex-shrink: 0;
1507
+ width: var(--fd-size-icon-btn);
1508
+ height: var(--fd-size-icon-btn);
1509
+ border: none;
1510
+ border-radius: var(--fd-radius-sm);
1511
+ background: transparent;
1512
+ color: var(--fd-muted-foreground);
1513
+ cursor: pointer;
1514
+ opacity: 0;
1515
+ transition: all var(--fd-transition-fast);
1516
+ }
1517
+
1518
+ .playground__session-popover-row:hover .playground__session-popover-delete {
1519
+ opacity: 1;
1520
+ }
1521
+
1522
+ .playground__session-popover-delete:hover {
1523
+ background-color: var(--fd-error-muted);
1524
+ color: var(--fd-error);
1525
+ opacity: 1;
1526
+ }
1527
+
995
1528
  /* Error */
996
1529
  .playground__error {
997
1530
  display: flex;