@flowdrop/flowdrop 1.12.0 → 1.14.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 (71) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ConfigForm.svelte +1 -0
  3. package/dist/components/ConfigPanel.svelte +7 -1
  4. package/dist/components/NodeSwapPicker.svelte +5 -1
  5. package/dist/components/PipelineStatus.svelte +11 -2
  6. package/dist/components/SchemaForm.svelte +1 -0
  7. package/dist/components/SettingsPanel.svelte +5 -1
  8. package/dist/components/WorkflowEditor.svelte +5 -1
  9. package/dist/components/chat/AIChatPanel.svelte +1 -5
  10. package/dist/components/form/FormAutocomplete.svelte +69 -15
  11. package/dist/components/form/FormField.svelte +21 -0
  12. package/dist/components/form/FormFieldLight.svelte +1 -0
  13. package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
  14. package/dist/components/interrupt/InterruptBubble.svelte +75 -17
  15. package/dist/components/interrupt/InterruptBubble.svelte.d.ts +11 -0
  16. package/dist/components/playground/ChatBubble.svelte +287 -0
  17. package/dist/components/playground/ChatBubble.svelte.d.ts +10 -0
  18. package/dist/components/playground/ChatInput.svelte +11 -5
  19. package/dist/components/playground/ControlPanel.svelte +42 -29
  20. package/dist/components/playground/ExecutionConsole.svelte +5 -1
  21. package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
  22. package/dist/components/playground/ExecutionList.svelte +7 -2
  23. package/dist/components/playground/HierarchyTrail.svelte +88 -0
  24. package/dist/components/playground/HierarchyTrail.svelte.d.ts +7 -0
  25. package/dist/components/playground/LogRow.svelte +179 -0
  26. package/dist/components/playground/LogRow.svelte.d.ts +8 -0
  27. package/dist/components/playground/MessageBubble.stories.svelte +89 -0
  28. package/dist/components/playground/MessageBubble.svelte +23 -738
  29. package/dist/components/playground/MessageBubble.svelte.d.ts +3 -11
  30. package/dist/components/playground/MessageCard.svelte +107 -0
  31. package/dist/components/playground/MessageCard.svelte.d.ts +10 -0
  32. package/dist/components/playground/MessageMarkdown.svelte +170 -0
  33. package/dist/components/playground/MessageMarkdown.svelte.d.ts +7 -0
  34. package/dist/components/playground/MessageNotice.svelte +121 -0
  35. package/dist/components/playground/MessageNotice.svelte.d.ts +9 -0
  36. package/dist/components/playground/MessageStream.svelte +215 -10
  37. package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
  38. package/dist/components/playground/MessageTagChip.svelte +117 -0
  39. package/dist/components/playground/MessageTagChip.svelte.d.ts +7 -0
  40. package/dist/components/playground/MessageTagStrip.svelte +37 -0
  41. package/dist/components/playground/MessageTagStrip.svelte.d.ts +7 -0
  42. package/dist/components/playground/PipelineKanbanView.svelte +40 -11
  43. package/dist/components/playground/PipelinePanel.svelte +5 -1
  44. package/dist/components/playground/PipelineTableView.svelte +20 -6
  45. package/dist/components/playground/Playground.svelte +84 -22
  46. package/dist/components/playground/PlaygroundStudio.svelte +99 -7
  47. package/dist/components/playground/messageDisplay.d.ts +19 -0
  48. package/dist/components/playground/messageDisplay.js +62 -0
  49. package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
  50. package/dist/form/autocomplete.d.ts +1 -0
  51. package/dist/form/autocomplete.js +1 -0
  52. package/dist/form/index.d.ts +17 -0
  53. package/dist/form/index.js +19 -0
  54. package/dist/messages/defaults.d.ts +5 -0
  55. package/dist/messages/defaults.js +6 -0
  56. package/dist/openapi/v1/openapi.yaml +6403 -0
  57. package/dist/schemas/v1/workflow.schema.json +46 -1
  58. package/dist/services/categoriesApi.d.ts +2 -1
  59. package/dist/services/categoriesApi.js +5 -3
  60. package/dist/services/playgroundService.d.ts +23 -4
  61. package/dist/services/playgroundService.js +22 -9
  62. package/dist/services/portConfigApi.d.ts +2 -1
  63. package/dist/services/portConfigApi.js +5 -3
  64. package/dist/stores/playgroundStore.svelte.d.ts +22 -1
  65. package/dist/stores/playgroundStore.svelte.js +109 -32
  66. package/dist/svelte-app.d.ts +1 -0
  67. package/dist/svelte-app.js +5 -5
  68. package/dist/types/index.d.ts +13 -0
  69. package/dist/types/playground.d.ts +112 -2
  70. package/dist/types/playground.js +14 -0
  71. package/package.json +12 -1
