@d34dman/flowdrop 0.0.32 → 0.0.34

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.
@@ -620,4 +620,26 @@
620
620
  stroke: var(--flowdrop-edge-data-color-selected);
621
621
  stroke-width: 2;
622
622
  }
623
+
624
+ /* Loopback Edge: Dashed gray line for loop iteration connections */
625
+ :global(.flowdrop--edge--loopback path.svelte-flow__edge-path) {
626
+ stroke: var(--flowdrop-edge-loopback-color);
627
+ stroke-width: var(--flowdrop-edge-loopback-width);
628
+ stroke-dasharray: var(--flowdrop-edge-loopback-dasharray);
629
+ opacity: var(--flowdrop-edge-loopback-opacity);
630
+ }
631
+
632
+ :global(.flowdrop--edge--loopback:hover path.svelte-flow__edge-path) {
633
+ stroke: var(--flowdrop-edge-loopback-color-hover);
634
+ stroke-width: var(--flowdrop-edge-loopback-width-hover);
635
+ opacity: 1;
636
+ }
637
+
638
+ :global(.flowdrop--edge--loopback.selected path.svelte-flow__edge-path) {
639
+ stroke: var(--flowdrop-edge-loopback-color-selected);
640
+ stroke-width: var(--flowdrop-edge-loopback-width-hover);
641
+ stroke-dasharray: var(--flowdrop-edge-loopback-dasharray);
642
+ filter: drop-shadow(0 0 3px rgba(139, 92, 246, 0.4));
643
+ opacity: 1;
644
+ }
623
645
  </style>
@@ -35,6 +35,8 @@
35
35
  onStopExecution?: () => void;
36
36
  /** Whether to show log messages inline (false = hide them) */
37
37
  showLogsInline?: boolean;
38
+ /** Whether to enable markdown rendering in messages */
39
+ enableMarkdown?: boolean;
38
40
  }
39
41
 
40
42
  let {
@@ -43,7 +45,8 @@
43
45
  placeholder = 'Type your message...',
44
46
  onSendMessage,
45
47
  onStopExecution,
46
- showLogsInline = false
48
+ showLogsInline = false,
49
+ enableMarkdown = true
47
50
  }: Props = $props();
48
51
 
49
52
  /** Input field value */
@@ -206,6 +209,7 @@
206
209
  {message}
207
210
  showTimestamp={showTimestamps}
208
211
  isLast={index === displayMessages.length - 1}
212
+ {enableMarkdown}
209
213
  />
210
214
  {/each}
211
215
 
@@ -14,6 +14,8 @@ interface Props {
14
14
  onStopExecution?: () => void;
15
15
  /** Whether to show log messages inline (false = hide them) */
16
16
  showLogsInline?: boolean;
17
+ /** Whether to enable markdown rendering in messages */
18
+ enableMarkdown?: boolean;
17
19
  }
18
20
  declare const ChatPanel: import("svelte").Component<Props, {}, "">;
19
21
  type ChatPanel = ReturnType<typeof ChatPanel>;
@@ -3,11 +3,13 @@
3
3
 
4
4
  Renders individual messages in the playground chat interface.
5
5
  Supports different message roles with distinct styling.
6
+ Supports markdown rendering for message content.
6
7
  Styled with BEM syntax.
7
8
  -->
8
9
 
9
10
  <script lang="ts">
10
11
  import Icon from '@iconify/svelte';
12
+ import { marked } from 'marked';
11
13
  import type { PlaygroundMessage, PlaygroundMessageRole } from '../../types/playground.js';
12
14
 
13
15
  /**
@@ -20,9 +22,20 @@
20
22
  showTimestamp?: boolean;
21
23
  /** Whether this is the last message (affects styling) */
22
24
  isLast?: boolean;
25
+ /** Whether to render markdown content */
26
+ enableMarkdown?: boolean;
23
27
  }
24
28
 
25
- let { message, showTimestamp = true, isLast = false }: Props = $props();
29
+ let { message, showTimestamp = true, isLast = false, enableMarkdown = true }: Props = $props();
30
+
31
+ /**
32
+ * Render content as markdown or plain text
33
+ */
34
+ const renderedContent = $derived(
35
+ enableMarkdown && message.role !== 'log'
36
+ ? marked.parse(message.content || '')
37
+ : message.content
38
+ );
26
39
 
27
40
  /**
28
41
  * Get the icon for the message role
@@ -144,7 +157,13 @@
144
157
 
145
158
  <!-- Message Text -->
146
159
  <div class="message-bubble__text">
147
- {message.content}
160
+ {#if enableMarkdown && message.role !== 'log'}
161
+ <!-- Markdown content - marked.js sanitizes content by default -->
162
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
163
+ {@html renderedContent}
164
+ {:else}
165
+ {message.content}
166
+ {/if}
148
167
  </div>
149
168
 
150
169
  <!-- Metadata Footer -->
@@ -188,32 +207,33 @@
188
207
  }
189
208
  }
190
209
 
191
- /* Role-specific styling */
210
+ /* Role-specific styling - Neutral theme */
192
211
  .message-bubble--user {
193
- background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
194
- color: #ffffff;
212
+ background-color: #f1f5f9;
213
+ border: 1px solid #e2e8f0;
214
+ color: #1e293b;
195
215
  margin-left: 2rem;
196
216
  flex-direction: row-reverse;
197
217
  }
