@morscherlab/mint-sdk 1.4.0-beta.17 → 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.
Files changed (76) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-SnsbVow-.js → AlertBox-CzLg0UaL.js} +2 -2
  3. package/dist/{AlertBox-SnsbVow-.js.map → AlertBox-CzLg0UaL.js.map} +1 -1
  4. package/dist/{AppSidebar-CB0-RQ4m.js → AppSidebar-6oTT-gPO.js} +43 -13
  5. package/dist/AppSidebar-6oTT-gPO.js.map +1 -0
  6. package/dist/{ExperimentPopover-ChKCrklH.js → ExperimentPopover-9l4QZlvQ.js} +2 -2
  7. package/dist/{ExperimentPopover-ChKCrklH.js.map → ExperimentPopover-9l4QZlvQ.js.map} +1 -1
  8. package/dist/{ExperimentSelectorModal-BqifjNaN.js → ExperimentSelectorModal-pR5fK3pA.js} +3 -3
  9. package/dist/{ExperimentSelectorModal-BqifjNaN.js.map → ExperimentSelectorModal-pR5fK3pA.js.map} +1 -1
  10. package/dist/{SegmentedControl-hOgBVdju.js → SegmentedControl-CJ7EAmOq.js} +2 -2
  11. package/dist/{SegmentedControl-hOgBVdju.js.map → SegmentedControl-CJ7EAmOq.js.map} +1 -1
  12. package/dist/{SettingsModal-CoZK3EDN.js → SettingsModal-Dyitgzfp.js} +6 -6
  13. package/dist/{SettingsModal-CoZK3EDN.js.map → SettingsModal-Dyitgzfp.js.map} +1 -1
  14. package/dist/components/AppTopBar.vue.d.ts +7 -0
  15. package/dist/components/JobsStatusTray.vue.d.ts +15 -4
  16. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  17. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  18. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  19. package/dist/composables/useJobCenter.d.ts +41 -3
  20. package/dist/{controlSchemaModel-CIH5Hqpw.js → controlSchemaModel-DCUOx8ll.js} +2 -2
  21. package/dist/{controlSchemaModel-CIH5Hqpw.js.map → controlSchemaModel-DCUOx8ll.js.map} +1 -1
  22. package/dist/{data-OZ94DhVC.js → data-DBnmeBZX.js} +9 -9
  23. package/dist/{data-OZ94DhVC.js.map → data-DBnmeBZX.js.map} +1 -1
  24. package/dist/data.js +7 -7
  25. package/dist/index.js +16 -16
  26. package/dist/{install-BKgb5qZw.js → install-C5psB1H0.js} +13 -13
  27. package/dist/{install-BKgb5qZw.js.map → install-C5psB1H0.js.map} +1 -1
  28. package/dist/install.js +1 -1
  29. package/dist/{lab-ZDKlx1Oo.js → lab-BHVT1qN2.js} +9 -9
  30. package/dist/{lab-ZDKlx1Oo.js.map → lab-BHVT1qN2.js.map} +1 -1
  31. package/dist/lab.js +4 -4
  32. package/dist/{records-B4yl2oIu.js → records-CR_pYzIP.js} +62 -62
  33. package/dist/{records-B4yl2oIu.js.map → records-CR_pYzIP.js.map} +1 -1
  34. package/dist/{runtime-Cz1GJa_O.js → runtime-CoHhBP10.js} +398 -31
  35. package/dist/runtime-CoHhBP10.js.map +1 -0
  36. package/dist/runtime.d.ts +1 -1
  37. package/dist/runtime.js +6 -6
  38. package/dist/{shell-kq9p4VsX.js → shell-BDuZf2zb.js} +4 -4
  39. package/dist/{shell-kq9p4VsX.js.map → shell-BDuZf2zb.js.map} +1 -1
  40. package/dist/shell.js +4 -4
  41. package/dist/styles.css +151 -307
  42. package/dist/types/components.d.ts +2 -0
  43. package/dist/{useExperimentSamples-BLx29CkM.js → useExperimentSamples-Dno1fFMq.js} +3 -3
  44. package/dist/{useExperimentSamples-BLx29CkM.js.map → useExperimentSamples-Dno1fFMq.js.map} +1 -1
  45. package/dist/{useListReorder-C_8RYeVP.js → useListReorder-CQ9OCScc.js} +76 -76
  46. package/dist/useListReorder-CQ9OCScc.js.map +1 -0
  47. package/dist/{useRackEditor-BhYlXKFM.js → useRackEditor-wL3kPrRT.js} +6 -6
  48. package/dist/{useRackEditor-BhYlXKFM.js.map → useRackEditor-wL3kPrRT.js.map} +1 -1
  49. package/dist/useRealtime-C3whhVvb.js +508 -0
  50. package/dist/useRealtime-C3whhVvb.js.map +1 -0
  51. package/dist/{useRequest-C9uwM79q.js → useRequest-CDkrXNU7.js} +2 -2
  52. package/dist/{useRequest-C9uwM79q.js.map → useRequest-CDkrXNU7.js.map} +1 -1
  53. package/package.json +1 -1
  54. package/src/__tests__/components/AppTopBar.test.ts +33 -2
  55. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  56. package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
  57. package/src/__tests__/components/PluginWorkspaceView.test.ts +56 -1
  58. package/src/__tests__/composables/useJobCenter.test.ts +262 -3
  59. package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
  60. package/src/components/AppTopBar.vue +14 -0
  61. package/src/components/GeneratedPluginView.vue +4 -9
  62. package/src/components/JobsStatusTray.story.vue +97 -43
  63. package/src/components/JobsStatusTray.vue +287 -290
  64. package/src/components/PluginWorkspaceView.props.ts +3 -0
  65. package/src/components/PluginWorkspaceView.vue +17 -0
  66. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  67. package/src/composables/useJobCenter.ts +188 -6
  68. package/src/runtime.ts +2 -1
  69. package/src/styles/components/app-top-bar.css +7 -0
  70. package/src/styles/components/jobs-status-tray.css +134 -296
  71. package/src/types/components.ts +2 -0
  72. package/dist/AppSidebar-CB0-RQ4m.js.map +0 -1
  73. package/dist/runtime-Cz1GJa_O.js.map +0 -1
  74. package/dist/useListReorder-C_8RYeVP.js.map +0 -1
  75. package/dist/useRealtime-CUMbW_ln.js +0 -699
  76. package/dist/useRealtime-CUMbW_ln.js.map +0 -1