package/README.md CHANGED
@@ -158,6 +158,11 @@ FlowDrop provides tree-shakeable sub-module exports so you can import only what
158
158
  | `@flowdrop/flowdrop/settings` | SettingsPanel, stores, services |
159
159
  | `@flowdrop/flowdrop/styles` | Base CSS stylesheet |
160
160
  | `@flowdrop/flowdrop/schema` | Workflow JSON schema |
161
+ | `@flowdrop/flowdrop/openapi` | OpenAPI spec (YAML) for the FlowDrop backend API |
162
+
163
+ ### OpenAPI spec
164
+
165
+ The full OpenAPI spec for the FlowDrop backend API ships with the package, version-matched to your installed release. It defines the node-config / form-element schema (`ConfigProperty`), playground messages, and every endpoint. Resolve it from `@flowdrop/flowdrop/openapi`, or read it directly at `node_modules/@flowdrop/flowdrop/dist/openapi/v1/openapi.yaml`. Point your AI assistant at that file when authoring node config schemas. The latest spec is also browsable at [api.flowdrop.io](https://api.flowdrop.io).
161
166
 
162
167
  ## Integration
163
168
 
@@ -191,6 +191,7 @@
191
191
  * This fixes the Svelte 5 reactivity warnings
192
192
  */
193
193
  let configValues = $state<Record<string, unknown>>({});
194
+ setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => configValues);
194
195
 
195
196
  /**
196
197
  * UI Extension values for display settings
@@ -77,7 +77,13 @@
77
77
  <Icon icon="heroicons:arrows-right-left" />
78
78
  </button>
79
79
  {/if}
80
- <button class="config-panel__close" onclick={onClose} aria-label={m().layout.closeConfigPanel}> × </button>
80
+ <button
81
+ class="config-panel__close"
82
+ onclick={onClose}
83
+ aria-label={m().layout.closeConfigPanel}
84
+ >
85
+ ×
86
+ </button>
81
87
  </div>
82
88
  </div>
83
89
 
@@ -81,7 +81,11 @@
81
81
  <div class="swap-picker">
82
82
  <!-- Header -->
83
83
  <div class="swap-picker__header">
84
- <button class="swap-picker__back" onclick={onCancel} aria-label={m().layout.backToConfiguration}>
84
+ <button
85
+ class="swap-picker__back"
86
+ onclick={onCancel}
87
+ aria-label={m().layout.backToConfiguration}
88
+ >
85
89
  <Icon icon="heroicons:arrow-left" />
86
90
  </button>
87
91
  <h2 class="swap-picker__title">Swap Node</h2>
@@ -37,8 +37,17 @@
37
37
  ) => void;
38
38
  }
39
39
 
40
- let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady, runLabel, isEmbedded = false, refreshTrigger = 0 }: Props =
41
- $props();
40
+ let {
41
+ pipelineId,
42
+ workflow,
43
+ apiClient,
44
+ baseUrl,
45
+ endpointConfig,
46
+ onActionsReady,
47
+ runLabel,
48
+ isEmbedded = false,
49
+ refreshTrigger = 0
50
+ }: Props = $props();
42
51
 
43
52
  // Track previous trigger value so the $effect only fires on increments, not on initial mount.
44
53
  // svelte-ignore state_referenced_locally
@@ -197,6 +197,7 @@
197
197
  * Internal reactive state for form values
198
198
  */