198
218
 
199
219
  .message-bubble--assistant {
200
- background-color: #f8fafc;
201
- border: 1px solid #e2e8f0;
202
- color: #1e293b;
220
+ background-color: #ffffff;
221
+ border: 1px solid #e5e7eb;
222
+ color: #1f2937;
203
223
  margin-right: 2rem;
204
224
  }
205
225
 
206
226
  .message-bubble--system {
207
- background-color: #fef3c7;
208
- border: 1px solid #fcd34d;
209
- color: #92400e;
227
+ background-color: #f9fafb;
228
+ border: 1px solid #e5e7eb;
229
+ color: #6b7280;
210
230
  margin: 0 1rem;
211
231
  font-size: 0.875rem;
212
232
  }
213
233
 
214
234
  .message-bubble--log {
215
- background-color: #f1f5f9;
216
- border: 1px solid #cbd5e1;
235
+ background-color: #f8fafc;
236
+ border: 1px solid #e2e8f0;
217
237
  color: #475569;
218
238
  margin: 0 1rem;
219
239
  font-size: 0.8125rem;
@@ -249,18 +269,18 @@
249
269
  }
250
270
 
251
271
  .message-bubble--user .message-bubble__avatar {
252
- background-color: rgba(255, 255, 255, 0.2);
253
- color: #ffffff;
272
+ background-color: #e2e8f0;
273
+ color: #475569;
254
274
  }
255
275
 
256
276
  .message-bubble--assistant .message-bubble__avatar {
257
- background-color: #dbeafe;
258
- color: #2563eb;
277
+ background-color: #e5e7eb;
278
+ color: #374151;
259
279
  }
260
280
 
261
281
  .message-bubble--system .message-bubble__avatar {
262
- background-color: #fde68a;
263
- color: #92400e;
282
+ background-color: #f3f4f6;
283
+ color: #6b7280;
264
284
  }
265
285
 
