@flowdrop/flowdrop 1.11.0 → 1.13.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 (84) hide show
  1. package/dist/api/enhanced-client.d.ts +29 -16
  2. package/dist/api/enhanced-client.js +0 -14
  3. package/dist/components/ConfigForm.svelte +1 -0
  4. package/dist/components/PipelineStatus.svelte +9 -12
  5. package/dist/components/SchemaForm.svelte +1 -0
  6. package/dist/components/WorkflowEditor.svelte +3 -0
  7. package/dist/components/form/FormAutocomplete.svelte +67 -10
  8. package/dist/components/form/FormField.svelte +21 -0
  9. package/dist/components/form/FormFieldLight.svelte +1 -0
  10. package/dist/components/interrupt/ChoicePrompt.svelte +24 -5
  11. package/dist/components/interrupt/ConfirmationPrompt.svelte +5 -0
  12. package/dist/components/interrupt/InterruptBubble.svelte +88 -17
  13. package/dist/components/interrupt/InterruptBubble.svelte.d.ts +11 -0
  14. package/dist/components/interrupt/ReviewPrompt.svelte +20 -0
  15. package/dist/components/interrupt/TextInputPrompt.svelte +5 -0
  16. package/dist/components/nodes/GatewayNode.svelte +2 -6
  17. package/dist/components/nodes/WorkflowNode.svelte +2 -6
  18. package/dist/components/playground/ChatBubble.svelte +289 -0
  19. package/dist/components/playground/ChatBubble.svelte.d.ts +10 -0
  20. package/dist/components/playground/ChatInput.svelte +359 -0
  21. package/dist/components/playground/ChatInput.svelte.d.ts +14 -0
  22. package/dist/components/playground/ChatPanel.svelte +100 -724
  23. package/dist/components/playground/ChatPanel.svelte.d.ts +9 -26
  24. package/dist/components/playground/ControlPanel.svelte +496 -0
  25. package/dist/components/playground/ControlPanel.svelte.d.ts +20 -0
  26. package/dist/components/playground/ExecutionConsole.svelte +163 -0
  27. package/dist/components/playground/ExecutionConsole.svelte.d.ts +14 -0
  28. package/dist/components/playground/HierarchyTrail.svelte +88 -0
  29. package/dist/components/playground/HierarchyTrail.svelte.d.ts +7 -0
  30. package/dist/components/playground/LogRow.svelte +178 -0
  31. package/dist/components/playground/LogRow.svelte.d.ts +8 -0
  32. package/dist/components/playground/MessageBubble.stories.svelte +89 -0
  33. package/dist/components/playground/MessageBubble.svelte +25 -737
  34. package/dist/components/playground/MessageBubble.svelte.d.ts +3 -11
  35. package/dist/components/playground/MessageCard.svelte +106 -0
  36. package/dist/components/playground/MessageCard.svelte.d.ts +10 -0
  37. package/dist/components/playground/MessageMarkdown.svelte +160 -0
  38. package/dist/components/playground/MessageMarkdown.svelte.d.ts +7 -0
  39. package/dist/components/playground/MessageNotice.svelte +120 -0
  40. package/dist/components/playground/MessageNotice.svelte.d.ts +9 -0
  41. package/dist/components/playground/MessageStream.svelte +367 -0
  42. package/dist/components/playground/MessageStream.svelte.d.ts +27 -0
  43. package/dist/components/playground/MessageTagChip.svelte +99 -0
  44. package/dist/components/playground/MessageTagChip.svelte.d.ts +7 -0
  45. package/dist/components/playground/MessageTagStrip.svelte +37 -0
  46. package/dist/components/playground/MessageTagStrip.svelte.d.ts +7 -0
  47. package/dist/components/playground/PipelineKanbanView.svelte +284 -0
  48. package/dist/components/playground/PipelineKanbanView.svelte.d.ts +11 -0
  49. package/dist/components/playground/PipelinePanel.svelte +204 -65
  50. package/dist/components/playground/PipelinePanel.svelte.d.ts +3 -1
  51. package/dist/components/playground/PipelineTableView.svelte +376 -0
  52. package/dist/components/playground/PipelineTableView.svelte.d.ts +11 -0
  53. package/dist/components/playground/Playground.svelte +262 -1200
  54. package/dist/components/playground/Playground.svelte.d.ts +0 -13
  55. package/dist/components/playground/PlaygroundStudio.svelte +113 -61
  56. package/dist/components/playground/PlaygroundStudio.svelte.d.ts +3 -1
  57. package/dist/components/playground/messageDisplay.d.ts +19 -0
  58. package/dist/components/playground/messageDisplay.js +62 -0
  59. package/dist/components/playground/pipelineViewUtils.svelte.d.ts +22 -0
  60. package/dist/components/playground/pipelineViewUtils.svelte.js +77 -0
  61. package/dist/form/autocomplete.d.ts +1 -0
  62. package/dist/form/autocomplete.js +1 -0
  63. package/dist/form/index.d.ts +17 -0
  64. package/dist/form/index.js +19 -0
  65. package/dist/messages/defaults.d.ts +29 -0
  66. package/dist/messages/defaults.js +30 -0
  67. package/dist/playground/index.d.ts +6 -1
  68. package/dist/playground/index.js +6 -0
  69. package/dist/playground/mount.d.ts +3 -0
  70. package/dist/playground/mount.js +3 -2
  71. package/dist/schemas/v1/workflow.schema.json +10 -1
  72. package/dist/services/categoriesApi.d.ts +2 -1
  73. package/dist/services/categoriesApi.js +5 -3
  74. package/dist/services/portConfigApi.d.ts +2 -1
  75. package/dist/services/portConfigApi.js +5 -3
  76. package/dist/stores/playgroundStore.svelte.d.ts +6 -0
  77. package/dist/stores/playgroundStore.svelte.js +21 -1
  78. package/dist/svelte-app.d.ts +1 -0
  79. package/dist/svelte-app.js +5 -5
  80. package/dist/types/index.d.ts +41 -2
  81. package/dist/types/playground.d.ts +81 -2
  82. package/dist/types/playground.js +19 -7
  83. package/dist/utils/nodeStatus.js +15 -5
  84. package/package.json +6 -1