199
199
  let formValues = $state<Record<string, unknown>>({});
200
+ setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => formValues);
200
201
 
201
202
  /**
202
203
  * Initialize form values when schema or values change
@@ -362,7 +362,11 @@
362
362
 
363
363
  <div class="flowdrop-settings-panel {className}">
364
364
  <!-- Tab Navigation -->
365
- <div class="flowdrop-settings-panel__tabs" role="tablist" aria-label={m().layout.settingsCategories}>
365
+ <div
366
+ class="flowdrop-settings-panel__tabs"
367
+ role="tablist"
368
+ aria-label={m().layout.settingsCategories}
369
+ >
366
370
  {#each categories as category, index (category)}
367
371
  <button
368
372
  class="flowdrop-settings-panel__tab"
@@ -277,7 +277,11 @@
277
277
  const rawStatus = statuses[node.id];
278
278
  if (!rawStatus) return node;
279
279
 
280
- const existing = node.data.executionInfo ?? { status: 'idle' as const, executionCount: 0, isExecuting: false };
280
+ const existing = node.data.executionInfo ?? {
281
+ status: 'idle' as const,
282
+ executionCount: 0,
283
+ isExecuting: false
284
+ };
281
285
  return {
282
286
  ...node,
283
287
  data: {
@@ -288,11 +288,7 @@
288
288
  return;
289
289
  }
290
290
 
291
- if (
292
- getBehaviorSettings().chatAutoRetry &&
293
- workflowId &&
294
- autoRetryCount < MAX_AUTO_RETRIES
295
- ) {
291
+ if (getBehaviorSettings().chatAutoRetry && workflowId && autoRetryCount < MAX_AUTO_RETRIES) {
296
292
  autoRetryCount++;
297
293
  const errorText = buildBatchErrorMessage(
298
294
  completedCount,
@@ -68,6 +68,9 @@
68
68
  'flowdrop:getAuthProvider'
69
69
  );
70
70
  const getBaseUrl = getContext<(() => string) | undefined>('flowdrop:getBaseUrl');
71
+ const getFormValues = getContext<(() => Record<string, unknown>) | undefined>(
72
+ 'flowdrop:getFormValues'
73
+ );
71
74
 
72
75
  // Hoist the autocomplete branch — seven reads in the template, one inside
73
76
  // an {#each tag} loop. One getter walk per render.
@@ -82,6 +85,7 @@
82
85
  const valueField = $derived(autocomplete.valueField ?? 'value');
83
86
  const allowFreeText = $derived(autocomplete.allowFreeText ?? false);
84
87
  const multiple = $derived(autocomplete.multiple ?? false);
88
+ const params = $derived(autocomplete.params ?? {});
85
89
 
86
90
  // Component state
87
91
  let inputElement: HTMLInputElement | undefined = $state(undefined);
@@ -102,6 +106,34 @@
102
106
  // Cache of value-to-label mappings for selected items
103
107
  let labelCache = $state<Map<string, string>>(new Map());
104
108
 
109
+ // Resolved values for each param dependency: { paramName -> currentFieldValue }
110
+ // Used both in buildUrl (appending to query) and in the dep-change effect.
111
+ const depParamValues = $derived.by(() => {
112
+ const all = getFormValues?.() ?? {};
113
+ const result: Record<string, string> = {};
114
+ for (const [paramName, fieldName] of Object.entries(params)) {
115
+ const val = all[fieldName];
116
+ if (val !== undefined && val !== '') result[paramName] = String(val);
117
+ }
118
+ return result;
119
+ });
120
+
121
+ // Stable fingerprint — any change triggers selection clearing.
122
+ // JSON.stringify gives a canonical string without null-byte ambiguity.
123
+ const depFingerprint = $derived(JSON.stringify(depParamValues));
124
+ // svelte-ignore state_referenced_locally — intentional initial snapshot; the effect below tracks subsequent changes
125
+ let prevDepFingerprint = depFingerprint;
126
+
127
+ $effect(() => {
128
+ if (Object.keys(params).length > 0 && !getFormValues) {
129
+ logger.warn(
130
+ '[FormAutocomplete] `params` is configured but no flowdrop:getFormValues context ' +
131
+ 'was found. Dependent field values will not be passed to the autocomplete URL. ' +
132
+ 'Ensure this component is rendered inside ConfigForm or SchemaForm.'
133
+ );
134
+ }
135
+ });
136
+
105
137
  // Generate unique IDs for accessibility
106
138
  // svelte-ignore state_referenced_locally — id prop never changes
107
139
  const listboxId = `${id}-listbox`;
@@ -160,7 +192,13 @@
160
192
  : `${baseUrl}${autocomplete.url}`;
161
193
 
162
194
  const separator = url.includes('?') ? '&' : '?';
163
- return `${url}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(query)}`;
195
+ let fullUrl = `${url}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(query)}`;
196
+
197
+ for (const [paramName, val] of Object.entries(depParamValues)) {
198
+ fullUrl += `&${encodeURIComponent(paramName)}=${encodeURIComponent(val)}`;
199
+ }
200
+
201
+ return fullUrl;
164
202
  }
165
203
 
166
204
  /**
@@ -198,25 +236,25 @@
198
236
 
199
237
  isLoading = true;
200
238
  error = null;
201
- abortController = new AbortController();
239
+ // Capture in a local const so the timeout closure and fetch signal
240
+ // always reference the same controller, even if a new request starts
241
+ // before the 5-second timeout fires.
242
+ const controller = new AbortController();
243
+ abortController = controller;
202
244
 
245
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
203
246
  try {
204
247
  // Build headers with authentication (call getter to get current value)
205
248
  const headers = await buildFetchHeaders(getAuthProvider?.());
206
249
 
207
- // Fetch with timeout
208
- const timeoutId = setTimeout(() => {
209
- abortController?.abort();
210
- }, 5000);
250
+ timeoutId = setTimeout(() => controller.abort(), 5000);
211
251
 
212
252
  const response = await fetch(buildUrl(query), {
213
253
  method: 'GET',
214
254
  headers,
215
- signal: abortController.signal
255
+ signal: controller.signal
216
256
  });
217
257
 
218
- clearTimeout(timeoutId);
219
-
220
258
  if (!response.ok) {
221
259
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
222
260
  }
@@ -240,6 +278,7 @@
240
278
  error = err instanceof Error ? err.message : 'Failed to fetch suggestions';
241
279
  suggestions = [];
242
280
  } finally {
281
+ if (timeoutId !== null) clearTimeout(timeoutId);
243
282
  isLoading = false;
244
283
  abortController = null;
245
284
  }
@@ -297,6 +336,13 @@
297
336
  setTimeout(() => {
298
337
  hideDropdown();
299
338
 
339
+ // When dependent params are configured, the suggestion list is bound to the
340
+ // current values of sibling fields. Clear it on blur so that fetchOnFocus
341
+ // always re-fetches with the current dep values rather than showing stale results.
342
+ if (Object.keys(params).length > 0) {
343
+ suggestions = [];
344
+ }
345
+
300
346
  // If not allowFreeText and single mode, validate the input
301
347
  if (!allowFreeText && !multiple && inputValue !== '') {
302
348
  const currentVal = selectedValues;
@@ -542,6 +588,18 @@
542
588
  }
543
589
  });
544
590
 
591
+ $effect(() => {
592
+ const current = depFingerprint;
593
+ if (current === prevDepFingerprint) return;
594
+ prevDepFingerprint = current;
595
+ // A dependency field changed — abort any in-flight fetch, clear state
596
+ abortController?.abort();
597
+ suggestions = [];
598
+ labelCache = new Map();
599
+ if (multiple) onChange([]);
600
+ else onChange('');
601
+ });
602
+
545
603
  /**
546
604
  * Cleanup on unmount
547
605
  */
