@flowdrop/flowdrop 1.8.0 → 1.9.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.
@@ -82,6 +82,13 @@ export function extractCommands(llmResponse) {
82
82
  currentExplanation.push(line);
83
83
  }
84
84
  }
85
+ // Flush dangling multiline buffer (LLM never closed """ and the response
86
+ // ended before any closing fence). Surface as a command so the parser
87
+ // produces a visible error rather than silently dropping the content.
88
+ if (multilineBuffer !== null) {
89
+ commands.push(multilineBuffer.join('\n'));
90
+ multilineBuffer = null;
91
+ }
85
92
  // Flush remaining explanation text
86
93
  if (currentExplanation.length > 0) {
87
94
  explanationParts.push(currentExplanation.join('\n'));
@@ -259,6 +259,18 @@ export function parseCommand(input) {
259
259
  if (!trimmed) {
260
260
  return { ok: false, error: 'Empty command', input };
261
261
  }
262
+ // Detect an unclosed multiline """ block — common when a low-quality LLM
263
+ // omits the closing """ on its own line. The opener pattern is `"""\n`
264
+ // (triple-quote followed by a newline), and a well-formed value must end
265
+ // with `"""`. If we see the opener but not the closer, surface a clear
266
+ // error instead of falling through to a generic "Invalid syntax".
267
+ if (trimmed.includes('"""\n') && !trimmed.endsWith('"""')) {
268
+ return {
269
+ ok: false,
270
+ error: 'Unclosed """ block — missing closing """ on its own line',
271
+ input
272
+ };
273
+ }
262
274
  for (const rule of rules) {
263
275
  const match = trimmed.match(rule.pattern);
264
276
  if (match) {
@@ -21,6 +21,9 @@
21
21
  apiClient?: EnhancedFlowDropApiClient;
22
22
  baseUrl?: string;
23
23
  endpointConfig?: EndpointConfig;
24
+ runLabel?: string;
25
+ /** When true, suppresses breadcrumb and layout events (used inside playground panel) */
26
+ isEmbedded?: boolean;
24
27
  onActionsReady?: (
25
28
  actions: Array<{
26
29
  label: string;
@@ -32,7 +35,7 @@
32
35
  ) => void;
33
36
  }
34
37
 
35
- let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady }: Props =
38
+ let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady, runLabel, isEmbedded = false }: Props =
36
39
  $props();
37
40
 
38
41
  // Initialize API client if not provided
@@ -213,8 +216,9 @@
213
216
  };
214
217
  });
215
218
 
