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

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 (78) 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 +11 -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 +11 -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-BIiV4kKw.js} +9 -9
  23. package/dist/{data-OZ94DhVC.js.map → data-BIiV4kKw.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-Dg-Z6ze5.js} +13 -13
  27. package/dist/{install-BKgb5qZw.js.map → install-Dg-Z6ze5.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-cHv1phcc.js} +379 -31
  35. package/dist/runtime-cHv1phcc.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 +163 -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-BW02xy4Z.js +398 -0
  50. package/dist/useRealtime-BW02xy4Z.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 +137 -71
  57. package/src/__tests__/components/PluginWorkspaceView.test.ts +56 -1
  58. package/src/__tests__/composables/useJobCenter.test.ts +223 -3
  59. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  60. package/src/__tests__/styles/v2AuditRules.test.ts +8 -0
  61. package/src/components/AppTopBar.vue +14 -0
  62. package/src/components/GeneratedPluginView.vue +4 -9
  63. package/src/components/JobsStatusTray.story.vue +78 -43
  64. package/src/components/JobsStatusTray.vue +171 -279
  65. package/src/components/PluginWorkspaceView.props.ts +3 -0
  66. package/src/components/PluginWorkspaceView.vue +17 -0
  67. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  68. package/src/composables/useJobCenter.ts +127 -6
  69. package/src/runtime.ts +0 -1
  70. package/src/styles/components/app-top-bar.css +7 -0
  71. package/src/styles/components/jobs-status-tray.css +129 -296
  72. package/src/styles/variables.css +16 -0
  73. package/src/types/components.ts +2 -0
  74. package/dist/AppSidebar-CB0-RQ4m.js.map +0 -1
  75. package/dist/runtime-Cz1GJa_O.js.map +0 -1
  76. package/dist/useListReorder-C_8RYeVP.js.map +0 -1
  77. package/dist/useRealtime-CUMbW_ln.js +0 -699
  78. package/dist/useRealtime-CUMbW_ln.js.map +0 -1
@@ -1,34 +1,45 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Floating, source-driven job status tray for plugin and platform workflows.
3
+ * Compact, non-modal job panel under the top bar, driven by a job center the parent owns.
4
+ *
5
+ * The parent passes the center's `topBarJob`, `completedCount` and `handleTopBarClick`
6
+ * to `AppTopBar`; its run slot opens this panel. `PluginWorkspaceView` does this for you
7
+ * when its spec sets `jobs`.
4
8
  *
5
9
  * @example Recommended
6
10
  * ```vue
7
- * <JobsStatusTray :source="jobs" />
11
+ * <AppTopBar :job="center.topBarJob.value" :jobs-done="center.completedCount.value"
12
+ * @job-click="center.handleTopBarClick(openResult)" />
13
+ * <JobsStatusTray :center="center" @select="openResult" />
8
14
  * ```
9
15
  */
10
- import { computed, nextTick, ref, toRef, useId } from 'vue'
11
-
16
+ import { computed, nextTick, onBeforeUnmount, ref, watch, type Component } from 'vue'
12
17
  import {
13
- jobStatusLabel,
14
- normalizeJobPercent,
15
- } from '../jobs'
16
- import {
17
- useJobCenter,
18
- type PluginJobCenterSource,
19
- } from '../composables/useJobCenter'
20
- import type { JobState } from '../types/jobs'
18
+ CircleAlert,
19
+ CircleCheck,
20
+ CircleSlash,
21
+ CircleX,
22
+ Clock,
23
+ LoaderCircle,
24
+ RefreshCw,
25
+ Square,
26
+ Trash2,
27
+ X,
28
+ } from 'lucide-vue-next'
29
+
30
+ import { isActiveJobStatus, jobStatusLabel, normalizeJobPercent } from '../jobs'
31
+ import type { UseJobCenterReturn } from '../composables/useJobCenter'
32
+ import type { JobState, JobStatus } from '../types/jobs'
21
33
 
22
34
  interface Props {
23
- source?: PluginJobCenterSource
24
- loadOnMount?: boolean
35
+ /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */
36
+ center: UseJobCenterReturn
25
37
  title?: string
26
38
  teleportTo?: string | HTMLElement | false
27
39
  }
28
40
 