@@ -567,11 +625,11 @@
567
625
  class:form-autocomplete--has-value={selectedValues.length > 0}
568
626
  >
569
627
  <!-- Main input container styled like a textfield/textarea -->
628
+ <!-- svelte-ignore a11y_no_static_element_interactions — role="presentation"; keyboard interaction is handled by the <input> inside -->
570
629
  <div
571
630
  class="form-autocomplete__field"
572
631
  class:form-autocomplete__field--focused={isOpen}
573
632
  onclick={() => inputElement?.focus()}
574
- onkeydown={() => {}}
575
633
  role="presentation"
576
634
  >
577
635
  <!-- Selected tags -->
@@ -664,11 +722,7 @@
664
722
  style={popoverStyle}
665
723
  onmousedown={(e) => e.preventDefault()}
666
724
  >
667
- <ul
668
- class="form-autocomplete__listbox"
669
- role="listbox"
670
- aria-label={t.suggestions}
671
- >
725
+ <ul class="form-autocomplete__listbox" role="listbox" aria-label={t.suggestions}>
672
726
  {#if isLoading}
673
727
  <li class="form-autocomplete__status form-autocomplete__status--loading">
674
728
  <Icon icon="heroicons:arrow-path" class="form-autocomplete__status-icon" />
@@ -45,6 +45,7 @@
45
45
  import { getSchemaOptions } from './types.js';
46
46
  import type { WorkflowNode, WorkflowEdge, AuthProvider } from '../../types/index.js';
47
47
  import { getResolvedTheme } from '../../stores/settingsStore.svelte.js';
48
+ import { fieldComponentRegistry } from '../../form/fieldRegistry.js';
48
49
 
49
50
  interface Props {
50
51
  /** Unique key/id for the field */
@@ -224,6 +225,10 @@
224
225
  return [];
225
226
  });
