@morscherlab/mint-sdk 1.0.51 → 1.0.52

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 (43) hide show
  1. package/dist/__tests__/components/JobsStatusTray.test.d.ts +1 -0
  2. package/dist/__tests__/composables/useJobsStatusTray.test.d.ts +1 -0
  3. package/dist/__tests__/utils/jobs.test.d.ts +1 -0
  4. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  5. package/dist/components/JobsStatusTray.vue.d.ts +26 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.js +2 -2
  8. package/dist/{components-C7UFkNIp.js → components-Lk_h_rON.js} +1663 -1339
  9. package/dist/components-Lk_h_rON.js.map +1 -0
  10. package/dist/composables/index.d.ts +1 -0
  11. package/dist/composables/index.js +3 -3
  12. package/dist/composables/useJobsStatusTray.d.ts +43 -0
  13. package/dist/composables/usePluginClient.d.ts +4 -1
  14. package/dist/{composables-CpBhNKHf.js → composables-DyfGpYZw.js} +50 -17
  15. package/dist/{composables-CpBhNKHf.js.map → composables-DyfGpYZw.js.map} +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +4 -4
  18. package/dist/install.js +1 -1
  19. package/dist/jobs.d.ts +19 -0
  20. package/dist/styles.css +731 -0
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/jobs.d.ts +49 -0
  23. package/dist/{useProtocolTemplates-C8-YlHj1.js → useJobsStatusTray-DAH999_N.js} +250 -2
  24. package/dist/useJobsStatusTray-DAH999_N.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/components/JobsStatusTray.test.ts +141 -0
  27. package/src/__tests__/composables/useJobsStatusTray.test.ts +241 -0
  28. package/src/__tests__/composables/usePluginClient.test.ts +243 -0
  29. package/src/__tests__/utils/jobs.test.ts +95 -0
  30. package/src/components/JobsStatusTray.story.vue +120 -0
  31. package/src/components/JobsStatusTray.vue +385 -0
  32. package/src/components/index.ts +1 -0
  33. package/src/composables/index.ts +8 -0
  34. package/src/composables/useJobsStatusTray.ts +304 -0
  35. package/src/composables/usePluginClient.ts +65 -15
  36. package/src/index.ts +3 -0
  37. package/src/jobs.ts +75 -0
  38. package/src/styles/components/jobs-status-tray.css +420 -0
  39. package/src/styles/index.css +1 -0
  40. package/src/types/index.ts +10 -0
  41. package/src/types/jobs.ts +61 -0
  42. package/dist/components-C7UFkNIp.js.map +0 -1
  43. package/dist/useProtocolTemplates-C8-YlHj1.js.map +0 -1
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ normalizeJobPercent,
5
+ normalizeJobState,
6
+ resolveJobCapabilities,
7
+ } from '../../jobs'
8
+ import type { JobStateInput, JobStatus } from '../../types/jobs'
9
+
10
+ const legacyCapabilities: Record<JobStatus, [boolean, boolean]> = {
11
+ awaiting_input: [false, false],
12
+ queued: [true, false],
13
+ running: [true, false],
14
+ completed: [false, true],
15
+ failed: [false, true],
16
+ cancelled: [false, true],
17
+ }
18
+
19
+ function job(status: JobStatus): JobStateInput {
20
+ return {
21
+ id: status,
22
+ job_id: status,
23
+ kind: 'analysis',
24
+ name: status,
25
+ status,
26
+ progress: {
27
+ current_file: 0,
28
+ total_files: 0,
29
+ current_filename: null,
30
+ percent: 0,
31
+ message: '',
32
+ stage: status,
33
+ },
34
+ queue_position: 0,
35
+ warnings: [],
36
+ error: null,
37
+ output_path: null,
38
+ save_path: null,
39
+ session_id: null,
40
+ folders: [],
41
+ folder_path: null,
42
+ user_id: '1',
43
+ owner_user_id: '1',
44
+ experiment_id: null,
45
+ extraction_result_id: null,
46
+ metadata: {},
47
+ created_at: '2026-07-14T10:00:00+00:00',
48
+ started_at: null,
49
+ completed_at: null,
50
+ }
51
+ }
52
+
53
+ describe('job capability normalization', () => {
54
+ it.each(Object.entries(legacyCapabilities))(
55
+ 'infers legacy capabilities for %s',
56
+ (status, expected) => {
57
+ const capabilities = resolveJobCapabilities({ status: status as JobStatus })
58
+
59
+ expect([capabilities.can_cancel, capabilities.can_delete]).toEqual(expected)
60
+ },
61
+ )
62
+
63
+ it('keeps explicit capabilities authoritative', () => {
64
+ const capabilities = resolveJobCapabilities({
65
+ status: 'running',
66
+ can_cancel: false,
67
+ can_delete: true,
68
+ })
69
+
70
+ expect(capabilities).toEqual({ can_cancel: false, can_delete: true })
71
+ })
72
+
73
+ it('fails closed for an unknown legacy status', () => {
74
+ const capabilities = resolveJobCapabilities({ status: 'custom-status' })
75
+
76
+ expect(capabilities).toEqual({ can_cancel: false, can_delete: false })
77
+ })
78
+
79
+ it('normalizes without mutating the transport input', () => {
80
+ const input = job('awaiting_input')
81
+
82
+ normalizeJobState(input)
83
+
84
+ expect('can_cancel' in input).toBe(false)
85
+ })
86
+
87
+ it.each([
88
+ [Number.NaN, 0],
89
+ [Number.POSITIVE_INFINITY, 0],
90
+ [-1, 0],
91
+ [101, 100],
92
+ ])('clamps unsafe progress value %s', (value, expected) => {
93
+ expect(normalizeJobPercent(value)).toBe(expected)
94
+ })
95
+ })
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import JobsStatusTray from './JobsStatusTray.vue'
3
+ import type { JobStateInput, JobStatus } from '../types/jobs'
4
+
5
+ function job(
6
+ status: JobStatus,
7
+ id: string,
8
+ overrides: Partial<JobStateInput> = {},
9
+ ): JobStateInput {
10
+ return {
11
+ id,
12
+ job_id: id,
13
+ kind: 'analysis',
14
+ name: `LC-MS analysis ${id}`,
15
+ status,
16
+ progress: {
17
+ current_file: status === 'running' ? 3 : 0,
18
+ total_files: status === 'running' ? 8 : 0,
19
+ current_filename: status === 'running' ? 'sample_03.raw' : null,
20
+ percent: status === 'running' ? 37.5 : 0,
21
+ message: status === 'awaiting_input' ? 'Select a reference library to continue' : '',
22
+ stage: status,
23
+ },
24
+ queue_position: status === 'queued' ? 2 : 0,
25
+ warnings: [],
26
+ error: status === 'failed' ? 'Reference peaks were not detected' : null,
27
+ output_path: null,
28
+ save_path: null,
29
+ session_id: null,
30
+ folders: [],
31
+ folder_path: null,
32
+ user_id: 'researcher-1',
33
+ owner_user_id: 'researcher-1',
34
+ experiment_id: 'EXP-042',
35
+ extraction_result_id: null,
36
+ metadata: {},
37
+ created_at: '2026-07-14T10:00:00+00:00',
38
+ started_at: status === 'running' ? '2026-07-14T10:01:00+00:00' : null,
39
+ completed_at: ['completed', 'failed', 'cancelled'].includes(status)
40
+ ? '2026-07-14T10:08:00+00:00'
41
+ : null,
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ const mixedJobs = [
47
+ job('awaiting_input', 'J-104'),
48
+ job('running', 'J-103'),
49
+ job('queued', 'J-102'),
50
+ job('completed', 'J-101'),
51
+ job('failed', 'J-100'),
52
+ ]
53
+ const explicitActionJobs = [
54
+ job('awaiting_input', 'J-201', { can_cancel: true }),
55
+ job('completed', 'J-200', { can_delete: false }),
56
+ ]
57
+ const adapter = {
58
+ cancelJob: async () => undefined,
59
+ deleteJob: async () => true,
60
+ }
61
+
62
+ function initState() {
63
+ return {
64
+ title: 'Analysis jobs',
65
+ jobs: mixedJobs,
66
+ }
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <Story title="Workflow/JobsStatusTray">
72
+ <Variant title="Playground" :init-state="initState">
73
+ <template #default="{ state }">
74
+ <div style="padding: 2rem; min-height: 520px;">
75
+ <JobsStatusTray
76
+ :title="state.title"
77
+ :jobs="state.jobs"
78
+ :adapter="adapter"
79
+ :load-on-mount="false"
80
+ :teleport-to="false"
81
+ />
82
+ </div>
83
+ </template>
84
+ <template #controls="{ state }">
85
+ <HstText v-model="state.title" title="Title" />
86
+ </template>
87
+ </Variant>
88
+
89
+ <Variant title="Awaiting input">
90
+ <div style="padding: 2rem; min-height: 520px;">
91
+ <JobsStatusTray
92
+ :jobs="[job('awaiting_input', 'J-301')]"
93
+ :load-on-mount="false"
94
+ :teleport-to="false"
95
+ />
96
+ </div>
97
+ </Variant>
98
+
99
+ <Variant title="Explicit actions">
100
+ <div style="padding: 2rem; min-height: 520px;">
101
+ <JobsStatusTray
102
+ :jobs="explicitActionJobs"
103
+ :adapter="adapter"
104
+ :load-on-mount="false"
105
+ :teleport-to="false"
106
+ />
107
+ </div>
108
+ </Variant>
109
+
110
+ <Variant title="Empty">
111
+ <div style="padding: 2rem; min-height: 520px;">
112
+ <JobsStatusTray
113
+ :jobs="[]"
114
+ :load-on-mount="false"
115
+ :teleport-to="false"
116
+ />
117
+ </div>
118
+ </Variant>
119
+ </Story>
120
+ </template>
@@ -0,0 +1,385 @@
1
+ <script setup lang="ts">
2
+ /** Floating, adapter-driven job status tray for plugin and platform workflows. */
3
+ import { computed, nextTick, ref, toRef, useId } from 'vue'
4
+
5
+ import {
6
+ jobStatusLabel,
7
+ normalizeJobPercent,
8
+ } from '../jobs'
9
+ import {
10
+ useJobsStatusTray,
11
+ type JobsStatusTrayAdapter,
12
+ } from '../composables/useJobsStatusTray'
13
+ import type { UsePluginEventStreamReturn } from '../composables/usePluginClient'
14
+ import type {
15
+ JobState,
16
+ JobStateInput,
17
+ JobStreamData,
18
+ } from '../types/jobs'
19
+
20
+ interface Props {
21
+ jobs?: readonly JobStateInput[]
22
+ adapter?: JobsStatusTrayAdapter
23
+ eventStream?: UsePluginEventStreamReturn<JobStreamData>
24
+ loadOnMount?: boolean
25
+ title?: string
26
+ teleportTo?: string | HTMLElement | false
27
+ }
28
+
29
+ const props = withDefaults(defineProps<Props>(), {
30
+ loadOnMount: true,
31
+ title: 'Analysis jobs',
32
+ teleportTo: 'body',
33
+ })
34
+
35
+ const emit = defineEmits<{
36
+ select: [job: JobState]
37
+ error: [message: string]
38
+ }>()
39
+
40
+ const panelId = `mint-jobs-tray-${useId().replace(/:/g, '')}`
41
+ const titleId = `${panelId}-title`
42
+ const triggerRef = ref<HTMLButtonElement | null>(null)
43
+ const panelRef = ref<HTMLElement | null>(null)
44
+ const teleportTarget = computed(() => props.teleportTo || 'body')
45
+ const tray = useJobsStatusTray({
46
+ jobs: toRef(props, 'jobs'),
47
+ adapter: toRef(props, 'adapter'),
48
+ eventStream: toRef(props, 'eventStream'),
49
+ loadOnMount: props.loadOnMount,
50
+ })
51
+ const canClear = computed(() => (
52
+ tray.deletableJobs.value.length > 0
53
+ && Boolean(props.adapter?.clearFinishedJobs || props.adapter?.deleteJob)
54
+ ))
55
+
56
+ async function openTray(): Promise<void> {
57
+ tray.open()
58
+ await nextTick()
59
+ panelRef.value?.focus()
60
+ }
61
+
62
+ async function closeTray(): Promise<void> {
63
+ tray.close()
64
+ await nextTick()
65
+ triggerRef.value?.focus()
66
+ }
67
+
68
+ async function toggleTray(): Promise<void> {
69
+ if (tray.isOpen.value) await closeTray()
70
+ else await openTray()
71
+ }
72
+
73
+ function onPanelKeydown(event: KeyboardEvent): void {
74
+ if (event.key === 'Escape') {
75
+ void closeTray()
76
+ return
77
+ }
78
+ if (event.key !== 'Tab' || !panelRef.value) return
79
+ const focusable = Array.from(panelRef.value.querySelectorAll<HTMLElement>(
80
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
81
+ ))
82
+ if (focusable.length === 0) {
83
+ event.preventDefault()
84
+ panelRef.value.focus()
85
+ return
86
+ }
87
+ const first = focusable[0]
88
+ const last = focusable[focusable.length - 1]
89
+ const activeElement = document.activeElement as HTMLElement | null
90
+ if (!activeElement || !focusable.includes(activeElement)) {
91
+ event.preventDefault()
92
+ if (event.shiftKey) last?.focus()
93
+ else first?.focus()
94
+ } else if (event.shiftKey && activeElement === first) {
95
+ event.preventDefault()
96
+ last?.focus()
97
+ } else if (!event.shiftKey && activeElement === last) {
98
+ event.preventDefault()
99
+ first?.focus()
100
+ }
101
+ }
102
+
103
+ function progressText(job: JobState): string {
104
+ if (job.status === 'awaiting_input') {
105
+ return job.progress.message || 'Waiting for external input'
106
+ }
107
+ if (job.status === 'queued' && job.queue_position > 0) {
108
+ return `Queue position ${job.queue_position}`
109
+ }
110
+ return job.progress.message || job.progress.stage || jobStatusLabel(job.status)
111
+ }
112
+
113
+ function formatTimestamp(value: string | null): string {
114
+ if (!value) return ''
115
+ const date = new Date(value)
116
+ return Number.isNaN(date.getTime())
117
+ ? ''
118
+ : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
119
+ }
120
+
121
+ function statusClass(job: JobState): string {
122
+ return `mint-jobs-tray__status--${job.status.replace('_', '-')}`
123
+ }
124
+
125
+ async function cancelJob(job: JobState): Promise<void> {
126
+ const succeeded = await tray.cancelJob(job)
127
+ if (!succeeded && tray.error.value) emit('error', tray.error.value)
128
+ }
129
+
130
+ async function deleteJob(job: JobState): Promise<void> {
131
+ const succeeded = await tray.deleteJob(job)
132
+ if (!succeeded && tray.error.value) emit('error', tray.error.value)
133
+ }
134
+
135
+ async function clearFinished(): Promise<void> {
136
+ const succeeded = await tray.clearFinished()
137
+ if (!succeeded && tray.error.value) emit('error', tray.error.value)
138
+ }
139
+ </script>
140
+
141
+ <template>
142
+ <Teleport :to="teleportTarget" :disabled="teleportTo === false">
143
+ <div class="mint-jobs-tray">
144
+ <button
145
+ ref="triggerRef"
146
+ type="button"
147
+ class="mint-jobs-tray__trigger"
148
+ aria-haspopup="dialog"
149
+ :aria-expanded="tray.isOpen.value"
150
+ :aria-controls="panelId"
151
+ @click="toggleTray"
152
+ >
153
+ <svg
154
+ class="mint-jobs-tray__trigger-icon"
155
+ :class="{ 'mint-jobs-tray__trigger-icon--active': tray.activeJobs.value.length > 0 }"
156
+ viewBox="0 0 24 24"
157
+ fill="none"
158
+ stroke="currentColor"
159
+ aria-hidden="true"
160
+ >
161
+ <path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
162
+ <path d="M9 5a3 3 0 0 1 6 0v2H9V5Z" />
163
+ </svg>
164
+ <span class="mint-jobs-tray__trigger-label">
165
+ {{ tray.featuredJob.value ? jobStatusLabel(tray.featuredJob.value.status) : 'Jobs' }}
166
+ </span>
167
+ <span
168
+ v-if="tray.activeJobs.value.length > 0"
169
+ class="mint-jobs-tray__count"
170
+ aria-live="polite"
171
+ >
172
+ {{ tray.activeJobs.value.length }}
173
+ </span>
174
+ </button>
175
+
176
+ <Transition name="mint-jobs-tray-fade">
177
+ <div
178
+ v-if="tray.isOpen.value"
179
+ class="mint-jobs-tray__backdrop"
180
+ aria-hidden="true"
181
+ @click="closeTray"
182
+ />
183
+ </Transition>
184
+
185
+ <Transition name="mint-jobs-tray-slide">
186
+ <section
187
+ v-if="tray.isOpen.value"
188
+ :id="panelId"
189
+ ref="panelRef"
190
+ class="mint-jobs-tray__panel"
191
+ role="dialog"
192
+ aria-modal="true"
193
+ :aria-labelledby="titleId"
194
+ tabindex="-1"
195
+ @keydown="onPanelKeydown"
196
+ >
197
+ <header class="mint-jobs-tray__header">
198
+ <div>
199
+ <h2 :id="titleId" class="mint-jobs-tray__title">{{ title }}</h2>
200
+ <p class="mint-jobs-tray__subtitle">{{ tray.jobs.value.length }} total</p>
201
+ </div>
202
+ <div class="mint-jobs-tray__header-actions">
203
+ <button
204
+ v-if="adapter?.listJobs"
205
+ type="button"
206
+ class="mint-jobs-tray__icon-button"
207
+ aria-label="Refresh jobs"
208
+ :disabled="tray.isLoading.value"
209
+ @click="tray.refresh"
210
+ >
211
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
212
+ <path d="M20 6v5h-5M4 18v-5h5" />
213
+ <path d="M18.4 9A7 7 0 0 0 6.1 6.1L4 8m2 7a7 7 0 0 0 11.9 2.9L20 16" />
214
+ </svg>
215
+ </button>
216
+ <button
217
+ type="button"
218
+ class="mint-jobs-tray__icon-button"
219
+ aria-label="Close jobs"
220
+ @click="closeTray"
221
+ >
222
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
223
+ <path d="m6 6 12 12M18 6 6 18" />
224
+ </svg>
225
+ </button>
226
+ </div>
227
+ </header>
228
+
229
+ <div class="mint-jobs-tray__tabs" role="tablist" aria-label="Job groups">
230
+ <button
231
+ type="button"
232
+ role="tab"
233
+ data-tab="active"
234
+ class="mint-jobs-tray__tab"
235
+ :class="{ 'mint-jobs-tray__tab--active': tray.activeTab.value === 'active' }"
236
+ :aria-selected="tray.activeTab.value === 'active'"
237
+ @click="tray.activeTab.value = 'active'"
238
+ >
239
+ Active
240
+ <span class="mint-jobs-tray__tab-count">{{ tray.activeJobs.value.length }}</span>
241
+ </button>
242
+ <button
243
+ type="button"
244
+ role="tab"
245
+ data-tab="history"
246
+ class="mint-jobs-tray__tab"
247
+ :class="{ 'mint-jobs-tray__tab--active': tray.activeTab.value === 'history' }"
248
+ :aria-selected="tray.activeTab.value === 'history'"
249
+ @click="tray.activeTab.value = 'history'"
250
+ >
251
+ History
252
+ <span class="mint-jobs-tray__tab-count">{{ tray.historyJobs.value.length }}</span>
253
+ </button>
254
+ </div>
255
+
256
+ <p v-if="tray.error.value" class="mint-jobs-tray__error" role="alert">
257
+ {{ tray.error.value }}
258
+ </p>
259
+
260
+ <div class="mint-jobs-tray__body">
261
+ <template v-if="tray.activeTab.value === 'active'">
262
+ <p v-if="tray.activeJobs.value.length === 0" class="mint-jobs-tray__empty">
263
+ No active jobs
264
+ </p>
265
+ <article
266
+ v-for="job in tray.activeJobs.value"
267
+ :key="job.job_id"
268
+ class="mint-jobs-tray__job mint-jobs-tray__job--active"
269
+ >
270
+ <div class="mint-jobs-tray__job-heading">
271
+ <div class="mint-jobs-tray__job-identity">
272
+ <button
273
+ type="button"
274
+ class="mint-jobs-tray__job-select"
275
+ @click="emit('select', job)"
276
+ >
277
+ {{ job.name }}
278
+ </button>
279
+ <span class="mint-jobs-tray__kind">{{ job.kind }}</span>
280
+ </div>
281
+ <span class="mint-jobs-tray__status" :class="statusClass(job)">
282
+ {{ jobStatusLabel(job.status) }}
283
+ </span>
284
+ </div>
285
+ <div
286
+ v-if="job.status === 'running'"
287
+ class="mint-jobs-tray__progress"
288
+ role="progressbar"
289
+ aria-label="Job progress"
290
+ aria-valuemin="0"
291
+ aria-valuemax="100"
292
+ :aria-valuenow="normalizeJobPercent(job.progress.percent)"
293
+ >
294
+ <span :style="{ width: `${normalizeJobPercent(job.progress.percent)}%` }" />
295
+ </div>
296
+ <div class="mint-jobs-tray__job-footer">
297
+ <span>{{ progressText(job) }}</span>
298
+ <div class="mint-jobs-tray__job-actions">
299
+ <button
300
+ v-if="job.can_cancel && adapter?.cancelJob"
301
+ type="button"
302
+ class="mint-jobs-tray__action mint-jobs-tray__action--danger"
303
+ aria-label="Cancel job"
304
+ :disabled="tray.isPending(job)"
305
+ @click="cancelJob(job)"
306
+ >
307
+ Cancel
308
+ </button>
309
+ <button
310
+ v-if="job.can_delete && adapter?.deleteJob"
311
+ type="button"
312
+ class="mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger"
313
+ aria-label="Delete job"
314
+ :disabled="tray.isPending(job)"
315
+ @click="deleteJob(job)"
316
+ >
317
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
318
+ <path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5M14 11v5" />
319
+ </svg>
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </article>
324
+ </template>
325
+
326
+ <template v-else>
327
+ <div v-if="canClear" class="mint-jobs-tray__clear-row">
328
+ <button
329
+ type="button"
330
+ class="mint-jobs-tray__action mint-jobs-tray__action--danger"
331
+ @click="clearFinished"
332
+ >
333
+ Clear finished
334
+ </button>
335
+ </div>
336
+ <p v-if="tray.historyJobs.value.length === 0" class="mint-jobs-tray__empty">
337
+ No job history
338
+ </p>
339
+ <article
340
+ v-for="job in tray.historyJobs.value"
341
+ :key="job.job_id"
342
+ class="mint-jobs-tray__job"
343
+ >
344
+ <div class="mint-jobs-tray__job-heading">
345
+ <div class="mint-jobs-tray__job-identity">
346
+ <button
347
+ type="button"
348
+ class="mint-jobs-tray__job-select"
349
+ @click="emit('select', job)"
350
+ >
351
+ {{ job.name }}
352
+ </button>
353
+ <span class="mint-jobs-tray__kind">{{ job.kind }}</span>
354
+ </div>
355
+ <span class="mint-jobs-tray__status" :class="statusClass(job)">
356
+ {{ jobStatusLabel(job.status) }}
357
+ </span>
358
+ </div>
359
+ <div class="mint-jobs-tray__job-footer">
360
+ <span>{{ formatTimestamp(job.completed_at || job.created_at) }}</span>
361
+ <button
362
+ v-if="job.can_delete && adapter?.deleteJob"
363
+ type="button"
364
+ class="mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger"
365
+ aria-label="Delete job"
366
+ :disabled="tray.isPending(job)"
367
+ @click="deleteJob(job)"
368
+ >
369
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
370
+ <path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5M14 11v5" />
371
+ </svg>
372
+ </button>
373
+ </div>
374
+ </article>
375
+ </template>
376
+ </div>
377
+ </section>
378
+ </Transition>
379
+ </div>
380
+ </Teleport>
381
+ </template>
382
+
383
+ <style>
384
+ @import '../styles/components/jobs-status-tray.css';
385
+ </style>
@@ -108,6 +108,7 @@ export { default as UnitInput } from './UnitInput.vue'
108
108
  export { default as StepWizard } from './StepWizard.vue'
109
109
  export { default as AuditTrail } from './AuditTrail.vue'
110
110
  export { default as BatchProgressList } from './BatchProgressList.vue'
111
+ export { default as JobsStatusTray } from './JobsStatusTray.vue'
111
112
 
112
113
  // Form builder components
113
114
  export { default as FormBuilder } from './FormBuilder.vue'
@@ -468,3 +468,11 @@ export {
468
468
  type UsePluginSettingsOptions,
469
469
  type UsePluginSettingsReturn,
470
470
  } from './usePluginClient'
471
+ export {
472
+ useJobsStatusTray,
473
+ type JobsStatusTrayAdapter,
474
+ type JobsStatusTrayTab,
475
+ type JobStreamMessage,
476
+ type UseJobsStatusTrayOptions,
477
+ type UseJobsStatusTrayReturn,
478
+ } from './useJobsStatusTray'