@morscherlab/mint-sdk 1.4.0-beta.18 → 1.4.0-beta.19

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.
@@ -27,20 +27,26 @@ import {
27
27
  X,
28
28
  } from 'lucide-vue-next'
29
29
 
30
- import { isActiveJobStatus, jobStatusLabel, normalizeJobPercent } from '../jobs'
30
+ import { isActiveJobStatus, jobStatusLabel } from '../jobs'
31
31
  import type { UseJobCenterReturn } from '../composables/useJobCenter'
32
32
  import type { JobState, JobStatus } from '../types/jobs'
33
+ import ConfirmDialog from './ConfirmDialog.vue'
34
+
35
+ type ConfirmAction = 'cancel' | 'delete' | 'clear'
33
36
 
34
37
  interface Props {
35
38
  /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */
36
39
  center: UseJobCenterReturn
37
40
  title?: string
38
41
  teleportTo?: string | HTMLElement | false
42
+ /** Ask before Cancel, Delete and Clear (`true`), or before the named ones only. Default off. */
43
+ confirm?: boolean | Partial<Record<ConfirmAction, boolean>>
39
44
  }
40
45
 
41
46
  const props = withDefaults(defineProps<Props>(), {
42
47
  title: 'Jobs',
43
48
  teleportTo: 'body',
49
+ confirm: false,
44
50
  })
45
51
 