226
227
 
228
+ const registeredAutocompleteComponent = $derived(
229
+ fieldType === 'autocomplete' ? fieldComponentRegistry.resolveFieldComponent(schema) : null
230
+ );
231
+
227
232
  /**
228
233
  * Get autocomplete value - can be string or string[] based on multiple setting
229
234
  */
@@ -387,6 +392,22 @@
387
392
  {authProvider}
388
393
  onChange={(val) => onChange(val)}
389
394
  />
395
+ {:else if fieldType === 'autocomplete' && schema.autocomplete && registeredAutocompleteComponent}
396
+ <registeredAutocompleteComponent.component
397
+ id={fieldKey}
398
+ value={autocompleteValue}
399
+ placeholder={schema.placeholder ?? ''}
400
+ {required}
401
+ {schema}
402
+ ariaDescribedBy={descriptionId}
403
+ disabled={isReadOnly}
404
+ {node}
405
+ {nodes}
406
+ {edges}
407
+ {workflowId}
408
+ {authProvider}
409
+ onChange={(val: unknown) => onChange(val)}
410
+ />
390
411
  {:else if fieldType === 'autocomplete' && schema.autocomplete}
391
412
  <FormAutocomplete
392
413
  id={fieldKey}
@@ -243,6 +243,7 @@
243
243
  spellChecker={schema.spellChecker as boolean | undefined}
244
244
  variables={schema.variables}
245
245
  placeholderExample={schema.placeholderExample as string | undefined}
246
+ autocomplete={schema.autocomplete}
246
247
  onChange={(val: unknown) => onChange(val)}
247
248
  />
248
249
  {:else if fieldType === 'checkbox-group'}
@@ -135,7 +135,11 @@
135
135
  {/if}
136
136
 
137
137
  <!-- Options -->