216
- // Send pipeline breadcrumbs to layout when they change
219
+ // Send pipeline breadcrumbs to layout when they change (skip when embedded in playground)
217
220
  $effect(() => {
221
+ if (isEmbedded) return;
218
222
  if (pipelineStatus && pipelineId && workflow) {
219
223
  const sp = m().status.pipeline;
220
224
  const breadcrumbs = [
@@ -239,7 +243,9 @@
239
243
  icon: 'mdi:source-branch'
240
244
  },
241
245
  {
242
- label: sp.pipelineCrumb({ id: pipelineId, status: pipelineStatus }),
246
+ label: runLabel
247
+ ? `${runLabel} – ${pipelineStatus}`
248
+ : sp.pipelineCrumb({ id: pipelineId, status: pipelineStatus }),
243
249
  icon: 'mdi:play-circle'
244
250
  }
245
251
  ];
@@ -281,11 +287,11 @@
281
287
  // In Svelte 5, $effect cleanup runs both on re-execution and component destroy.
282
288
  </script>
283
289
 
284
- <div class="pipeline-status-container">
290
+ <div class="pipeline-status-container" class:pipeline-status-container--embedded={isEmbedded}>
285
291
  <!-- Workflow Visualization using App component -->
286
292
  <App
287
293
  {workflow}
288
- height="100vh"
294
+ height={isEmbedded ? '100%' : '100vh'}
289
295
  width="100%"
290
296
  showNavbar={false}
291
297
  disableSidebar={true}
@@ -310,8 +316,9 @@
310
316
  background: var(--fd-layout-background, var(--fd-muted));
311
317
  }
312
318
 
313
- /* Dark mode override */
314
- :global([data-theme='dark']) .pipeline-status-container {
315
- background: linear-gradient(135deg, #141418 0%, #1a1a2e 50%, #16162a 100%);
319
+ .pipeline-status-container--embedded {
320
+ height: 100%;
321
+ background: var(--fd-muted);
322
+ --fd-layout-background: var(--fd-muted);
316
323
  }
317
324
  </style>
@@ -7,6 +7,9 @@ interface Props {
7
7
  apiClient?: EnhancedFlowDropApiClient;
8
8
  baseUrl?: string;
9
9
  endpointConfig?: EndpointConfig;
10
+ runLabel?: string;
11
+ /** When true, suppresses breadcrumb and layout events (used inside playground panel) */
12
+ isEmbedded?: boolean;
10
13
  onActionsReady?: (actions: Array<{
11
14
  label: string;
12
15
  href: string;
@@ -191,6 +191,23 @@
191
191
  const msg = displayMessages[messageIndex];
192
192
  if (!msg?.commandPreview) return;
193
193
 
194
+ // Refuse to run the batch if any command failed to parse. A corrupted batch
195
+ // (e.g. multiline set without """) causes partial execution and can hang the
196
+ // app — rejecting the whole batch is safer than executing the healthy subset.
197
+ const parseErrorCount = msg.commandPreview.filter((c) => c.status === 'error').length;
198
+ if (parseErrorCount > 0) {
199
+ for (const cmd of msg.commandPreview) {
200
+ if (cmd.status === 'pending') {
201
+ cmd.status = 'error';
202
+ cmd.result = 'Batch refused: fix parse errors before executing';
203
+ }
204
+ }
205
+ appendErrorToHistory(
206
+ `Batch was not executed: ${parseErrorCount} command${parseErrorCount > 1 ? 's have' : ' has'} parse errors. Dismiss this batch and ask the AI to provide corrected commands.`
207
+ );
208
+ return;
209
+ }
210
+
194
211
  const context = getCommandContext();
195
212
  if (!context) {
196
213
  for (const cmd of msg.commandPreview) {
@@ -271,7 +288,11 @@
271
288
  return;
272
289
  }
273
290
 
274
- if (getBehaviorSettings().chatAutoRetry && workflowId && autoRetryCount < MAX_AUTO_RETRIES) {
291
+ if (
292
+ getBehaviorSettings().chatAutoRetry &&
293
+ workflowId &&
294
+ autoRetryCount < MAX_AUTO_RETRIES
295
+ ) {
275
296
  autoRetryCount++;
276
297
  const errorText = buildBatchErrorMessage(
277
298
  completedCount,
@@ -25,6 +25,7 @@
25
25
  getSessionStatus,
26
26
  getCurrentSession
27
27
  } from '../../stores/playgroundStore.svelte.js';
28
+ import type { PlaygroundExecution } from '../../types/playground.js';
28
29
  import {
29
30
  getInterruptsMap,
30
31
  interruptActions,
@@ -74,6 +75,8 @@
74
75
  * @default true
75
76
  */
76
77
  compactSystemMessages?: boolean;
78
+ /** Whether log messages are visible — bindable so parent can host the toggle */
79
+ showLogs?: boolean;
77
80
  }
78
81
 
79
82
  let {
@@ -88,7 +91,8 @@
88
91
  showChatInput = true,
89
92
  showRunButton = true,
90
93
  predefinedMessage,
91
- compactSystemMessages = true
94
+ compactSystemMessages = true,
95
+ showLogs = $bindable(true)
92
96
  }: Props = $props();
93
97
 
94
98
  // Hoist playground branches — states/actions are read 8+ times each in the
@@ -125,9 +129,53 @@
125
129
  let inputField = $state<HTMLTextAreaElement>();
126
130
 
127
131
  /**
128
- * Filter messages based on showLogsInline setting
132
+ * Filter messages based on local showLogs toggle.
133
+ * The showLogsInline prop is still honoured as the initial hint when explicitly set to false.
129
134
  */
130
- const displayMessages = $derived(showLogsInline ? getMessages() : getChatMessages());
135
+ const displayMessages = $derived(showLogs ? getMessages() : getChatMessages());
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Execution separators
139
+ // ---------------------------------------------------------------------------
140
+
141
+ type ChatItem =
142
+ | { type: 'message'; message: PlaygroundMessage; msgIndex: number }
143
+ | { type: 'separator'; key: string; label: string; status: PlaygroundExecution['status'] };
144
+
145
+ /** Map executionId → { label, status } derived from the current session */
146
+ const executionMeta = $derived(
147
+ new Map(
148
+ (getCurrentSession()?.executions ?? []).map((e, i) => [
149
+ e.id,
150
+ { label: `Run #${i + 1}`, status: e.status }
151
+ ])
152
+ )
153
+ );
154
+
155
+ /**
156
+ * Interleave execution-boundary separators into the message list.
157
+ * A separator is inserted before the first message of each new execution.
158
+ */
159
+ const chatItems = $derived(
160
+ (() => {
161
+ const items: ChatItem[] = [];
162
+ let lastExecId: string | null = null;
163
+
164
+ displayMessages.forEach((msg, i) => {
165
+ const execId = msg.executionId ?? null;
166
+ if (execId !== null && execId !== lastExecId) {
167
+ const meta = executionMeta.get(execId);
168
+ if (meta) {
169
+ items.push({ type: 'separator', key: `sep-${execId}`, label: meta.label, status: meta.status });
170
+ lastExecId = execId;
171
+ }
172
+ }
173
+ items.push({ type: 'message', message: msg, msgIndex: i });
174
+ });
175
+
176
+ return items;
177
+ })()
178
+ );
131
179
 
132
180
  /**
133
181
  * Track previous message count for detecting new messages.
@@ -534,26 +582,50 @@
534
582
  {/if}
535
583
  </div>
536
584
  {:else}
537
- <!-- Messages -->
538
- {#each displayMessages as message, index (message.id)}
539
- {#if isInterruptMessage(message)}
540
- <!-- Render interrupt inline -->
541
- {@const interrupt = getInterruptForMessage(message)}
542
- {#if interrupt}
543
- <InterruptBubble
544
- {interrupt}
585
+ <!-- Messages with execution separators -->
586
+ {#each chatItems as item (item.type === 'message' ? item.message.id : item.key)}
587
+ {#if item.type === 'separator'}
588
+ <div
589
+ class="chat-panel__exec-sep"
590
+ class:chat-panel__exec-sep--completed={item.status === 'completed'}
591
+ class:chat-panel__exec-sep--failed={item.status === 'failed'}
592
+ class:chat-panel__exec-sep--running={item.status === 'running'}
593
+ >
594
+ <span class="chat-panel__exec-sep-line"></span>
595
+ <span class="chat-panel__exec-sep-label">
596
+ {#if item.status === 'completed'}
597
+ <Icon icon="mdi:check-circle" class="chat-panel__exec-sep-icon" />
598
+ {:else if item.status === 'failed'}
599
+ <Icon icon="mdi:alert-circle" class="chat-panel__exec-sep-icon" />
600
+ {:else}
601
+ <Icon icon="mdi:play-circle" class="chat-panel__exec-sep-icon" />
602
+ {/if}
603
+ {item.label}
604
+ <span class="chat-panel__exec-sep-status">{item.status}</span>
605
+ </span>
606
+ <span class="chat-panel__exec-sep-line"></span>
607
+ </div>
608
+ {:else}
609
+ {@const message = item.message}
610
+ {@const index = item.msgIndex}
611
+ {#if isInterruptMessage(message)}
612
+ {@const interrupt = getInterruptForMessage(message)}
613
+ {#if interrupt}
614
+ <InterruptBubble
615
+ {interrupt}
616
+ showTimestamp={showTimestamps}
617
+ onResolved={onInterruptResolved}
618
+ />
619
+ {/if}
620
+ {:else}
621
+ <MessageBubble
622
+ {message}
545
623
  showTimestamp={showTimestamps}
546
- onResolved={onInterruptResolved}
624
+ isLast={index === displayMessages.length - 1}
625
+ {enableMarkdown}
626
+ {compactSystemMessages}
547
627
  />
548
628
  {/if}
549
- {:else}
550
- <MessageBubble
551
- {message}
552
- showTimestamp={showTimestamps}
553
- isLast={index === displayMessages.length - 1}
554
- {enableMarkdown}
555
- {compactSystemMessages}
556
- />
557
629
  {/if}
558
630
  {/each}
559
631
 
@@ -644,6 +716,58 @@
644
716
  background-color: var(--fd-background);
645
717
  }
646
718
 
719
+ /* Execution separator */
720
+ .chat-panel__exec-sep {
721
+ display: flex;
722
+ align-items: center;
723
+ gap: var(--fd-space-sm);
724
+ padding: var(--fd-space-md) var(--fd-space-3xl);
725
+ margin: var(--fd-space-sm) 0;
726
+ }
727
+
728
+ .chat-panel__exec-sep-line {
729
+ flex: 1;
730
+ height: 1px;
731
+ background-color: var(--fd-border);
732
+ }
733
+
734
+ .chat-panel__exec-sep-label {
735
+ display: flex;
736
+ align-items: center;
737
+ gap: var(--fd-space-3xs);
738
+ font-size: var(--fd-text-xs);
739
+ font-weight: 600;
740
+ white-space: nowrap;
741
+ color: var(--fd-muted-foreground);
742
+ }
743
+
744
+ :global(.chat-panel__exec-sep-icon) {
745
+ font-size: 0.875rem;
746
+ }
747
+
748
+ .chat-panel__exec-sep--completed .chat-panel__exec-sep-label {
749
+ color: var(--fd-success, #16a34a);
750
+ }
751
+
752
+ .chat-panel__exec-sep--completed .chat-panel__exec-sep-line {
753
+ background-color: var(--fd-success-muted, rgba(22, 163, 74, 0.2));
754
+ }
755
+
756
+ .chat-panel__exec-sep--failed .chat-panel__exec-sep-label {
757
+ color: var(--fd-error);
758
+ }
759
+
760
+ .chat-panel__exec-sep--failed .chat-panel__exec-sep-line {
761
+ background-color: var(--fd-error-muted);
762
+ }
763
+
764
+ .chat-panel__exec-sep-status {
765
+ text-transform: uppercase;
766
+ letter-spacing: 0.04em;
767
+ opacity: 0.8;
768
+ font-size: var(--fd-text-2xs);
769
+ }
770
+
647
771
  /* Messages Container - Scrollable area that takes remaining space */
648
772
  .chat-panel__messages {
649
773
  flex: 1;
@@ -757,7 +881,7 @@
757
881
  display: flex;
758
882
  align-items: flex-end;
759
883
  gap: var(--fd-space-md);
760
- max-width: 800px;
884
+ max-width: 760px;
761
885
  margin: 0 auto;
762
886
  }
763
887
 
@@ -822,8 +946,9 @@
822
946
  }
823
947
 
824
948
  .chat-panel__send-btn:disabled {
825
- background-color: var(--fd-border);
826
- color: var(--fd-muted-foreground);
949
+ background-color: var(--fd-foreground);
950
+ color: var(--fd-background);
951
+ opacity: 0.3;
827
952
  cursor: not-allowed;
828
953
  }
829
954
 
@@ -890,7 +1015,7 @@
890
1015
  border-radius: var(--fd-radius-lg);
891
1016
  color: var(--fd-muted-foreground);
892
1017
  font-size: var(--fd-text-sm);
893
- max-width: 800px;
1018
+ max-width: 760px;
894
1019
  margin: 0 auto;
895
1020
  }
896
1021
 
@@ -40,7 +40,9 @@ interface Props {
40
40
  * @default true
41
41
  */
42
42
  compactSystemMessages?: boolean;
43
+ /** Whether log messages are visible — bindable so parent can host the toggle */
44
+ showLogs?: boolean;
43
45
  }
44
- declare const ChatPanel: import("svelte").Component<Props, {}, "">;
46
+ declare const ChatPanel: import("svelte").Component<Props, {}, "showLogs">;
45
47
  type ChatPanel = ReturnType<typeof ChatPanel>;
46
48
  export default ChatPanel;
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ import Icon from '@iconify/svelte';
3
+ import type { PlaygroundExecution } from '../../types/playground.js';
4
+
5
+ interface Props {
6
+ executions: PlaygroundExecution[];
7
+ activeExecutionId: string | null;
8
+ latestExecutionId: string | null;
9
+ onSelect: (executionId: string) => void;
10
+ }
11
+
12
+ let { executions, activeExecutionId, latestExecutionId, onSelect }: Props = $props();
13
+
14
+ function label(index: number): string {
15
+ return `Run #${index + 1}`;
16
+ }
17
+
18
+ function statusIcon(status: PlaygroundExecution['status']): string {
19
+ if (status === 'completed') return 'mdi:check-circle';
20
+ if (status === 'failed') return 'mdi:alert-circle';
21
+ return '';
22
+ }
23
+ </script>
24
+
25
+ <div class="execution-list">
26
+ {#each executions as execution, i (execution.id)}
27
+ <div
28
+ class="execution-list__item"
29
+ class:execution-list__item--active={execution.id === activeExecutionId}
30
+ class:execution-list__item--running={execution.status === 'running'}
31
+ class:execution-list__item--completed={execution.status === 'completed'}
32
+ class:execution-list__item--failed={execution.status === 'failed'}
33
+ role="button"
34
+ tabindex="0"
35
+ onclick={() => onSelect(execution.id)}
36
+ onkeydown={(e) => e.key === 'Enter' && onSelect(execution.id)}
37
+ >
38
+ {#if execution.status === 'running'}
39
+ <span class="execution-list__running-dot" aria-hidden="true"></span>
40
+ {:else if statusIcon(execution.status)}
41
+ <Icon
42
+ icon={statusIcon(execution.status)}
43
+ class="execution-list__status-icon execution-list__status-icon--{execution.status}"
44
+ />
45
+ {/if}
46
+ <span class="execution-list__label">{label(i)}</span>
47
+ {#if execution.id === latestExecutionId}
48
+ <span class="execution-list__badge">latest</span>
49
+ {/if}
50
+ </div>
51
+ {/each}
52
+ </div>
53
+
54
+ <style>
55
+ /* Match the visual weight of .playground__session items */
56
+ .execution-list {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--fd-space-3xs);
60
+ padding: 0 var(--fd-space-sm);
61
+ }
62
+
63
+ .execution-list__item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: var(--fd-space-xs);
67
+ padding: var(--fd-space-sm) var(--fd-space-md);
68
+ border-radius: var(--fd-radius-md);
69
+ border-left: 3px solid transparent;
70
+ cursor: pointer;
71
+ font-size: var(--fd-text-sm);
72
+ color: var(--fd-foreground);
73
+ transition:
74
+ background-color var(--fd-transition-fast),
75
+ border-left-color var(--fd-transition-fast);
76
+ user-select: none;
77
+ }
78
+
79
+ .execution-list__item:hover {
80
+ background-color: var(--fd-muted);
81
+ border-left-color: var(--fd-border);
82
+ }
83
+
84
+ .execution-list__item--active {
85
+ background-color: var(--fd-primary-muted);
86
+ border-left-color: var(--fd-primary);
87
+ }
88
+
89
+ .execution-list__item--active:hover {
90
+ background-color: var(--fd-primary-muted);
91
+ border-left-color: var(--fd-primary);
92
+ }
93
+
94
+ .execution-list__label {
95
+ flex: 1;
96
+ min-width: 0;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ white-space: nowrap;
100
+ }
101
+
102
+ .execution-list__item--active .execution-list__label {
103
+ color: var(--fd-primary);
104
+ font-weight: 500;
105
+ }
106
+
107
+ .execution-list__badge {
108
+ font-size: var(--fd-text-2xs);
109
+ font-weight: 600;
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.04em;
112
+ color: var(--fd-success);
113
+ flex-shrink: 0;
114
+ }
115
+
116
+ .execution-list__running-dot {
117
+ width: 6px;
118
+ height: 6px;
119
+ border-radius: 50%;
120
+ background-color: var(--fd-success);
121
+ flex-shrink: 0;
122
+ animation: pulse 1.5s ease-in-out infinite;
123
+ }
124
+
125
+ @keyframes pulse {
126
+ 0%, 100% { opacity: 1; }
127
+ 50% { opacity: 0.4; }
128
+ }
129
+
130
+ :global(.execution-list__status-icon) {
131
+ flex-shrink: 0;
132
+ font-size: var(--fd-text-sm);
133
+ }
134
+
135
+ :global(.execution-list__status-icon--completed) {
136
+ color: var(--fd-success, #22c55e);
137
+ }
138
+
139
+ :global(.execution-list__status-icon--failed) {
140
+ color: var(--fd-error, #ef4444);
141
+ }
142
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { PlaygroundExecution } from '../../types/playground.js';
2
+ interface Props {
3
+ executions: PlaygroundExecution[];
4
+ activeExecutionId: string | null;
5
+ latestExecutionId: string | null;
6
+ onSelect: (executionId: string) => void;
7
+ }
8
+ declare const ExecutionList: import("svelte").Component<Props, {}, "">;
9
+ type ExecutionList = ReturnType<typeof ExecutionList>;
10
+ export default ExecutionList;