266
286
  .message-bubble--log .message-bubble__avatar {
@@ -292,14 +312,15 @@
292
312
  .message-bubble__role {
293
313
  font-weight: 600;
294
314
  font-size: 0.8125rem;
315
+ color: #374151;
295
316
  }
296
317
 
297
318
  .message-bubble--user .message-bubble__role {
298
- color: rgba(255, 255, 255, 0.9);
319
+ color: #475569;
299
320
  }
300
321
 
301
322
  .message-bubble--assistant .message-bubble__role {
302
- color: #3b82f6;
323
+ color: #374151;
303
324
  }
304
325
 
305
326
  .message-bubble--log .message-bubble__role {
@@ -319,8 +340,8 @@
319
340
  }
320
341
 
321
342
  .message-bubble__log-level--info {
322
- background-color: #dbeafe;
323
- color: #1d4ed8;
343
+ background-color: #e0f2fe;
344
+ color: #0369a1;
324
345
  }
325
346
 
326
347
  .message-bubble__log-level--warning {
@@ -340,24 +361,152 @@
340
361
 
341
362
  .message-bubble__timestamp {
342
363
  font-size: 0.6875rem;
343
- opacity: 0.7;
364
+ color: #9ca3af;
344
365
  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
345
366
  }
346
367
 
347
368
  .message-bubble--user .message-bubble__timestamp {
348
- color: rgba(255, 255, 255, 0.7);
369
+ color: #9ca3af;
349
370
  }
350
371
 
351
372
  /* Message text */
352
373
  .message-bubble__text {
353
- line-height: 1.5;
354
- white-space: pre-wrap;
374
+ line-height: 1.6;
355
375
  word-break: break-word;
356
376
  }
357
377
 
358
378
  .message-bubble--log .message-bubble__text {
359
379
  font-size: 0.8125rem;
360
380
  line-height: 1.4;
381
+ white-space: pre-wrap;
382
+ }
383
+
384
+ /* Markdown styling for message content */
385
+ .message-bubble__text :global(p) {
386
+ margin: 0 0 0.75rem 0;
387
+ }
388
+
389
+ .message-bubble__text :global(p:last-child) {
390
+ margin-bottom: 0;
391
+ }
392
+
393
+ .message-bubble__text :global(h1),
394
+ .message-bubble__text :global(h2),
395
+ .message-bubble__text :global(h3),
396
+ .message-bubble__text :global(h4),
397
+ .message-bubble__text :global(h5),
398
+ .message-bubble__text :global(h6) {
399
+ margin: 1rem 0 0.5rem 0;
400
+ font-weight: 600;
401
+ line-height: 1.3;
402
+ }
403
+
404
+ .message-bubble__text :global(h1:first-child),
405
+ .message-bubble__text :global(h2:first-child),
406
+ .message-bubble__text :global(h3:first-child),
407
+ .message-bubble__text :global(h4:first-child),
408
+ .message-bubble__text :global(h5:first-child),
409
+ .message-bubble__text :global(h6:first-child) {
410
+ margin-top: 0;
411
+ }
412
+
413
+ .message-bubble__text :global(h1) {
414
+ font-size: 1.25rem;
415
+ }
416
+
417
+ .message-bubble__text :global(h2) {
418
+ font-size: 1.125rem;
419
+ }
420
+
421
+ .message-bubble__text :global(h3) {
422
+ font-size: 1rem;
423
+ }
424
+
425
+ .message-bubble__text :global(ul),
426
+ .message-bubble__text :global(ol) {
427
+ margin: 0.5rem 0;
428
+ padding-left: 1.5rem;
429
+ }
430
+
431
+ .message-bubble__text :global(li) {
432
+ margin: 0.25rem 0;
433
+ }
434
+
435
+ .message-bubble__text :global(code) {
436
+ background-color: rgba(0, 0, 0, 0.06);
437
+ padding: 0.125rem 0.375rem;
438
+ border-radius: 0.25rem;
439
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
440
+ font-size: 0.875em;
441
+ }
442
+
443
+ .message-bubble__text :global(pre) {
444
+ background-color: #1e293b;
445
+ color: #e2e8f0;
446
+ padding: 0.75rem 1rem;
447
+ border-radius: 0.5rem;
448
+ overflow-x: auto;
449
+ margin: 0.75rem 0;
450
+ font-size: 0.8125rem;
451
+ line-height: 1.5;
452
+ }
453
+
454
+ .message-bubble__text :global(pre code) {
455
+ background-color: transparent;
456
+ padding: 0;
457
+ border-radius: 0;
458
+ color: inherit;
459
+ font-size: inherit;
460
+ }
461
+
462
+ .message-bubble__text :global(blockquote) {
463
+ border-left: 3px solid #d1d5db;
464
+ padding-left: 1rem;
465
+ margin: 0.75rem 0;
466
+ color: #6b7280;
467
+ font-style: italic;
468
+ }
469
+
470
+ .message-bubble__text :global(a) {
471
+ color: #2563eb;
472
+ text-decoration: none;
473
+ }
474
+
475
+ .message-bubble__text :global(a:hover) {
476
+ text-decoration: underline;
477
+ }
478
+
479
+ .message-bubble__text :global(hr) {
480
+ border: none;
481
+ border-top: 1px solid #e5e7eb;
482
+ margin: 1rem 0;
483
+ }
484
+
485
+ .message-bubble__text :global(table) {
486
+ border-collapse: collapse;
487
+ width: 100%;
488
+ margin: 0.75rem 0;
489
+ font-size: 0.875rem;
490
+ }
491
+
492
+ .message-bubble__text :global(th),
493
+ .message-bubble__text :global(td) {
494
+ border: 1px solid #e5e7eb;
495
+ padding: 0.5rem 0.75rem;
496
+ text-align: left;
497
+ }
498
+
499
+ .message-bubble__text :global(th) {
500
+ background-color: #f9fafb;
501
+ font-weight: 600;
502
+ }
503
+
504
+ .message-bubble__text :global(strong) {
505
+ font-weight: 600;
506
+ }
507
+
508
+ .message-bubble__text :global(em) {
509
+ font-style: italic;
361
510
  }
362
511
 
363
512
  /* Footer */
@@ -367,7 +516,7 @@
367
516
  gap: 0.75rem;
368
517
  margin-top: 0.5rem;
369
518
  font-size: 0.6875rem;
370
- opacity: 0.7;
519
+ color: #9ca3af;
371
520
  }
372
521
 
373
522
  .message-bubble--user .message-bubble__footer {
@@ -9,6 +9,8 @@ interface Props {
9
9
  showTimestamp?: boolean;
10
10
  /** Whether this is the last message (affects styling) */
11
11
  isLast?: boolean;
12
+ /** Whether to render markdown content */
13
+ enableMarkdown?: boolean;
12
14
  }
13
15
  declare const MessageBubble: import("svelte").Component<Props, {}, "">;
14
16
  type MessageBubble = ReturnType<typeof MessageBubble>;
@@ -335,15 +335,20 @@
335
335
  playgroundActions.addMessages(response.data);
336
336
  }
337
337
 
338
- // Update session status
339
- if (response.sessionStatus) {
340
- playgroundActions.updateSessionStatus(response.sessionStatus);
341
-
342
- // Stop executing if completed or failed
343
- if (response.sessionStatus === 'completed' || response.sessionStatus === 'failed') {
344
- playgroundActions.setExecuting(false);
345
- }
338
+ // Update session status
339
+ if (response.sessionStatus) {
340
+ playgroundActions.updateSessionStatus(response.sessionStatus);
341
+
342
+ // Stop executing if idle, completed, or failed
343
+ // "idle" means no processing is happening (execution finished)
344
+ if (
345
+ response.sessionStatus === 'idle' ||
346
+ response.sessionStatus === 'completed' ||
347
+ response.sessionStatus === 'failed'
348
+ ) {
349
+ playgroundActions.setExecuting(false);
346
350
  }
351
+ }
347
352
  },
348
353
  pollingInterval
349
354
  );
