@morscherlab/mint-sdk 1.4.0-beta.20 → 1.4.0-beta.22

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 (97) hide show
  1. package/dist/{AlertBox-CzLg0UaL.js → AlertBox-DwgNTEqf.js} +3 -3
  2. package/dist/{AlertBox-CzLg0UaL.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  3. package/dist/{AppSidebar-6oTT-gPO.js → AppSidebar-CZZaxni0.js} +7 -7
  4. package/dist/{AppSidebar-6oTT-gPO.js.map → AppSidebar-CZZaxni0.js.map} +1 -1
  5. package/dist/{ConfirmDialog-CSls2Nuv.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  6. package/dist/{ConfirmDialog-CSls2Nuv.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  7. package/dist/{ExperimentPopover-9l4QZlvQ.js → ExperimentPopover-DujiuQIO.js} +5 -5
  8. package/dist/{ExperimentPopover-9l4QZlvQ.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  9. package/dist/{ExperimentSelectorModal-pR5fK3pA.js → ExperimentSelectorModal-B4j4eUOJ.js} +5 -5
  10. package/dist/{ExperimentSelectorModal-pR5fK3pA.js.map → ExperimentSelectorModal-B4j4eUOJ.js.map} +1 -1
  11. package/dist/{SegmentedControl-CJ7EAmOq.js → SegmentedControl-DyUPH3I5.js} +2 -2
  12. package/dist/{SegmentedControl-CJ7EAmOq.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  13. package/dist/{SettingsModal-Dyitgzfp.js → SettingsModal-D2MOOtuO.js} +7 -7
  14. package/dist/{SettingsModal-Dyitgzfp.js.map → SettingsModal-D2MOOtuO.js.map} +1 -1
  15. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  19. package/dist/composables/useFileImport.d.ts +7 -0
  20. package/dist/composables/useFloatingPanel.d.ts +29 -0
  21. package/dist/{controlSchemaModel-DCUOx8ll.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  22. package/dist/{controlSchemaModel-DCUOx8ll.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  23. package/dist/{data-DBnmeBZX.js → data-Boeh4uPI.js} +12 -11
  24. package/dist/{data-DBnmeBZX.js.map → data-Boeh4uPI.js.map} +1 -1
  25. package/dist/data.d.ts +1 -1
  26. package/dist/data.js +9 -9
  27. package/dist/{experimentStatus-BFrKpHAH.js → experimentStatus-DCn2QKG7.js} +2 -2
  28. package/dist/{experimentStatus-BFrKpHAH.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  29. package/dist/index.js +18 -18
  30. package/dist/{install-qjELyNwc.js → install-BEHp7Mo-.js} +15 -15
  31. package/dist/{install-qjELyNwc.js.map → install-BEHp7Mo-.js.map} +1 -1
  32. package/dist/install.js +1 -1
  33. package/dist/{lab-UyTbokQC.js → lab-D61eaX6f.js} +10 -10
  34. package/dist/{lab-UyTbokQC.js.map → lab-D61eaX6f.js.map} +1 -1
  35. package/dist/lab.js +4 -4
  36. package/dist/{records-CR_pYzIP.js → records-D4aQXyZu.js} +132 -72
  37. package/dist/records-D4aQXyZu.js.map +1 -0
  38. package/dist/{runtime-CoHhBP10.js → runtime-q-R0CNLh.js} +9 -9
  39. package/dist/{runtime-CoHhBP10.js.map → runtime-q-R0CNLh.js.map} +1 -1
  40. package/dist/runtime.js +6 -6
  41. package/dist/{shell-BDuZf2zb.js → shell-6CGFvUUw.js} +5 -5
  42. package/dist/{shell-BDuZf2zb.js.map → shell-6CGFvUUw.js.map} +1 -1
  43. package/dist/shell.js +5 -5
  44. package/dist/styles.css +36 -33
  45. package/dist/{useDropdownState-Bb2N4m40.js → useDropdownState-uFIz6Yv8.js} +2 -2
  46. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  47. package/dist/{useExperimentSamples-Dno1fFMq.js → useExperimentSamples-QEFoq4tf.js} +47 -5
  48. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  49. package/dist/{useListReorder-CQ9OCScc.js → useListReorder-DsuzwWGS.js} +47 -97
  50. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  51. package/dist/{useRackEditor-wL3kPrRT.js → useRackEditor-KpZ4iJ6o.js} +20 -30
  52. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  53. package/dist/{useRealtime-C3whhVvb.js → useRealtime-B58Lo-Tm.js} +3 -3
  54. package/dist/{useRealtime-C3whhVvb.js.map → useRealtime-B58Lo-Tm.js.map} +1 -1
  55. package/dist/{useRequest-CDkrXNU7.js → useRequest-BuDmW8Jc.js} +7 -36
  56. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  57. package/package.json +3 -2
  58. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  59. package/src/__tests__/components/BaseModal.test.ts +13 -0
  60. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  61. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  62. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  63. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  64. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  65. package/src/components/ActionMenu.vue +6 -37
  66. package/src/components/BaseModal.vue +2 -1
  67. package/src/components/Tooltip.vue +9 -32
  68. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  69. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  70. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  71. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  72. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  73. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  74. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  75. package/src/composables/useFileImport.ts +51 -1
  76. package/src/composables/useFloatingPanel.ts +107 -0
  77. package/src/data.ts +1 -0
  78. package/src/styles/components/action-menu.css +1 -1
  79. package/src/styles/components/app-layout.css +3 -1
  80. package/src/styles/components/date-picker.css +4 -2
  81. package/src/styles/components/datetime-picker.css +4 -4
  82. package/src/styles/components/jobs-status-tray.css +3 -3
  83. package/src/styles/components/modal.css +1 -1
  84. package/src/styles/components/onboarding.css +2 -2
  85. package/src/styles/components/searchable-select.css +3 -7
  86. package/src/styles/components/time-picker.css +3 -6
  87. package/src/styles/components/toast.css +1 -1
  88. package/src/styles/components/tooltip.css +2 -2
  89. package/src/styles/components/unit-input.css +1 -1
  90. package/src/styles/components/well-edit-popup.css +2 -2
  91. package/src/styles/variables.css +6 -0
  92. package/dist/records-CR_pYzIP.js.map +0 -1
  93. package/dist/useDropdownState-Bb2N4m40.js.map +0 -1
  94. package/dist/useExperimentSamples-Dno1fFMq.js.map +0 -1
  95. package/dist/useListReorder-CQ9OCScc.js.map +0 -1
  96. package/dist/useRackEditor-wL3kPrRT.js.map +0 -1
  97. package/dist/useRequest-CDkrXNU7.js.map +0 -1
@@ -1,5 +1,5 @@
1
- import { d as isActiveJobStatus, p as jobStatusLabel } from "./records-CR_pYzIP.js";
2
- import { t as ConfirmDialog_default } from "./ConfirmDialog-CSls2Nuv.js";
1
+ import { d as isActiveJobStatus, p as jobStatusLabel } from "./records-D4aQXyZu.js";
2
+ import { t as ConfirmDialog_default } from "./ConfirmDialog-CjWoxAD1.js";
3
3
  import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, getCurrentScope, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onScopeDispose, openBlock, readonly, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, toValue, unref, watch, withCtx } from "vue";
4
4
  import { CircleAlert, CircleCheck, CircleSlash, CircleX, Clock, LoaderCircle, RefreshCw, Square, Trash2, X } from "lucide-vue-next";
5
5
  //#region src/components/JobsStatusTray.vue?vue&type=script&setup=true&lang.ts
@@ -505,4 +505,4 @@ function subscribeRealtime(topics, handler, withPresence) {
505
505
  //#endregion
506
506
  export { JobsStatusTray_default as i, subscribeRealtime as n, useRealtime as r, currentPresence as t };
507
507
 
508
- //# sourceMappingURL=useRealtime-C3whhVvb.js.map
508
+ //# sourceMappingURL=useRealtime-B58Lo-Tm.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useRealtime-C3whhVvb.js","names":[],"sources":["../src/components/JobsStatusTray.vue","../src/components/JobsStatusTray.vue","../src/composables/useRealtime.ts"],"sourcesContent":["<script setup lang=\"ts\">\n/**\n * Compact, non-modal job panel under the top bar, driven by a job center the parent owns.\n *\n * The parent passes the center's `topBarJob`, `completedCount` and `handleTopBarClick`\n * to `AppTopBar`; its run slot opens this panel. `PluginWorkspaceView` does this for you\n * when its spec sets `jobs`.\n *\n * @example Recommended\n * ```vue\n * <AppTopBar :job=\"center.topBarJob.value\" :jobs-done=\"center.completedCount.value\"\n * @job-click=\"center.handleTopBarClick(openResult)\" />\n * <JobsStatusTray :center=\"center\" @select=\"openResult\" />\n * ```\n */\nimport { computed, nextTick, onBeforeUnmount, ref, watch, type Component } from 'vue'\nimport {\n CircleAlert,\n CircleCheck,\n CircleSlash,\n CircleX,\n Clock,\n LoaderCircle,\n RefreshCw,\n Square,\n Trash2,\n X,\n} from 'lucide-vue-next'\n\nimport { isActiveJobStatus, jobStatusLabel } from '../jobs'\nimport type { UseJobCenterReturn } from '../composables/useJobCenter'\nimport type { JobState, JobStatus } from '../types/jobs'\nimport ConfirmDialog from './ConfirmDialog.vue'\n\ntype ConfirmAction = 'cancel' | 'delete' | 'clear'\n\ninterface Props {\n /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */\n center: UseJobCenterReturn\n title?: string\n teleportTo?: string | HTMLElement | false\n /** Ask before Cancel, Delete and Clear (`true`), or before the named ones only. Default off. */\n confirm?: boolean | Partial<Record<ConfirmAction, boolean>>\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n title: 'Jobs',\n teleportTo: 'body',\n confirm: false,\n})\n\nconst emit = defineEmits<{\n select: [job: JobState]\n error: [message: string]\n}>()\n\ndefineSlots<{\n /** Extra per-row actions, rendered before the built-in Cancel / Delete. */\n 'job-actions'?: (props: { job: JobState }) => unknown\n /** Row title. Default: the job name. */\n 'job-title'?: (props: { job: JobState }) => unknown\n /** Detail line under the title. Default: the error, progress message or status. */\n 'job-detail'?: (props: { job: JobState }) => unknown\n}>()\n\nconst STATUS_ICONS: Record<JobStatus, Component> = {\n running: LoaderCircle,\n awaiting_input: CircleAlert,\n queued: Clock,\n completed: CircleCheck,\n failed: CircleX,\n cancelled: CircleSlash,\n}\n\nconst panelRef = ref<HTMLElement | null>(null)\nconst teleportTarget = computed(() => props.teleportTo || 'body')\nconst tray = props.center\nconst titleId = `${tray.panelId}-title`\nconst rows = (jobs: JobState[]) => jobs.map(job => ({ job, view: tray.rowView(job) }))\n// A job waiting for input is the only one that needs a person: pin it first.\nconst activeRows = computed(() => {\n const active = rows(tray.activeJobs.value)\n return [\n ...active.filter(row => row.view.status === 'awaiting_input'),\n ...active.filter(row => row.view.status !== 'awaiting_input'),\n ]\n})\nconst sections = computed(() => [\n { key: 'active', title: 'Active', rows: activeRows.value },\n { key: 'finished', title: 'Finished', rows: rows(tray.historyJobs.value) },\n].filter(section => section.rows.length > 0))\nconst headCount = computed(() => {\n const parts = [\n [tray.activeJobs.value.length, 'active'],\n [tray.completedCount.value, 'done'],\n [tray.historyJobs.value.filter(job => job.status === 'failed').length, 'failed'],\n ].filter(([n]) => n).map(([n, word]) => `${n} ${word}`)\n return parts.length ? parts.join(' · ') : 'No jobs'\n})\nconst canClear = computed(() => (\n tray.deletableJobs.value.length > 0\n && tray.canClearFinished.value\n))\n\n// Escape returns focus to the element that opened the panel (the top-bar chip).\n// The panel is non-modal, so Escape is heard on the document while it is open.\n// An Escape that an inner control handled, or that belongs to a modal dialog\n// above the tray (its own confirm dialog too), leaves the tray open.\nlet opener: HTMLElement | null = null\nfunction onDocumentKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape' || event.defaultPrevented || confirmOpen.value) return\n if (event.target instanceof Element && event.target.closest('[aria-modal=\"true\"]')) return\n closeTray()\n}\nwatch(tray.isOpen, async (open) => {\n if (!open) {\n document.removeEventListener('keydown', onDocumentKeydown)\n return\n }\n opener = document.activeElement instanceof HTMLElement ? document.activeElement : null\n document.addEventListener('keydown', onDocumentKeydown)\n await nextTick()\n panelRef.value?.focus()\n}, { flush: 'sync' })\nonBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))\n\n// A focused row that leaves the list (an action or a refresh) drops focus to body:\n// note the focused element before the update and keep focus in the panel after it.\nlet focusedInPanel: Element | null = null\nwatch(tray.jobs, () => {\n const focused = document.activeElement\n focusedInPanel = focused && panelRef.value?.contains(focused) ? focused : null\n}, { flush: 'pre' })\nwatch(tray.jobs, () => {\n if (tray.isOpen.value && focusedInPanel && !focusedInPanel.isConnected) panelRef.value?.focus()\n focusedInPanel = null\n}, { flush: 'post' })\n\nfunction closeTray(): void {\n tray.close()\n // Safari leaves a clicked button unfocused (opener = body), and the chip node is\n // replaced when it switches between job chip and idle button: fall back to the\n // top-bar jobs control.\n const target = opener?.isConnected && opener !== document.body\n ? opener\n : document.querySelector<HTMLElement>('.mint-topbar__job')\n target?.focus()\n}\n\nfunction formatTime(value: string | null): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime())\n ? ''\n : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\n/** Right-hand value: the percent while a job moves, else when it ended or started. */\nfunction jobValue(job: JobState, percent: number | null): string {\n if (percent != null) return `${percent}%`\n return formatTime(job.completed_at || job.started_at || job.created_at)\n}\n\n// Confirmation: the action waits for the dialog; it runs on the live job.\n// `ids`: the finished jobs that the user confirmed to clear.\nconst pending = ref<{ action: ConfirmAction; job?: JobState; ids?: string[] } | null>(null)\nconst confirmOpen = ref(false)\n// Set on request and kept, so the dialog text stays while it fades out.\nconst confirmText = ref<{ title: string; message: string; confirmLabel: string; cancelLabel: string } | null>(null)\nconst confirmEnabled = computed(() => props.confirm === true\n || (typeof props.confirm === 'object' && Object.values(props.confirm).some(Boolean)))\n\nfunction textFor(action: ConfirmAction, job?: JobState) {\n if (action === 'clear') {\n const count = tray.deletableJobs.value.length\n return {\n title: 'Clear finished jobs?',\n message: `${count} finished ${count === 1 ? 'job' : 'jobs'} and their stored results are deleted.`,\n confirmLabel: 'Clear jobs',\n cancelLabel: 'Cancel',\n }\n }\n return action === 'cancel'\n ? { title: 'Cancel job?', message: `${job?.name} stops. Its partial results are lost.`, confirmLabel: 'Cancel job', cancelLabel: 'Keep running' }\n : { title: 'Delete job?', message: `${job?.name} and its stored result are deleted.`, confirmLabel: 'Delete job', cancelLabel: 'Cancel' }\n}\n\nfunction needsConfirm(action: ConfirmAction): boolean {\n return props.confirm === true || (typeof props.confirm === 'object' && props.confirm[action] === true)\n}\n\nfunction request(action: ConfirmAction, job?: JobState): void {\n if (!needsConfirm(action)) {\n void run(action, job)\n return\n }\n pending.value = { action, job, ids: action === 'clear' ? deletableIds() : undefined }\n confirmText.value = textFor(action, job)\n confirmOpen.value = true\n}\n\nfunction deletableIds(): string[] {\n return tray.deletableJobs.value.map(job => job.job_id).sort()\n}\n\n// A new source is a new job list: the confirmed target is gone.\nwatch(tray.source, () => onConfirmOpen(false))\n\nfunction onConfirmOpen(open: boolean): void {\n confirmOpen.value = open\n if (!open) pending.value = null\n}\n\nasync function confirmPending(): Promise<void> {\n // A second Confirm click while the dialog fades out does nothing.\n const current = pending.value\n if (!confirmOpen.value || !current) return\n if (current.action === 'clear') {\n // Polling changed the finished jobs: ask again with the new count.\n const ids = deletableIds()\n if (ids.join() !== current.ids?.join()) {\n pending.value = { ...current, ids }\n confirmText.value = textFor('clear')\n return\n }\n }\n confirmOpen.value = false\n pending.value = null\n if (current.action === 'clear') return clearFinished()\n // Polling can end or remove the job while the dialog is open: then do nothing.\n const live = tray.jobs.value.find(job => job.job_id === current.job?.job_id)\n const allowed = live && (current.action === 'cancel'\n ? isActiveJobStatus(live.status) && live.can_cancel\n : live.can_delete)\n if (allowed) await run(current.action, live)\n}\n\nfunction run(action: ConfirmAction, job?: JobState): Promise<void> {\n if (action === 'clear') return clearFinished()\n return action === 'cancel' ? cancelJob(job!) : deleteJob(job!)\n}\n\nasync function cancelJob(job: JobState): Promise<void> {\n const succeeded = await tray.cancelJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function deleteJob(job: JobState): Promise<void> {\n const succeeded = await tray.deleteJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function clearFinished(): Promise<void> {\n const succeeded = await tray.clearFinished()\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTarget\" :disabled=\"teleportTo === false\">\n <div class=\"mint-jobs-tray\">\n <!-- Clicks outside close the panel; the page is not dimmed. -->\n <div\n v-if=\"tray.isOpen.value\"\n class=\"mint-jobs-tray__scrim\"\n aria-hidden=\"true\"\n @click=\"tray.close()\"\n />\n\n <Transition name=\"mint-jobs-tray-fade\">\n <section\n v-if=\"tray.isOpen.value\"\n :id=\"tray.panelId\"\n ref=\"panelRef\"\n class=\"mint-jobs-tray__panel\"\n :aria-labelledby=\"titleId\"\n tabindex=\"-1\"\n >\n <header class=\"mint-jobs-tray__head\">\n <h2 :id=\"titleId\" class=\"mint-jobs-tray__title\">{{ title }}</h2>\n <span class=\"mint-jobs-tray__head-count\">{{ headCount }}</span>\n <button\n v-if=\"tray.canRefresh.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Refresh jobs\"\n title=\"Refresh jobs\"\n :disabled=\"tray.isLoading.value\"\n @click=\"tray.refresh\"\n >\n <RefreshCw :size=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Close jobs\"\n title=\"Close\"\n @click=\"closeTray\"\n >\n <X :size=\"14\" aria-hidden=\"true\" />\n </button>\n </header>\n\n <p v-if=\"tray.error.value\" class=\"mint-jobs-tray__error\" role=\"alert\">\n {{ tray.error.value }}\n </p>\n\n <div class=\"mint-jobs-tray__body\">\n <p v-if=\"sections.length === 0\" class=\"mint-jobs-tray__empty\">No jobs yet</p>\n\n <template v-for=\"section in sections\" :key=\"section.key\">\n <div class=\"mint-jobs-tray__section\" :data-section=\"section.key\">\n {{ section.title }}\n <span class=\"mint-jobs-tray__section-count\">· {{ section.rows.length }}</span>\n <button\n v-if=\"section.key === 'finished' && canClear\"\n type=\"button\"\n class=\"mint-jobs-tray__section-action\"\n @click=\"request('clear')\"\n >\n Clear\n </button>\n </div>\n\n <div\n v-for=\"{ job, view } in section.rows\"\n :key=\"job.job_id\"\n class=\"mint-jobs-tray__row\"\n :class=\"{ 'mint-jobs-tray__row--awaiting-input': view.status === 'awaiting_input' }\"\n :data-status=\"job.status\"\n >\n <!-- data-status is the real job status; the row view only changes how it looks. -->\n <component\n :is=\"STATUS_ICONS[view.status]\"\n :size=\"15\"\n :class=\"['mint-jobs-tray__status', `mint-jobs-tray__status--${view.status.replace('_', '-')}`]\"\n role=\"img\"\n :aria-label=\"jobStatusLabel(view.status)\"\n />\n <button type=\"button\" class=\"mint-jobs-tray__row-title\" @click=\"emit('select', job)\">\n <slot name=\"job-title\" :job=\"job\">{{ job.name }}</slot>\n </button>\n <span class=\"mint-jobs-tray__tools\">\n <span class=\"mint-jobs-tray__value\">{{ jobValue(job, view.percent) }}</span>\n <slot name=\"job-actions\" :job=\"job\" />\n <button\n v-if=\"isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger\"\n :aria-label=\"`Cancel ${job.name}`\"\n title=\"Cancel\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('cancel', job)\"\n >\n <Square :size=\"12\" aria-hidden=\"true\" />\n </button>\n <button\n v-if=\"job.can_delete && tray.canDelete.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n :aria-label=\"`Delete ${job.name}`\"\n title=\"Delete\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('delete', job)\"\n >\n <Trash2 :size=\"14\" aria-hidden=\"true\" />\n </button>\n </span>\n <div\n v-if=\"view.percent != null\"\n class=\"mint-jobs-tray__progress\"\n role=\"progressbar\"\n :aria-label=\"`${job.name} progress`\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n :aria-valuenow=\"view.percent\"\n >\n <span :style=\"{ width: `${view.percent}%` }\" />\n </div>\n <span\n class=\"mint-jobs-tray__detail\"\n :class=\"{ 'mint-jobs-tray__detail--error': job.status === 'failed' }\"\n >\n <slot name=\"job-detail\" :job=\"job\">{{ view.message }}</slot>\n </span>\n </div>\n </template>\n </div>\n </section>\n </Transition>\n\n <ConfirmDialog\n v-if=\"confirmEnabled\"\n :open=\"confirmOpen\"\n tone=\"error\"\n :title=\"confirmText?.title\"\n :confirm-label=\"confirmText?.confirmLabel\"\n :cancel-label=\"confirmText?.cancelLabel\"\n @update:open=\"onConfirmOpen\"\n @confirm=\"confirmPending\"\n >\n <!-- The marker class lifts the dialog above the tray (jobs-status-tray.css). -->\n <p class=\"mint-confirm__message mint-jobs-tray__confirm\">{{ confirmText?.message }}</p>\n </ConfirmDialog>\n </div>\n </Teleport>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Compact, non-modal job panel under the top bar, driven by a job center the parent owns.\n *\n * The parent passes the center's `topBarJob`, `completedCount` and `handleTopBarClick`\n * to `AppTopBar`; its run slot opens this panel. `PluginWorkspaceView` does this for you\n * when its spec sets `jobs`.\n *\n * @example Recommended\n * ```vue\n * <AppTopBar :job=\"center.topBarJob.value\" :jobs-done=\"center.completedCount.value\"\n * @job-click=\"center.handleTopBarClick(openResult)\" />\n * <JobsStatusTray :center=\"center\" @select=\"openResult\" />\n * ```\n */\nimport { computed, nextTick, onBeforeUnmount, ref, watch, type Component } from 'vue'\nimport {\n CircleAlert,\n CircleCheck,\n CircleSlash,\n CircleX,\n Clock,\n LoaderCircle,\n RefreshCw,\n Square,\n Trash2,\n X,\n} from 'lucide-vue-next'\n\nimport { isActiveJobStatus, jobStatusLabel } from '../jobs'\nimport type { UseJobCenterReturn } from '../composables/useJobCenter'\nimport type { JobState, JobStatus } from '../types/jobs'\nimport ConfirmDialog from './ConfirmDialog.vue'\n\ntype ConfirmAction = 'cancel' | 'delete' | 'clear'\n\ninterface Props {\n /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */\n center: UseJobCenterReturn\n title?: string\n teleportTo?: string | HTMLElement | false\n /** Ask before Cancel, Delete and Clear (`true`), or before the named ones only. Default off. */\n confirm?: boolean | Partial<Record<ConfirmAction, boolean>>\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n title: 'Jobs',\n teleportTo: 'body',\n confirm: false,\n})\n\nconst emit = defineEmits<{\n select: [job: JobState]\n error: [message: string]\n}>()\n\ndefineSlots<{\n /** Extra per-row actions, rendered before the built-in Cancel / Delete. */\n 'job-actions'?: (props: { job: JobState }) => unknown\n /** Row title. Default: the job name. */\n 'job-title'?: (props: { job: JobState }) => unknown\n /** Detail line under the title. Default: the error, progress message or status. */\n 'job-detail'?: (props: { job: JobState }) => unknown\n}>()\n\nconst STATUS_ICONS: Record<JobStatus, Component> = {\n running: LoaderCircle,\n awaiting_input: CircleAlert,\n queued: Clock,\n completed: CircleCheck,\n failed: CircleX,\n cancelled: CircleSlash,\n}\n\nconst panelRef = ref<HTMLElement | null>(null)\nconst teleportTarget = computed(() => props.teleportTo || 'body')\nconst tray = props.center\nconst titleId = `${tray.panelId}-title`\nconst rows = (jobs: JobState[]) => jobs.map(job => ({ job, view: tray.rowView(job) }))\n// A job waiting for input is the only one that needs a person: pin it first.\nconst activeRows = computed(() => {\n const active = rows(tray.activeJobs.value)\n return [\n ...active.filter(row => row.view.status === 'awaiting_input'),\n ...active.filter(row => row.view.status !== 'awaiting_input'),\n ]\n})\nconst sections = computed(() => [\n { key: 'active', title: 'Active', rows: activeRows.value },\n { key: 'finished', title: 'Finished', rows: rows(tray.historyJobs.value) },\n].filter(section => section.rows.length > 0))\nconst headCount = computed(() => {\n const parts = [\n [tray.activeJobs.value.length, 'active'],\n [tray.completedCount.value, 'done'],\n [tray.historyJobs.value.filter(job => job.status === 'failed').length, 'failed'],\n ].filter(([n]) => n).map(([n, word]) => `${n} ${word}`)\n return parts.length ? parts.join(' · ') : 'No jobs'\n})\nconst canClear = computed(() => (\n tray.deletableJobs.value.length > 0\n && tray.canClearFinished.value\n))\n\n// Escape returns focus to the element that opened the panel (the top-bar chip).\n// The panel is non-modal, so Escape is heard on the document while it is open.\n// An Escape that an inner control handled, or that belongs to a modal dialog\n// above the tray (its own confirm dialog too), leaves the tray open.\nlet opener: HTMLElement | null = null\nfunction onDocumentKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape' || event.defaultPrevented || confirmOpen.value) return\n if (event.target instanceof Element && event.target.closest('[aria-modal=\"true\"]')) return\n closeTray()\n}\nwatch(tray.isOpen, async (open) => {\n if (!open) {\n document.removeEventListener('keydown', onDocumentKeydown)\n return\n }\n opener = document.activeElement instanceof HTMLElement ? document.activeElement : null\n document.addEventListener('keydown', onDocumentKeydown)\n await nextTick()\n panelRef.value?.focus()\n}, { flush: 'sync' })\nonBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))\n\n// A focused row that leaves the list (an action or a refresh) drops focus to body:\n// note the focused element before the update and keep focus in the panel after it.\nlet focusedInPanel: Element | null = null\nwatch(tray.jobs, () => {\n const focused = document.activeElement\n focusedInPanel = focused && panelRef.value?.contains(focused) ? focused : null\n}, { flush: 'pre' })\nwatch(tray.jobs, () => {\n if (tray.isOpen.value && focusedInPanel && !focusedInPanel.isConnected) panelRef.value?.focus()\n focusedInPanel = null\n}, { flush: 'post' })\n\nfunction closeTray(): void {\n tray.close()\n // Safari leaves a clicked button unfocused (opener = body), and the chip node is\n // replaced when it switches between job chip and idle button: fall back to the\n // top-bar jobs control.\n const target = opener?.isConnected && opener !== document.body\n ? opener\n : document.querySelector<HTMLElement>('.mint-topbar__job')\n target?.focus()\n}\n\nfunction formatTime(value: string | null): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime())\n ? ''\n : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\n/** Right-hand value: the percent while a job moves, else when it ended or started. */\nfunction jobValue(job: JobState, percent: number | null): string {\n if (percent != null) return `${percent}%`\n return formatTime(job.completed_at || job.started_at || job.created_at)\n}\n\n// Confirmation: the action waits for the dialog; it runs on the live job.\n// `ids`: the finished jobs that the user confirmed to clear.\nconst pending = ref<{ action: ConfirmAction; job?: JobState; ids?: string[] } | null>(null)\nconst confirmOpen = ref(false)\n// Set on request and kept, so the dialog text stays while it fades out.\nconst confirmText = ref<{ title: string; message: string; confirmLabel: string; cancelLabel: string } | null>(null)\nconst confirmEnabled = computed(() => props.confirm === true\n || (typeof props.confirm === 'object' && Object.values(props.confirm).some(Boolean)))\n\nfunction textFor(action: ConfirmAction, job?: JobState) {\n if (action === 'clear') {\n const count = tray.deletableJobs.value.length\n return {\n title: 'Clear finished jobs?',\n message: `${count} finished ${count === 1 ? 'job' : 'jobs'} and their stored results are deleted.`,\n confirmLabel: 'Clear jobs',\n cancelLabel: 'Cancel',\n }\n }\n return action === 'cancel'\n ? { title: 'Cancel job?', message: `${job?.name} stops. Its partial results are lost.`, confirmLabel: 'Cancel job', cancelLabel: 'Keep running' }\n : { title: 'Delete job?', message: `${job?.name} and its stored result are deleted.`, confirmLabel: 'Delete job', cancelLabel: 'Cancel' }\n}\n\nfunction needsConfirm(action: ConfirmAction): boolean {\n return props.confirm === true || (typeof props.confirm === 'object' && props.confirm[action] === true)\n}\n\nfunction request(action: ConfirmAction, job?: JobState): void {\n if (!needsConfirm(action)) {\n void run(action, job)\n return\n }\n pending.value = { action, job, ids: action === 'clear' ? deletableIds() : undefined }\n confirmText.value = textFor(action, job)\n confirmOpen.value = true\n}\n\nfunction deletableIds(): string[] {\n return tray.deletableJobs.value.map(job => job.job_id).sort()\n}\n\n// A new source is a new job list: the confirmed target is gone.\nwatch(tray.source, () => onConfirmOpen(false))\n\nfunction onConfirmOpen(open: boolean): void {\n confirmOpen.value = open\n if (!open) pending.value = null\n}\n\nasync function confirmPending(): Promise<void> {\n // A second Confirm click while the dialog fades out does nothing.\n const current = pending.value\n if (!confirmOpen.value || !current) return\n if (current.action === 'clear') {\n // Polling changed the finished jobs: ask again with the new count.\n const ids = deletableIds()\n if (ids.join() !== current.ids?.join()) {\n pending.value = { ...current, ids }\n confirmText.value = textFor('clear')\n return\n }\n }\n confirmOpen.value = false\n pending.value = null\n if (current.action === 'clear') return clearFinished()\n // Polling can end or remove the job while the dialog is open: then do nothing.\n const live = tray.jobs.value.find(job => job.job_id === current.job?.job_id)\n const allowed = live && (current.action === 'cancel'\n ? isActiveJobStatus(live.status) && live.can_cancel\n : live.can_delete)\n if (allowed) await run(current.action, live)\n}\n\nfunction run(action: ConfirmAction, job?: JobState): Promise<void> {\n if (action === 'clear') return clearFinished()\n return action === 'cancel' ? cancelJob(job!) : deleteJob(job!)\n}\n\nasync function cancelJob(job: JobState): Promise<void> {\n const succeeded = await tray.cancelJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function deleteJob(job: JobState): Promise<void> {\n const succeeded = await tray.deleteJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function clearFinished(): Promise<void> {\n const succeeded = await tray.clearFinished()\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTarget\" :disabled=\"teleportTo === false\">\n <div class=\"mint-jobs-tray\">\n <!-- Clicks outside close the panel; the page is not dimmed. -->\n <div\n v-if=\"tray.isOpen.value\"\n class=\"mint-jobs-tray__scrim\"\n aria-hidden=\"true\"\n @click=\"tray.close()\"\n />\n\n <Transition name=\"mint-jobs-tray-fade\">\n <section\n v-if=\"tray.isOpen.value\"\n :id=\"tray.panelId\"\n ref=\"panelRef\"\n class=\"mint-jobs-tray__panel\"\n :aria-labelledby=\"titleId\"\n tabindex=\"-1\"\n >\n <header class=\"mint-jobs-tray__head\">\n <h2 :id=\"titleId\" class=\"mint-jobs-tray__title\">{{ title }}</h2>\n <span class=\"mint-jobs-tray__head-count\">{{ headCount }}</span>\n <button\n v-if=\"tray.canRefresh.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Refresh jobs\"\n title=\"Refresh jobs\"\n :disabled=\"tray.isLoading.value\"\n @click=\"tray.refresh\"\n >\n <RefreshCw :size=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Close jobs\"\n title=\"Close\"\n @click=\"closeTray\"\n >\n <X :size=\"14\" aria-hidden=\"true\" />\n </button>\n </header>\n\n <p v-if=\"tray.error.value\" class=\"mint-jobs-tray__error\" role=\"alert\">\n {{ tray.error.value }}\n </p>\n\n <div class=\"mint-jobs-tray__body\">\n <p v-if=\"sections.length === 0\" class=\"mint-jobs-tray__empty\">No jobs yet</p>\n\n <template v-for=\"section in sections\" :key=\"section.key\">\n <div class=\"mint-jobs-tray__section\" :data-section=\"section.key\">\n {{ section.title }}\n <span class=\"mint-jobs-tray__section-count\">· {{ section.rows.length }}</span>\n <button\n v-if=\"section.key === 'finished' && canClear\"\n type=\"button\"\n class=\"mint-jobs-tray__section-action\"\n @click=\"request('clear')\"\n >\n Clear\n </button>\n </div>\n\n <div\n v-for=\"{ job, view } in section.rows\"\n :key=\"job.job_id\"\n class=\"mint-jobs-tray__row\"\n :class=\"{ 'mint-jobs-tray__row--awaiting-input': view.status === 'awaiting_input' }\"\n :data-status=\"job.status\"\n >\n <!-- data-status is the real job status; the row view only changes how it looks. -->\n <component\n :is=\"STATUS_ICONS[view.status]\"\n :size=\"15\"\n :class=\"['mint-jobs-tray__status', `mint-jobs-tray__status--${view.status.replace('_', '-')}`]\"\n role=\"img\"\n :aria-label=\"jobStatusLabel(view.status)\"\n />\n <button type=\"button\" class=\"mint-jobs-tray__row-title\" @click=\"emit('select', job)\">\n <slot name=\"job-title\" :job=\"job\">{{ job.name }}</slot>\n </button>\n <span class=\"mint-jobs-tray__tools\">\n <span class=\"mint-jobs-tray__value\">{{ jobValue(job, view.percent) }}</span>\n <slot name=\"job-actions\" :job=\"job\" />\n <button\n v-if=\"isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger\"\n :aria-label=\"`Cancel ${job.name}`\"\n title=\"Cancel\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('cancel', job)\"\n >\n <Square :size=\"12\" aria-hidden=\"true\" />\n </button>\n <button\n v-if=\"job.can_delete && tray.canDelete.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n :aria-label=\"`Delete ${job.name}`\"\n title=\"Delete\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('delete', job)\"\n >\n <Trash2 :size=\"14\" aria-hidden=\"true\" />\n </button>\n </span>\n <div\n v-if=\"view.percent != null\"\n class=\"mint-jobs-tray__progress\"\n role=\"progressbar\"\n :aria-label=\"`${job.name} progress`\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n :aria-valuenow=\"view.percent\"\n >\n <span :style=\"{ width: `${view.percent}%` }\" />\n </div>\n <span\n class=\"mint-jobs-tray__detail\"\n :class=\"{ 'mint-jobs-tray__detail--error': job.status === 'failed' }\"\n >\n <slot name=\"job-detail\" :job=\"job\">{{ view.message }}</slot>\n </span>\n </div>\n </template>\n </div>\n </section>\n </Transition>\n\n <ConfirmDialog\n v-if=\"confirmEnabled\"\n :open=\"confirmOpen\"\n tone=\"error\"\n :title=\"confirmText?.title\"\n :confirm-label=\"confirmText?.confirmLabel\"\n :cancel-label=\"confirmText?.cancelLabel\"\n @update:open=\"onConfirmOpen\"\n @confirm=\"confirmPending\"\n >\n <!-- The marker class lifts the dialog above the tray (jobs-status-tray.css). -->\n <p class=\"mint-confirm__message mint-jobs-tray__confirm\">{{ confirmText?.message }}</p>\n </ConfirmDialog>\n </div>\n </Teleport>\n</template>\n","import {\n getCurrentScope,\n onScopeDispose,\n readonly,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref,\n} from 'vue'\n\n/**\n * One event of the platform's realtime stream (`GET /api/events`).\n *\n * Platform events carry no entity data: refetch the entity through REST.\n * `type` is `resync` (refetch everything you show), the audit action of a\n * committed write, such as `experiment.update` or `analysis.artifact_create`,\n * or the `\"<plugin>.<event>\"` name of a plugin event, which carries `payload`.\n * Only `usePresence` receives `presence.changed`.\n */\nexport interface RealtimeEvent {\n type: string\n id?: string\n /**\n * `experiment:{id}`, `project:{id}`, `experiments`, `user:{id}`, or a plugin's\n * `plugin:{name}` / `plugin:{name}:experiment:{id}`; absent on `resync`.\n */\n topic?: string\n /** A plugin event: the JSON payload the plugin published (at most 16 KiB). */\n payload?: unknown\n /** Changed fields of an update, when the write names them. */\n fields?: string[] | null\n actor_id?: string | null\n request_id?: string | null\n at?: string\n /** `presence.changed`: the topic's viewers (at most 20, `editing` first). */\n viewers?: { user_id: string, activity: string }[]\n /** `presence.changed`: the number of viewers, also those beyond `viewers`. */\n count?: number\n}\n\nexport type RealtimeHandler = (event: RealtimeEvent) => void\n\nexport interface UseRealtimeReturn {\n /** True while the shared stream is open. */\n connected: Readonly<Ref<boolean>>\n /** Unsubscribe; also runs when the calling component or effect scope is disposed. */\n stop: () => void\n}\n\nconst EVENTS_URL = '/api/events'\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\n/** Merges the subscriptions of components that mount together into one connection. */\nconst TOPIC_DEBOUNCE_MS = 50\nconst RESYNC: RealtimeEvent = { type: 'resync' }\n\ninterface Listener {\n topics: () => string[]\n handler: RealtimeHandler\n /** Receives `presence.changed`; a write listener would refetch on each viewer change. */\n presence: boolean\n}\n\nconst listeners = new Set<Listener>()\n/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */\nconst presence = new Map<string, RealtimeEvent>()\nconst connected = ref(false)\nlet source: EventSource | null = null\nlet sourceTopics = ''\nlet failures = 0\nlet syncTimer: ReturnType<typeof setTimeout> | null = null\nlet retryTimer: ReturnType<typeof setTimeout> | null = null\n\nfunction dispatch(event: RealtimeEvent): void {\n const isPresence = event.type === 'presence.changed'\n if (isPresence && event.topic) presence.set(event.topic, event)\n for (const listener of [...listeners]) {\n if (isPresence && !listener.presence) continue\n if (event.type === 'resync' || (event.topic && listener.topics().includes(event.topic))) {\n listener.handler(event)\n }\n }\n}\n\nfunction close(): void {\n source?.close()\n source = null\n sourceTopics = ''\n presence.clear()\n connected.value = false\n}\n\nfunction open(topics: string): void {\n const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}`)\n source = stream\n sourceTopics = topics\n stream.onopen = () => {\n connected.value = true\n // The server subscribes only now: a write between a caller's REST load (or\n // the close of the previous stream) and this point sent no event, so every caller refetches.\n dispatch(RESYNC)\n failures = 0\n }\n stream.onmessage = (message) => {\n dispatch(JSON.parse(message.data) as RealtimeEvent)\n }\n stream.onerror = () => {\n // EventSource retries a dropped connection itself but gives up after an\n // HTTP error, so one backoff path handles both.\n if (source !== stream) return\n close()\n // A topic change during the wait does not shorten it: the retry reads the topics when it runs.\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = null\n retryTimer = setTimeout(() => {\n retryTimer = null\n sync()\n }, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures))\n failures += 1\n }\n}\n\nfunction sync(): void {\n syncTimer = null\n const topics = [...new Set([...listeners].flatMap(listener => listener.topics()))].sort().join(',')\n if (source && topics === sourceTopics) return\n close()\n if (topics) open(topics)\n}\n\nfunction schedule(): void {\n if (retryTimer !== null) return\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS)\n}\n\n/**\n * Subscribe to topics of the platform's realtime stream.\n *\n * Every caller in a tab shares one `EventSource`, opened on the union of\n * their topics and reopened when that union changes. `handler` receives the\n * write events of the caller's own topics (not `presence.changed`, which goes\n * to `usePresence`) plus every `resync`: one comes from the\n * server when this tab fell behind, and one follows each open of the stream:\n * the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for\n * a changed topic union. On `resync`, refetch. The union of all callers' topics\n * in a tab may hold at most 50 topics; the platform refuses a larger stream.\n *\n * The stream authenticates with the session cookie, so it works in the\n * platform and in plugin frontends served by it. It exists only when the\n * platform's `realtime` feature flag is on; check that flag first.\n *\n * A plugin topic reaches a caller whose role may use the plugin; an\n * experiment plugin topic also needs a caller who may view the experiment.\n *\n * @example\n * useRealtime(() => [`experiment:${id.value}`], () => refetch())\n * @example\n * useRealtime(['plugin:drp'], (event) => {\n * if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress\n * })\n */\nexport function useRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n): UseRealtimeReturn {\n return subscribeRealtime(topics, handler, false)\n}\n\n/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */\nexport function currentPresence(topic: string): RealtimeEvent | undefined {\n return presence.get(topic)\n}\n\n/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */\nexport function subscribeRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n withPresence: boolean,\n): UseRealtimeReturn {\n const listener: Listener = { topics: () => toValue(topics), handler, presence: withPresence }\n listeners.add(listener)\n const stopWatch = watch(() => listener.topics().join(','), () => schedule())\n schedule()\n\n function stop(): void {\n stopWatch()\n if (listeners.delete(listener)) schedule()\n }\n\n if (getCurrentScope()) onScopeDispose(stop)\n return { connected: readonly(connected), stop }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useRealtime-B58Lo-Tm.js","names":[],"sources":["../src/components/JobsStatusTray.vue","../src/components/JobsStatusTray.vue","../src/composables/useRealtime.ts"],"sourcesContent":["<script setup lang=\"ts\">\n/**\n * Compact, non-modal job panel under the top bar, driven by a job center the parent owns.\n *\n * The parent passes the center's `topBarJob`, `completedCount` and `handleTopBarClick`\n * to `AppTopBar`; its run slot opens this panel. `PluginWorkspaceView` does this for you\n * when its spec sets `jobs`.\n *\n * @example Recommended\n * ```vue\n * <AppTopBar :job=\"center.topBarJob.value\" :jobs-done=\"center.completedCount.value\"\n * @job-click=\"center.handleTopBarClick(openResult)\" />\n * <JobsStatusTray :center=\"center\" @select=\"openResult\" />\n * ```\n */\nimport { computed, nextTick, onBeforeUnmount, ref, watch, type Component } from 'vue'\nimport {\n CircleAlert,\n CircleCheck,\n CircleSlash,\n CircleX,\n Clock,\n LoaderCircle,\n RefreshCw,\n Square,\n Trash2,\n X,\n} from 'lucide-vue-next'\n\nimport { isActiveJobStatus, jobStatusLabel } from '../jobs'\nimport type { UseJobCenterReturn } from '../composables/useJobCenter'\nimport type { JobState, JobStatus } from '../types/jobs'\nimport ConfirmDialog from './ConfirmDialog.vue'\n\ntype ConfirmAction = 'cancel' | 'delete' | 'clear'\n\ninterface Props {\n /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */\n center: UseJobCenterReturn\n title?: string\n teleportTo?: string | HTMLElement | false\n /** Ask before Cancel, Delete and Clear (`true`), or before the named ones only. Default off. */\n confirm?: boolean | Partial<Record<ConfirmAction, boolean>>\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n title: 'Jobs',\n teleportTo: 'body',\n confirm: false,\n})\n\nconst emit = defineEmits<{\n select: [job: JobState]\n error: [message: string]\n}>()\n\ndefineSlots<{\n /** Extra per-row actions, rendered before the built-in Cancel / Delete. */\n 'job-actions'?: (props: { job: JobState }) => unknown\n /** Row title. Default: the job name. */\n 'job-title'?: (props: { job: JobState }) => unknown\n /** Detail line under the title. Default: the error, progress message or status. */\n 'job-detail'?: (props: { job: JobState }) => unknown\n}>()\n\nconst STATUS_ICONS: Record<JobStatus, Component> = {\n running: LoaderCircle,\n awaiting_input: CircleAlert,\n queued: Clock,\n completed: CircleCheck,\n failed: CircleX,\n cancelled: CircleSlash,\n}\n\nconst panelRef = ref<HTMLElement | null>(null)\nconst teleportTarget = computed(() => props.teleportTo || 'body')\nconst tray = props.center\nconst titleId = `${tray.panelId}-title`\nconst rows = (jobs: JobState[]) => jobs.map(job => ({ job, view: tray.rowView(job) }))\n// A job waiting for input is the only one that needs a person: pin it first.\nconst activeRows = computed(() => {\n const active = rows(tray.activeJobs.value)\n return [\n ...active.filter(row => row.view.status === 'awaiting_input'),\n ...active.filter(row => row.view.status !== 'awaiting_input'),\n ]\n})\nconst sections = computed(() => [\n { key: 'active', title: 'Active', rows: activeRows.value },\n { key: 'finished', title: 'Finished', rows: rows(tray.historyJobs.value) },\n].filter(section => section.rows.length > 0))\nconst headCount = computed(() => {\n const parts = [\n [tray.activeJobs.value.length, 'active'],\n [tray.completedCount.value, 'done'],\n [tray.historyJobs.value.filter(job => job.status === 'failed').length, 'failed'],\n ].filter(([n]) => n).map(([n, word]) => `${n} ${word}`)\n return parts.length ? parts.join(' · ') : 'No jobs'\n})\nconst canClear = computed(() => (\n tray.deletableJobs.value.length > 0\n && tray.canClearFinished.value\n))\n\n// Escape returns focus to the element that opened the panel (the top-bar chip).\n// The panel is non-modal, so Escape is heard on the document while it is open.\n// An Escape that an inner control handled, or that belongs to a modal dialog\n// above the tray (its own confirm dialog too), leaves the tray open.\nlet opener: HTMLElement | null = null\nfunction onDocumentKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape' || event.defaultPrevented || confirmOpen.value) return\n if (event.target instanceof Element && event.target.closest('[aria-modal=\"true\"]')) return\n closeTray()\n}\nwatch(tray.isOpen, async (open) => {\n if (!open) {\n document.removeEventListener('keydown', onDocumentKeydown)\n return\n }\n opener = document.activeElement instanceof HTMLElement ? document.activeElement : null\n document.addEventListener('keydown', onDocumentKeydown)\n await nextTick()\n panelRef.value?.focus()\n}, { flush: 'sync' })\nonBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))\n\n// A focused row that leaves the list (an action or a refresh) drops focus to body:\n// note the focused element before the update and keep focus in the panel after it.\nlet focusedInPanel: Element | null = null\nwatch(tray.jobs, () => {\n const focused = document.activeElement\n focusedInPanel = focused && panelRef.value?.contains(focused) ? focused : null\n}, { flush: 'pre' })\nwatch(tray.jobs, () => {\n if (tray.isOpen.value && focusedInPanel && !focusedInPanel.isConnected) panelRef.value?.focus()\n focusedInPanel = null\n}, { flush: 'post' })\n\nfunction closeTray(): void {\n tray.close()\n // Safari leaves a clicked button unfocused (opener = body), and the chip node is\n // replaced when it switches between job chip and idle button: fall back to the\n // top-bar jobs control.\n const target = opener?.isConnected && opener !== document.body\n ? opener\n : document.querySelector<HTMLElement>('.mint-topbar__job')\n target?.focus()\n}\n\nfunction formatTime(value: string | null): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime())\n ? ''\n : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\n/** Right-hand value: the percent while a job moves, else when it ended or started. */\nfunction jobValue(job: JobState, percent: number | null): string {\n if (percent != null) return `${percent}%`\n return formatTime(job.completed_at || job.started_at || job.created_at)\n}\n\n// Confirmation: the action waits for the dialog; it runs on the live job.\n// `ids`: the finished jobs that the user confirmed to clear.\nconst pending = ref<{ action: ConfirmAction; job?: JobState; ids?: string[] } | null>(null)\nconst confirmOpen = ref(false)\n// Set on request and kept, so the dialog text stays while it fades out.\nconst confirmText = ref<{ title: string; message: string; confirmLabel: string; cancelLabel: string } | null>(null)\nconst confirmEnabled = computed(() => props.confirm === true\n || (typeof props.confirm === 'object' && Object.values(props.confirm).some(Boolean)))\n\nfunction textFor(action: ConfirmAction, job?: JobState) {\n if (action === 'clear') {\n const count = tray.deletableJobs.value.length\n return {\n title: 'Clear finished jobs?',\n message: `${count} finished ${count === 1 ? 'job' : 'jobs'} and their stored results are deleted.`,\n confirmLabel: 'Clear jobs',\n cancelLabel: 'Cancel',\n }\n }\n return action === 'cancel'\n ? { title: 'Cancel job?', message: `${job?.name} stops. Its partial results are lost.`, confirmLabel: 'Cancel job', cancelLabel: 'Keep running' }\n : { title: 'Delete job?', message: `${job?.name} and its stored result are deleted.`, confirmLabel: 'Delete job', cancelLabel: 'Cancel' }\n}\n\nfunction needsConfirm(action: ConfirmAction): boolean {\n return props.confirm === true || (typeof props.confirm === 'object' && props.confirm[action] === true)\n}\n\nfunction request(action: ConfirmAction, job?: JobState): void {\n if (!needsConfirm(action)) {\n void run(action, job)\n return\n }\n pending.value = { action, job, ids: action === 'clear' ? deletableIds() : undefined }\n confirmText.value = textFor(action, job)\n confirmOpen.value = true\n}\n\nfunction deletableIds(): string[] {\n return tray.deletableJobs.value.map(job => job.job_id).sort()\n}\n\n// A new source is a new job list: the confirmed target is gone.\nwatch(tray.source, () => onConfirmOpen(false))\n\nfunction onConfirmOpen(open: boolean): void {\n confirmOpen.value = open\n if (!open) pending.value = null\n}\n\nasync function confirmPending(): Promise<void> {\n // A second Confirm click while the dialog fades out does nothing.\n const current = pending.value\n if (!confirmOpen.value || !current) return\n if (current.action === 'clear') {\n // Polling changed the finished jobs: ask again with the new count.\n const ids = deletableIds()\n if (ids.join() !== current.ids?.join()) {\n pending.value = { ...current, ids }\n confirmText.value = textFor('clear')\n return\n }\n }\n confirmOpen.value = false\n pending.value = null\n if (current.action === 'clear') return clearFinished()\n // Polling can end or remove the job while the dialog is open: then do nothing.\n const live = tray.jobs.value.find(job => job.job_id === current.job?.job_id)\n const allowed = live && (current.action === 'cancel'\n ? isActiveJobStatus(live.status) && live.can_cancel\n : live.can_delete)\n if (allowed) await run(current.action, live)\n}\n\nfunction run(action: ConfirmAction, job?: JobState): Promise<void> {\n if (action === 'clear') return clearFinished()\n return action === 'cancel' ? cancelJob(job!) : deleteJob(job!)\n}\n\nasync function cancelJob(job: JobState): Promise<void> {\n const succeeded = await tray.cancelJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function deleteJob(job: JobState): Promise<void> {\n const succeeded = await tray.deleteJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function clearFinished(): Promise<void> {\n const succeeded = await tray.clearFinished()\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTarget\" :disabled=\"teleportTo === false\">\n <div class=\"mint-jobs-tray\">\n <!-- Clicks outside close the panel; the page is not dimmed. -->\n <div\n v-if=\"tray.isOpen.value\"\n class=\"mint-jobs-tray__scrim\"\n aria-hidden=\"true\"\n @click=\"tray.close()\"\n />\n\n <Transition name=\"mint-jobs-tray-fade\">\n <section\n v-if=\"tray.isOpen.value\"\n :id=\"tray.panelId\"\n ref=\"panelRef\"\n class=\"mint-jobs-tray__panel\"\n :aria-labelledby=\"titleId\"\n tabindex=\"-1\"\n >\n <header class=\"mint-jobs-tray__head\">\n <h2 :id=\"titleId\" class=\"mint-jobs-tray__title\">{{ title }}</h2>\n <span class=\"mint-jobs-tray__head-count\">{{ headCount }}</span>\n <button\n v-if=\"tray.canRefresh.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Refresh jobs\"\n title=\"Refresh jobs\"\n :disabled=\"tray.isLoading.value\"\n @click=\"tray.refresh\"\n >\n <RefreshCw :size=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Close jobs\"\n title=\"Close\"\n @click=\"closeTray\"\n >\n <X :size=\"14\" aria-hidden=\"true\" />\n </button>\n </header>\n\n <p v-if=\"tray.error.value\" class=\"mint-jobs-tray__error\" role=\"alert\">\n {{ tray.error.value }}\n </p>\n\n <div class=\"mint-jobs-tray__body\">\n <p v-if=\"sections.length === 0\" class=\"mint-jobs-tray__empty\">No jobs yet</p>\n\n <template v-for=\"section in sections\" :key=\"section.key\">\n <div class=\"mint-jobs-tray__section\" :data-section=\"section.key\">\n {{ section.title }}\n <span class=\"mint-jobs-tray__section-count\">· {{ section.rows.length }}</span>\n <button\n v-if=\"section.key === 'finished' && canClear\"\n type=\"button\"\n class=\"mint-jobs-tray__section-action\"\n @click=\"request('clear')\"\n >\n Clear\n </button>\n </div>\n\n <div\n v-for=\"{ job, view } in section.rows\"\n :key=\"job.job_id\"\n class=\"mint-jobs-tray__row\"\n :class=\"{ 'mint-jobs-tray__row--awaiting-input': view.status === 'awaiting_input' }\"\n :data-status=\"job.status\"\n >\n <!-- data-status is the real job status; the row view only changes how it looks. -->\n <component\n :is=\"STATUS_ICONS[view.status]\"\n :size=\"15\"\n :class=\"['mint-jobs-tray__status', `mint-jobs-tray__status--${view.status.replace('_', '-')}`]\"\n role=\"img\"\n :aria-label=\"jobStatusLabel(view.status)\"\n />\n <button type=\"button\" class=\"mint-jobs-tray__row-title\" @click=\"emit('select', job)\">\n <slot name=\"job-title\" :job=\"job\">{{ job.name }}</slot>\n </button>\n <span class=\"mint-jobs-tray__tools\">\n <span class=\"mint-jobs-tray__value\">{{ jobValue(job, view.percent) }}</span>\n <slot name=\"job-actions\" :job=\"job\" />\n <button\n v-if=\"isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger\"\n :aria-label=\"`Cancel ${job.name}`\"\n title=\"Cancel\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('cancel', job)\"\n >\n <Square :size=\"12\" aria-hidden=\"true\" />\n </button>\n <button\n v-if=\"job.can_delete && tray.canDelete.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n :aria-label=\"`Delete ${job.name}`\"\n title=\"Delete\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('delete', job)\"\n >\n <Trash2 :size=\"14\" aria-hidden=\"true\" />\n </button>\n </span>\n <div\n v-if=\"view.percent != null\"\n class=\"mint-jobs-tray__progress\"\n role=\"progressbar\"\n :aria-label=\"`${job.name} progress`\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n :aria-valuenow=\"view.percent\"\n >\n <span :style=\"{ width: `${view.percent}%` }\" />\n </div>\n <span\n class=\"mint-jobs-tray__detail\"\n :class=\"{ 'mint-jobs-tray__detail--error': job.status === 'failed' }\"\n >\n <slot name=\"job-detail\" :job=\"job\">{{ view.message }}</slot>\n </span>\n </div>\n </template>\n </div>\n </section>\n </Transition>\n\n <ConfirmDialog\n v-if=\"confirmEnabled\"\n :open=\"confirmOpen\"\n tone=\"error\"\n :title=\"confirmText?.title\"\n :confirm-label=\"confirmText?.confirmLabel\"\n :cancel-label=\"confirmText?.cancelLabel\"\n @update:open=\"onConfirmOpen\"\n @confirm=\"confirmPending\"\n >\n <!-- The marker class lifts the dialog above the tray (jobs-status-tray.css). -->\n <p class=\"mint-confirm__message mint-jobs-tray__confirm\">{{ confirmText?.message }}</p>\n </ConfirmDialog>\n </div>\n </Teleport>\n</template>\n","<script setup lang=\"ts\">\n/**\n * Compact, non-modal job panel under the top bar, driven by a job center the parent owns.\n *\n * The parent passes the center's `topBarJob`, `completedCount` and `handleTopBarClick`\n * to `AppTopBar`; its run slot opens this panel. `PluginWorkspaceView` does this for you\n * when its spec sets `jobs`.\n *\n * @example Recommended\n * ```vue\n * <AppTopBar :job=\"center.topBarJob.value\" :jobs-done=\"center.completedCount.value\"\n * @job-click=\"center.handleTopBarClick(openResult)\" />\n * <JobsStatusTray :center=\"center\" @select=\"openResult\" />\n * ```\n */\nimport { computed, nextTick, onBeforeUnmount, ref, watch, type Component } from 'vue'\nimport {\n CircleAlert,\n CircleCheck,\n CircleSlash,\n CircleX,\n Clock,\n LoaderCircle,\n RefreshCw,\n Square,\n Trash2,\n X,\n} from 'lucide-vue-next'\n\nimport { isActiveJobStatus, jobStatusLabel } from '../jobs'\nimport type { UseJobCenterReturn } from '../composables/useJobCenter'\nimport type { JobState, JobStatus } from '../types/jobs'\nimport ConfirmDialog from './ConfirmDialog.vue'\n\ntype ConfirmAction = 'cancel' | 'delete' | 'clear'\n\ninterface Props {\n /** Job center from `useJobCenter()`; the parent owns it and opens the panel through it. */\n center: UseJobCenterReturn\n title?: string\n teleportTo?: string | HTMLElement | false\n /** Ask before Cancel, Delete and Clear (`true`), or before the named ones only. Default off. */\n confirm?: boolean | Partial<Record<ConfirmAction, boolean>>\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n title: 'Jobs',\n teleportTo: 'body',\n confirm: false,\n})\n\nconst emit = defineEmits<{\n select: [job: JobState]\n error: [message: string]\n}>()\n\ndefineSlots<{\n /** Extra per-row actions, rendered before the built-in Cancel / Delete. */\n 'job-actions'?: (props: { job: JobState }) => unknown\n /** Row title. Default: the job name. */\n 'job-title'?: (props: { job: JobState }) => unknown\n /** Detail line under the title. Default: the error, progress message or status. */\n 'job-detail'?: (props: { job: JobState }) => unknown\n}>()\n\nconst STATUS_ICONS: Record<JobStatus, Component> = {\n running: LoaderCircle,\n awaiting_input: CircleAlert,\n queued: Clock,\n completed: CircleCheck,\n failed: CircleX,\n cancelled: CircleSlash,\n}\n\nconst panelRef = ref<HTMLElement | null>(null)\nconst teleportTarget = computed(() => props.teleportTo || 'body')\nconst tray = props.center\nconst titleId = `${tray.panelId}-title`\nconst rows = (jobs: JobState[]) => jobs.map(job => ({ job, view: tray.rowView(job) }))\n// A job waiting for input is the only one that needs a person: pin it first.\nconst activeRows = computed(() => {\n const active = rows(tray.activeJobs.value)\n return [\n ...active.filter(row => row.view.status === 'awaiting_input'),\n ...active.filter(row => row.view.status !== 'awaiting_input'),\n ]\n})\nconst sections = computed(() => [\n { key: 'active', title: 'Active', rows: activeRows.value },\n { key: 'finished', title: 'Finished', rows: rows(tray.historyJobs.value) },\n].filter(section => section.rows.length > 0))\nconst headCount = computed(() => {\n const parts = [\n [tray.activeJobs.value.length, 'active'],\n [tray.completedCount.value, 'done'],\n [tray.historyJobs.value.filter(job => job.status === 'failed').length, 'failed'],\n ].filter(([n]) => n).map(([n, word]) => `${n} ${word}`)\n return parts.length ? parts.join(' · ') : 'No jobs'\n})\nconst canClear = computed(() => (\n tray.deletableJobs.value.length > 0\n && tray.canClearFinished.value\n))\n\n// Escape returns focus to the element that opened the panel (the top-bar chip).\n// The panel is non-modal, so Escape is heard on the document while it is open.\n// An Escape that an inner control handled, or that belongs to a modal dialog\n// above the tray (its own confirm dialog too), leaves the tray open.\nlet opener: HTMLElement | null = null\nfunction onDocumentKeydown(event: KeyboardEvent): void {\n if (event.key !== 'Escape' || event.defaultPrevented || confirmOpen.value) return\n if (event.target instanceof Element && event.target.closest('[aria-modal=\"true\"]')) return\n closeTray()\n}\nwatch(tray.isOpen, async (open) => {\n if (!open) {\n document.removeEventListener('keydown', onDocumentKeydown)\n return\n }\n opener = document.activeElement instanceof HTMLElement ? document.activeElement : null\n document.addEventListener('keydown', onDocumentKeydown)\n await nextTick()\n panelRef.value?.focus()\n}, { flush: 'sync' })\nonBeforeUnmount(() => document.removeEventListener('keydown', onDocumentKeydown))\n\n// A focused row that leaves the list (an action or a refresh) drops focus to body:\n// note the focused element before the update and keep focus in the panel after it.\nlet focusedInPanel: Element | null = null\nwatch(tray.jobs, () => {\n const focused = document.activeElement\n focusedInPanel = focused && panelRef.value?.contains(focused) ? focused : null\n}, { flush: 'pre' })\nwatch(tray.jobs, () => {\n if (tray.isOpen.value && focusedInPanel && !focusedInPanel.isConnected) panelRef.value?.focus()\n focusedInPanel = null\n}, { flush: 'post' })\n\nfunction closeTray(): void {\n tray.close()\n // Safari leaves a clicked button unfocused (opener = body), and the chip node is\n // replaced when it switches between job chip and idle button: fall back to the\n // top-bar jobs control.\n const target = opener?.isConnected && opener !== document.body\n ? opener\n : document.querySelector<HTMLElement>('.mint-topbar__job')\n target?.focus()\n}\n\nfunction formatTime(value: string | null): string {\n if (!value) return ''\n const date = new Date(value)\n return Number.isNaN(date.getTime())\n ? ''\n : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\n/** Right-hand value: the percent while a job moves, else when it ended or started. */\nfunction jobValue(job: JobState, percent: number | null): string {\n if (percent != null) return `${percent}%`\n return formatTime(job.completed_at || job.started_at || job.created_at)\n}\n\n// Confirmation: the action waits for the dialog; it runs on the live job.\n// `ids`: the finished jobs that the user confirmed to clear.\nconst pending = ref<{ action: ConfirmAction; job?: JobState; ids?: string[] } | null>(null)\nconst confirmOpen = ref(false)\n// Set on request and kept, so the dialog text stays while it fades out.\nconst confirmText = ref<{ title: string; message: string; confirmLabel: string; cancelLabel: string } | null>(null)\nconst confirmEnabled = computed(() => props.confirm === true\n || (typeof props.confirm === 'object' && Object.values(props.confirm).some(Boolean)))\n\nfunction textFor(action: ConfirmAction, job?: JobState) {\n if (action === 'clear') {\n const count = tray.deletableJobs.value.length\n return {\n title: 'Clear finished jobs?',\n message: `${count} finished ${count === 1 ? 'job' : 'jobs'} and their stored results are deleted.`,\n confirmLabel: 'Clear jobs',\n cancelLabel: 'Cancel',\n }\n }\n return action === 'cancel'\n ? { title: 'Cancel job?', message: `${job?.name} stops. Its partial results are lost.`, confirmLabel: 'Cancel job', cancelLabel: 'Keep running' }\n : { title: 'Delete job?', message: `${job?.name} and its stored result are deleted.`, confirmLabel: 'Delete job', cancelLabel: 'Cancel' }\n}\n\nfunction needsConfirm(action: ConfirmAction): boolean {\n return props.confirm === true || (typeof props.confirm === 'object' && props.confirm[action] === true)\n}\n\nfunction request(action: ConfirmAction, job?: JobState): void {\n if (!needsConfirm(action)) {\n void run(action, job)\n return\n }\n pending.value = { action, job, ids: action === 'clear' ? deletableIds() : undefined }\n confirmText.value = textFor(action, job)\n confirmOpen.value = true\n}\n\nfunction deletableIds(): string[] {\n return tray.deletableJobs.value.map(job => job.job_id).sort()\n}\n\n// A new source is a new job list: the confirmed target is gone.\nwatch(tray.source, () => onConfirmOpen(false))\n\nfunction onConfirmOpen(open: boolean): void {\n confirmOpen.value = open\n if (!open) pending.value = null\n}\n\nasync function confirmPending(): Promise<void> {\n // A second Confirm click while the dialog fades out does nothing.\n const current = pending.value\n if (!confirmOpen.value || !current) return\n if (current.action === 'clear') {\n // Polling changed the finished jobs: ask again with the new count.\n const ids = deletableIds()\n if (ids.join() !== current.ids?.join()) {\n pending.value = { ...current, ids }\n confirmText.value = textFor('clear')\n return\n }\n }\n confirmOpen.value = false\n pending.value = null\n if (current.action === 'clear') return clearFinished()\n // Polling can end or remove the job while the dialog is open: then do nothing.\n const live = tray.jobs.value.find(job => job.job_id === current.job?.job_id)\n const allowed = live && (current.action === 'cancel'\n ? isActiveJobStatus(live.status) && live.can_cancel\n : live.can_delete)\n if (allowed) await run(current.action, live)\n}\n\nfunction run(action: ConfirmAction, job?: JobState): Promise<void> {\n if (action === 'clear') return clearFinished()\n return action === 'cancel' ? cancelJob(job!) : deleteJob(job!)\n}\n\nasync function cancelJob(job: JobState): Promise<void> {\n const succeeded = await tray.cancelJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function deleteJob(job: JobState): Promise<void> {\n const succeeded = await tray.deleteJob(job)\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n\nasync function clearFinished(): Promise<void> {\n const succeeded = await tray.clearFinished()\n if (!succeeded && tray.error.value) emit('error', tray.error.value)\n}\n</script>\n\n<template>\n <Teleport :to=\"teleportTarget\" :disabled=\"teleportTo === false\">\n <div class=\"mint-jobs-tray\">\n <!-- Clicks outside close the panel; the page is not dimmed. -->\n <div\n v-if=\"tray.isOpen.value\"\n class=\"mint-jobs-tray__scrim\"\n aria-hidden=\"true\"\n @click=\"tray.close()\"\n />\n\n <Transition name=\"mint-jobs-tray-fade\">\n <section\n v-if=\"tray.isOpen.value\"\n :id=\"tray.panelId\"\n ref=\"panelRef\"\n class=\"mint-jobs-tray__panel\"\n :aria-labelledby=\"titleId\"\n tabindex=\"-1\"\n >\n <header class=\"mint-jobs-tray__head\">\n <h2 :id=\"titleId\" class=\"mint-jobs-tray__title\">{{ title }}</h2>\n <span class=\"mint-jobs-tray__head-count\">{{ headCount }}</span>\n <button\n v-if=\"tray.canRefresh.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Refresh jobs\"\n title=\"Refresh jobs\"\n :disabled=\"tray.isLoading.value\"\n @click=\"tray.refresh\"\n >\n <RefreshCw :size=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n aria-label=\"Close jobs\"\n title=\"Close\"\n @click=\"closeTray\"\n >\n <X :size=\"14\" aria-hidden=\"true\" />\n </button>\n </header>\n\n <p v-if=\"tray.error.value\" class=\"mint-jobs-tray__error\" role=\"alert\">\n {{ tray.error.value }}\n </p>\n\n <div class=\"mint-jobs-tray__body\">\n <p v-if=\"sections.length === 0\" class=\"mint-jobs-tray__empty\">No jobs yet</p>\n\n <template v-for=\"section in sections\" :key=\"section.key\">\n <div class=\"mint-jobs-tray__section\" :data-section=\"section.key\">\n {{ section.title }}\n <span class=\"mint-jobs-tray__section-count\">· {{ section.rows.length }}</span>\n <button\n v-if=\"section.key === 'finished' && canClear\"\n type=\"button\"\n class=\"mint-jobs-tray__section-action\"\n @click=\"request('clear')\"\n >\n Clear\n </button>\n </div>\n\n <div\n v-for=\"{ job, view } in section.rows\"\n :key=\"job.job_id\"\n class=\"mint-jobs-tray__row\"\n :class=\"{ 'mint-jobs-tray__row--awaiting-input': view.status === 'awaiting_input' }\"\n :data-status=\"job.status\"\n >\n <!-- data-status is the real job status; the row view only changes how it looks. -->\n <component\n :is=\"STATUS_ICONS[view.status]\"\n :size=\"15\"\n :class=\"['mint-jobs-tray__status', `mint-jobs-tray__status--${view.status.replace('_', '-')}`]\"\n role=\"img\"\n :aria-label=\"jobStatusLabel(view.status)\"\n />\n <button type=\"button\" class=\"mint-jobs-tray__row-title\" @click=\"emit('select', job)\">\n <slot name=\"job-title\" :job=\"job\">{{ job.name }}</slot>\n </button>\n <span class=\"mint-jobs-tray__tools\">\n <span class=\"mint-jobs-tray__value\">{{ jobValue(job, view.percent) }}</span>\n <slot name=\"job-actions\" :job=\"job\" />\n <button\n v-if=\"isActiveJobStatus(job.status) && job.can_cancel && tray.canCancel.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button mint-jobs-tray__icon-button--danger\"\n :aria-label=\"`Cancel ${job.name}`\"\n title=\"Cancel\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('cancel', job)\"\n >\n <Square :size=\"12\" aria-hidden=\"true\" />\n </button>\n <button\n v-if=\"job.can_delete && tray.canDelete.value\"\n type=\"button\"\n class=\"mint-jobs-tray__icon-button\"\n :aria-label=\"`Delete ${job.name}`\"\n title=\"Delete\"\n :disabled=\"tray.isPending(job)\"\n @click=\"request('delete', job)\"\n >\n <Trash2 :size=\"14\" aria-hidden=\"true\" />\n </button>\n </span>\n <div\n v-if=\"view.percent != null\"\n class=\"mint-jobs-tray__progress\"\n role=\"progressbar\"\n :aria-label=\"`${job.name} progress`\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n :aria-valuenow=\"view.percent\"\n >\n <span :style=\"{ width: `${view.percent}%` }\" />\n </div>\n <span\n class=\"mint-jobs-tray__detail\"\n :class=\"{ 'mint-jobs-tray__detail--error': job.status === 'failed' }\"\n >\n <slot name=\"job-detail\" :job=\"job\">{{ view.message }}</slot>\n </span>\n </div>\n </template>\n </div>\n </section>\n </Transition>\n\n <ConfirmDialog\n v-if=\"confirmEnabled\"\n :open=\"confirmOpen\"\n tone=\"error\"\n :title=\"confirmText?.title\"\n :confirm-label=\"confirmText?.confirmLabel\"\n :cancel-label=\"confirmText?.cancelLabel\"\n @update:open=\"onConfirmOpen\"\n @confirm=\"confirmPending\"\n >\n <!-- The marker class lifts the dialog above the tray (jobs-status-tray.css). -->\n <p class=\"mint-confirm__message mint-jobs-tray__confirm\">{{ confirmText?.message }}</p>\n </ConfirmDialog>\n </div>\n </Teleport>\n</template>\n","import {\n getCurrentScope,\n onScopeDispose,\n readonly,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref,\n} from 'vue'\n\n/**\n * One event of the platform's realtime stream (`GET /api/events`).\n *\n * Platform events carry no entity data: refetch the entity through REST.\n * `type` is `resync` (refetch everything you show), the audit action of a\n * committed write, such as `experiment.update` or `analysis.artifact_create`,\n * or the `\"<plugin>.<event>\"` name of a plugin event, which carries `payload`.\n * Only `usePresence` receives `presence.changed`.\n */\nexport interface RealtimeEvent {\n type: string\n id?: string\n /**\n * `experiment:{id}`, `project:{id}`, `experiments`, `user:{id}`, or a plugin's\n * `plugin:{name}` / `plugin:{name}:experiment:{id}`; absent on `resync`.\n */\n topic?: string\n /** A plugin event: the JSON payload the plugin published (at most 16 KiB). */\n payload?: unknown\n /** Changed fields of an update, when the write names them. */\n fields?: string[] | null\n actor_id?: string | null\n request_id?: string | null\n at?: string\n /** `presence.changed`: the topic's viewers (at most 20, `editing` first). */\n viewers?: { user_id: string, activity: string }[]\n /** `presence.changed`: the number of viewers, also those beyond `viewers`. */\n count?: number\n}\n\nexport type RealtimeHandler = (event: RealtimeEvent) => void\n\nexport interface UseRealtimeReturn {\n /** True while the shared stream is open. */\n connected: Readonly<Ref<boolean>>\n /** Unsubscribe; also runs when the calling component or effect scope is disposed. */\n stop: () => void\n}\n\nconst EVENTS_URL = '/api/events'\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\n/** Merges the subscriptions of components that mount together into one connection. */\nconst TOPIC_DEBOUNCE_MS = 50\nconst RESYNC: RealtimeEvent = { type: 'resync' }\n\ninterface Listener {\n topics: () => string[]\n handler: RealtimeHandler\n /** Receives `presence.changed`; a write listener would refetch on each viewer change. */\n presence: boolean\n}\n\nconst listeners = new Set<Listener>()\n/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */\nconst presence = new Map<string, RealtimeEvent>()\nconst connected = ref(false)\nlet source: EventSource | null = null\nlet sourceTopics = ''\nlet failures = 0\nlet syncTimer: ReturnType<typeof setTimeout> | null = null\nlet retryTimer: ReturnType<typeof setTimeout> | null = null\n\nfunction dispatch(event: RealtimeEvent): void {\n const isPresence = event.type === 'presence.changed'\n if (isPresence && event.topic) presence.set(event.topic, event)\n for (const listener of [...listeners]) {\n if (isPresence && !listener.presence) continue\n if (event.type === 'resync' || (event.topic && listener.topics().includes(event.topic))) {\n listener.handler(event)\n }\n }\n}\n\nfunction close(): void {\n source?.close()\n source = null\n sourceTopics = ''\n presence.clear()\n connected.value = false\n}\n\nfunction open(topics: string): void {\n const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}`)\n source = stream\n sourceTopics = topics\n stream.onopen = () => {\n connected.value = true\n // The server subscribes only now: a write between a caller's REST load (or\n // the close of the previous stream) and this point sent no event, so every caller refetches.\n dispatch(RESYNC)\n failures = 0\n }\n stream.onmessage = (message) => {\n dispatch(JSON.parse(message.data) as RealtimeEvent)\n }\n stream.onerror = () => {\n // EventSource retries a dropped connection itself but gives up after an\n // HTTP error, so one backoff path handles both.\n if (source !== stream) return\n close()\n // A topic change during the wait does not shorten it: the retry reads the topics when it runs.\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = null\n retryTimer = setTimeout(() => {\n retryTimer = null\n sync()\n }, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures))\n failures += 1\n }\n}\n\nfunction sync(): void {\n syncTimer = null\n const topics = [...new Set([...listeners].flatMap(listener => listener.topics()))].sort().join(',')\n if (source && topics === sourceTopics) return\n close()\n if (topics) open(topics)\n}\n\nfunction schedule(): void {\n if (retryTimer !== null) return\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS)\n}\n\n/**\n * Subscribe to topics of the platform's realtime stream.\n *\n * Every caller in a tab shares one `EventSource`, opened on the union of\n * their topics and reopened when that union changes. `handler` receives the\n * write events of the caller's own topics (not `presence.changed`, which goes\n * to `usePresence`) plus every `resync`: one comes from the\n * server when this tab fell behind, and one follows each open of the stream:\n * the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for\n * a changed topic union. On `resync`, refetch. The union of all callers' topics\n * in a tab may hold at most 50 topics; the platform refuses a larger stream.\n *\n * The stream authenticates with the session cookie, so it works in the\n * platform and in plugin frontends served by it. It exists only when the\n * platform's `realtime` feature flag is on; check that flag first.\n *\n * A plugin topic reaches a caller whose role may use the plugin; an\n * experiment plugin topic also needs a caller who may view the experiment.\n *\n * @example\n * useRealtime(() => [`experiment:${id.value}`], () => refetch())\n * @example\n * useRealtime(['plugin:drp'], (event) => {\n * if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress\n * })\n */\nexport function useRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n): UseRealtimeReturn {\n return subscribeRealtime(topics, handler, false)\n}\n\n/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */\nexport function currentPresence(topic: string): RealtimeEvent | undefined {\n return presence.get(topic)\n}\n\n/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */\nexport function subscribeRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n withPresence: boolean,\n): UseRealtimeReturn {\n const listener: Listener = { topics: () => toValue(topics), handler, presence: withPresence }\n listeners.add(listener)\n const stopWatch = watch(() => listener.topics().join(','), () => schedule())\n schedule()\n\n function stop(): void {\n stopWatch()\n if (listeners.delete(listener)) schedule()\n }\n\n if (getCurrentScope()) onScopeDispose(stop)\n return { connected: readonly(connected), stop }\n}\n"],"mappings":"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"}
@@ -1,9 +1,8 @@
1
- import { a as BaseButton_default, t as useDropdownState } from "./useDropdownState-Bb2N4m40.js";
1
+ import { a as BaseButton_default, t as useDropdownState } from "./useDropdownState-uFIz6Yv8.js";
2
2
  import { r as readErrorMessage } from "./experiment-DyO54FQB.js";
3
- import { E as useMenuKeyboard } from "./records-CR_pYzIP.js";
4
- import { Fragment, Teleport, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onMounted, openBlock, ref, renderList, renderSlot, toDisplayString, unref, watch } from "vue";
3
+ import { E as useMenuKeyboard, W as useFloatingPanel } from "./records-D4aQXyZu.js";
4
+ import { Fragment, Teleport, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onMounted, openBlock, ref, renderList, renderSlot, toDisplayString, unref } from "vue";
5
5
  import { Check, Ellipsis } from "lucide-vue-next";
6
- import { computePosition, flip, offset, shift } from "@floating-ui/dom";
7
6
  //#region src/components/ActionMenu.vue?vue&type=script&setup=true&lang.ts
8
7
  var _hoisted_1 = {
9
8
  key: 0,
@@ -44,16 +43,12 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
44
43
  const props = __props;
45
44
  const emit = __emit;
46
45
  const panelRef = ref(null);
47
- const panelStyle = ref({ visibility: "hidden" });
48
46
  const { isOpen, rootRef, triggerRef, open, close } = useDropdownState({
49
- closeOnScroll: true,
50
47
  insideRefs: [panelRef],
51
- onOpen: () => {
52
- panelStyle.value = { visibility: "hidden" };
53
- emit("open");
54
- },
48
+ onOpen: () => emit("open"),
55
49
  onClose: () => emit("close")
56
50
  });
51
+ const { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: "bottom-end" });
57
52
  onMounted(() => {
58
53
  triggerRef.value = rootRef.value?.querySelector("button") ?? null;
59
54
  });
@@ -61,25 +56,6 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
61
56
  if (!panelRef.value) return [];
62
57
  return Array.from(panelRef.value.querySelectorAll(".mint-action-menu__item:not(:disabled)"));
63
58
  }
64
- async function positionPanel() {
65
- const trigger = triggerRef.value;
66
- const panel = panelRef.value;
67
- if (!trigger || !panel) return;
68
- const position = await computePosition(trigger, panel, {
69
- placement: "bottom-end",
70
- strategy: "fixed",
71
- middleware: [
72
- offset(4),
73
- flip(),
74
- shift({ padding: 8 })
75
- ]
76
- });
77
- panelStyle.value = {
78
- left: `${position.x}px`,
79
- top: `${position.y}px`,
80
- visibility: "visible"
81
- };
82
- }
83
59
  /** `focusFirst` is false when the keyboard composable owns the initial focus. */
84
60
  function openMenu(focusFirst = true) {
85
61
  if (props.disabled || isOpen.value) return;
@@ -102,11 +78,6 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
102
78
  emit("select", item);
103
79
  close();
104
80
  }
105
- watch(isOpen, async (opened) => {
106
- if (!opened) return;
107
- await nextTick();
108
- await positionPanel();
109
- });
110
81
  return (_ctx, _cache) => {
111
82
  return openBlock(), createElementBlock(Fragment, null, [createElementVNode("span", {
112
83
  ref_key: "rootRef",
@@ -137,7 +108,7 @@ var ActionMenu_default = /* @__PURE__ */ defineComponent({
137
108
  ref_key: "panelRef",
138
109
  ref: panelRef,
139
110
  class: "mint-popover mint-action-menu__panel",
140
- style: normalizeStyle(panelStyle.value),
111
+ style: normalizeStyle(unref(panelStyle)),
141
112
  role: "menu",
142
113
  onKeydown: _cache[0] || (_cache[0] = (...args) => unref(handlePanelKeydown) && unref(handlePanelKeydown)(...args))
143
114
  }, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.items, (item, index) => {
@@ -241,4 +212,4 @@ function useRequest(defaultErrorMessage = "Request failed.") {
241
212
  //#endregion
242
213
  export { ActionMenu_default as n, useRequest as t };
243
214
 
244
- //# sourceMappingURL=useRequest-CDkrXNU7.js.map
215
+ //# sourceMappingURL=useRequest-BuDmW8Jc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRequest-BuDmW8Jc.js","names":[],"sources":["../src/components/ActionMenu.vue","../src/components/ActionMenu.vue","../src/composables/useRequest.ts"],"sourcesContent":["<script setup lang=\"ts\">\n/** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */\nimport { Check, Ellipsis } from 'lucide-vue-next'\nimport { nextTick, onMounted, ref } from 'vue'\nimport { useDropdownState } from '../composables/useDropdownState'\nimport { useFloatingPanel } from '../composables/useFloatingPanel'\nimport { useMenuKeyboard } from '../composables/useMenuKeyboard'\nimport type { ActionMenuItem } from '../types/components'\nimport BaseButton from './BaseButton.vue'\n\ninterface Props {\n label: string\n items: ActionMenuItem[]\n disabled?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n disabled: false,\n})\n\nconst emit = defineEmits<{\n select: [item: ActionMenuItem]\n open: []\n close: []\n}>()\n\ndefineSlots<{\n /** Replaces the ellipsis button; `toggle` and `keydown` go on the slotted button. */\n trigger?: (props: { open: boolean; toggle: () => void; keydown: (event: KeyboardEvent) => void }) => unknown\n}>()\n\nconst panelRef = ref<HTMLElement | null>(null)\n\n// Disclosure state (outside-click, Escape, focus return) and placement come from\n// the shared composables; the open menu follows its trigger on scroll.\nconst { isOpen, rootRef, triggerRef, open, close } = useDropdownState({\n insideRefs: [panelRef],\n onOpen: () => emit('open'),\n onClose: () => emit('close'),\n})\nconst { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })\n\n// BaseButton owns the actual <button>, so the trigger element is resolved from\n// the wrapper once mounted rather than bound with a template ref.\nonMounted(() => {\n triggerRef.value = rootRef.value?.querySelector('button') ?? null\n})\n\nfunction menuItems(): HTMLElement[] {\n if (!panelRef.value) return []\n return Array.from(\n panelRef.value.querySelectorAll<HTMLElement>('.mint-action-menu__item:not(:disabled)'),\n )\n}\n\n/** `focusFirst` is false when the keyboard composable owns the initial focus. */\nfunction openMenu(focusFirst = true) {\n if (props.disabled || isOpen.value) return\n open()\n if (focusFirst) {\n void nextTick(() => menuItems()[0]?.focus())\n }\n}\n\nfunction toggleMenu() {\n if (props.disabled) return\n if (isOpen.value) close()\n else openMenu()\n}\n\nconst { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({\n items: menuItems,\n isOpen: () => isOpen.value,\n open: () => openMenu(false),\n close,\n})\n\nfunction handleSelect(item: ActionMenuItem) {\n if (item.disabled) return\n emit('select', item)\n close()\n}\n</script>\n\n<template>\n <span ref=\"rootRef\" class=\"mint-action-menu\">\n <!-- A custom trigger must render one <button> and wire toggle + keydown to it. -->\n <slot name=\"trigger\" :open=\"isOpen\" :toggle=\"toggleMenu\" :keydown=\"handleTriggerKeydown\">\n <BaseButton\n variant=\"secondary\"\n size=\"sm\"\n :icon=\"Ellipsis\"\n :label=\"label\"\n :disabled=\"disabled\"\n aria-haspopup=\"menu\"\n :aria-expanded=\"isOpen\"\n @click=\"toggleMenu\"\n @keydown=\"handleTriggerKeydown\"\n />\n </slot>\n </span>\n\n <Teleport to=\"body\">\n <div\n v-if=\"isOpen\"\n ref=\"panelRef\"\n class=\"mint-popover mint-action-menu__panel\"\n :style=\"panelStyle\"\n role=\"menu\"\n @keydown=\"handlePanelKeydown\"\n >\n <template v-for=\"(item, index) in items\" :key=\"item.id\">\n <div\n v-if=\"item.danger && index > 0 && !items[index - 1].danger\"\n class=\"mint-menu-separator\"\n role=\"separator\"\n />\n <a\n v-if=\"item.href !== undefined && !item.disabled\"\n :href=\"item.href\"\n :target=\"item.target\"\n :rel=\"item.target === '_blank' ? 'noopener noreferrer' : undefined\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n role=\"menuitem\"\n @click=\"handleSelect(item)\"\n >\n {{ item.label }}\n </a>\n <button\n v-else\n type=\"button\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n :role=\"item.checked === undefined ? 'menuitem' : 'menuitemradio'\"\n :aria-checked=\"item.checked\"\n :disabled=\"item.disabled\"\n @click=\"handleSelect(item)\"\n >\n <Check\n v-if=\"item.checked !== undefined\"\n class=\"mint-action-menu__check\"\n :class=\"{ 'mint-action-menu__check--off': !item.checked }\"\n :size=\"14\"\n aria-hidden=\"true\"\n />\n {{ item.label }}\n </button>\n </template>\n </div>\n </Teleport>\n</template>\n","<script setup lang=\"ts\">\n/** Compact overflow actions with an icon-only BaseButton trigger and a viewport-safe teleported menu. */\nimport { Check, Ellipsis } from 'lucide-vue-next'\nimport { nextTick, onMounted, ref } from 'vue'\nimport { useDropdownState } from '../composables/useDropdownState'\nimport { useFloatingPanel } from '../composables/useFloatingPanel'\nimport { useMenuKeyboard } from '../composables/useMenuKeyboard'\nimport type { ActionMenuItem } from '../types/components'\nimport BaseButton from './BaseButton.vue'\n\ninterface Props {\n label: string\n items: ActionMenuItem[]\n disabled?: boolean\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n disabled: false,\n})\n\nconst emit = defineEmits<{\n select: [item: ActionMenuItem]\n open: []\n close: []\n}>()\n\ndefineSlots<{\n /** Replaces the ellipsis button; `toggle` and `keydown` go on the slotted button. */\n trigger?: (props: { open: boolean; toggle: () => void; keydown: (event: KeyboardEvent) => void }) => unknown\n}>()\n\nconst panelRef = ref<HTMLElement | null>(null)\n\n// Disclosure state (outside-click, Escape, focus return) and placement come from\n// the shared composables; the open menu follows its trigger on scroll.\nconst { isOpen, rootRef, triggerRef, open, close } = useDropdownState({\n insideRefs: [panelRef],\n onOpen: () => emit('open'),\n onClose: () => emit('close'),\n})\nconst { style: panelStyle } = useFloatingPanel(triggerRef, panelRef, isOpen, { placement: 'bottom-end' })\n\n// BaseButton owns the actual <button>, so the trigger element is resolved from\n// the wrapper once mounted rather than bound with a template ref.\nonMounted(() => {\n triggerRef.value = rootRef.value?.querySelector('button') ?? null\n})\n\nfunction menuItems(): HTMLElement[] {\n if (!panelRef.value) return []\n return Array.from(\n panelRef.value.querySelectorAll<HTMLElement>('.mint-action-menu__item:not(:disabled)'),\n )\n}\n\n/** `focusFirst` is false when the keyboard composable owns the initial focus. */\nfunction openMenu(focusFirst = true) {\n if (props.disabled || isOpen.value) return\n open()\n if (focusFirst) {\n void nextTick(() => menuItems()[0]?.focus())\n }\n}\n\nfunction toggleMenu() {\n if (props.disabled) return\n if (isOpen.value) close()\n else openMenu()\n}\n\nconst { handleTriggerKeydown, handlePanelKeydown } = useMenuKeyboard({\n items: menuItems,\n isOpen: () => isOpen.value,\n open: () => openMenu(false),\n close,\n})\n\nfunction handleSelect(item: ActionMenuItem) {\n if (item.disabled) return\n emit('select', item)\n close()\n}\n</script>\n\n<template>\n <span ref=\"rootRef\" class=\"mint-action-menu\">\n <!-- A custom trigger must render one <button> and wire toggle + keydown to it. -->\n <slot name=\"trigger\" :open=\"isOpen\" :toggle=\"toggleMenu\" :keydown=\"handleTriggerKeydown\">\n <BaseButton\n variant=\"secondary\"\n size=\"sm\"\n :icon=\"Ellipsis\"\n :label=\"label\"\n :disabled=\"disabled\"\n aria-haspopup=\"menu\"\n :aria-expanded=\"isOpen\"\n @click=\"toggleMenu\"\n @keydown=\"handleTriggerKeydown\"\n />\n </slot>\n </span>\n\n <Teleport to=\"body\">\n <div\n v-if=\"isOpen\"\n ref=\"panelRef\"\n class=\"mint-popover mint-action-menu__panel\"\n :style=\"panelStyle\"\n role=\"menu\"\n @keydown=\"handlePanelKeydown\"\n >\n <template v-for=\"(item, index) in items\" :key=\"item.id\">\n <div\n v-if=\"item.danger && index > 0 && !items[index - 1].danger\"\n class=\"mint-menu-separator\"\n role=\"separator\"\n />\n <a\n v-if=\"item.href !== undefined && !item.disabled\"\n :href=\"item.href\"\n :target=\"item.target\"\n :rel=\"item.target === '_blank' ? 'noopener noreferrer' : undefined\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n role=\"menuitem\"\n @click=\"handleSelect(item)\"\n >\n {{ item.label }}\n </a>\n <button\n v-else\n type=\"button\"\n :class=\"['mint-menu-item mint-action-menu__item', { 'mint-menu-item--danger mint-action-menu__item--danger': item.danger }]\"\n :role=\"item.checked === undefined ? 'menuitem' : 'menuitemradio'\"\n :aria-checked=\"item.checked\"\n :disabled=\"item.disabled\"\n @click=\"handleSelect(item)\"\n >\n <Check\n v-if=\"item.checked !== undefined\"\n class=\"mint-action-menu__check\"\n :class=\"{ 'mint-action-menu__check--off': !item.checked }\"\n :size=\"14\"\n aria-hidden=\"true\"\n />\n {{ item.label }}\n </button>\n </template>\n </div>\n </Teleport>\n</template>\n","import { ref, type Ref } from 'vue'\nimport { readErrorMessage as readSharedErrorMessage } from '../utils/errors'\n\nexport type RequestKind = 'load' | 'save' | 'run'\n\n/**\n * The unit of work `run()` drives.\n *\n * It receives an `AbortSignal` that fires when the run is superseded by a\n * newer `run()` or by `cancel()`. Zero-argument operations stay valid and\n * simply ignore it.\n */\nexport type RequestOperation<T> = (signal: AbortSignal) => Promise<T>\n\nexport interface RequestRunOptions {\n /** Which timestamp a successful run moves: `lastLoadedAt`, `lastSavedAt` or `lastRunAt`. */\n kind?: RequestKind\n errorMessage?: string\n /**\n * Abort the previously started run when this one starts. Default `true`;\n * pass `false` to let overlapping runs finish their own work (the stale\n * guard on shared state still applies either way).\n */\n abortPrevious?: boolean\n /** Caller-owned signal; aborting it aborts this run's signal too. */\n signal?: AbortSignal\n}\n\nexport interface UseRequestReturn {\n /** Whether a request is in progress. */\n isLoading: Ref<boolean>\n /** Message from the last failed request, or null. */\n error: Ref<string | null>\n /** Timestamp of the last successful load operation, or null. */\n lastLoadedAt: Ref<Date | null>\n /** Timestamp of the last successful save operation, or null. */\n lastSavedAt: Ref<Date | null>\n /** Timestamp of the last successful run operation, or null. */\n lastRunAt: Ref<Date | null>\n /** Clear the current error without changing loading or timestamps. */\n clearError: () => void\n /** Convert thrown values into a developer-facing error message. */\n readErrorMessage: (value: unknown, fallback?: string) => string\n /** Store and return a normalized error message. */\n setError: (value: unknown, fallback?: string) => string\n /**\n * Run a request with shared loading/error handling and optional sync marking.\n *\n * ## Stale-response semantics\n *\n * Every call takes a monotonically increasing token. Starting a run\n * supersedes any run started before it, and a superseded run writes **no**\n * shared state when it finally settles: it does not clear `isLoading`, does not\n * set `error`, and does not move `lastLoadedAt` / `lastSavedAt` / `lastRunAt`.\n * Only the newest run owns those.\n *\n * The caller's own promise is untouched by the guard — a superseded run still\n * resolves with its value or rejects with its own error, exactly as the\n * operation did. The guard never invents a rejection, so a caller awaiting\n * `run()` never sees a \"superseded\" error it did not cause.\n *\n * The operation additionally receives an `AbortSignal` that fires when its\n * run is superseded or `cancel()` is called, so an operation that forwards\n * the signal (`fetch(url, { signal })`) stops work instead of racing.\n */\n run: <T>(operation: RequestOperation<T>, options?: RequestRunOptions) => Promise<T>\n /**\n * Abort the in-flight run and drop `isLoading`. The aborted run's completion\n * is treated as stale and writes no state.\n */\n cancel: () => void\n}\n\n/** Loading, error and timestamp state for one request at a time; `run()` drives it. */\nexport function useRequest(\n defaultErrorMessage = 'Request failed.',\n): UseRequestReturn {\n const isLoading = ref(false)\n const error = ref<string | null>(null)\n const lastLoadedAt = ref<Date | null>(null)\n const lastSavedAt = ref<Date | null>(null)\n const lastRunAt = ref<Date | null>(null)\n\n function clearError(): void {\n error.value = null\n }\n\n function readErrorMessage(value: unknown, fallback = defaultErrorMessage): string {\n return readSharedErrorMessage(value, fallback)\n }\n\n function setError(value: unknown, fallback?: string): string {\n const message = readErrorMessage(value, fallback)\n error.value = message\n return message\n }\n\n // Monotonic token guarding every write to the shared loading/error/timestamp\n // state. Overlapping runs settle in any order, and a superseded one must not\n // clear a newer run's loading flag or overwrite its error.\n let runToken = 0\n let activeController: AbortController | null = null\n\n function cancel(): void {\n runToken += 1\n activeController?.abort()\n activeController = null\n isLoading.value = false\n }\n\n async function run<T>(\n operation: RequestOperation<T>,\n options: RequestRunOptions = {},\n ): Promise<T> {\n if (options.abortPrevious !== false) {\n activeController?.abort()\n }\n\n const token = (runToken += 1)\n const controller = new AbortController()\n activeController = controller\n\n const callerSignal = options.signal\n if (callerSignal) {\n if (callerSignal.aborted) controller.abort(callerSignal.reason)\n else callerSignal.addEventListener(\n 'abort',\n () => controller.abort(callerSignal.reason),\n { once: true },\n )\n }\n\n const isCurrent = (): boolean => token === runToken\n\n isLoading.value = true\n clearError()\n try {\n const response = await operation(controller.signal)\n if (isCurrent() && options.kind) {\n const settledAt = new Date()\n if (options.kind === 'load') lastLoadedAt.value = settledAt\n else if (options.kind === 'save') lastSavedAt.value = settledAt\n else lastRunAt.value = settledAt\n }\n return response\n } catch (err) {\n if (isCurrent()) {\n setError(err, options.errorMessage)\n }\n throw err\n } finally {\n if (isCurrent()) {\n isLoading.value = false\n }\n if (activeController === controller) {\n activeController = null\n }\n }\n }\n\n return {\n isLoading,\n error,\n lastLoadedAt,\n lastSavedAt,\n lastRunAt,\n clearError,\n readErrorMessage,\n setError,\n run,\n cancel,\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.4.0-beta.20",
3
+ "version": "1.4.0-beta.22",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -84,7 +84,8 @@
84
84
  "@floating-ui/dom": "^1.8.0",
85
85
  "axios": "1.20.0",
86
86
  "lucide-vue-next": "1.0.0",
87
- "plotly.js-dist-min": "^4.1.1"
87
+ "plotly.js-dist-min": "^4.1.1",
88
+ "read-excel-file": "9.3.14"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@histoire/plugin-vue": "1.0.0-beta.1",
@@ -132,7 +132,7 @@ describe('ActionMenu', () => {
132
132
  expect(document.body.querySelector('.mint-menu-separator')).toBeNull()
133
133
  })
134
134
 
135
- it('should close an open menu when its scroll context moves', async () => {
135
+ it('should keep an open menu open when its scroll context moves', async () => {
136
136
  const wrapper = mount(ActionMenu, {
137
137
  attachTo: document.body,
138
138
  props: { label: 'More user actions', items },
@@ -142,7 +142,7 @@ describe('ActionMenu', () => {
142
142
  window.dispatchEvent(new Event('scroll'))
143
143
  await wrapper.vm.$nextTick()
144
144
 
145
- expect(document.body.querySelector('.mint-action-menu__panel')).toBeNull()
145
+ expect(document.body.querySelector('.mint-action-menu__panel')).not.toBeNull()
146
146
  })
147
147
 
148
148
  it('should open from the trigger keyboard shortcuts and focus the first item', async () => {
@@ -196,6 +196,19 @@ describe('BaseModal', () => {
196
196
  inner.unmount()
197
197
  })
198
198
 
199
+ it('should stay open when a control inside it already handled the Escape', () => {
200
+ const wrapper = mount(BaseModal, {
201
+ props: { open: true, title: 'Settings' },
202
+ slots: { default: '<input class="handled" @keydown.escape.prevent>' },
203
+ attachTo: document.body,
204
+ })
205
+ document.querySelector('.handled')!.dispatchEvent(
206
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
207
+ )
208
+ expect(wrapper.emitted('update:open')).toBeUndefined()
209
+ wrapper.unmount()
210
+ })
211
+
199
212
  it('should focus the first focusable element when mounted already open', async () => {
200
213
  const attached = mount(BaseModal, {
201
214
  props: { open: true, title: 'Settings' },
@@ -0,0 +1,94 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { defineComponent, nextTick } from 'vue'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import BaseModal from '../../components/BaseModal.vue'
5
+ import BaseSelect from '../../components/BaseSelect.vue'
6
+ import DatePicker from '../../components/DatePicker.vue'
7
+ import WellEditPopupInternal from '../../components/internal/WellEditPopupInternal.vue'
8
+
9
+ const options = ['Orbitrap Exploris 480', 'Q Exactive HF', 'Vanquish UHPLC', 'Exploris 240', 'timsTOF Pro 2']
10
+
11
+ // [name, control markup, component root, trigger selector, panel selector]
12
+ const movedPanels = [
13
+ ['multiple select', '<BaseSelect multiple :model-value="[]" :options="options" />', '.mint-multi-select', '.mint-multi-select__field', '.mint-searchable-select__panel'],
14
+ ['searchable select', '<BaseSelect searchable :options="options" />', '.mint-searchable-select', '.mint-searchable-select__trigger', '.mint-searchable-select__panel'],
15
+ ['date-time picker', '<DatePicker type="datetime" />', '.mint-datetime-picker', '.mint-datetime-picker__input', '.mint-datetime-picker__dropdown'],
16
+ ['time picker', '<DatePicker type="time" />', '.mint-time-picker', '.mint-time-picker__input', '.mint-time-picker__dropdown'],
17
+ ] as const
18
+
19
+ async function settle() {
20
+ for (let i = 0; i < 3; i++) await nextTick()
21
+ }
22
+
23
+ describe('floating panels inside BaseModal', () => {
24
+ afterEach(() => {
25
+ document.body.innerHTML = ''
26
+ })
27
+
28
+ it.each(movedPanels)(
29
+ 'should keep the %s panel in its root after the trigger and position it fixed, so the modal body cannot clip it',
30
+ async (_, control, rootSelector, triggerSelector, panelSelector) => {
31
+ mount(defineComponent({
32
+ components: { BaseModal, BaseSelect, DatePicker },
33
+ setup: () => ({ options }),
34
+ template: `<BaseModal open title="New service token">${control}</BaseModal>`,
35
+ }), { attachTo: document.body })
36
+ await settle()
37
+
38
+ document.querySelector<HTMLElement>(triggerSelector)!.click()
39
+ await settle()
40
+
41
+ const panel = document.querySelector<HTMLElement>(panelSelector)!
42
+ const root = document.querySelector(rootSelector)!
43
+ expect(root.contains(panel)).toBe(true)
44
+ expect(root.querySelector(triggerSelector)!.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
45
+ expect(panel.style.position).toBe('fixed')
46
+ },
47
+ )
48
+ })
49
+
50
+ describe('Escape in a calendar panel inside BaseModal', () => {
51
+ afterEach(() => {
52
+ document.body.innerHTML = ''
53
+ })
54
+
55
+ it.each([
56
+ ['date', '.mint-date-picker__input', '.mint-date-picker__dropdown'],
57
+ ['datetime', '.mint-datetime-picker__input', '.mint-datetime-picker__dropdown'],
58
+ ])('should close only the %s panel when Escape is pressed in it', async (type, triggerSelector, panelSelector) => {
59
+ const wrapper = mount(defineComponent({
60
+ components: { BaseModal, DatePicker },
61
+ emits: ['close'],
62
+ template: `<BaseModal open title="Edit experiment" @update:open="$emit('close')"><DatePicker type="${type}" /></BaseModal>`,
63
+ }), { attachTo: document.body })
64
+ await settle()
65
+ document.querySelector<HTMLElement>(triggerSelector)!.click()
66
+ await settle()
67
+
68
+ document.querySelector(panelSelector)!.querySelector('button')!.dispatchEvent(
69
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
70
+ )
71
+ await settle()
72
+
73
+ expect(document.querySelector(panelSelector)).toBeNull()
74
+ expect(wrapper.emitted('close')).toBeUndefined()
75
+ })
76
+
77
+ it('should close only the well popup when Escape is pressed in it', async () => {
78
+ const wrapper = mount(defineComponent({
79
+ components: { BaseModal, WellEditPopupInternal },
80
+ emits: ['close', 'popupClose'],
81
+ template: `<BaseModal open title="Plate" @update:open="$emit('close')">
82
+ <WellEditPopupInternal well-id="A1" :position="{ x: 40, y: 40 }" @close="$emit('popupClose')" />
83
+ </BaseModal>`,
84
+ }), { attachTo: document.body })
85
+ await settle()
86
+
87
+ document.querySelector('.mint-well-edit-popup input')!.dispatchEvent(
88
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
89
+ )
90
+
91
+ expect(wrapper.emitted('popupClose')).toHaveLength(1)
92
+ expect(wrapper.emitted('close')).toBeUndefined()
93
+ })
94
+ })
@@ -1,12 +1,21 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
1
3
  import { describe, expect, it } from 'vitest'
2
4
 
3
5
  import {
4
6
  detectDelimiter,
5
7
  parseDelimitedText,
8
+ readTableFileAsCsv,
6
9
  useFileImport,
7
10
  validateImportFile,
8
11
  } from '../../composables/useFileImport'
9
12
 
13
+ const SAMPLES_CSV = [
14
+ 'Sample Name,Treatment,Inj Vol,Date',
15
+ 'S1,Vehicle,5,2026-10-01',
16
+ 'S2,"Drug ""A"", 10 uM",2.5,2026-10-02',
17
+ ].join('\n')
18
+
10
19
  describe('file import helpers', () => {
11
20
  it('parses quoted CSV fields', () => {
12
21
  const parsed = parseDelimitedText('name,notes\nSample A,"hello, world"\n')
@@ -41,4 +50,31 @@ describe('file import helpers', () => {
41
50
  expect(result.rows).toEqual([{ name: 'A' }])
42
51
  expect(importer.loading.value).toBe(false)
43
52
  })
53
+
54
+ it.each([
55
+ ['samples.csv', SAMPLES_CSV],
56
+ ['samples.tsv', 'Sample Name\tTreatment\tInj Vol\tDate\nS1\tVehicle\t5\t2026-10-01\nS2\t"Drug ""A"", 10 uM"\t2.5\t2026-10-02'],
57
+ ['samples.xlsx', readFileSync(resolve(__dirname, '../fixtures/table-import/samples.xlsx'))],
58
+ ])('should read the first table as CSV text when the upload is %s', async (name, content) => {
59
+ const file = new File([content], name)
60
+
61
+ expect(await readTableFileAsCsv(file)).toBe(SAMPLES_CSV)
62
+ })
63
+
64
+ it('should keep a quote inside a field as text when the quote does not open the field', () => {
65
+ expect(parseDelimitedText('S1\t5" vial\nS2\tok', { hasHeaderRow: false }).rawRows)
66
+ .toEqual([['S1', '5" vial'], ['S2', 'ok']])
67
+ })
68
+
69
+ it('should keep the stored cell text when the upload is .xlsx', async () => {
70
+ const file = new File([readFileSync(resolve(__dirname, '../fixtures/table-import/cells.xlsx'))], 'cells.xlsx')
71
+
72
+ expect(await readTableFileAsCsv(file)).toBe('2026-10-01T13:45:00,08:30:00,TRUE\n S1 ,9007199254740993,0.1')
73
+ })
74
+
75
+ it('should reject a file that is not a workbook when the name ends in .xlsx', async () => {
76
+ const file = new File(['Sample Name\nS1\n'], 'renamed.xlsx')
77
+
78
+ await expect(readTableFileAsCsv(file)).rejects.toThrow('not a valid .xlsx workbook')
79
+ })
44
80
  })