138
- <div class="choice-prompt__options" role={isMultiple ? 'group' : 'radiogroup'} aria-label={config.message}>
138
+ <div
139
+ class="choice-prompt__options"
140
+ role={isMultiple ? 'group' : 'radiogroup'}
141
+ aria-label={config.message}
142
+ >
139
143
  {#each config.options as option (option.value)}
140
144
  {@const isChecked = isResolved ? isOptionResolved(option) : selectedValues.has(option.value)}
141
145
  <label
@@ -14,6 +14,9 @@
14
14
  import TextInputPrompt from './TextInputPrompt.svelte';
15
15
  import FormPrompt from './FormPrompt.svelte';
16
16
  import ReviewPrompt from './ReviewPrompt.svelte';
17
+ import MessageTagStrip from '../playground/MessageTagStrip.svelte';
18
+ import HierarchyTrail from '../playground/HierarchyTrail.svelte';
19
+ import type { MessageHierarchyItem, MessageTag } from '../../types/playground.js';
17
20
  import type {
18
21
  Interrupt,
19
22
  InterruptType,
@@ -49,9 +52,25 @@
49
52
  showTimestamp?: boolean;
50
53
  /** Callback to refresh messages after interrupt resolution */
51
54
  onResolved?: () => void;
52
- }
53
-
54
- let { interrupt: initialInterrupt, showTimestamp = true, onResolved }: Props = $props();
55
+ /**
56
+ * Hierarchy items forwarded from the parent playground message. Rendered
57
+ * as a chevron-separated trail in the footer.
58
+ */
59
+ hierarchy?: MessageHierarchyItem[];
60
+ /**
61
+ * Server-emitted tags forwarded from the parent playground message.
62
+ * Rendered as chips in the footer.
63
+ */
64
+ tags?: MessageTag[];
65
+ }
66
+
67
+ let {
68
+ interrupt: initialInterrupt,
69
+ showTimestamp = true,
70
+ onResolved,
71
+ hierarchy,
72
+ tags
73
+ }: Props = $props();
55
74
 
56
75
  /**
57
76
  * Get the current interrupt state from the store.
@@ -61,6 +80,11 @@
61
80
  getInterruptsMap().get(initialInterrupt.id) ?? addMachineState(initialInterrupt)
62
81
  );
63
82
 
83
+ const hierarchyItems = $derived(hierarchy ?? []);
84
+ const tagItems = $derived(tags ?? []);
85
+ const hasHierarchy = $derived(hierarchyItems.length > 0);
86
+ const hasTags = $derived(tagItems.length > 0);
87
+
64
88
  /**
65
89
  * Helper to ensure interrupt has machine state
66
90
  */
@@ -262,7 +286,7 @@
262
286
  <!-- Header -->
263
287
  <div class="interrupt-bubble__header">
264
288
  <span class="interrupt-bubble__type">