@@ -1,13 +1,13 @@
1
- import { mount } from '@vue/test-utils'
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
2
  import { defineComponent, nextTick, shallowRef } from 'vue'
3
- import { describe, expect, it, vi } from 'vitest'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import {
6
6
  combinePluginJobSources,
7
7
  useJobCenter,
8
8
  type UseJobCenterOptions,
9
9
  } from '../../composables/useJobCenter'
10
- import type { JobStateInput, JobStatus } from '../../types/jobs'
10
+ import type { JobStateInput, JobStatus, TopBarJob } from '../../types/jobs'
11
11
 
12
12
  function job(status: JobStatus, id: string = status): JobStateInput {
13
13
  return {
@@ -281,3 +281,262 @@ describe('useJobCenter', () => {
281
281
  expect(tray.deletableJobs.value).toEqual([])
282
282
  })
283
283
  })
284
+
285
+ describe('useJobCenter top-bar chip', () => {
286
+ const platformWindow = window as unknown as { __MINT_PLATFORM__?: unknown }
287
+ function integrateAs(userId: number, pluginId = 'peak-qc') {
288
+ platformWindow.__MINT_PLATFORM__ = { user: { id: userId }, plugin: { id: pluginId } }
289
+ }
290
+ const owned = (status: JobStatus, id: string, owner: string | null, user = owner): JobStateInput => ({
291
+ ...job(status, id), owner_user_id: owner, user_id: user,
292
+ })
293
+
294
+ beforeEach(() => localStorage.clear())
295
+ afterEach(() => {
296
+ delete platformWindow.__MINT_PLATFORM__
297
+ vi.useRealTimers()
298
+ })
299
+
300
+ it.each<[string, JobStateInput[], TopBarJob | null]>([
301
+ ['an awaiting-input job first', [job('running', 'r'), job('awaiting_input', 'a')],
302
+ { status: 'awaiting_input', label: 'a waiting for input', percent: null }],
303
+ ['the first active job', [job('queued', 'q'), job('running', 'r')],
304
+ { status: 'queued', label: 'q queued', percent: null }],
305
+ ['the progress message and percent of a running job',
306
+ [{ ...job('running', 'r'), progress: { ...job('running', 'r').progress, message: 'Integrating' } }],
307
+ { status: 'running', label: 'Integrating', percent: 42 }],
308
+ ['nothing without active, held or failed jobs', [job('completed', 'c'), job('cancelled', 'x')], null],
309
+ ])('should show %s when the viewer runs jobs', (_case, jobs, expected) => {
310
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
311
+ expect(tray.topBarJob.value).toEqual(expected)
312
+ })
313
+
314
+ it('should show only the viewer own jobs when the page is integrated', () => {
315
+ integrateAs(1)
316
+ const { tray } = mountComposable({
317
+ source: { jobs: [owned('running', 'theirs', '2'), owned('queued', 'mine', null, '1')] },
318
+ loadOnMount: false,
319
+ })
320
+ expect(tray.topBarJob.value).toMatchObject({ status: 'queued', label: 'mine queued' })
321
+ })
322
+
323
+ it.each([
324
+ ['auth is off', false, 'running'],
325
+ ['auth is required', true, undefined],
326
+ ])('should treat jobs of an integrated page without a signed-in user as own only when %s', (_case, authRequired, status) => {
327
+ platformWindow.__MINT_PLATFORM__ = { user: null, plugin: { id: 'peak-qc' }, authRequired }
328
+ const { tray } = mountComposable({ source: { jobs: [owned('running', 'theirs', '2')] }, loadOnMount: false })
329
+ expect(tray.topBarJob.value?.status).toBe(status)
330
+ })
331
+
332
+ it('should hold a completed job when several jobs leave the active list in one update', async () => {
333
+ const jobs = shallowRef([job('running', 'a'), job('running', 'b')])
334
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
335
+
336
+ jobs.value = [job('failed', 'a'), job('completed', 'b')]
337
+ await nextTick()
338
+
339
+ expect(tray.topBarJob.value).toEqual({ status: 'completed', label: 'b completed' })
340
+ })
341
+
342
+ it('should close the open tray instead of selecting the held job when the chip is clicked', async () => {
343
+ const jobs = shallowRef([job('running', 'r')])
344
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
345
+ const onSelect = vi.fn()
346
+ jobs.value = [job('completed', 'r')]
347
+ await nextTick()
348
+ tray.open()
349
+
350
+ tray.handleTopBarClick(onSelect)
351
+
352
+ expect([tray.isOpen.value, onSelect.mock.calls.length]).toEqual([false, 0])
353
+ })
354
+
355
+ it('should drop the held job when it leaves the job list', async () => {
356
+ const jobs = shallowRef([job('running', 'r')])
357
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
358
+ const onSelect = vi.fn()
359
+ jobs.value = [job('completed', 'r')]
360
+ await nextTick()
361
+ jobs.value = []
362
+ await nextTick()
363
+
364
+ tray.handleTopBarClick(onSelect)
365
+
366
+ expect([tray.topBarJob.value, onSelect.mock.calls.length, tray.isOpen.value]).toEqual([null, 0, true])
367
+ })
368
+
369
+ it('should mark a failure seen when the job fails while the tray is open', async () => {
370
+ localStorage.setItem('mint.jobs.standalone.seen-failed', '[]')
371
+ const jobs = shallowRef([job('running', 'r')])
372
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
373
+ tray.open()
374
+ await nextTick()
375
+
376
+ jobs.value = [job('failed', 'r')]
377
+ await nextTick()
378
+ tray.close()
379
+ await nextTick()
380
+
381
+ expect(tray.topBarJob.value).toBeNull()
382
+ })
383
+
384
+ it.each([
385
+ ['an empty list', [] as JobStateInput[], { status: 'failed', label: '1 failed' }],
386
+ ['an old failure', [job('failed', 'old')], { status: 'failed', label: '1 failed' }],
387
+ ])('should take the baseline when the initial load returns %s', async (_case, loaded, later) => {
388
+ const jobs = shallowRef<JobStateInput[]>([])
389
+ const { tray } = mountComposable({ source: { jobs, refresh: async () => { jobs.value = loaded } } })
390
+ await flushPromises()
391
+ const baseline = tray.topBarJob.value
392
+
393
+ jobs.value = [...loaded, job('failed', 'new')]
394
+ await nextTick()
395
+
396
+ expect([baseline, tray.topBarJob.value]).toEqual([null, later])
397
+ })
398
+
399
+ it('should not hold a job when the source is replaced by one where it already completed', async () => {
400
+ const source = shallowRef({ jobs: [job('running', 'x')] })
401
+ const { tray } = mountComposable({ source, loadOnMount: false })
402
+
403
+ source.value = { jobs: [job('completed', 'x')] }
404
+ await nextTick()
405
+
406
+ expect(tray.topBarJob.value).toBeNull()
407
+ })
408
+
409
+ it('should alert only failures after the first job list when nothing was stored', async () => {
410
+ const jobs = shallowRef<JobStateInput[]>([])
411
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
412
+
413
+ jobs.value = [job('failed', 'old')]
414
+ await nextTick()
415
+ const baseline = tray.topBarJob.value
416
+ jobs.value = [job('failed', 'old'), job('failed', 'new')]
417
+ await nextTick()
418
+
419
+ expect({
420
+ baseline,
421
+ later: tray.topBarJob.value,
422
+ stored: JSON.parse(localStorage.getItem('mint.jobs.standalone.seen-failed') ?? 'null'),
423
+ }).toEqual({ baseline: null, later: { status: 'failed', label: '1 failed' }, stored: ['old'] })
424
+ })
425
+
426
+ it.each<[string, TopBarJob | null, TopBarJob]>([
427
+ ['use the override', { status: 'completed', label: 'Saved to the experiment' },
428
+ { status: 'completed', label: 'Saved to the experiment' }],
429
+ ['keep the default', null, { status: 'completed', label: 'r completed' }],
430
+ ])('should %s for the held job when the topBarJob option returns that value', async (_case, override, expected) => {
431
+ const jobs = shallowRef([job('running', 'r')])
432
+ const topBarJob = vi.fn((shown: { status: JobStatus }) => (shown.status === 'completed' ? override : null))
433
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false, topBarJob })
434
+
435
+ jobs.value = [job('completed', 'r')]
436
+ await nextTick()
437
+
438
+ expect(tray.topBarJob.value).toEqual(expected)
439
+ })
440
+
441
+ it('should hold a just-completed job for 6 s and select it on click when nothing else runs', async () => {
442
+ vi.useFakeTimers()
443
+ const jobs = shallowRef([job('running', 'r')])
444
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
445
+ const onSelect = vi.fn()
446
+
447
+ jobs.value = [{ ...job('completed', 'r') }]
448
+ await nextTick()
449
+ const held = tray.topBarJob.value
450
+ tray.handleTopBarClick(onSelect)
451
+ vi.advanceTimersByTime(5999)
452
+ const stillHeld = tray.topBarJob.value
453
+ vi.advanceTimersByTime(1)
454
+ tray.handleTopBarClick(onSelect)
455
+
456
+ expect({
457
+ held,
458
+ stillHeld: stillHeld?.status,
459
+ after: tray.topBarJob.value,
460
+ selected: onSelect.mock.calls.map(([selected]) => selected.job_id),
461
+ open: tray.isOpen.value,
462
+ }).toEqual({
463
+ held: { status: 'completed', label: 'r completed' },
464
+ stillHeld: 'completed',
465
+ after: null,
466
+ selected: ['r'],
467
+ open: true,
468
+ })
469
+ })
470
+
471
+ it.each([
472
+ ['standalone', () => undefined, 'mint.jobs.standalone.seen-failed'],
473
+ ['integrated', () => integrateAs(1), 'mint.jobs.peak-qc.seen-failed'],
474
+ ])('should count unseen failures until the tray opens when %s', async (_mode, setup, key) => {
475
+ setup()
476
+ localStorage.setItem(key, '[]')
477
+ const jobs = [owned('failed', 'f1', '1'), owned('failed', 'f2', '1'), owned('failed', 'other', '2')]
478
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
479
+ const before = tray.topBarJob.value
480
+
481
+ tray.open()
482
+ await nextTick()
483
+ const reopened = mountComposable({ source: { jobs }, loadOnMount: false }).tray
484
+
485
+ expect({
486
+ before,
487
+ after: tray.topBarJob.value,
488
+ reopened: reopened.topBarJob.value,
489
+ stored: JSON.parse(localStorage.getItem(key) ?? 'null'),
490
+ }).toEqual({
491
+ before: { status: 'failed', label: _mode === 'standalone' ? '3 failed' : '2 failed' },
492
+ after: null,
493
+ reopened: null,
494
+ stored: _mode === 'standalone' ? ['f1', 'f2', 'other'] : ['f1', 'f2'],
495
+ })
496
+ })
497
+
498
+ it.each<[string, TopBarJob | null, TopBarJob]>([
499
+ ['replace the default mapping', { status: 'running', label: 'Decoding in the browser', percent: 10 },
500
+ { status: 'running', label: 'Decoding in the browser', percent: 10 }],
501
+ ['keep the default mapping', null, { status: 'awaiting_input', label: 'a waiting for input', percent: null }],
502
+ ])('should %s when the topBarJob option returns that value', (_case, override, expected) => {
503
+ const { tray } = mountComposable({
504
+ source: { jobs: [job('awaiting_input', 'a')] },
505
+ loadOnMount: false,
506
+ topBarJob: () => override,
507
+ })
508
+ expect(tray.topBarJob.value).toEqual(expected)
509
+ })
510
+
511
+ it('should count only completed jobs when the list holds finished and active jobs', () => {
512
+ const { tray } = mountComposable({
513
+ source: { jobs: [job('completed', 'c1'), job('completed', 'c2'), job('failed', 'f'), job('running', 'r')] },
514
+ loadOnMount: false,
515
+ })
516
+ expect(tray.completedCount.value).toBe(2)
517
+ })
518
+ })
519
+
520
+ describe('useJobCenter row view', () => {
521
+ const awaiting = job('awaiting_input', 'a')
522
+ const running = job('running', 'r')
523
+
524
+ it.each<[string, JobStateInput, UseJobCenterOptions['rowJob'], ReturnType<ReturnType<typeof useJobCenter>['rowView']>]>([
525
+ ['absent', running, undefined, { status: 'running', percent: 42, message: 'running' }],
526
+ ['null', awaiting, () => null,
527
+ { status: 'awaiting_input', percent: null, message: 'Waiting for external input' }],
528
+ ['a status and percent', awaiting, () => ({ status: 'running', percent: 40 }),
529
+ { status: 'running', percent: 40, message: 'Waiting for external input' }],
530
+ ['a message only', running, () => ({ message: 'Decoding 3/8' }),
531
+ { status: 'running', percent: 42, message: 'Decoding 3/8' }],
532
+ ['undefined and null fields', running, () => ({ status: null, percent: undefined, message: null }),
533
+ { status: 'running', percent: 42, message: 'running' }],
534
+ ['a status only', running, () => ({ status: 'awaiting_input' }),
535
+ { status: 'awaiting_input', percent: 42, message: 'running' }],
536
+ ['a null percent', running, () => ({ percent: null }),
537
+ { status: 'running', percent: null, message: 'running' }],
538
+ ])('should resolve the row view from the job when the row override is %s', (_case, input, rowJob, expected) => {
539
+ const { tray } = mountComposable({ source: { jobs: [input] }, loadOnMount: false, rowJob })
540
+ expect(tray.rowView(tray.jobs.value[0]!)).toEqual(expected)
541
+ })
542
+ })
@@ -266,13 +266,13 @@ describe('frontend docs catalog coverage', () => {
266
266
  })