@@ -34,6 +34,34 @@ export declare class ApiError extends Error {
34
34
  * const client = new EnhancedFlowDropApiClient(config);
35
35
  * ```
36
36
  */
37
+ export interface PipelineDataResponse {
38
+ status: string;
39
+ jobs: Array<Record<string, unknown>>;
40
+ node_statuses: Record<string, {
41
+ status: string;
42
+ [key: string]: unknown;
43
+ }>;
44
+ job_status_summary: {
45
+ total: number;
46
+ pending: number;
47
+ running: number;
48
+ completed: number;
49
+ failed: number;
50
+ cancelled: number;
51
+ skipped?: number;
52
+ paused?: number;
53
+ interrupted?: number;
54
+ };
55
+ kanban_config?: {
56
+ columns: Array<{
57
+ key: string;
58
+ label: string;
59
+ statuses: string[];
60
+ icon?: string;
61
+ color?: string;
62
+ }>;
63
+ };
64
+ }
37
65
  export declare class EnhancedFlowDropApiClient {
38
66
  private config;
39
67
  private authProvider;
@@ -164,20 +192,5 @@ export declare class EnhancedFlowDropApiClient {
164
192
  /**
165
193
  * Fetch pipeline data including job information and status
166
194
  */
167
- getPipelineData(pipelineId: string): Promise<{
168
- status: string;
169
- jobs: Array<Record<string, unknown>>;
170
- node_statuses: Record<string, {
171
- status: string;
172
- [key: string]: unknown;
173
- }>;
174
- job_status_summary: {
175
- total: number;
176
- pending: number;
177
- running: number;
178
- completed: number;
179
- failed: number;
180
- cancelled: number;
181
- };
182
- }>;
195
+ getPipelineData(pipelineId: string): Promise<PipelineDataResponse>;
183
196
  }
@@ -28,20 +28,6 @@ export class ApiError extends Error {
28
28
  this.errorData = errorData;
29
29
  }
30
30
  }
31
- /**
32
- * Enhanced HTTP API client for FlowDrop with configurable endpoints
33
- *
34
- * Supports pluggable authentication via AuthProvider interface.
35
- *
36
- * @example
37
- * ```typescript
38
- * // With AuthProvider
39
- * const client = new EnhancedFlowDropApiClient(config, authProvider);
40
- *
41
- * // Without authentication
42
- * const client = new EnhancedFlowDropApiClient(config);
43
- * ```
44
- */
45
31
  export class EnhancedFlowDropApiClient {
46
32
  config;
47
33
  authProvider;
@@ -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
@@ -117,23 +117,20 @@
117
117
  }
118
118
  };
119
119
 
120
- // Update node statuses based on job data
120
+ // Update node statuses based on job data — only set what the server reported
121
121
  if (jobStatusData.node_statuses) {
122
122
  const newNodeStatuses: Record<string, 'pending' | 'running' | 'completed' | 'error'> = {};
123
123
 
124
- // Initialize all nodes as pending
125
- if (workflow && workflow.nodes) {
126
- workflow.nodes.forEach((node) => {
127
- newNodeStatuses[node.id] = 'pending';
128
- });
129
- }
130
-
131
- // Override with actual job statuses
132
124
  for (const nodeId in jobStatusData.node_statuses) {
133
125
  const status = jobStatusData.node_statuses[nodeId].status;
134
- if (['pending', 'running', 'completed', 'failed', 'cancelled'].includes(status)) {
135
- newNodeStatuses[nodeId] =
136
- status === 'failed' ? 'error' : (status as 'pending' | 'running' | 'completed');
126
+ if (status === 'failed' || status === 'cancelled') {
127
+ newNodeStatuses[nodeId] = 'error';
128
+ } else if (status === 'running' || status === 'paused' || status === 'interrupted') {
129
+ newNodeStatuses[nodeId] = 'running';
130
+ } else if (status === 'completed' || status === 'skipped') {
131
+ newNodeStatuses[nodeId] = 'completed';
132
+ } else if (status === 'pending' || status === 'idle') {
133
+ newNodeStatuses[nodeId] = 'pending';
137
134
  }
138
135
  }
139
136
  nodeStatuses = newNodeStatuses;
@@ -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
@@ -855,6 +855,9 @@
855
855
  {initialViewport}
856
856
  colorMode={getResolvedTheme() as ColorMode}
857
857
  fitView={getEditorSettings().fitViewOnLoad}
858
+ nodesDraggable={!props.lockWorkflow && !props.readOnly}
859
+ nodesConnectable={!props.lockWorkflow && !props.readOnly}
860
+ elementsSelectable={!props.lockWorkflow && !props.readOnly}
858
861
  >
859
862
  <Controls />
860
863
  {#if !props.readOnly && !props.lockWorkflow && props.onToggleConsole}
@@ -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,33 @@
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
+ let prevDepFingerprint = depFingerprint;
125
+
126
+ $effect(() => {
127
+ if (Object.keys(params).length > 0 && !getFormValues) {
128
+ logger.warn(
129
+ '[FormAutocomplete] `params` is configured but no flowdrop:getFormValues context ' +
130
+ 'was found. Dependent field values will not be passed to the autocomplete URL. ' +
131
+ 'Ensure this component is rendered inside ConfigForm or SchemaForm.'
132
+ );
133
+ }
134
+ });
135
+
105
136
  // Generate unique IDs for accessibility
106
137
  // svelte-ignore state_referenced_locally — id prop never changes
107
138
  const listboxId = `${id}-listbox`;
@@ -160,7 +191,13 @@
160
191
  : `${baseUrl}${autocomplete.url}`;
161
192
 
162
193
  const separator = url.includes('?') ? '&' : '?';
163
- return `${url}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(query)}`;
194
+ let fullUrl = `${url}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(query)}`;
195
+
196
+ for (const [paramName, val] of Object.entries(depParamValues)) {
197
+ fullUrl += `&${encodeURIComponent(paramName)}=${encodeURIComponent(val)}`;
198
+ }
199
+
200
+ return fullUrl;
164
201
  }
165
202
 
166
203
  /**
@@ -198,25 +235,25 @@
198
235
 
199
236
  isLoading = true;
200
237
  error = null;
201
- abortController = new AbortController();
238
+ // Capture in a local const so the timeout closure and fetch signal
239
+ // always reference the same controller, even if a new request starts
240
+ // before the 5-second timeout fires.
241
+ const controller = new AbortController();
242
+ abortController = controller;
202
243
 
244
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
203
245
  try {
204
246
  // Build headers with authentication (call getter to get current value)
205
247
  const headers = await buildFetchHeaders(getAuthProvider?.());
206
248
 
207
- // Fetch with timeout
208
- const timeoutId = setTimeout(() => {
209
- abortController?.abort();
210
- }, 5000);
249
+ timeoutId = setTimeout(() => controller.abort(), 5000);
211
250
 
212
251
  const response = await fetch(buildUrl(query), {
213
252
  method: 'GET',
214
253
  headers,
215
- signal: abortController.signal
254
+ signal: controller.signal
216
255
  });
217
256
 
218
- clearTimeout(timeoutId);
219
-
220
257
  if (!response.ok) {
221
258
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
222
259
  }
@@ -240,6 +277,7 @@
240
277
  error = err instanceof Error ? err.message : 'Failed to fetch suggestions';
241
278
  suggestions = [];
242
279
  } finally {
280
+ if (timeoutId !== null) clearTimeout(timeoutId);
243
281
  isLoading = false;
244
282
  abortController = null;
245
283
  }
@@ -297,6 +335,13 @@
297
335
  setTimeout(() => {
298
336
  hideDropdown();
299
337
 
338
+ // When dependent params are configured, the suggestion list is bound to the
339
+ // current values of sibling fields. Clear it on blur so that fetchOnFocus
340
+ // always re-fetches with the current dep values rather than showing stale results.
341
+ if (Object.keys(params).length > 0) {
342
+ suggestions = [];
343
+ }
344
+
300
345
  // If not allowFreeText and single mode, validate the input
301
346
  if (!allowFreeText && !multiple && inputValue !== '') {
302
347
  const currentVal = selectedValues;
@@ -542,6 +587,18 @@
542
587
  }
543
588
  });
544
589
 
590
+ $effect(() => {
591
+ const current = depFingerprint;
592
+ if (current === prevDepFingerprint) return;
593
+ prevDepFingerprint = current;
594
+ // A dependency field changed — abort any in-flight fetch, clear state
595
+ abortController?.abort();
596
+ suggestions = [];
597
+ labelCache = new Map();
598
+ if (multiple) onChange([]);
599
+ else onChange('');
600
+ });
601
+
545
602
  /**
546
603
  * Cleanup on unmount
547
604
  */
@@ -567,11 +624,11 @@
567
624
  class:form-autocomplete--has-value={selectedValues.length > 0}
568
625
  >
569
626
  <!-- Main input container styled like a textfield/textarea -->
627
+ <!-- svelte-ignore a11y_no_static_element_interactions — role="presentation"; keyboard interaction is handled by the <input> inside -->
570
628
  <div
571
629
  class="form-autocomplete__field"
572
630
  class:form-autocomplete__field--focused={isOpen}
573
631
  onclick={() => inputElement?.focus()}
574
- onkeydown={() => {}}
575
632
  role="presentation"
576
633
  >
577
634
  <!-- Selected tags -->
@@ -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'}
@@ -45,6 +45,10 @@
45
45
  // Hoist the choice branch — counter, min, max, submit reads.
46
46
  const t = $derived(m().interrupt.choice);
47
47
 
48
+ // Unique name for the radio/checkbox group so multiple ChoicePrompts on
49
+ // screen don't share the same HTML group and interfere with each other.
50
+ const groupName = `choice-option-${Math.random().toString(36).slice(2, 8)}`;
51
+
48
52
  /** Local state for selected values */
49
53
  let selectedValues = $state<Set<string>>(new Set());
50
54
 
@@ -131,7 +135,7 @@
131
135
  {/if}
132
136
 
133
137
  <!-- Options -->
134
- <div class="choice-prompt__options" role={isMultiple ? 'group' : 'radiogroup'}>
138
+ <div class="choice-prompt__options" role={isMultiple ? 'group' : 'radiogroup'} aria-label={config.message}>
135
139
  {#each config.options as option (option.value)}
136
140
  {@const isChecked = isResolved ? isOptionResolved(option) : selectedValues.has(option.value)}
137
141
  <label
@@ -141,7 +145,7 @@
141
145
  >
142
146
  <input
143
147
  type={isMultiple ? 'checkbox' : 'radio'}
144
- name="choice-option"
148
+ name={groupName}
145
149
  value={option.value}
146
150
  checked={isChecked}
147
151
  disabled={isResolved || isSubmitting}
@@ -295,9 +299,19 @@
295
299
 
296
300
  .choice-prompt__input {
297
301
  position: absolute;
298
- opacity: 0;
299
- width: 0;
300
- height: 0;
302
+ width: 1px;
303
+ height: 1px;
304
+ padding: 0;
305
+ margin: -1px;
306
+ overflow: hidden;
307
+ clip: rect(0, 0, 0, 0);
308
+ white-space: nowrap;
309
+ border: 0;
310
+ }
311
+
312
+ .choice-prompt__option:focus-within {
313
+ outline: 2px solid var(--fd-ring);
314
+ outline-offset: 2px;
301
315
  }
302
316
 
303
317
  .choice-prompt__checkmark {
@@ -372,6 +386,11 @@
372
386
  transform: translateY(-1px);
373
387
  }
374
388
 
389
+ .choice-prompt__submit:focus-visible {
390
+ outline: 2px solid var(--fd-ring);
391
+ outline-offset: 2px;
392
+ }
393
+
375
394
  .choice-prompt__submit:disabled {
376
395
  opacity: 0.5;
377
396
  cursor: not-allowed;
@@ -184,6 +184,11 @@
184
184
  min-width: 100px;
185
185
  }
186
186
 
187
+ .confirmation-prompt__button:focus-visible {
188
+ outline: 2px solid var(--fd-ring);
189
+ outline-offset: 2px;
190
+ }
191
+
187
192
  .confirmation-prompt__button:disabled {
188
193
  cursor: not-allowed;
189
194
  }
@@ -14,6 +14,12 @@
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 {
20
+ MessageHierarchyItem,
21
+ MessageTag
22
+ } from '../../types/playground.js';
17
23
  import type {
18
24
  Interrupt,
19
25
  InterruptType,
@@ -49,9 +55,25 @@
49
55
  showTimestamp?: boolean;
50
56
  /** Callback to refresh messages after interrupt resolution */
51
57
  onResolved?: () => void;
52
- }
53
-
54
- let { interrupt: initialInterrupt, showTimestamp = true, onResolved }: Props = $props();
58
+ /**
59
+ * Hierarchy items forwarded from the parent playground message. Rendered
60
+ * as a chevron-separated trail in the footer.
61
+ */
62
+ hierarchy?: MessageHierarchyItem[];
63
+ /**
64
+ * Server-emitted tags forwarded from the parent playground message.
65
+ * Rendered as chips in the footer.
66
+ */
67
+ tags?: MessageTag[];
68
+ }
69
+
70
+ let {
71
+ interrupt: initialInterrupt,
72
+ showTimestamp = true,
73
+ onResolved,
74
+ hierarchy,
75
+ tags
76
+ }: Props = $props();
55
77
 
56
78
  /**
57
79
  * Get the current interrupt state from the store.
@@ -61,6 +83,11 @@
61
83
  getInterruptsMap().get(initialInterrupt.id) ?? addMachineState(initialInterrupt)
62
84
  );
63
85
 
86
+ const hierarchyItems = $derived(hierarchy ?? []);
87
+ const tagItems = $derived(tags ?? []);
88
+ const hasHierarchy = $derived(hierarchyItems.length > 0);
89
+ const hasTags = $derived(tagItems.length > 0);
90
+
64
91
  /**
65
92
  * Helper to ensure interrupt has machine state
66
93
  */
@@ -256,11 +283,13 @@
256
283
  class:interrupt-bubble--cancelled={currentInterrupt.machineState.status === 'cancelled'}
257
284
  class:interrupt-bubble--submitting={isSubmitting}
258
285
  class:interrupt-bubble--error={currentInterrupt.machineState.status === 'error'}
286
+ role="group"
287
+ aria-label={getTypeLabel(currentInterrupt.type)}
259
288
  >
260
289
  <!-- Header -->
261
290
  <div class="interrupt-bubble__header">
262
291
  <span class="interrupt-bubble__type">
263
- <Icon icon={getTypeIcon(currentInterrupt.type)} />
292
+ <Icon icon={getTypeIcon(currentInterrupt.type)} aria-hidden="true" />
264
293
  {#if isResolved}
265
294
  {currentInterrupt.machineState.status === 'cancelled'
266
295
  ? t.cancelled
@@ -272,9 +301,13 @@
272
301
  {/if}
273
302
  </span>
274
303
  {#if showTimestamp}
275
- <span class="interrupt-bubble__timestamp">
304
+ <time
305
+ class="interrupt-bubble__timestamp"
306
+ datetime={currentInterrupt.resolvedAt ?? currentInterrupt.createdAt}
307
+ aria-label="sent at {formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}"
308
+ >
276
309
  {formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}
277
- </span>
310
+ </time>
278
311
  {/if}
279
312
  </div>
280
313
 
@@ -347,17 +380,21 @@
347
380
  </div>
348
381
 
349
382
  <!-- Footer -->
350
- {#if currentInterrupt.nodeId || (currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation')}
383
+ {#if currentInterrupt.nodeId || hasHierarchy || hasTags || (currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation')}
351
384
  <div class="interrupt-bubble__footer">
352
- {#if currentInterrupt.nodeId}
353
- <span
354
- class="interrupt-bubble__node"
355
- title={t.nodeIdTooltip({ id: currentInterrupt.nodeId })}
356
- >
357
- <Icon icon="mdi:graph" />
358
- <span>{t.fromWorkflow}</span>
359
- </span>
360
- {/if}
385
+ <div class="interrupt-bubble__attribution">
386
+ {#if currentInterrupt.nodeId}
387
+ <span
388
+ class="interrupt-bubble__node"
389
+ title={t.nodeIdTooltip({ id: currentInterrupt.nodeId })}
390
+ >
391
+ <Icon icon="mdi:graph" aria-hidden="true" />
392
+ <span>{t.fromWorkflow}</span>
393
+ </span>
394
+ {/if}
395
+ <HierarchyTrail items={hierarchyItems} />
396
+ <MessageTagStrip tags={tagItems} />
397
+ </div>
361
398
  {#if currentInterrupt.allowCancel && !isResolved && currentInterrupt.type !== 'confirmation'}
362
399
  <button
363
400
  type="button"
@@ -365,7 +402,7 @@
365
402
  onclick={handleCancel}
366
403
  disabled={isSubmitting}
367
404
  >
368
- <Icon icon="mdi:close" />
405
+ <Icon icon="mdi:close" aria-hidden="true" />
369
406
  <span>{t.cancel}</span>
370
407
  </button>
371
408
  {/if}
@@ -517,6 +554,11 @@
517
554
  background-color: var(--fd-error-hover);
518
555
  }
519
556
 
557
+ .interrupt-bubble__retry-btn:focus-visible {
558
+ outline: 2px solid var(--fd-ring);
559
+ outline-offset: 2px;
560
+ }
561
+
520
562
  /* Body - prompt content area, full width */
521
563
  .interrupt-bubble__body {
522
564
  padding: var(--fd-space-xl);
@@ -535,6 +577,7 @@
535
577
  /* Footer */
536
578
  .interrupt-bubble__footer {
537
579
  display: flex;
580
+ flex-wrap: wrap;
538
581
  align-items: center;
539
582
  justify-content: space-between;
540
583
  gap: var(--fd-space-xs);
@@ -558,6 +601,15 @@
558
601
  border-top-color: var(--fd-interrupt-prompt-border-error);
559
602
  }
560
603
 
604
+ .interrupt-bubble__attribution {
605
+ display: flex;
606
+ flex-wrap: wrap;
607
+ align-items: center;
608
+ gap: var(--fd-space-xs);
609
+ min-width: 0;
610
+ flex: 1 1 auto;
611
+ }
612
+
561
613
  .interrupt-bubble__node {
562
614
  display: flex;
563
615
  align-items: center;
@@ -589,6 +641,11 @@
589
641
  background-color: var(--fd-error-muted);
590
642
  }
591
643
 
644
+ .interrupt-bubble__cancel-btn:focus-visible {
645
+ outline: 2px solid var(--fd-ring);
646
+ outline-offset: 2px;
647
+ }
648
+
592
649
  .interrupt-bubble__cancel-btn:disabled {
593
650
  opacity: 0.5;
594
651
  cursor: not-allowed;
@@ -606,5 +663,19 @@
606
663
  padding-left: var(--fd-space-lg);
607
664
  padding-right: var(--fd-space-lg);
608
665
  }
666
+
667
+ .interrupt-bubble__cancel-btn {
668
+ min-height: 2.5rem;
669
+ padding: var(--fd-space-xs) var(--fd-space-md);
670
+ }
671
+ }
672
+
673
+ @media (prefers-reduced-motion: reduce) {
674
+ .interrupt-bubble {
675
+ animation: none;
676
+ }
677
+ .interrupt-bubble__cancel-btn {
678
+ transition: none;
679
+ }
609
680
  }
610
681
  </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>;
@@ -533,6 +533,11 @@
533
533
  color: var(--fd-error);
534
534
  }
535
535
 
536
+ .review-prompt__bulk-btn:focus-visible {
537
+ outline: 2px solid var(--fd-ring);
538
+ outline-offset: 2px;
539
+ }
540
+
536
541
  .review-prompt__bulk-btn:disabled {
537
542
  opacity: 0.5;
538
543
  cursor: not-allowed;
@@ -630,6 +635,11 @@
630
635
  color: var(--fd-error-foreground);
631
636
  }
632
637
 
638
+ .review-prompt__toggle-btn:focus-visible {
639
+ outline: 2px solid var(--fd-ring);
640
+ outline-offset: 2px;
641
+ }
642
+
633
643
  .review-prompt__toggle-btn:disabled {
634
644
  opacity: 0.5;
635
645
  cursor: not-allowed;
@@ -722,6 +732,11 @@
722
732
  border-color: var(--fd-border-strong);
723
733
  }
724
734
 
735
+ .review-prompt__html-toggle-btn:focus-visible {
736
+ outline: 2px solid var(--fd-ring);
737
+ outline-offset: 2px;
738
+ }
739
+
725
740
  /* Raw HTML code display */
726
741
  .review-prompt__raw-html {
727
742
  font-family: var(--fd-review-font-mono);
@@ -820,6 +835,11 @@
820
835
  transform: translateY(-1px);
821
836
  }
822
837
 
838
+ .review-prompt__submit:focus-visible {
839
+ outline: 2px solid var(--fd-ring);
840
+ outline-offset: 2px;
841
+ }
842
+
823
843
  .review-prompt__submit:disabled {
824
844
  opacity: 0.5;
825
845
  cursor: not-allowed;