@@ -513,6 +518,7 @@
513
518
  showTimestamps={config.showTimestamps ?? true}
514
519
  autoScroll={config.autoScroll ?? true}
515
520
  showLogsInline={config.logDisplayMode === 'inline'}
521
+ enableMarkdown={config.enableMarkdown ?? true}
516
522
  onSendMessage={handleSendMessage}
517
523
  onStopExecution={handleStopExecution}
518
524
  />
@@ -14,7 +14,7 @@
14
14
  * import { getStatusColor, createDefaultExecutionInfo } from "@d34dman/flowdrop/core";
15
15
  * ```
16
16
  */
17
- export type { NodeCategory, NodeDataType, NodePort, DynamicPort, Branch, NodeMetadata, NodeExtensions, NodeUIExtensions, ConfigValues, WorkflowNode, WorkflowEdge, Workflow, ApiResponse, NodesResponse, WorkflowResponse, WorkflowsResponse, ExecutionStatus, ExecutionResult, FlowDropConfig, WorkflowEvents, BuiltinNodeType, PortConfig, PortCompatibilityRule, ConfigSchema, ConfigProperty, HttpMethod, DynamicSchemaEndpoint, ExternalEditLink, ConfigEditOptions } from '../types/index.js';
17
+ export type { NodeCategory, NodeDataType, NodePort, DynamicPort, Branch, NodeMetadata, NodeExtensions, NodeUIExtensions, ConfigValues, WorkflowNode, WorkflowEdge, Workflow, ApiResponse, NodesResponse, WorkflowResponse, WorkflowsResponse, ExecutionStatus, ExecutionResult, FlowDropConfig, WorkflowEvents, BuiltinNodeType, PortConfig, PortCompatibilityRule, ConfigSchema, ConfigProperty, HttpMethod, DynamicSchemaEndpoint, ExternalEditLink, ConfigEditOptions, EdgeCategory } from '../types/index.js';
18
18
  export type { WorkflowEditorConfig, EditorFeatures, UIConfig, APIConfig, ExecutionConfig, StorageConfig } from '../types/config.js';
19
19
  export type { AuthProvider, StaticAuthConfig, CallbackAuthConfig } from '../types/auth.js';
20
20
  export type { WorkflowChangeType, FlowDropEventHandlers, FlowDropFeatures } from '../types/events.js';
@@ -35,6 +35,7 @@ export * from '../utils/colors.js';
35
35
  export * from '../utils/icons.js';
36
36
  export * from '../utils/config.js';
37
37
  export * from '../utils/nodeTypes.js';
38
+ export { isLoopbackEdge, isValidLoopbackCycle, hasCycles, hasInvalidCycles } from '../utils/connections.js';
38
39
  export { isFieldOptionArray, normalizeOptions } from '../components/form/types.js';
39
40
  export { DEFAULT_PORT_CONFIG } from '../config/defaultPortConfig.js';
40
41
  export { defaultEndpointConfig, createEndpointConfig } from '../config/endpoints.js';
@@ -38,6 +38,8 @@ export * from '../utils/icons.js';
38
38
  export * from '../utils/config.js';
39
39
  // Node type utilities
40
40
  export * from '../utils/nodeTypes.js';
41
+ // Connection utilities (including loopback edge detection)
42
+ export { isLoopbackEdge, isValidLoopbackCycle, hasCycles, hasInvalidCycles } from '../utils/connections.js';
41
43
  // Form type utilities
42
44
  export { isFieldOptionArray, normalizeOptions } from '../components/form/types.js';
43
45
  // ============================================================================
@@ -14,9 +14,10 @@ export declare function generateNodeId(nodeTypeId: string, existingNodes: Workfl
14
14
  * Edge category type for styling purposes
15
15
  * - trigger: For control flow connections (dataType: "trigger")
16
16
  * - tool: Dashed amber line for tool connections (dataType: "tool")
17
+ * - loopback: Dashed gray line for loop iteration connections (targets loop_back port)
17
18
  * - data: Normal gray line for all other data connections
18
19
  */
19
- export type EdgeCategory = 'trigger' | 'tool' | 'data';
20
+ export type EdgeCategory = 'trigger' | 'tool' | 'loopback' | 'data';
20
21
  /**
21
22
  * Edge styling configuration based on source port data type
22
23
  */
@@ -49,12 +50,25 @@ export declare class EdgeStylingHelper {
49
50
  static getPortDataType(node: WorkflowNodeType, portId: string, portType: 'input' | 'output'): string | null;
50
51
  /**
51
52
  * Determine the edge category based on source port data type
53
+ * Note: This method does not check for loopback edges.
54
+ * Use getEdgeCategoryWithLoopback() for full edge categorization.
55
+ *
52
56
  * @param sourcePortDataType - The data type of the source output port
53
57
  * @returns The edge category for styling
54
58
  */
55
59
  static getEdgeCategory(sourcePortDataType: string | null): EdgeCategory;
56
60
  /**
57
- * Apply custom styling to connection edges based on source port data type:
61
+ * Determine the full edge category including loopback detection
62
+ * Loopback edges take precedence over source port data type
63
+ *
64
+ * @param edge - The edge to categorize
65
+ * @param sourcePortDataType - The data type of the source output port
66
+ * @returns The edge category for styling
67
+ */
68
+ static getEdgeCategoryWithLoopback(edge: WorkflowEdge, sourcePortDataType: string | null): EdgeCategory;
69
+ /**
70
+ * Apply custom styling to connection edges based on edge type:
71
+ * - Loopback: Dashed gray line for loop iteration (targets loop_back port)
58
72
  * - Trigger ports: Solid black line with arrow
59
73
  * - Tool ports: Dashed amber line with arrow
60
74
  * - Data ports: Normal gray line with arrow
@@ -118,9 +132,24 @@ export declare class WorkflowOperationsHelper {
118
132
  */
119
133
  static exportWorkflow(workflow: Workflow | null): void;
120
134
  /**
121
- * Check if workflow has cycles
135
+ * Check if workflow has invalid cycles (excludes valid loopback cycles)
136
+ * Valid loopback cycles are used for ForEach node iteration and should not
137
+ * trigger a warning.
138
+ *
139
+ * @param nodes - Array of workflow nodes
140
+ * @param edges - Array of workflow edges
141
+ * @returns True if there are invalid (non-loopback) cycles
122
142
  */
123
143
  static checkWorkflowCycles(nodes: WorkflowNodeType[], edges: WorkflowEdge[]): boolean;
144
+ /**
145
+ * Check if workflow has any cycles (including valid loopback cycles)
146
+ * Use this when you need to detect ALL cycles regardless of type.
147
+ *
148
+ * @param nodes - Array of workflow nodes
149
+ * @param edges - Array of workflow edges
150
+ * @returns True if any cycle exists
151
+ */
152
+ static checkWorkflowHasAnyCycles(nodes: WorkflowNodeType[], edges: WorkflowEdge[]): boolean;
124
153
  }
125
154
  /**
126
155
  * Configuration helper
@@ -3,7 +3,7 @@
3
3
  * Contains business logic for workflow operations
4
4
  */
5
5
  import { MarkerType } from '@xyflow/svelte';
6
- import { hasCycles } from '../utils/connections.js';
6
+ import { hasCycles, hasInvalidCycles, isLoopbackEdge } from '../utils/connections.js';
7
7
  import { workflowApi, nodeApi, setEndpointConfig } from '../services/api.js';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { workflowActions } from '../stores/workflowStore.js';
@@ -104,6 +104,9 @@ export class EdgeStylingHelper {
104
104
  }
105
105
  /**
106
106
  * Determine the edge category based on source port data type
107
+ * Note: This method does not check for loopback edges.
108
+ * Use getEdgeCategoryWithLoopback() for full edge categorization.
109
+ *
107
110
  * @param sourcePortDataType - The data type of the source output port
108
111
  * @returns The edge category for styling
109
112
  */
@@ -118,7 +121,25 @@ export class EdgeStylingHelper {
118
121
  return 'data';
119
122
  }
120
123
  /**
121
- * Apply custom styling to connection edges based on source port data type:
124
+ * Determine the full edge category including loopback detection
125
+ * Loopback edges take precedence over source port data type
126
+ *
127
+ * @param edge - The edge to categorize
128
+ * @param sourcePortDataType - The data type of the source output port
129
+ * @returns The edge category for styling
130
+ */
131
+ static getEdgeCategoryWithLoopback(edge, sourcePortDataType) {
132
+ // Loopback edges are identified by their target handle
133
+ // Check this first as it takes precedence
134
+ if (isLoopbackEdge(edge)) {
135
+ return 'loopback';
136
+ }
137
+ // Fall back to source port data type categorization
138
+ return this.getEdgeCategory(sourcePortDataType);
139
+ }
140
+ /**
141
+ * Apply custom styling to connection edges based on edge type:
142
+ * - Loopback: Dashed gray line for loop iteration (targets loop_back port)
122
143
  * - Trigger ports: Solid black line with arrow
123
144
  * - Tool ports: Dashed amber line with arrow
124
145
  * - Data ports: Normal gray line with arrow
@@ -130,17 +151,30 @@ export class EdgeStylingHelper {
130
151
  const sourcePortDataType = sourcePortId
131
152
  ? this.getPortDataType(sourceNode, sourcePortId, 'output')
132
153
  : null;
133
- // Determine edge category based on source port data type
134
- const edgeCategory = this.getEdgeCategory(sourcePortDataType);
154
+ // Determine edge category (loopback takes precedence)
155
+ const edgeCategory = this.getEdgeCategoryWithLoopback(edge, sourcePortDataType);
135
156
  // Edge color constants (matching CSS tokens in base.css)
136
157
  const EDGE_COLORS = {
137
158
  trigger: '#111827', // --color-ref-gray-900
138
159
  tool: '#f59e0b', // --color-ref-amber-500
160
+ loopback: '#6b7280', // --color-ref-gray-500
139
161
  data: '#9ca3af' // --color-ref-gray-400
140
162
  };
141
163
  // Apply styling based on edge category
142
164
  // CSS classes handle styling via tokens; inline styles are fallback
143
165
  switch (edgeCategory) {
166
+ case 'loopback':
167
+ // Loopback edges: dashed gray line for loop iteration
168
+ edge.style =
169
+ 'stroke: var(--flowdrop-edge-loopback-color); stroke-dasharray: 5 5; stroke-width: var(--flowdrop-edge-loopback-width);';
170
+ edge.class = 'flowdrop--edge--loopback';
171
+ edge.markerEnd = {
172
+ type: MarkerType.ArrowClosed,
173
+ width: 14,
174
+ height: 14,
175
+ color: EDGE_COLORS.loopback
176
+ };
177
+ break;
144
178
  case 'trigger':
145
179
  // Trigger edges: solid dark line for control flow
146
180
  edge.style =
@@ -183,7 +217,7 @@ export class EdgeStylingHelper {
183
217
  metadata: {
184
218
  ...(edge.data?.metadata || {}),
185
219
  edgeType: edgeCategory,
186
- sourcePortDataType: sourcePortDataType || undefined
220
+ sourcePortDataType: sourcePortDataType ?? undefined
187
221
  },
188
222
  targetNodeType: targetNode.type,
189
223
  targetCategory: targetNode.data.metadata.category
@@ -484,9 +518,26 @@ export class WorkflowOperationsHelper {
484
518
  URL.revokeObjectURL(url);
485
519
  }
486
520
  /**
487
- * Check if workflow has cycles
521
+ * Check if workflow has invalid cycles (excludes valid loopback cycles)
522
+ * Valid loopback cycles are used for ForEach node iteration and should not
523
+ * trigger a warning.
524
+ *
525
+ * @param nodes - Array of workflow nodes
526
+ * @param edges - Array of workflow edges
527
+ * @returns True if there are invalid (non-loopback) cycles
488
528
  */
489
529
  static checkWorkflowCycles(nodes, edges) {
530
+ return hasInvalidCycles(nodes, edges);
531
+ }
532
+ /**
533
+ * Check if workflow has any cycles (including valid loopback cycles)
534
+ * Use this when you need to detect ALL cycles regardless of type.
535
+ *
536
+ * @param nodes - Array of workflow nodes
537
+ * @param edges - Array of workflow edges
538
+ * @returns True if any cycle exists
539
+ */
540
+ static checkWorkflowHasAnyCycles(nodes, edges) {
490
541
  return hasCycles(nodes, edges);
491
542
  }
492
543
  }
@@ -263,8 +263,11 @@ export class PlaygroundService {
263
263
  this.currentBackoff = interval;
264
264
  // Call the callback with new messages
265
265
  callback(response);
266
- // Stop polling if session is completed or failed
267
- if (response.sessionStatus === 'completed' || response.sessionStatus === 'failed') {
266
+ // Stop polling if session is idle, completed, or failed
267
+ // "idle" means no processing is happening (execution finished)
268
+ if (response.sessionStatus === 'idle' ||
269
+ response.sessionStatus === 'completed' ||
270
+ response.sessionStatus === 'failed') {
268
271
  this.stopPolling();
269
272
  return;
270
273
  }
@@ -1157,6 +1157,15 @@
1157
1157
  --flowdrop-edge-data-color-hover: var(--color-ref-gray-500);
1158
1158
  --flowdrop-edge-data-color-selected: var(--color-ref-violet-600);
1159
1159
 
1160
+ /* Loopback edge styling tokens */
1161
+ --flowdrop-edge-loopback-color: var(--color-ref-gray-500);
1162
+ --flowdrop-edge-loopback-color-hover: var(--color-ref-gray-600);
1163
+ --flowdrop-edge-loopback-color-selected: var(--color-ref-violet-600);
1164
+ --flowdrop-edge-loopback-width: 1.5px;
1165
+ --flowdrop-edge-loopback-width-hover: 2.5px;
1166
+ --flowdrop-edge-loopback-dasharray: 5 5;
1167
+ --flowdrop-edge-loopback-opacity: 0.85;
1168
+
1160
1169
  /* Tool node theming tokens */
1161
1170
  --flowdrop-tool-node-color: var(--color-ref-amber-500);
1162
1171
  --flowdrop-tool-node-color-light: var(--color-ref-amber-50);
@@ -656,13 +656,14 @@ export interface WorkflowNode extends Node {
656
656
  };
657
657
  }
658
658
  /**
659
- * Edge category types based on source port data type
659
+ * Edge category types based on source port data type or target handle
660
660
  * Used for visual styling of edges on the canvas
661
661
  * - trigger: For control flow connections (dataType: "trigger")
662
662
  * - tool: Dashed amber line for tool connections (dataType: "tool")
663
+ * - loopback: Dashed gray line for loop iteration (targets loop_back port)
663
664
  * - data: Normal gray line for all other data connections
664
665
  */
665
- export type EdgeCategory = 'trigger' | 'tool' | 'data';
666
+ export type EdgeCategory = 'trigger' | 'tool' | 'loopback' | 'data';
666
667
  /**
667
668
  * Extended edge type for workflows
668
669
  */
@@ -178,6 +178,8 @@ export interface PlaygroundConfig {
178
178
  showTimestamps?: boolean;
179
179
  /** Show log messages inline or in collapsible section (default: "collapsible") */
180
180
  logDisplayMode?: 'inline' | 'collapsible';
181
+ /** Enable markdown rendering in messages (default: true) */
182
+ enableMarkdown?: boolean;
181
183
  }
182
184
  /**
183
185
  * Display mode for the Playground component
@@ -2,6 +2,29 @@
2
2
  * Connection validation utilities for FlowDrop
3
3
  */
4
4
  import type { NodeMetadata, NodePort, NodeDataType, WorkflowNode, WorkflowEdge, PortConfig, PortDataTypeConfig } from '../types/index.js';
5
+ /**
6
+ * Determines if an edge is a loopback edge.
7
+ * Loopback edges target the special `loop_back` input port on ForEach nodes.
8
+ * These edges are used to trigger the next iteration in a loop construct.
9
+ *
10
+ * @param edge - The edge to check
11
+ * @returns True if the edge is a loopback edge
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const edge = { targetHandle: "foreach.1-input-loop_back", ... };
16
+ * const isLoop = isLoopbackEdge(edge); // true
17
+ * ```
18
+ */
19
+ export declare function isLoopbackEdge(edge: WorkflowEdge): boolean;
20
+ /**
21
+ * Checks if a cycle consists entirely of loopback edges.
22
+ * A valid loopback cycle only contains edges that target loop_back ports.
23
+ *
24
+ * @param cycleEdges - Array of edges that form a cycle
25
+ * @returns True if all edges in the cycle are loopback edges
26
+ */
27
+ export declare function isValidLoopbackCycle(cycleEdges: WorkflowEdge[]): boolean;
5
28
  /**
6
29
  * Configurable port compatibility checker
7
30
  */