29
41
  const props = withDefaults(defineProps<Props>(), {
30
- loadOnMount: true,
31
- title: 'Analysis jobs',
42
+ title: 'Jobs',
32
43
  teleportTo: 'body',
33
44
  })
34
45
 
@@ -40,84 +51,78 @@ const emit = defineEmits<{
40
51
  defineSlots<{
41
52
  /** Extra per-row actions, rendered before the built-in Cancel / Delete. */
42
53
  'job-actions'?: (props: { job: JobState }) => unknown
54
+ /** Row title. Default: the job name. */
55
+ 'job-title'?: (props: { job: JobState }) => unknown
56
+ /** Detail line under the title. Default: the error, progress message or status. */
57
+ 'job-detail'?: (props: { job: JobState }) => unknown
43
58
  }>()
44
59
 
45
- const panelId = `mint-jobs-tray-${useId().replace(/:/g, '')}`
46
- const titleId = `${panelId}-title`
47
- const triggerRef = ref<HTMLButtonElement | null>(null)
60
+ const STATUS_ICONS: Record<JobStatus, Component> = {
61
+ running: LoaderCircle,
62
+ awaiting_input: CircleAlert,
63
+ queued: Clock,
64
+ completed: CircleCheck,
65
+ failed: CircleX,
66
+ cancelled: CircleSlash,
67
+ }
68
+
48
69
  const panelRef = ref<HTMLElement | null>(null)
49
70
  const teleportTarget = computed(() => props.teleportTo || 'body')
50
- const tray = useJobCenter({
51
- source: toRef(props, 'source'),
52
- loadOnMount: props.loadOnMount,
53
- })
54
- // 2f: a job waiting for input is the only one that needs a person — pin it first.
71
+ const tray = props.center
72
+ const titleId = `${tray.panelId}-title`
73
+ // A job waiting for input is the only one that needs a person: pin it first.
55
74
  const activeRows = computed(() => [
56
75
  ...tray.activeJobs.value.filter(job => job.status === 'awaiting_input'),
57
76
  ...tray.activeJobs.value.filter(job => job.status !== 'awaiting_input'),
58
77
  ])
59
- const subtitle = computed(() => {
60
- const count = (status: JobState['status']) => tray.activeJobs.value.filter(job => job.status === status).length
78
+ 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))
82
+ const headCount = computed(() => {
61
83
  const parts = [
62
- [count('running'), 'running'],
63
- [count('queued'), 'queued'],
64
- [count('awaiting_input'), 'waiting'],
84
+ [tray.activeJobs.value.length, 'active'],
85
+ [tray.completedCount.value, 'done'],
86
+ [tray.historyJobs.value.filter(job => job.status === 'failed').length, 'failed'],
65
87
  ].filter(([n]) => n).map(([n, word]) => `${n} ${word}`)
66
- return parts.length ? parts.join(' · ') : `${tray.jobs.value.length} total`
88
+ return parts.length ? parts.join(' · ') : 'No jobs'
67
89
  })
68
90
  const canClear = computed(() => (
69
91
  tray.deletableJobs.value.length > 0
70
92
  && tray.canClearFinished.value
71
93
  ))
72
94
 
73
- async function openTray(): Promise<void> {
74
- tray.open()
95
+ // Escape returns focus to the element that opened the panel (the top-bar chip).
96
+ // The panel is non-modal, so Escape is heard on the document while it is open.
97
+ let opener: HTMLElement | null = null
98
+ function onDocumentKeydown(event: KeyboardEvent): void {
99
+ if (event.key === 'Escape') closeTray()
100
+ }
101
+ watch(tray.isOpen, async (open) => {
102
+ if (!open) {
103
+ document.removeEventListener('keydown', onDocumentKeydown)
104
+ return
105
+ }
106
+ opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
107
+ document.addEventListener('keydown', onDocumentKeydown)
75
108
  await nextTick()
76
109
  panelRef.value?.focus()
77
- }
110
+ }, { flush: 'sync' })
111
+ onBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))
78
112
 