267
267
  })
268
268
 
269
- it('documents only the source-driven JobsStatusTray props', () => {
269
+ it('documents only the center-driven JobsStatusTray props', () => {
270
270
  const manifest = extractFrontendManifest()
271
271
  const jobsStatusTray = manifest.categories.components.find(item => item.name === 'JobsStatusTray')
272
272
 
273
273
  expect(jobsStatusTray?.props?.map(prop => prop.name).sort()).toEqual([
274
- 'loadOnMount',
275
- 'source',
274
+ 'center',
275
+ 'confirm',
276
276
  'teleportTo',
277
277
  'title',
278
278
  ])
@@ -375,6 +375,7 @@ describe('frontend docs catalog coverage', () => {
375
375
  'usePluginJobs',
376
376
  'useGeneratedAnalysis',
377
377
  'usePluginJobCenter',
378
+ 'JobsStatusTrayTab',
378
379
  'useRequestSyncState',
379
380
  'useAnalysisArtifacts',
380
381
  'useTimeUtils',
@@ -49,10 +49,18 @@ interface Props {
49
49
  * `null` shows the idle Jobs button. Both emit `job-click`.
50
50
  */
51
51
  job?: TopBarJob | null
52
+ /** Completed jobs, shown as a small count on the idle Jobs button. Hidden when 0. */
53
+ jobsDone?: number
54
+ /** Whether the jobs panel is open; `aria-expanded` of the job control. */
55
+ jobsOpen?: boolean
56
+ /** DOM id of the jobs panel; `aria-controls` of the job control. */
57
+ jobsPanelId?: string
52
58
  }
53
59
 
54
60
  const props = withDefaults(defineProps<Props>(), {
55
61
  spec: () => ({}),
62
+ // No boolean cast: without a jobs panel the control has no aria-expanded.
63
+ jobsOpen: undefined,
56
64
  })
57
65
 
58
66
  const config = computed(() => applySpecDefaults({
@@ -377,6 +385,8 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
377
385
  :class="['mint-topbar__job', `mint-topbar__job--${JOB_TONES[job.status]}`]"
378
386
  :title="jobName"
379
387
  :aria-label="jobName"
388
+ :aria-expanded="jobsOpen"
389
+ :aria-controls="jobsPanelId"
380
390
  @click="emit('job-click')"
381
391
  >
382
392
  <component
@@ -394,10 +404,14 @@ const normalizedSettingsTabs = computed<SettingsTab[]>(() =>
394
404
  key="idle"
395
405
  type="button"
396
406
  class="mint-topbar__job mint-topbar__job--idle"
407
+ :aria-label="jobsDone ? `Jobs, ${jobsDone} completed` : undefined"
408
+ :aria-expanded="jobsOpen"
409
+ :aria-controls="jobsPanelId"
397
410
  @click="emit('job-click')"
398
411
  >
399
412
  <ListChecks class="mint-topbar__job-icon" :size="14" aria-hidden="true" />
400
413
  <span class="mint-topbar__job-label">Jobs</span>
414
+ <span v-if="jobsDone" class="mint-topbar__job-count" aria-hidden="true">{{ jobsDone }}</span>
401
415
  </button>
402
416
  </Transition>
403
417
  </div>
@@ -12,11 +12,11 @@ import { computed, onMounted, provide, reactive, ref, watch } from 'vue'
12
12
  import { generatedJobDefaults, generatedJobFormSchema, normalizeGeneratedJobInput } from '../generated/schema'
13
13
  import type { FormSchema } from '../types/form-builder'
14
14
  import type {
15
- GeneratedAnalysisJob,
16
15
  GeneratedAnalysisResult,
17
16
  GeneratedJobDefinition,
18
17
  } from '../types/generated-ui'
19
18
  import type { PillNavItem, SidebarToolSection } from '../types'
19
+ import type { JobState } from '../types/jobs'
20
20
  import {
21
21
  useJobsEndpoint,
22
22
  type GeneratedAnalysisTransport,
@@ -24,7 +24,6 @@ import {
24
24
  import AlertBox from './AlertBox.vue'
25
25
  import EmptyState from './EmptyState.vue'
26
26
  import GeneratedResultRenderer from './GeneratedResultRenderer.vue'
27
- import JobsStatusTray from './JobsStatusTray.vue'
28
27
  import LoadingSpinner from './LoadingSpinner.vue'
29
28
  import PluginWorkspaceView from './PluginWorkspaceView.vue'
30
29
  import { GENERATED_JOBS_API_KEY } from './internal/generatedJobsApi'
@@ -84,6 +83,7 @@ const workspace = computed(() => definePluginWorkspaceView({
84
83
  standaloneLabel: props.showStandaloneLabel ? 'Standalone' : false,
85
84
  },
86
85
  sidebar: { title: 'Parameters', panels: panels.value, forms: forms.value },
86
+ jobs: generated,
87
87
  }))
88
88
 
89
89
  // Seeds the standalone top bar icon; the title and subtitle above win over the seeded text.
@@ -137,7 +137,7 @@ async function submitJob(sectionId: string, values: Record<string, unknown>): Pr
137
137
  await generated.submit(definition.id, normalizeGeneratedJobInput(definition, values))
138
138
  }
139
139
 
140
- function selectJob(job: GeneratedAnalysisJob): void {
140
+ function selectJob(job: JobState): void {
141
141
  selectedJobId.value = job.job_id
142
142
  activeDefinitionId.value = String(job.metadata.definition_id ?? activeDefinitionId.value)
143
143
  }
@@ -166,6 +166,7 @@ onMounted(() => {
166
166
  :form-disabled="generated.isSubmitting.value"
167
167
  @update:values="updateValues"
168
168
  @form-submit="submitJob"
169
+ @job-select="selectJob"
169
170
  >
170
171
  <main class="mint-generated-workspace">
171
172
  <div v-if="generated.isLoading.value" class="mint-generated-workspace__loading">
@@ -200,12 +201,6 @@ onMounted(() => {
200
201
  />
201
202
  </section>
202
203
  </template>
203
-
204
- <JobsStatusTray
205
- :source="generated"
206
- :teleport-to="false"
207
- @select="selectJob"
208
- />
209
204
  </main>
210
205
  </PluginWorkspaceView>
211
206
  </template>
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
+ import { defineComponent, h, onMounted, type PropType } from 'vue'
3
+ import AppTopBar from './AppTopBar.vue'
2
4
  import JobsStatusTray from './JobsStatusTray.vue'
3
- import type { JobStateInput, JobStatus } from '../types/jobs'
5
+ import { useJobCenter } from '../composables/useJobCenter'
6
+ import type { JobState, JobStateInput, JobStatus } from '../types/jobs'
4
7
 
5
8
  function job(
6
9
  status: JobStatus,
@@ -43,13 +46,6 @@ function job(
43
46
  }
44
47
  }
45
48
 
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
49
  const explicitActionJobs = [
54
50
  job('awaiting_input', 'J-201', { can_cancel: true }),
55
51
  job('completed', 'J-200', { can_delete: false }),
@@ -57,69 +53,127 @@ const explicitActionJobs = [
57
53
  const cancelJob = async () => undefined
58
54
  const deleteJob = async () => true
59
55
 
60
- function openJob(jobId: string) {
61
- console.info('open', jobId)
56
+ function openJob(selected: JobState) {
57
+ console.info('open', selected.job_id)
58
+ }
59
+
60
+ /** The parent owns the job center: it feeds the top-bar chip and the tray. */
61
+ const TrayDemo = defineComponent({
62
+ props: {
63
+ jobs: { type: Array as PropType<JobStateInput[]>, required: true },
64
+ title: { type: String, default: undefined },
65
+ open: Boolean,
66
+ confirm: Boolean,
67
+ /** Show the awaiting-input job as a browser decode, as LEAF does. */
68
+ decode: Boolean,
69
+ },
70
+ setup(props) {
71
+ // One source object: a new source identity resets the just-completed hold.
72
+ const center = useJobCenter({
73
+ source: { jobs: () => props.jobs, cancel: cancelJob, delete: deleteJob },
74
+ rowJob: row => (props.decode && row.status === 'awaiting_input'
75
+ ? { status: 'running', percent: 62, message: 'Decoding in the browser · 5/8' }
76
+ : null),
77
+ topBarJob: shown => (shown.status === 'completed' ? { status: 'completed', label: `Saved to ${shown.experiment_id}` } : null),
78
+ })
79
+ onMounted(() => { if (props.open) center.open() })
80
+ return () => [
81
+ h(AppTopBar, {
82
+ title: 'Peak QC',
83
+ job: center.topBarJob.value,
84
+ jobsDone: center.completedCount.value,
85
+ jobsOpen: center.isOpen.value,
86
+ jobsPanelId: center.panelId,
87
+ onJobClick: () => center.handleTopBarClick(openJob),
88
+ }),
89
+ h(JobsStatusTray, { center, title: props.title, confirm: props.confirm, onSelect: openJob }, {
90
+ 'job-actions': ({ job: row }: { job: JobState }) => row.status === 'completed'
91
+ ? h('button', { type: 'button', class: 'mint-jobs-tray__section-action', onClick: () => openJob(row) }, 'Open')
92
+ : null,
93
+ }),
94
+ ]
95
+ },
96
+ })
97
+
98
+ function playgroundJobs(percent: number, finished: boolean): JobStateInput[] {
99
+ const running = finished
100
+ ? job('completed', 'J-103', { completed_at: '2026-07-14T10:12:00+00:00' })
101
+ : job('running', 'J-103', {
102
+ progress: { ...job('running', 'J-103').progress, percent, message: 'Integrating peaks' },
103
+ })
104
+ return [
105
+ job('awaiting_input', 'J-104'),
106
+ running,
107
+ job('queued', 'J-102'),
108
+ job('completed', 'J-101'),
109
+ job('failed', 'J-100'),
110
+ ]
62
111
  }
63
112
 
64
113
  function initState() {
65
114
  return {
66
- title: 'Analysis jobs',
67
- jobs: mixedJobs,
115
+ title: 'Jobs',
116
+ percent: 37,
117
+ finished: false,
118
+ open: true,
119
+ confirm: false,
120
+ decode: false,
68
121
  }
69
122
  }
70
123
  </script>
71
124
 
72
125
  <template>
73
- <Story title="Workflow/JobsStatusTray">
126
+ <Story title="Workflow/JobsStatusTray" :layout="{ type: 'single', iframe: true }">
74
127
  <Variant title="Playground" :init-state="initState">
75
128
  <template #default="{ state }">
76
- <div style="padding: 2rem; min-height: 520px;">
77
- <JobsStatusTray
129
+ <div style="min-height: 560px;">
130
+ <TrayDemo
131
+ :key="String(state.open)"
132
+ :jobs="playgroundJobs(state.percent, state.finished)"
78
133
  :title="state.title"
79
- :source="{ jobs: state.jobs, cancel: cancelJob, delete: deleteJob }"
80
- :load-on-mount="false"
81
- :teleport-to="false"
82
- >
83
- <template #job-actions="{ job }">
84
- <button type="button" class="mint-jobs-tray__action" @click="() => openJob(job.job_id)">
85
- Open
86
- </button>
87
- </template>
88
- </JobsStatusTray>
134
+ :open="state.open"
135
+ :confirm="state.confirm"
136
+ :decode="state.decode"
137
+ />
89
138
  </div>
90
139
  </template>
91
140
  <template #controls="{ state }">
92
141
  <HstText v-model="state.title" title="Title" />
142
+ <HstSlider v-model="state.percent" title="Running percent" :min="0" :max="100" />
143
+ <HstCheckbox v-model="state.finished" title="Running job completed (6 s chip)" />
144
+ <HstCheckbox v-model="state.open" title="Open on mount" />
145
+ <HstCheckbox v-model="state.confirm" title="Confirm cancel, delete and clear" />
146
+ <HstCheckbox v-model="state.decode" title="Row override: J-104 as a browser decode" />
93
147
  </template>
94
148
  </Variant>
95
149
 
150
+ <Variant title="Confirm and row override">
151
+ <div style="min-height: 560px;">
152
+ <TrayDemo :jobs="playgroundJobs(37, false)" open confirm decode />
153
+ </div>
154
+ </Variant>
155
+
96
156
  <Variant title="Awaiting input">
97
- <div style="padding: 2rem; min-height: 520px;">
98
- <JobsStatusTray
99
- :source="{ jobs: [job('awaiting_input', 'J-301')] }"
100
- :load-on-mount="false"
101
- :teleport-to="false"
102
- />
157
+ <div style="min-height: 560px;">
158
+ <TrayDemo :jobs="[job('awaiting_input', 'J-301')]" open />
103
159
  </div>
104
160
  </Variant>
105
161
 
106
162
  <Variant title="Explicit actions">
107
- <div style="padding: 2rem; min-height: 520px;">
108
- <JobsStatusTray
109
- :source="{ jobs: explicitActionJobs, cancel: cancelJob, delete: deleteJob }"
110
- :load-on-mount="false"
111
- :teleport-to="false"
112
- />
163
+ <div style="min-height: 560px;">
164
+ <TrayDemo :jobs="explicitActionJobs" open />
165
+ </div>
166
+ </Variant>
167
+
168
+ <Variant title="Idle with completed jobs">
169
+ <div style="min-height: 560px;">
170
+ <TrayDemo :jobs="[job('completed', 'J-401'), job('completed', 'J-402'), job('cancelled', 'J-403')]" />
113
171
  </div>
114
172
  </Variant>
115
173
 
116
174
  <Variant title="Empty">
117
- <div style="padding: 2rem; min-height: 520px;">
118
- <JobsStatusTray
119
- :source="{ jobs: [] }"
120
- :load-on-mount="false"
121
- :teleport-to="false"
122
- />
175
+ <div style="min-height: 560px;">
176
+ <TrayDemo :jobs="[]" open />
123
177
  </div>
124
178
  </Variant>
125
179
  </Story>