@@ -71,8 +94,31 @@ export declare function getConnectionSuggestions(nodeId: string, nodes: Workflow
71
94
  }>;
72
95
  /**
73
96
  * Check if a workflow has any cycles (prevent infinite loops)
97
+ * Note: This function detects ALL cycles, including valid loopback cycles.
98
+ * Use `hasInvalidCycles` to check only for cycles that could cause infinite execution.
99
+ *
100
+ * @param nodes - Array of workflow nodes
101
+ * @param edges - Array of workflow edges
102
+ * @returns True if any cycle exists in the workflow
74
103
  */
75
104
  export declare function hasCycles(nodes: WorkflowNode[], edges: WorkflowEdge[]): boolean;
105
+ /**
106
+ * Check if a workflow has any invalid cycles (non-loopback cycles).
107
+ * This excludes valid loopback cycles used for ForEach iteration.
108
+ * Only cycles that could cause infinite execution are detected.
109
+ *
110
+ * @param nodes - Array of workflow nodes
111
+ * @param edges - Array of workflow edges
112
+ * @returns True if any invalid (non-loopback) cycle exists
113
+ *
114
+ * @example
115
+ * ```typescript
116
+ * // A cycle through a loopback edge is valid (returns false)
117
+ * // A cycle through regular data edges is invalid (returns true)
118
+ * const hasInvalid = hasInvalidCycles(nodes, edges);
119
+ * ```
120
+ */
121
+ export declare function hasInvalidCycles(nodes: WorkflowNode[], edges: WorkflowEdge[]): boolean;
76
122
  /**
77
123
  * Get the execution order for a workflow (topological sort)
78
124
  */