265
- <Icon icon={getTypeIcon(currentInterrupt.type)} />
289
+ <Icon icon={getTypeIcon(currentInterrupt.type)} aria-hidden="true" />
266
290
  {#if isResolved}
267
291
  {currentInterrupt.machineState.status === 'cancelled'
268
292
  ? t.cancelled
@@ -274,9 +298,15 @@
274
298
  {/if}
275
299
  </span>
276
300
  {#if showTimestamp}
277
- <span class="interrupt-bubble__timestamp">
301
+ <time
302
+ class="interrupt-bubble__timestamp"
303
+ datetime={currentInterrupt.resolvedAt ?? currentInterrupt.createdAt}
304
+ aria-label="sent at {formatTimestamp(
305
+ currentInterrupt.resolvedAt ?? currentInterrupt.createdAt
306
+ )}"
307
+ >
278
308
  {formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}
279
- </span>
309
+ </time>
280
310
  {/if}
281
311
  </div>
282
312
 
@@ -349,17 +379,21 @@
349
379
  </div>
350
380
 
351
381
  <!-- Footer -->
352
- {#if currentInterrupt.nodeId || (currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation')}
382
+ {#if currentInterrupt.nodeId || hasHierarchy || hasTags || (currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation')}
353
383
  <div class="interrupt-bubble__footer">
354
- {#if currentInterrupt.nodeId}
355
- <span
356
- class="interrupt-bubble__node"
357
- title={t.nodeIdTooltip({ id: currentInterrupt.nodeId })}
358
- >
359
- <Icon icon="mdi:graph" />
360
- <span>{t.fromWorkflow}</span>
361
- </span>
362
- {/if}
384
+ <div class="interrupt-bubble__attribution">
385
+ {#if currentInterrupt.nodeId}
386
+ <span
387
+ class="interrupt-bubble__node"
388
+ title={t.nodeIdTooltip({ id: currentInterrupt.nodeId })}
389
+ >
390
+ <Icon icon="mdi:graph" aria-hidden="true" />
391
+ <span>{t.fromWorkflow}</span>
392
+ </span>
393
+ {/if}
394
+ <HierarchyTrail items={hierarchyItems} />
395
+ <MessageTagStrip tags={tagItems} />
396
+ </div>
363
397
  {#if currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation'}
364
398
  <button
365
399
  type="button"
@@ -367,7 +401,7 @@
367
401
  onclick={handleCancel}
368
402
  disabled={isSubmitting}
369
403
  >
370
- <Icon icon="mdi:close" />
404
+ <Icon icon="mdi:close" aria-hidden="true" />
371
405
  <span>{t.cancel}</span>
372
406
  </button>
373
407
  {/if}
@@ -542,6 +576,7 @@
542
576
  /* Footer */
543
577
  .interrupt-bubble__footer {
544
578
  display: flex;
579
+ flex-wrap: wrap;
545
580
  align-items: center;
546
581
  justify-content: space-between;
547
582
  gap: var(--fd-space-xs);
@@ -565,6 +600,15 @@
565
600
  border-top-color: var(--fd-interrupt-prompt-border-error);
566
601
  }
567
602
 
603
+ .interrupt-bubble__attribution {
604
+ display: flex;
605
+ flex-wrap: wrap;
606
+ align-items: center;
607
+ gap: var(--fd-space-xs);
608
+ min-width: 0;
609
+ flex: 1 1 auto;
610
+ }
611
+
568
612
  .interrupt-bubble__node {
569
613
  display: flex;
570
614
  align-items: center;
@@ -618,5 +662,19 @@
618
662
  padding-left: var(--fd-space-lg);
619
663
  padding-right: var(--fd-space-lg);
620
664
  }
665
+
666
+ .interrupt-bubble__cancel-btn {
667
+ min-height: 2.5rem;
668
+ padding: var(--fd-space-xs) var(--fd-space-md);
669
+ }
670
+ }
671
+
672
+ @media (prefers-reduced-motion: reduce) {
673
+ .interrupt-bubble {
674
+ animation: none;
675
+ }
676
+ .interrupt-bubble__cancel-btn {
677
+ transition: none;
678
+ }
621
679
  }
622
680
  </style>
@@ -1,3 +1,4 @@
1
+ import type { MessageHierarchyItem, MessageTag } from '../../types/playground.js';
1
2
  import type { Interrupt } from '../../types/interrupt.js';
2
3
  import { type InterruptWithState } from '../../stores/interruptStore.svelte.js';
3
4
  /**
@@ -10,6 +11,16 @@ interface Props {
10
11
  showTimestamp?: boolean;
11
12
  /** Callback to refresh messages after interrupt resolution */
12
13
  onResolved?: () => void;
14
+ /**
15
+ * Hierarchy items forwarded from the parent playground message. Rendered
16
+ * as a chevron-separated trail in the footer.
17
+ */
18
+ hierarchy?: MessageHierarchyItem[];
19
+ /**
20
+ * Server-emitted tags forwarded from the parent playground message.
21
+ * Rendered as chips in the footer.
22
+ */
23
+ tags?: MessageTag[];
13
24
  }
14
25
  declare const InterruptBubble: import("svelte").Component<Props, {}, "">;
15
26
  type InterruptBubble = ReturnType<typeof InterruptBubble>;