46
52
  const emit = defineEmits<{
@@ -70,15 +76,19 @@ const panelRef = ref<HTMLElement | null>(null)
70
76
  const teleportTarget = computed(() => props.teleportTo || 'body')
71
77
  const tray = props.center
72
78
  const titleId = `${tray.panelId}-title`
79
+ const rows = (jobs: JobState[]) => jobs.map(job => ({ job, view: tray.rowView(job) }))
73
80
  // A job waiting for input is the only one that needs a person: pin it first.
74
- const activeRows = computed(() => [
75
- ...tray.activeJobs.value.filter(job => job.status === 'awaiting_input'),
76
- ...tray.activeJobs.value.filter(job => job.status !== 'awaiting_input'),
77
- ])
81
+ const activeRows = computed(() => {
82
+ const active = rows(tray.activeJobs.value)
83
+ return [
84
+ ...active.filter(row => row.view.status === 'awaiting_input'),
85
+ ...active.filter(row => row.view.status !== 'awaiting_input'),
86
+ ]
87
+ })
78
88
  const sections = computed(() => [
79
- { key: 'active', title: 'Active', jobs: activeRows.value },
80
- { key: 'finished', title: 'Finished', jobs: tray.historyJobs.value },
81
- ].filter(section => section.jobs.length > 0))
89
+ { key: 'active', title: 'Active', rows: activeRows.value },
90
+ { key: 'finished', title: 'Finished', rows: rows(tray.historyJobs.value) },
91
+ ].filter(section => section.rows.length > 0))
82
92
  const headCount = computed(() => {
83
93
  const parts = [
84
94
  [tray.activeJobs.value.length, 'active'],
@@ -94,9 +104,13 @@ const canClear = computed(() => (
94
104
 
95
105
  // Escape returns focus to the element that opened the panel (the top-bar chip).
96
106
  // The panel is non-modal, so Escape is heard on the document while it is open.
107
+ // An Escape that an inner control handled, or that belongs to a modal dialog
108
+ // above the tray (its own confirm dialog too), leaves the tray open.
97
109
  let opener: HTMLElement | null = null
98
110
  function onDocumentKeydown(event: KeyboardEvent): void {
99
- if (event.key === 'Escape') closeTray()
111
+ if (event.key !== 'Escape' || event.defaultPrevented || confirmOpen.value) return
112
+ if (event.target instanceof Element && event.target.closest('[aria-modal="true"]')) return
113
+ closeTray()
100
114
  }
101
115
  watch(tray.isOpen, async (open) => {
102
116
  if (!open) {
@@ -110,6 +124,18 @@ watch(tray.isOpen, async (open) => {
110
124
  }, { flush: 'sync' })
111
125
  onBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))
112
126
 
127
+ // A focused row that leaves the list (an action or a refresh) drops focus to body:
128
+ // note the focused element before the update and keep focus in the panel after it.
129
+ let focusedInPanel: Element | null = null
130
+ watch(tray.jobs, () => {
131
+ const focused = document.activeElement
132
+ focusedInPanel = focused && panelRef.value?.contains(focused) ? focused : null
133
+ }, { flush: 'pre' })
134
+ watch(tray.jobs, () => {
135
+ if (tray.isOpen.value && focusedInPanel && !focusedInPanel.isConnected) panelRef.value?.focus()
136
+ focusedInPanel = null
137
+ }, { flush: 'post' })
138
+
113
139
  function closeTray(): void {
114
140
  tray.close()
115
141
  // Safari leaves a clicked button unfocused (opener = body), and the chip node is
@@ -121,17 +147,6 @@ function closeTray(): void {
121
147
  target?.focus()
122
148
  }
123
149
 
124
- function progressText(job: JobState): string {
125
- if (job.error) return job.error
126
- if (job.status === 'awaiting_input') {
127
- return job.progress.message || 'Waiting for external input'
128
- }
129
- if (job.status === 'queued' && job.queue_position > 0) {
130
- return `Queue position ${job.queue_position}`
131
- }
132
- return job.progress.message || job.progress.stage || jobStatusLabel(job.status)
133
- }
134
-
135
150
  function formatTime(value: string | null): string {
136
151
  if (!value) return ''
137
152
  const date = new Date(value)
@@ -140,14 +155,89 @@ function formatTime(value: string | null): string {
140
155
  : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
141
156
  }
142
157
 
143
- function jobPercent(job: JobState): number {
144
- return Math.round(normalizeJobPercent(job.progress.percent))
158
+ /** Right-hand value: the percent while a job moves, else when it ended or started. */
159
+ function jobValue(job: JobState, percent: number | null): string {
160
+ if (percent != null) return `${percent}%`
161
+ return formatTime(job.completed_at || job.started_at || job.created_at)
145
162
  }
146
163
 
147
- /** Right-hand value: the percent while a job runs, else when it ended or started. */
148
- function jobValue(job: JobState): string {
149
- if (job.status === 'running') return `${jobPercent(job)}%`
150
- return formatTime(job.completed_at || job.started_at || job.created_at)
164
+ // Confirmation: the action waits for the dialog; it runs on the live job.
165
+ // `ids`: the finished jobs that the user confirmed to clear.
166
+ const pending = ref<{ action: ConfirmAction; job?: JobState; ids?: string[] } | null>(null)
167
+ const confirmOpen = ref(false)
168
+ // Set on request and kept, so the dialog text stays while it fades out.
169
+ const confirmText = ref<{ title: string; message: string; confirmLabel: string; cancelLabel: string } | null>(null)
170
+ const confirmEnabled = computed(() => props.confirm === true
171
+ || (typeof props.confirm === 'object' && Object.values(props.confirm).some(Boolean)))
172
+
173
+ function textFor(action: ConfirmAction, job?: JobState) {
174
+ if (action === 'clear') {
175
+ const count = tray.deletableJobs.value.length
176
+ return {
177
+ title: 'Clear finished jobs?',
178
+ message: `${count} finished ${count === 1 ? 'job' : 'jobs'} and their stored results are deleted.`,
179
+ confirmLabel: 'Clear jobs',
180
+ cancelLabel: 'Cancel',
181
+ }
182
+ }
183
+ return action === 'cancel'
184
+ ? { title: 'Cancel job?', message: `${job?.name} stops. Its partial results are lost.`, confirmLabel: 'Cancel job', cancelLabel: 'Keep running' }
185
+ : { title: 'Delete job?', message: `${job?.name} and its stored result are deleted.`, confirmLabel: 'Delete job', cancelLabel: 'Cancel' }
186
+ }
187
+
188
+ function needsConfirm(action: ConfirmAction): boolean {
189
+ return props.confirm === true || (typeof props.confirm === 'object' && props.confirm[action] === true)
190
+ }
191
+
192
+ function request(action: ConfirmAction, job?: JobState): void {
193
+ if (!needsConfirm(action)) {
194
+ void run(action, job)
195
+ return
196
+ }
197
+ pending.value = { action, job, ids: action === 'clear' ? deletableIds() : undefined }
198
+ confirmText.value = textFor(action, job)
199
+ confirmOpen.value = true
200
+ }
201
+
202
+ function deletableIds(): string[] {
203
+ return tray.deletableJobs.value.map(job => job.job_id).sort()
204
+ }
205
+
206
+ // A new source is a new job list: the confirmed target is gone.
207
+ watch(tray.source, () => onConfirmOpen(false))
208
+
209
+ function onConfirmOpen(open: boolean): void {
210
+ confirmOpen.value = open
211
+ if (!open) pending.value = null
212
+ }
213
+
214
+ async function confirmPending(): Promise<void> {
215
+ // A second Confirm click while the dialog fades out does nothing.
216
+ const current = pending.value
217
+ if (!confirmOpen.value || !current) return
218
+ if (current.action === 'clear') {
219
+ // Polling changed the finished jobs: ask again with the new count.
220
+ const ids = deletableIds()
221
+ if (ids.join() !== current.ids?.join()) {
222
+ pending.value = { ...current, ids }
223
+ confirmText.value = textFor('clear')
224
+ return
225
+ }
226
+ }
227
+ confirmOpen.value = false
228
+ pending.value = null
229
+ if (current.action === 'clear') return clearFinished()
230
+ // Polling can end or remove the job while the dialog is open: then do nothing.
231
+ const live = tray.jobs.value.find(job => job.job_id === current.job?.job_id)
232
+ const allowed = live && (current.action === 'cancel'
233
+ ? isActiveJobStatus(live.status) && live.can_cancel
234
+ : live.can_delete)
235
+ if (allowed) await run(current.action, live)
236
+ }
237
+
238
+ function run(action: ConfirmAction, job?: JobState): Promise<void> {
239
+ if (action === 'clear') return clearFinished()
240
+ return action === 'cancel' ? cancelJob(job!) : deleteJob(job!)
151
241
  }
152
242
 
153
243
  async function cancelJob(job: JobState): Promise<void> {
@@ -221,36 +311,37 @@ async function clearFinished(): Promise<void> {
221
311
  <template v-for="section in sections" :key="section.key">
222
312
  <div class="mint-jobs-tray__section" :data-section="section.key">
223
313
  {{ section.title }}
224
- <span class="mint-jobs-tray__section-count">· {{ section.jobs.length }}</span>
314
+ <span class="mint-jobs-tray__section-count">· {{ section.rows.length }}</span>
225
315
  <button
226
316
  v-if="section.key === 'finished' && canClear"
227
317
  type="button"
228
318
  class="mint-jobs-tray__section-action"
229
- @click="clearFinished"
319
+ @click="request('clear')"
230
320
  >
231
321
  Clear
232
322
  </button>
233
323
  </div>
234
324
 
235
325
  <div
236
- v-for="job in section.jobs"
326
+ v-for="{ job, view } in section.rows"
237
327
  :key="job.job_id"
238
328
  class="mint-jobs-tray__row"
239
- :class="{ 'mint-jobs-tray__row--awaiting-input': job.status === 'awaiting_input' }"
329
+ :class="{ 'mint-jobs-tray__row--awaiting-input': view.status === 'awaiting_input' }"
240
330
  :data-status="job.status"
241
331
  >
332
+ <!-- data-status is the real job status; the row view only changes how it looks. -->
242
333
  <component
243
- :is="STATUS_ICONS[job.status]"
334
+ :is="STATUS_ICONS[view.status]"
244
335
  :size="15"
245
- :class="['mint-jobs-tray__status', `mint-jobs-tray__status--${job.status.replace('_', '-')}`]"
336
+ :class="['mint-jobs-tray__status', `mint-jobs-tray__status--${view.status.replace('_', '-')}`]"
246
337
  role="img"
247
- :aria-label="jobStatusLabel(job.status)"
338
+ :aria-label="jobStatusLabel(view.status)"
248
339
  />
249
340
  <button type="button" class="mint-jobs-tray__row-title" @click="emit('select', job)">
250
341
  <slot name="job-title" :job="job">{{ job.name }}</slot>
251
342
  </button>
252
343
  <span class="mint-jobs-tray__tools">
253
- <span class="mint-jobs-tray__value">{{ jobValue(job) }}</span>
344
+ <span class="mint-jobs-tray__value">{{ jobValue(job, view.percent) }}</span>
254
345
  <slot name="job-actions" :job="job" />
255
346
  <button
256
347
  v-if="isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value"
@@ -259,7 +350,7 @@ async function clearFinished(): Promise<void> {
259
350
  :aria-label="`Cancel ${job.name}`"
260
351
  title="Cancel"
261
352
  :disabled="tray.isPending(job)"
262
- @click="cancelJob(job)"
353
+ @click="request('cancel', job)"
263
354
  >
264
355
  <Square :size="12" aria-hidden="true" />
265
356
  </button>
@@ -270,33 +361,47 @@ async function clearFinished(): Promise<void> {
270
361
  :aria-label="`Delete ${job.name}`"
271
362
  title="Delete"
272
363
  :disabled="tray.isPending(job)"
273
- @click="deleteJob(job)"
364
+ @click="request('delete', job)"
274
365
  >
275
366
  <Trash2 :size="14" aria-hidden="true" />
276
367
  </button>
277
368
  </span>
278
369
  <div
279
- v-if="job.status === 'running'"
370
+ v-if="view.percent != null"
280
371
  class="mint-jobs-tray__progress"
281
372
  role="progressbar"
282
373
  :aria-label="`${job.name} progress`"
283
374
  aria-valuemin="0"
284
375
  aria-valuemax="100"
285
- :aria-valuenow="jobPercent(job)"
376
+ :aria-valuenow="view.percent"
286
377
  >
287
- <span :style="{ width: `${jobPercent(job)}%` }" />
378
+ <span :style="{ width: `${view.percent}%` }" />
288
379
  </div>
289
380
  <span
290
381
  class="mint-jobs-tray__detail"
291
382
  :class="{ 'mint-jobs-tray__detail--error': job.status === 'failed' }"
292
383
  >
293
- <slot name="job-detail" :job="job">{{ progressText(job) }}</slot>
384
+ <slot name="job-detail" :job="job">{{ view.message }}</slot>
294
385
  </span>
295
386
  </div>
296
387
  </template>
297
388
  </div>
298
389
  </section>
299
390
  </Transition>
391
+
392
+ <ConfirmDialog
393
+ v-if="confirmEnabled"
394
+ :open="confirmOpen"
395
+ tone="error"
396
+ :title="confirmText?.title"
397
+ :confirm-label="confirmText?.confirmLabel"
398
+ :cancel-label="confirmText?.cancelLabel"
399
+ @update:open="onConfirmOpen"
400
+ @confirm="confirmPending"
401
+ >
402
+ <!-- The marker class lifts the dialog above the tray (jobs-status-tray.css). -->
403
+ <p class="mint-confirm__message mint-jobs-tray__confirm">{{ confirmText?.message }}</p>
404
+ </ConfirmDialog>
300
405
  </div>
301
406
  </Teleport>
302
407
  </template>
@@ -18,6 +18,7 @@ import type {
18
18
  JobListPayload,
19
19
  JobState,
20
20
  JobStateInput,
21
+ JobStatus,
21
22
  TopBarJob,
22
23
  } from '../types/jobs'
23
24
  import { usePlatform } from './usePlatform'
@@ -148,11 +149,41 @@ function duplicatePluginJobId(jobId: string): Error {
148
149
  export interface UseJobCenterOptions {
149
150
  source?: MaybeRefOrGetter<PluginJobCenterSource | undefined>
150
151
  loadOnMount?: boolean
151
- /** Maps the chosen own active job to the top-bar chip; `null` keeps the default mapping. */
152
+ /**
153
+ * Maps the job that the top-bar chip shows. It is called for the chosen own
154
+ * active job AND for the held just-completed job, so check `job.status`.
155
+ * `null` keeps the default mapping.
156
+ */
152
157
  topBarJob?: (job: JobState) => TopBarJob | null
158
+ /**
159
+ * Overrides how a tray row looks: `status` (icon, awaiting-input tint and
160
+ * pinning), `percent` (progress bar and value) and `message` (detail line).
161
+ * A missing or `undefined` field, or a `null` status or message, keeps the
162
+ * job's own; `percent: null` hides the bar and shows the time, and a status
163
+ * alone keeps the job's own percent. The job's section, actions and counts
164
+ * do not change.
165
+ */
166
+ rowJob?: (job: JobState) => JobRowOverride | null
167
+ }
168
+
169
+ /** A `rowJob` result; see `UseJobCenterOptions.rowJob`. */
170
+ export interface JobRowOverride {
171
+ status?: JobStatus | null
172
+ percent?: number | null
173
+ message?: string | null
174
+ }
175
+
176
+ /** How a tray row shows a job; see `UseJobCenterReturn.rowView`. */
177
+ export interface JobRowView {
178
+ status: JobStatus
179
+ /** 0-100, rounded; `null` hides the progress bar and shows the time instead. */
180
+ percent: number | null
181
+ message: string
153
182
  }
154
183
 
155
184
  export interface UseJobCenterReturn {
185
+ /** The current source; a new identity is a new job list. */
186
+ source: ComputedRef<PluginJobCenterSource | undefined>
156
187
  jobs: ComputedRef<JobState[]>
157
188
  activeJobs: ComputedRef<JobState[]>
158
189
  historyJobs: ComputedRef<JobState[]>
@@ -164,6 +195,8 @@ export interface UseJobCenterReturn {
164
195
  canClearFinished: ComputedRef<boolean>
165
196
  /** DOM id of the JobsStatusTray panel, for `AppTopBar`'s `jobsPanelId` prop. */
166
197
  panelId: string
198
+ /** The row view of a job, with the `rowJob` option applied. */
199
+ rowView: (job: JobState) => JobRowView
167
200
  /** The viewer's own job for `AppTopBar`'s `job` prop. */
168
201
  topBarJob: ComputedRef<TopBarJob | null>
169
202
  /** Jobs in the list with status `completed`, for `AppTopBar`'s `jobsDone` prop. */
@@ -202,6 +235,17 @@ function writeSeenFailed(key: string, seen: Set<string>): void {
202
235
  }
203
236
  }
204
237
 
238
+ function jobMessage(job: JobState): string {
239
+ if (job.error) return job.error
240
+ if (job.status === 'awaiting_input') {
241
+ return job.progress.message || 'Waiting for external input'
242
+ }
243
+ if (job.status === 'queued' && job.queue_position > 0) {
244
+ return `Queue position ${job.queue_position}`
245
+ }
246
+ return job.progress.message || job.progress.stage || jobStatusLabel(job.status)
247
+ }
248
+
205
249
  function errorMessage(error: unknown, fallback: string): string {
206
250
  return error instanceof Error ? error.message : fallback
207
251
  }
@@ -302,11 +346,26 @@ export function useJobCenter(
302
346
  percent: shown.status === 'running' ? normalizeJobPercent(shown.progress.percent) : null,
303
347
  }
304
348
  }
305
- if (heldJob.value) return { status: 'completed', label: `${heldJob.value.name} completed` }
349
+ const held = heldJob.value
350
+ if (held) return options.topBarJob?.(held) ?? { status: 'completed', label: `${held.name} completed` }
306
351
  const failed = unseenFailedJobs.value.length
307
352
  return failed > 0 ? { status: 'failed', label: `${failed} failed` } : null
308
353
  })
309
354
 
355
+ function rowView(job: JobState): JobRowView {
356
+ const override = options.rowJob?.(job) ?? {}
357
+ const status = override.status ?? job.status
358
+ // Only an explicit percent changes the bar; a status override keeps the job's own.
359
+ const percent = override.percent !== undefined
360
+ ? override.percent
361
+ : job.status === 'running' ? job.progress.percent : null
362
+ return {
363
+ status,
364
+ percent: percent == null ? null : Math.round(normalizeJobPercent(percent)),
365
+ message: override.message ?? jobMessage(job),
366
+ }
367
+ }
368
+
310
369
  function handleTopBarClick(onSelect: (job: JobState) => void): void {
311
370
  const finished = heldJob.value
312
371
  if (!isOpen.value && ownActiveJobs.value.length === 0 && finished) onSelect(finished)
@@ -416,6 +475,7 @@ export function useJobCenter(
416
475
  })
417
476
 
418
477
  return {
478
+ source,
419
479
  jobs,
420
480
  activeJobs,
421
481
  historyJobs,
@@ -426,6 +486,7 @@ export function useJobCenter(
426
486
  canDelete,
427
487
  canClearFinished,
428
488
  panelId: `mint-jobs-tray-${useId()}`,
489
+ rowView,
429
490
  topBarJob,
430
491
  completedCount,
431
492
  isOpen,
package/src/runtime.ts CHANGED
@@ -77,6 +77,8 @@ export {
77
77
  combinePluginJobSources,
78
78
  useJobCenter,
79
79
  type CombinedPluginJobCenterSource,
80
+ type JobRowOverride,
81
+ type JobRowView,
80
82
  type PluginJobCenterSource,
81
83
  type UseJobCenterOptions,
82
84
  type UseJobCenterReturn,
@@ -278,3 +278,8 @@
278
278
  transition-duration: 0.01ms !important;
279
279
  }
280
280
  }
281
+
282
+ /* The tray's confirm dialog sits above the tray and its scrim (BaseModal drops class fallthrough). */
283
+ .mint-modal:has(.mint-jobs-tray__confirm) {
284
+ z-index: 1001;
285
+ }