79
- async function closeTray(): Promise<void> {
113
+ function closeTray(): void {
80
114
  tray.close()
81
- await nextTick()
82
- triggerRef.value?.focus()
83
- }
84
-
85
- async function toggleTray(): Promise<void> {
86
- if (tray.isOpen.value) await closeTray()
87
- else await openTray()
88
- }
89
-
90
- function onPanelKeydown(event: KeyboardEvent): void {
91
- if (event.key === 'Escape') {
92
- void closeTray()
93
- return
94
- }
95
- if (event.key !== 'Tab' || !panelRef.value) return
96
- const focusable = Array.from(panelRef.value.querySelectorAll<HTMLElement>(
97
- 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
98
- ))
99
- if (focusable.length === 0) {
100
- event.preventDefault()
101
- panelRef.value.focus()
102
- return
103
- }
104
- const first = focusable[0]
105
- const last = focusable[focusable.length - 1]
106
- const activeElement = document.activeElement as HTMLElement | null
107
- if (!activeElement || !focusable.includes(activeElement)) {
108
- event.preventDefault()
109
- if (event.shiftKey) last?.focus()
110
- else first?.focus()
111
- } else if (event.shiftKey && activeElement === first) {
112
- event.preventDefault()
113
- last?.focus()
114
- } else if (!event.shiftKey && activeElement === last) {
115
- event.preventDefault()
116
- first?.focus()
117
- }
115
+ // Safari leaves a clicked button unfocused (opener = body), and the chip node is
116
+ // replaced when it switches between job chip and idle button: fall back to the
117
+ // top-bar jobs control.
118
+ const target = opener?.isConnected && opener !== document.body
119
+ ? opener
120
+ : document.querySelector<HTMLElement>('.mint-topbar__job')
121
+ target?.focus()
118
122
  }
119
123
 
120
124
  function progressText(job: JobState): string {
125
+ if (job.error) return job.error
121
126
  if (job.status === 'awaiting_input') {
122
127
  return job.progress.message || 'Waiting for external input'
123
128
  }
@@ -127,22 +132,24 @@ function progressText(job: JobState): string {
127
132
  return job.progress.message || job.progress.stage || jobStatusLabel(job.status)
128
133
  }
129
134
 
130
- function formatTimestamp(value: string | null): string {
135
+ function formatTime(value: string | null): string {
131
136
  if (!value) return ''
132
137
  const date = new Date(value)
133
138
  return Number.isNaN(date.getTime())
134
139
  ? ''
135
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
136
- }
137
-
138
- function statusClass(job: JobState): string {
139
- return `mint-jobs-tray__status--${job.status.replace('_', '-')}`
140
+ : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
140
141
  }
141
142
 
142
143
  function jobPercent(job: JobState): number {
143
144
  return Math.round(normalizeJobPercent(job.progress.percent))
144
145
  }
145
146
 
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)
151
+ }
152
+
146
153
  async function cancelJob(job: JobState): Promise<void> {
147
154
  const succeeded = await tray.cancelJob(job)
148
155
  if (!succeeded && tray.error.value) emit('error', tray.error.value)
@@ -162,245 +169,130 @@ async function clearFinished(): Promise<void> {
162
169
  <template>
163
170
  <Teleport :to="teleportTarget" :disabled="teleportTo === false">
164
171
  <div class="mint-jobs-tray">
165
- <button
166
- ref="triggerRef"
167
- type="button"
168
- class="mint-jobs-tray__trigger"
169
- aria-haspopup="dialog"
170
- :aria-expanded="tray.isOpen.value"
171
- :aria-controls="panelId"
172
- @click="toggleTray"
173
- >
174
- <svg
175
- class="mint-jobs-tray__trigger-icon"
176
- :class="{ 'mint-jobs-tray__trigger-icon--active': tray.activeJobs.value.length > 0 }"
177
- viewBox="0 0 24 24"
178
- fill="none"
179
- stroke="currentColor"
180
- aria-hidden="true"
181
- >
182
- <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" />
183
- <path d="M9 5a3 3 0 0 1 6 0v2H9V5Z" />
184
- </svg>
185
- <span class="mint-jobs-tray__trigger-label">
186
- {{ tray.featuredJob.value ? jobStatusLabel(tray.featuredJob.value.status) : 'Jobs' }}
187
- </span>
188
- <span
189
- v-if="tray.activeJobs.value.length > 0"
190
- class="mint-jobs-tray__count"
191
- aria-live="polite"
192
- >
193
- {{ tray.activeJobs.value.length }}
194
- </span>
195
- </button>
172
+ <!-- Clicks outside close the panel; the page is not dimmed. -->
173
+ <div
174
+ v-if="tray.isOpen.value"
175
+ class="mint-jobs-tray__scrim"
176
+ aria-hidden="true"
177
+ @click="tray.close()"
178
+ />
196
179
 
197
180
  <Transition name="mint-jobs-tray-fade">
198
- <div
199
- v-if="tray.isOpen.value"
200
- class="mint-jobs-tray__backdrop"
201
- aria-hidden="true"
202
- @click="closeTray"
203
- />
204
- </Transition>
205
-
206
- <Transition name="mint-jobs-tray-slide">
207
181
  <section
208
182
  v-if="tray.isOpen.value"
209
- :id="panelId"
183
+ :id="tray.panelId"
210
184
  ref="panelRef"
211
185
  class="mint-jobs-tray__panel"
212
- role="dialog"
213
- aria-modal="true"
214
186
  :aria-labelledby="titleId"
215
187
  tabindex="-1"
216
- @keydown="onPanelKeydown"
217
188
  >
218
- <header class="mint-jobs-tray__header">
219
- <div>
220
- <h2 :id="titleId" class="mint-jobs-tray__title">{{ title }}</h2>
221
- <p class="mint-jobs-tray__subtitle" :title="subtitle">{{ subtitle }}</p>
222
- </div>
223
- <div class="mint-jobs-tray__header-actions">
224
- <button
225
- v-if="tray.canRefresh.value"
226
- type="button"
227
- class="mint-jobs-tray__icon-button"
228
- aria-label="Refresh jobs"
229
- :disabled="tray.isLoading.value"
230
- @click="tray.refresh"
231
- >
232
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
233
- <path d="M20 6v5h-5M4 18v-5h5" />
234
- <path d="M18.4 9A7 7 0 0 0 6.1 6.1L4 8m2 7a7 7 0 0 0 11.9 2.9L20 16" />
235
- </svg>
236
- </button>
237
- <button
238
- type="button"
239
- class="mint-jobs-tray__icon-button"
240
- aria-label="Close jobs"
241
- @click="closeTray"
242
- >
243
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
244
- <path d="m6 6 12 12M18 6 6 18" />
245
- </svg>
246
- </button>
247
- </div>
248
- </header>
249
-
250
- <div class="mint-jobs-tray__tabs" role="tablist" aria-label="Job groups">
189
+ <header class="mint-jobs-tray__head">
190
+ <h2 :id="titleId" class="mint-jobs-tray__title">{{ title }}</h2>
191
+ <span class="mint-jobs-tray__head-count">{{ headCount }}</span>
251
192
  <button
193
+ v-if="tray.canRefresh.value"
252
194
  type="button"
253
- role="tab"
254
- data-tab="active"
255
- class="mint-jobs-tray__tab"
256
- :class="{ 'mint-jobs-tray__tab--active': tray.activeTab.value === 'active' }"
257
- :aria-selected="tray.activeTab.value === 'active'"
258
- @click="tray.activeTab.value = 'active'"
195
+ class="mint-jobs-tray__icon-button"
196
+ aria-label="Refresh jobs"
197
+ title="Refresh jobs"
198
+ :disabled="tray.isLoading.value"
199
+ @click="tray.refresh"
259
200
  >
260
- Active
261
- <span class="mint-jobs-tray__tab-count">{{ tray.activeJobs.value.length }}</span>
201
+ <RefreshCw :size="14" aria-hidden="true" />
262
202
  </button>
263
203
  <button
264
204
  type="button"
265
- role="tab"
266
- data-tab="history"
267
- class="mint-jobs-tray__tab"
268
- :class="{ 'mint-jobs-tray__tab--active': tray.activeTab.value === 'history' }"
269
- :aria-selected="tray.activeTab.value === 'history'"
270
- @click="tray.activeTab.value = 'history'"
205
+ class="mint-jobs-tray__icon-button"
206
+ aria-label="Close jobs"
207
+ title="Close"
208
+ @click="closeTray"
271
209
  >
272
- History
273
- <span class="mint-jobs-tray__tab-count">{{ tray.historyJobs.value.length }}</span>
210
+ <X :size="14" aria-hidden="true" />
274
211
  </button>
275
- </div>
212
+ </header>
276
213
 
277
214
  <p v-if="tray.error.value" class="mint-jobs-tray__error" role="alert">
278
215
  {{ tray.error.value }}
279
216
  </p>
280
217
 
281
218
  <div class="mint-jobs-tray__body">
282
- <template v-if="tray.activeTab.value === 'active'">
283
- <p v-if="tray.activeJobs.value.length === 0" class="mint-jobs-tray__empty">
284
- No active jobs
285
- </p>
286
- <article
287
- v-for="job in activeRows"
219
+ <p v-if="sections.length === 0" class="mint-jobs-tray__empty">No jobs yet</p>
220
+
221
+ <template v-for="section in sections" :key="section.key">
222
+ <div class="mint-jobs-tray__section" :data-section="section.key">
223
+ {{ section.title }}
224
+ <span class="mint-jobs-tray__section-count">· {{ section.jobs.length }}</span>
225
+ <button
226
+ v-if="section.key === 'finished' && canClear"
227
+ type="button"
228
+ class="mint-jobs-tray__section-action"
229
+ @click="clearFinished"
230
+ >
231
+ Clear
232
+ </button>
233
+ </div>
234
+
235
+ <div
236
+ v-for="job in section.jobs"
288
237
  :key="job.job_id"
289
- class="mint-jobs-tray__job"
290
- :class="{ 'mint-jobs-tray__job--awaiting-input': job.status === 'awaiting_input' }"
238
+ class="mint-jobs-tray__row"
239
+ :class="{ 'mint-jobs-tray__row--awaiting-input': job.status === 'awaiting_input' }"
240
+ :data-status="job.status"
291
241
  >
292
- <div class="mint-jobs-tray__job-heading">
293
- <div class="mint-jobs-tray__job-identity">
294
- <button
295
- type="button"
296
- class="mint-jobs-tray__job-select"
297
- @click="emit('select', job)"
298
- >
299
- {{ job.name }}
300
- </button>
301
- <span class="mint-jobs-tray__kind">{{ job.kind }}</span>
302
- </div>
303
- <span class="mint-jobs-tray__status" :class="statusClass(job)">
304
- <span class="mint-jobs-tray__status-dot" aria-hidden="true" />
305
- {{ jobStatusLabel(job.status) }}
306
- </span>
307
- <span v-if="job.status === 'running'" class="mint-jobs-tray__percent">{{ jobPercent(job) }}%</span>
308
- </div>
242
+ <component
243
+ :is="STATUS_ICONS[job.status]"
244
+ :size="15"
245
+ :class="['mint-jobs-tray__status', `mint-jobs-tray__status--${job.status.replace('_', '-')}`]"
246
+ role="img"
247
+ :aria-label="jobStatusLabel(job.status)"
248
+ />
249
+ <button type="button" class="mint-jobs-tray__row-title" @click="emit('select', job)">
250
+ <slot name="job-title" :job="job">{{ job.name }}</slot>
251
+ </button>
252
+ <span class="mint-jobs-tray__tools">
253
+ <span class="mint-jobs-tray__value">{{ jobValue(job) }}</span>
254
+ <slot name="job-actions" :job="job" />
255
+ <button
256
+ v-if="isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value"
257
+ type="button"
258
+ class="mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger"
259
+ :aria-label="`Cancel ${job.name}`"
260
+ title="Cancel"
261
+ :disabled="tray.isPending(job)"
262
+ @click="cancelJob(job)"
263
+ >
264
+ <Square :size="12" aria-hidden="true" />
265
+ </button>
266
+ <button
267
+ v-if="job.can_delete && tray.canDelete.value"
268
+ type="button"
269
+ class="mint-jobs-tray__icon-button"
270
+ :aria-label="`Delete ${job.name}`"
271
+ title="Delete"
272
+ :disabled="tray.isPending(job)"
273
+ @click="deleteJob(job)"
274
+ >
275
+ <Trash2 :size="14" aria-hidden="true" />
276
+ </button>
277
+ </span>
309
278
  <div
310
279
  v-if="job.status === 'running'"
311
280
  class="mint-jobs-tray__progress"
312
281
  role="progressbar"
313
- aria-label="Job progress"
282
+ :aria-label="`${job.name} progress`"
314
283
  aria-valuemin="0"
315
284
  aria-valuemax="100"
316
285
  :aria-valuenow="jobPercent(job)"
317
286
  >
318
287
  <span :style="{ width: `${jobPercent(job)}%` }" />
319
288
  </div>
320
- <div class="mint-jobs-tray__job-footer">
321
- <span>{{ progressText(job) }}</span>
322
- <div class="mint-jobs-tray__job-actions">
323
- <slot name="job-actions" :job="job" />
324
- <button
325
- v-if="job.can_cancel && tray.canCancel.value"
326
- type="button"
327
- class="mint-jobs-tray__action mint-jobs-tray__action--danger"
328
- aria-label="Cancel job"
329
- :disabled="tray.isPending(job)"
330
- @click="cancelJob(job)"
331
- >
332
- Cancel
333
- </button>
334
- <button
335
- v-if="job.can_delete && tray.canDelete.value"
336
- type="button"
337
- class="mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger"
338
- aria-label="Delete job"
339
- :disabled="tray.isPending(job)"
340
- @click="deleteJob(job)"
341
- >
342
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
343
- <path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5M14 11v5" />
344
- </svg>
345
- </button>
346
- </div>
347
- </div>
348
- </article>
349
- </template>
350
-
351
- <template v-else>
352
- <div v-if="canClear" class="mint-jobs-tray__clear-row">
353
- <button
354
- type="button"
355
- class="mint-jobs-tray__action mint-jobs-tray__action--danger"
356
- @click="clearFinished"
289
+ <span
290
+ class="mint-jobs-tray__detail"
291
+ :class="{ 'mint-jobs-tray__detail--error': job.status === 'failed' }"
357
292
  >
358
- Clear finished
359
- </button>
293
+ <slot name="job-detail" :job="job">{{ progressText(job) }}</slot>
294
+ </span>
360
295
  </div>
361
- <p v-if="tray.historyJobs.value.length === 0" class="mint-jobs-tray__empty">
362
- No job history
363
- </p>
364
- <article
365
- v-for="job in tray.historyJobs.value"
366
- :key="job.job_id"
367
- class="mint-jobs-tray__job"
368
- >
369
- <div class="mint-jobs-tray__job-heading">
370
- <div class="mint-jobs-tray__job-identity">
371
- <button
372
- type="button"
373
- class="mint-jobs-tray__job-select"
374
- @click="emit('select', job)"
375
- >
376
- {{ job.name }}
377
- </button>
378
- <span class="mint-jobs-tray__kind">{{ job.kind }}</span>
379
- </div>
380
- <span class="mint-jobs-tray__status" :class="statusClass(job)">
381
- <span class="mint-jobs-tray__status-dot" aria-hidden="true" />
382
- {{ jobStatusLabel(job.status) }}
383
- </span>
384
- </div>
385
- <div class="mint-jobs-tray__job-footer">
386
- <span>{{ formatTimestamp(job.completed_at || job.created_at) }}</span>
387
- <div class="mint-jobs-tray__job-actions">
388
- <slot name="job-actions" :job="job" />
389
- <button
390
- v-if="job.can_delete && tray.canDelete.value"
391
- type="button"
392
- class="mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger"
393
- aria-label="Delete job"
394
- :disabled="tray.isPending(job)"
395
- @click="deleteJob(job)"
396
- >
397
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
398
- <path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5M14 11v5" />
399
- </svg>
400
- </button>
401
- </div>
402
- </div>
403
- </article>
404
296
  </template>
405
297
  </div>
406
298
  </section>
@@ -4,6 +4,7 @@ import type {
4
4
  PillNavOption,
5
5
  PluginWorkspaceViewSpec,
6
6
  } from '../types'
7
+ import type { JobState } from '../types/jobs'
7
8
  import type {
8
9
  ControlComponentBinding,
9
10
  ControlComponentBindingsById,
@@ -46,6 +47,8 @@ export interface PluginWorkspaceViewEmits {
46
47
  'section-action': [sectionId: string, actionId: string]
47
48
  'form-submit': [sectionId: string, values: Record<string, unknown>]
48
49
  'form-cancel': [sectionId: string]
50
+ /** A job row in the jobs tray, or the just-completed job on the top-bar chip, was chosen. */
51
+ 'job-select': [job: JobState]
49
52
  }
50
53
 
51
54
  /** Typed `emit` function shape produced by `defineEmits<PluginWorkspaceViewEmits>()`. */
@@ -20,9 +20,12 @@ import type {
20
20
  PillNavItem,
21
21
  PillNavOption,
22
22
  } from '../types'
23
+ import type { JobState } from '../types/jobs'
23
24
  import { useAppExperiment } from '../composables/useAppExperiment'
25
+ import { useJobCenter } from '../composables/useJobCenter'
24
26
  import { usePluginWorkspace } from '../composables/usePluginWorkspace'
25
27
  import AppToastContainer from './AppToastContainer.vue'
28
+ import JobsStatusTray from './JobsStatusTray.vue'
26
29
  import WorkspaceControlsInternal from './internal/WorkspaceControlsInternal.vue'
27
30
  import WorkspaceShellInternal from './internal/WorkspaceShellInternal.vue'
28
31
  import WorkspaceSidebarInternal from './internal/WorkspaceSidebarInternal.vue'
@@ -76,6 +79,14 @@ if (props.spec.experiment) {
76
79
  })
77
80
  }
78
81
 
82
+ // The job center is setup-time structure too: no `jobs`, no jobs control.
83
+ const jobs = props.spec.jobs
84
+ const jobCenter = jobs ? useJobCenter({ source: jobs }) : null
85
+
86
+ function handleJobSelect(job: JobState) {
87
+ emit('job-select', job)
88
+ }
89
+
79
90
  const {
80
91
  shell,
81
92
  sidebar,
@@ -105,6 +116,11 @@ function handleFormCancel(sectionId: string) {
105
116
  <template>
106
117
  <WorkspaceShellInternal
107
118
  :shell="shell"
119
+ :job="jobCenter ? jobCenter.topBarJob.value : undefined"
120
+ :jobs-done="jobCenter?.completedCount.value"
121
+ :jobs-open="jobCenter?.isOpen.value"
122
+ :jobs-panel-id="jobCenter?.panelId"
123
+ @job-click="jobCenter?.handleTopBarClick(handleJobSelect)"
108
124
  @pill-select="handlePillSelect"
109
125
  @pill-option-select="(option: PillNavOption, item: PillNavItem) => emit('pill-option-select', option, item)"
110
126
  @settings-values-change="emit('settings-values-change', $event)"
@@ -158,6 +174,7 @@ function handleFormCancel(sectionId: string) {
158
174
  </template>
159
175
  </WorkspaceControlsInternal>
160
176
  <AppToastContainer v-if="spec.toasts !== false" />
177
+ <JobsStatusTray v-if="jobCenter" :center="jobCenter" @select="handleJobSelect" />
161
178
  </template>
162
179
  </WorkspaceShellInternal>
163
180
  </template>
@@ -12,15 +12,22 @@ import type {
12
12
  import AppLayout from '../AppLayout.vue'
13
13
  import AppTopBar from '../AppTopBar.vue'
14
14
  import type { PluginWorkspaceShellModel } from '../../composables/pluginWorkspaceModel'
15
+ import type { TopBarJob } from '../../types/jobs'
15
16
 
16
17
  defineProps<{
17
18
  shell: PluginWorkspaceShellModel
19
+ /** Passed to AppTopBar; undefined means no jobs control. */
20
+ job?: TopBarJob | null
21
+ jobsDone?: number
22
+ jobsOpen?: boolean
23
+ jobsPanelId?: string
18
24
  }>()
19
25
 
20
26
  const emit = defineEmits<{
21
27
  'pill-select': [item: PillNavItem]
22
28
  'pill-option-select': [option: PillNavOption, item: PillNavItem]
23
29
  'settings-values-change': [data: Record<string, unknown>]
30
+ 'job-click': []
24
31
  }>()
25
32
 
26
33
  function handlePillOptionSelect(option: PillNavOption, item: PillNavItem) {
@@ -58,6 +65,11 @@ defineSlots<{
58
65
  :subtitle="shell.subtitle"
59
66
  :spec="shell.topBar"
60
67
  :pill="shell.pill"
68
+ :job="job"
69
+ :jobs-done="jobsDone"
70
+ :jobs-open="jobsOpen"
71
+ :jobs-panel-id="jobsPanelId"
72
+ @job-click="emit('job-click')"
61
73
  @pill-select="emit('pill-select', $event)"
62
74
  @pill-option-select="handlePillOptionSelect"
63
75
  @settings-values-change="emit('settings-values-change', $event)"