@@ -1,6 +1,39 @@
1
1
  /**
2
2
  * Connection validation utilities for FlowDrop
3
3
  */
4
+ /**
5
+ * Loopback port name constant
6
+ * This is the standard input port name used for loop iteration triggers
7
+ */
8
+ const LOOPBACK_PORT_NAME = "loop_back";
9
+ /**
10
+ * Determines if an edge is a loopback edge.
11
+ * Loopback edges target the special `loop_back` input port on ForEach nodes.
12
+ * These edges are used to trigger the next iteration in a loop construct.
13
+ *
14
+ * @param edge - The edge to check
15
+ * @returns True if the edge is a loopback edge
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const edge = { targetHandle: "foreach.1-input-loop_back", ... };
20
+ * const isLoop = isLoopbackEdge(edge); // true
21
+ * ```
22
+ */
23
+ export function isLoopbackEdge(edge) {
24
+ const targetHandle = edge.targetHandle ?? "";
25
+ return targetHandle.includes(`-input-${LOOPBACK_PORT_NAME}`);
26
+ }
27
+ /**
28
+ * Checks if a cycle consists entirely of loopback edges.
29
+ * A valid loopback cycle only contains edges that target loop_back ports.
30
+ *
31
+ * @param cycleEdges - Array of edges that form a cycle
32
+ * @returns True if all edges in the cycle are loopback edges
33
+ */
34
+ export function isValidLoopbackCycle(cycleEdges) {
35
+ return cycleEdges.every((edge) => isLoopbackEdge(edge));
36
+ }
4
37
  /**
5
38
  * Configurable port compatibility checker
6
39
  */
@@ -225,6 +258,12 @@ export function getConnectionSuggestions(nodeId, nodes, nodeTypes) {
225
258
  }
226
259
  /**
227
260
  * Check if a workflow has any cycles (prevent infinite loops)
261
+ * Note: This function detects ALL cycles, including valid loopback cycles.
262
+ * Use `hasInvalidCycles` to check only for cycles that could cause infinite execution.
263
+ *
264
+ * @param nodes - Array of workflow nodes
265
+ * @param edges - Array of workflow edges
266
+ * @returns True if any cycle exists in the workflow
228
267
  */
229
268
  export function hasCycles(nodes, edges) {
230
269
  const visited = new Set();
@@ -254,6 +293,58 @@ export function hasCycles(nodes, edges) {
254
293
  }
255
294
  return false;
256
295
  }
296
+ /**
297
+ * Check if a workflow has any invalid cycles (non-loopback cycles).
298
+ * This excludes valid loopback cycles used for ForEach iteration.
299
+ * Only cycles that could cause infinite execution are detected.
300
+ *
301
+ * @param nodes - Array of workflow nodes
302
+ * @param edges - Array of workflow edges
303
+ * @returns True if any invalid (non-loopback) cycle exists
304
+ *
305
+ * @example
306
+ * ```typescript
307
+ * // A cycle through a loopback edge is valid (returns false)
308
+ * // A cycle through regular data edges is invalid (returns true)
309
+ * const hasInvalid = hasInvalidCycles(nodes, edges);
310
+ * ```
311
+ */
312
+ export function hasInvalidCycles(nodes, edges) {
313
+ // Filter out loopback edges - these create valid cycles for loop iteration
314
+ const nonLoopbackEdges = edges.filter((edge) => !isLoopbackEdge(edge));
315
+ // Check for cycles using only non-loopback edges
316
+ const visited = new Set();
317
+ const recursionStack = new Set();
318
+ /**
319
+ * DFS utility to detect cycles in the graph
320
+ * @param nodeId - Current node being visited
321
+ * @returns True if a cycle is found from this node
322
+ */
323
+ function hasCycleUtil(nodeId) {
324
+ if (recursionStack.has(nodeId))
325
+ return true;
326
+ if (visited.has(nodeId))
327
+ return false;
328
+ visited.add(nodeId);
329
+ recursionStack.add(nodeId);
330
+ // Get all outgoing non-loopback edges from this node
331
+ const outgoingEdges = nonLoopbackEdges.filter((e) => e.source === nodeId);
332
+ for (const edge of outgoingEdges) {
333
+ if (hasCycleUtil(edge.target))
334
+ return true;
335
+ }
336
+ recursionStack.delete(nodeId);
337
+ return false;
338
+ }
339
+ // Check each node for cycles
340
+ for (const node of nodes) {
341
+ if (!visited.has(node.id)) {
342
+ if (hasCycleUtil(node.id))
343
+ return true;
344
+ }
345
+ }
346
+ return false;
347
+ }
257
348
  /**
258
349
  * Get the execution order for a workflow (topological sort)
259
350
  */
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@d34dman/flowdrop",
3
3
  "license": "MIT",
4
4
  "private": false,
5
- "version": "0.0.32",
5
+ "version": "0.0.34",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
8
8
  "build": "vite build && npm run prepack",