@morscherlab/mint-sdk 1.4.0-beta.9 → 1.4.1

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 (198) hide show
  1. package/README.md +14 -3
  2. package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-DwgNTEqf.js} +3 -3
  3. package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
  4. package/dist/{AppSidebar-Dq9W1jE2.js → AppSidebar-CZZaxni0.js} +1103 -144
  5. package/dist/AppSidebar-CZZaxni0.js.map +1 -0
  6. package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CjWoxAD1.js} +2 -2
  7. package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
  8. package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-DujiuQIO.js} +5 -5
  9. package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
  10. package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-B4j4eUOJ.js} +114 -98
  11. package/dist/ExperimentSelectorModal-B4j4eUOJ.js.map +1 -0
  12. package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-DyUPH3I5.js} +2 -2
  13. package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
  14. package/dist/{SettingsModal-483nyI6b.js → SettingsModal-D2MOOtuO.js} +26 -25
  15. package/dist/SettingsModal-D2MOOtuO.js.map +1 -0
  16. package/dist/__stories__/experiment-helpers.d.ts +10 -0
  17. package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
  18. package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
  20. package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
  21. package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
  22. package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
  23. package/dist/components/AppTopBar.vue.d.ts +20 -5
  24. package/dist/components/BaseButton.vue.d.ts +4 -2
  25. package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
  26. package/dist/components/JobsStatusTray.vue.d.ts +15 -4
  27. package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
  28. package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
  29. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -3
  30. package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
  31. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
  32. package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
  33. package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
  34. package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
  35. package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
  36. package/dist/composables/useFileImport.d.ts +7 -0
  37. package/dist/composables/useFloatingPanel.d.ts +29 -0
  38. package/dist/composables/useJobCenter.d.ts +41 -3
  39. package/dist/composables/useOnboarding.d.ts +119 -0
  40. package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
  41. package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
  42. package/dist/{data-CgILxDLW.js → data-Boeh4uPI.js} +13 -12
  43. package/dist/{data-CgILxDLW.js.map → data-Boeh4uPI.js.map} +1 -1
  44. package/dist/data.d.ts +1 -1
  45. package/dist/data.js +9 -9
  46. package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
  47. package/dist/experiment-DyO54FQB.js.map +1 -0
  48. package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-DCn2QKG7.js} +2 -2
  49. package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
  50. package/dist/index.js +20 -20
  51. package/dist/{install-BtLWTk6H.js → install-BEHp7Mo-.js} +15 -15
  52. package/dist/{install-BtLWTk6H.js.map → install-BEHp7Mo-.js.map} +1 -1
  53. package/dist/install.js +1 -1
  54. package/dist/{lab-MHzp-_J8.js → lab-D61eaX6f.js} +68 -61
  55. package/dist/{lab-MHzp-_J8.js.map → lab-D61eaX6f.js.map} +1 -1
  56. package/dist/lab.js +4 -4
  57. package/dist/{records-B6gpM_3e.js → records-D4aQXyZu.js} +193 -133
  58. package/dist/records-D4aQXyZu.js.map +1 -0
  59. package/dist/{runtime-7vfl_bI3.js → runtime-q-R0CNLh.js} +400 -33
  60. package/dist/runtime-q-R0CNLh.js.map +1 -0
  61. package/dist/runtime.d.ts +1 -1
  62. package/dist/runtime.js +7 -7
  63. package/dist/{shell-Cs9OJMnm.js → shell-6CGFvUUw.js} +10 -5
  64. package/dist/{shell-Cs9OJMnm.js.map → shell-6CGFvUUw.js.map} +1 -1
  65. package/dist/shell.d.ts +1 -0
  66. package/dist/shell.js +6 -6
  67. package/dist/stores/index.d.ts +1 -1
  68. package/dist/stores/index.js +2 -2
  69. package/dist/stores/settings.d.ts +4 -12
  70. package/dist/styles.css +1191 -479
  71. package/dist/types/components.d.ts +2 -0
  72. package/dist/types/index.d.ts +1 -1
  73. package/dist/types/jobs.d.ts +9 -0
  74. package/dist/types/platform.d.ts +1 -1
  75. package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-uFIz6Yv8.js} +16 -4
  76. package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
  77. package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-QEFoq4tf.js} +48 -6
  78. package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
  79. package/dist/{useListReorder-BitdUhxn.js → useListReorder-DsuzwWGS.js} +122 -172
  80. package/dist/useListReorder-DsuzwWGS.js.map +1 -0
  81. package/dist/{useRackEditor-DiwCt77M.js → useRackEditor-KpZ4iJ6o.js} +22 -32
  82. package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
  83. package/dist/useRealtime-B58Lo-Tm.js +508 -0
  84. package/dist/useRealtime-B58Lo-Tm.js.map +1 -0
  85. package/dist/{useRequest-D506eHi8.js → useRequest-BuDmW8Jc.js} +8 -37
  86. package/dist/useRequest-BuDmW8Jc.js.map +1 -0
  87. package/package.json +3 -2
  88. package/src/__stories__/experiment-helpers.ts +3 -3
  89. package/src/__tests__/components/ActionMenu.test.ts +2 -2
  90. package/src/__tests__/components/AppTopBar.test.ts +206 -1
  91. package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
  92. package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
  93. package/src/__tests__/components/BaseButton.test.ts +19 -0
  94. package/src/__tests__/components/BaseModal.test.ts +13 -0
  95. package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
  96. package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
  98. package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
  99. package/src/__tests__/components/PluginWorkspaceView.test.ts +63 -1
  100. package/src/__tests__/components/SampleSelector.test.ts +20 -0
  101. package/src/__tests__/components/SettingsModal.test.ts +9 -2
  102. package/src/__tests__/composables/useFileImport.test.ts +36 -0
  103. package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
  104. package/src/__tests__/composables/useJobCenter.test.ts +262 -3
  105. package/src/__tests__/composables/useOnboarding.test.ts +317 -0
  106. package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
  107. package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
  108. package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
  109. package/src/__tests__/stores/settings.test.ts +21 -21
  110. package/src/__tests__/styles/cssTokens.test.ts +4 -1
  111. package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
  112. package/src/__tests__/styles/paletteTokens.test.ts +44 -0
  113. package/src/__tests__/styles/v2AuditRules.test.ts +104 -0
  114. package/src/components/ActionMenu.vue +6 -37
  115. package/src/components/AppTopBar.onboarding.story.vue +69 -0
  116. package/src/components/AppTopBar.story.vue +59 -4
  117. package/src/components/AppTopBar.vue +132 -10
  118. package/src/components/BaseButton.vue +16 -2
  119. package/src/components/BaseModal.vue +2 -1
  120. package/src/components/ExperimentSelectorModal.story.vue +38 -0
  121. package/src/components/ExperimentSelectorModal.vue +20 -1
  122. package/src/components/GeneratedPluginView.vue +4 -9
  123. package/src/components/GeneratedResultRenderer.vue +3 -4
  124. package/src/components/JobsStatusTray.story.vue +97 -43
  125. package/src/components/JobsStatusTray.vue +287 -290
  126. package/src/components/LayoutResizeHandle.story.vue +65 -4
  127. package/src/components/PluginWorkspaceView.props.ts +3 -0
  128. package/src/components/PluginWorkspaceView.vue +17 -0
  129. package/src/components/SampleSelector.story.vue +21 -6
  130. package/src/components/SampleSelector.vue +10 -3
  131. package/src/components/SectionCard.story.vue +33 -0
  132. package/src/components/SettingsModal.vue +7 -12
  133. package/src/components/Tooltip.vue +9 -32
  134. package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
  135. package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
  136. package/src/components/internal/DatePickerDateInternal.vue +7 -45
  137. package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
  138. package/src/components/internal/DatePickerTimeInternal.vue +8 -1
  139. package/src/components/internal/NumberUnitInputInternal.vue +4 -18
  140. package/src/components/internal/OnboardingLayerInternal.vue +173 -0
  141. package/src/components/internal/SelectMultipleInternal.vue +6 -1
  142. package/src/components/internal/SelectSearchableInternal.vue +6 -1
  143. package/src/components/internal/WellEditPopupInternal.vue +11 -22
  144. package/src/components/internal/WorkspaceShellInternal.vue +12 -0
  145. package/src/composables/pluginWorkspaceModel.ts +2 -2
  146. package/src/composables/useFileImport.ts +51 -1
  147. package/src/composables/useFloatingPanel.ts +107 -0
  148. package/src/composables/useJobCenter.ts +188 -6
  149. package/src/composables/useOnboarding.ts +465 -0
  150. package/src/composables/usePluginWorkspace.ts +1 -1
  151. package/src/data.ts +1 -0
  152. package/src/runtime.ts +2 -1
  153. package/src/shell.ts +17 -0
  154. package/src/stores/index.ts +0 -2
  155. package/src/stores/settings.ts +20 -50
  156. package/src/styles/components/action-menu.css +1 -1
  157. package/src/styles/components/app-layout.css +6 -3
  158. package/src/styles/components/app-page-selector.css +120 -45
  159. package/src/styles/components/app-top-bar.css +165 -3
  160. package/src/styles/components/button.css +8 -0
  161. package/src/styles/components/dataframe.css +1 -1
  162. package/src/styles/components/date-picker.css +4 -2
  163. package/src/styles/components/datetime-picker.css +4 -4
  164. package/src/styles/components/experiment-selector-modal.css +13 -0
  165. package/src/styles/components/jobs-status-tray.css +134 -296
  166. package/src/styles/components/layout-resize-handle.css +59 -16
  167. package/src/styles/components/modal.css +3 -3
  168. package/src/styles/components/number-input.css +1 -1
  169. package/src/styles/components/onboarding.css +368 -0
  170. package/src/styles/components/progress-bar.css +3 -3
  171. package/src/styles/components/sample-selector.css +43 -0
  172. package/src/styles/components/searchable-select.css +3 -7
  173. package/src/styles/components/section-card.css +30 -6
  174. package/src/styles/components/settings-modal.css +7 -2
  175. package/src/styles/components/time-picker.css +3 -6
  176. package/src/styles/components/toast.css +1 -1
  177. package/src/styles/components/tooltip.css +2 -2
  178. package/src/styles/components/unit-input.css +1 -1
  179. package/src/styles/components/well-edit-popup.css +2 -2
  180. package/src/styles/index.css +1 -0
  181. package/src/styles/variables.css +153 -15
  182. package/src/types/components.ts +2 -0
  183. package/src/types/index.ts +1 -0
  184. package/src/types/jobs.ts +10 -0
  185. package/src/types/platform.ts +1 -1
  186. package/dist/AppSidebar-Dq9W1jE2.js.map +0 -1
  187. package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
  188. package/dist/SettingsModal-483nyI6b.js.map +0 -1
  189. package/dist/experiment-43OJtH7R.js.map +0 -1
  190. package/dist/records-B6gpM_3e.js.map +0 -1
  191. package/dist/runtime-7vfl_bI3.js.map +0 -1
  192. package/dist/useDropdownState-8yWMu-7_.js.map +0 -1
  193. package/dist/useExperimentSamples-DHnxKFAF.js.map +0 -1
  194. package/dist/useListReorder-BitdUhxn.js.map +0 -1
  195. package/dist/useRackEditor-DiwCt77M.js.map +0 -1
  196. package/dist/useRealtime-C3iy9QkU.js +0 -699
  197. package/dist/useRealtime-C3iy9QkU.js.map +0 -1
  198. package/dist/useRequest-D506eHi8.js.map +0 -1
@@ -4,6 +4,7 @@ import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
4
4
  import { Check, ChevronDown, X } from 'lucide-vue-next'
5
5
  import type { OptionPrimitive, SelectOption, SelectOptionInput } from '../../types'
6
6
  import { useEventListener } from '../../composables/useEventListener'
7
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
8
  import { useListSelection } from '../../composables/useListSelection'
8
9
  import { normalizeOptionInput } from '../../utils/options'
9
10
 
@@ -49,6 +50,7 @@ const listboxId = `mint-multi-select-${id}`
49
50
  const root = ref<HTMLElement | null>(null)
50
51
  const field = ref<HTMLElement | null>(null)
51
52
  const search = ref<HTMLInputElement | null>(null)
53
+ const panel = ref<HTMLElement | null>(null)
52
54
  const open = ref(false)
53
55
  const query = ref('')
54
56
  const activeIndex = ref(-1)
@@ -136,6 +138,9 @@ function handleSearchKeydown(event: KeyboardEvent): void {
136
138
  event.preventDefault()
137
139
  }
138
140
 
141
+ // Fixed next to the field, so a modal body's overflow does not clip it; it stays in place in the DOM.
142
+ const { style: panelStyle } = useFloatingPanel(field, panel, open, { matchWidth: true })
143
+
139
144
  watch(visibleOptions, options => { activeIndex.value = options.length > 0 ? 0 : -1 })
140
145
  watch(() => props.disabled, disabled => { if (disabled) void closePanel() })
141
146
  useEventListener(() => document, 'pointerdown', event => {
@@ -192,7 +197,7 @@ useEventListener(() => document, 'pointerdown', event => {
192
197
  <ChevronDown class="mint-multi-select__icon" :size="14" aria-hidden="true" />
193
198
  </div>
194
199
 
195
- <div v-if="open" class="mint-popover mint-searchable-select__panel">
200
+ <div v-if="open" ref="panel" class="mint-popover mint-searchable-select__panel" :style="panelStyle">
196
201
  <div class="mint-searchable-select__search-wrap">
197
202
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
198
203
  <circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" />
@@ -4,6 +4,7 @@ import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
4
4
  import { ChevronsUpDown } from 'lucide-vue-next'
5
5
  import type { SelectOption, SelectOptionInput } from '../../types'
6
6
  import { useEventListener } from '../../composables/useEventListener'
7
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
8
  import { normalizeOptionInput } from '../../utils/options'
8
9
 
9
10
  interface Props {
@@ -53,6 +54,7 @@ const triggerId = `${listboxId}-trigger`
53
54
  const root = ref<HTMLElement | null>(null)
54
55
  const trigger = ref<HTMLButtonElement | null>(null)
55
56
  const search = ref<HTMLInputElement | null>(null)
57
+ const panel = ref<HTMLElement | null>(null)
56
58
  const open = ref(false)
57
59
  const query = ref('')
58
60
  const activeIndex = ref(-1)
@@ -144,6 +146,9 @@ function handleTriggerKeydown(event: KeyboardEvent): void {
144
146
  })
145
147
  }
146
148
 
149
+ // Fixed next to the trigger, so a modal body's overflow does not clip it; it stays in place in the DOM.
150
+ const { style: panelStyle } = useFloatingPanel(trigger, panel, open, { matchWidth: true })
151
+
147
152
  watch(visibleOptions, options => { activeIndex.value = firstEnabledIndex(options) })
148
153
  watch(() => props.disabled, disabled => { if (disabled) void closePanel() })
149
154
  useEventListener(() => document, 'pointerdown', event => {
@@ -180,7 +185,7 @@ useEventListener(() => document, 'pointerdown', event => {
180
185
  <ChevronsUpDown :size="14" aria-hidden="true" />
181
186
  </button>
182
187
 
183
- <div v-if="open" class="mint-popover mint-searchable-select__panel">
188
+ <div v-if="open" ref="panel" class="mint-popover mint-searchable-select__panel" :style="panelStyle">
184
189
  <div class="mint-searchable-select__search-wrap">
185
190
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
186
191
  <circle cx="8.5" cy="8.5" r="5.5" /><path d="m13 13 4 4" />
@@ -1,9 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  /** Single-well inspector for WellPlate / PlateEditor, composed from SDK fields (3b). */
3
3
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
4
- import { computePosition, flip, shift } from '@floating-ui/dom'
5
4
  import { ChevronRight, X } from 'lucide-vue-next'
6
5
  import type { Well, WellEditData, WellEditField } from '../../types'
6
+ import { useFloatingPanel } from '../../composables/useFloatingPanel'
7
7
  import { useFocusTrap } from '../../composables/useFocusTrap'
8
8
  import { sampleTypeMarker } from '../../utils/sampleTypeMarker'
9
9
  import { DEFAULT_SAMPLE_TYPE_COLORS } from '../WellPlate.colors'
@@ -157,28 +157,18 @@ function handleInlineEnter(event: KeyboardEvent) {
157
157
  emit('close')
158
158
  }
159
159
 
160
- // Placement: flip and clamp to the viewport. Transparent (not hidden) until
161
- // placed, so the focus trap can move focus in on mount.
160
+ // Placement at the clicked point: flip and clamp to the viewport. Inline, the
161
+ // inspector stays in place and is not positioned.
162
162
  const containerRef = ref<HTMLElement | null>(null)
163
- const panelStyle = ref<Record<string, string>>({ opacity: '0' })
164
-
165
- async function place() {
166
- const panel = containerRef.value
167
- if (!panel || props.inline) return
163
+ const pointAnchor = () => {
164
+ if (props.inline) return null
168
165
  const { x, y } = props.position
169
- const anchor = {
170
- getBoundingClientRect: () => ({ x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0 }),
171
- }
172
- const placed = await computePosition(anchor, panel, {
173
- placement: 'right-start',
174
- strategy: 'fixed',
175
- middleware: [flip(), shift({ padding: 8 })],
176
- })
177
- panelStyle.value = { left: `${placed.x}px`, top: `${placed.y}px` }
166
+ return { getBoundingClientRect: () => ({ x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0 }) }
178
167
  }
179
-
180
- watch(() => props.position, place)
181
- watch(showAdvanced, () => nextTick(place))
168
+ const { style: panelStyle } = useFloatingPanel(pointAnchor, containerRef, computed(() => !props.inline), {
169
+ placement: 'right-start',
170
+ offset: 0,
171
+ })
182
172
 
183
173
  // The parent mounts and unmounts the popup, so the trap follows the lifecycle,
184
174
  // the same way BaseModal drives it off `modelValue`.
@@ -200,7 +190,6 @@ watch(() => props.wellId, () => { if (props.inline) focusInline() }, { flush: 'p
200
190
  onMounted(() => {
201
191
  if (props.inline) return focusInline()
202
192
  trapActive.value = true
203
- void place()
204
193
  // Runs after the trap's own first-element focus: start in the name field.
205
194
  void nextTick(() => containerRef.value?.querySelector<HTMLInputElement>('[data-field="label"] input')?.focus())
206
195
  })
@@ -223,7 +212,7 @@ onBeforeUnmount(() => {
223
212
  :aria-label="`Edit well ${wellId}`"
224
213
  :tabindex="inline ? undefined : -1"
225
214
  @click="inline || $event.stopPropagation()"
226
- @keydown.escape="commit(); emit('close')"
215
+ @keydown.escape.prevent="commit(); emit('close')"
227
216
  @keydown="inline && handleInlineEnter($event)"
228
217
  @focusout="handleFocusout"
229
218
  >
@@ -12,15 +12,22 @@ import type {
12
12
  import AppLayout from '../AppLayout.vue'
13
13
  import AppTopBar from '../AppTopBar.vue'
14
14
  import type { PluginWorkspaceShellModel } from '../../composables/pluginWorkspaceModel'
15
+ import type { TopBarJob } from '../../types/jobs'
15
16
 
16
17
  defineProps<{
17
18
  shell: PluginWorkspaceShellModel
19
+ /** Passed to AppTopBar; undefined means no jobs control. */
20
+ job?: TopBarJob | null
21
+ jobsDone?: number
22
+ jobsOpen?: boolean
23
+ jobsPanelId?: string
18
24
  }>()
19
25
 
20
26
  const emit = defineEmits<{
21
27
  'pill-select': [item: PillNavItem]
22
28
  'pill-option-select': [option: PillNavOption, item: PillNavItem]
23
29
  'settings-values-change': [data: Record<string, unknown>]
30
+ 'job-click': []
24
31
  }>()
25
32
 
26
33
  function handlePillOptionSelect(option: PillNavOption, item: PillNavItem) {
@@ -58,6 +65,11 @@ defineSlots<{
58
65
  :subtitle="shell.subtitle"
59
66
  :spec="shell.topBar"
60
67
  :pill="shell.pill"
68
+ :job="job"
69
+ :jobs-done="jobsDone"
70
+ :jobs-open="jobsOpen"
71
+ :jobs-panel-id="jobsPanelId"
72
+ @job-click="emit('job-click')"
61
73
  @pill-select="emit('pill-select', $event)"
62
74
  @pill-option-select="handlePillOptionSelect"
63
75
  @settings-values-change="emit('settings-values-change', $event)"
@@ -50,8 +50,8 @@ export interface PluginWorkspaceShellModel {
50
50
  sidebarCloseLabel: string
51
51
  /** Whether AppLayout should render a `#sidebar` template at all. */
52
52
  hasSidebarSurface: boolean
53
- /** App or plugin title shown in the top bar. */
54
- title: string
53
+ /** Top bar title. Unset, the top bar shows the plugin identity (`providePluginIdentity` or the platform). */
54
+ title?: string
55
55
  /** Secondary copy shown under the title. */
56
56
  subtitle?: string
57
57
  /** AppTopBar spec with the workspace defaults applied and the generated pills filled in; `false` removes the top bar. */
@@ -1,4 +1,5 @@
1
1
  import { ref, shallowRef, type Ref, type ShallowRef } from 'vue'
2
+ import type { SheetData } from 'read-excel-file/universal'
2
3
  import { readErrorMessage } from '../utils/errors'
3
4
 
4
5
  export interface DelimitedTable {
@@ -116,6 +117,53 @@ export function readFileAsText(file: File, encoding?: string): Promise<string> {
116
117
  })
117
118
  }
118
119
 
120
+ /**
121
+ * Read a `.csv`, `.tsv` or `.xlsx` upload as comma-delimited CSV text.
122
+ * A `.csv` file (or any other extension) comes back unchanged. An `.xlsx`
123
+ * file gives its first sheet; each cell is its stored value, so number
124
+ * formats such as leading zeros are not applied. Dates become ISO text.
125
+ */
126
+ export async function readTableFileAsCsv(file: File): Promise<string> {
127
+ const name = file.name.toLowerCase()
128
+ if (name.endsWith('.xlsx')) return rowsToCsv(await readFirstSheet(file))
129
+ const text = await readFileAsText(file)
130
+ return name.endsWith('.tsv') ? rowsToCsv(parseDelimitedRows(text, '\t')) : text
131
+ }
132
+
133
+ async function readFirstSheet(file: File): Promise<string[][]> {
134
+ // Lazy import: plugins that never read .xlsx do not load the library.
135
+ const { readSheet } = await import('read-excel-file/universal')
136
+ let rows: SheetData<string>
137
+ try {
138
+ // Keep the stored text: no trim, and no float parse that rounds big numbers.
139
+ rows = await readSheet(file, { trim: false, parseNumber: text => text })
140
+ } catch {
141
+ throw new Error(`${file.name} is not a valid .xlsx workbook.`)
142
+ }
143
+ return rows.map(row => row.map(formatCell))
144
+ }
145
+
146
+ function formatCell(value: unknown): string {
147
+ if (value === null || value === undefined) return ''
148
+ if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
149
+ if (value instanceof Date) {
150
+ // Excel stores dates as float day counts; round off the float error.
151
+ const [date, time] = new Date(Math.round(value.getTime() / 1000) * 1000)
152
+ .toISOString().slice(0, 19).split('T')
153
+ if (date === '1899-12-30') return time // a time-only cell
154
+ return time === '00:00:00' ? date : `${date}T${time}`
155
+ }
156
+ return String(value)
157
+ }
158
+
159
+ function rowsToCsv(rows: readonly (readonly string[])[]): string {
160
+ return rows.map(row => row.map(quoteCsvField).join(',')).join('\n')
161
+ }
162
+
163
+ function quoteCsvField(value: string): string {
164
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
165
+ }
166
+
119
167
  export function parseDelimitedText(
120
168
  text: string,
121
169
  options: ParseDelimitedTextOptions = {},
@@ -170,8 +218,10 @@ function parseDelimitedRows(text: string, delimiter: string): string[][] {
170
218
  if (quoted && next === '"') {
171
219
  field += '"'
172
220
  index++
173
- } else {
221
+ } else if (quoted || field === '') {
174
222
  quoted = !quoted
223
+ } else {
224
+ field += '"' // a quote inside an unquoted field is text
175
225
  }
176
226
  continue
177
227
  }
@@ -0,0 +1,107 @@
1
+ import {
2
+ autoUpdate,
3
+ computePosition,
4
+ flip,
5
+ hide,
6
+ offset,
7
+ shift,
8
+ size,
9
+ type Placement,
10
+ type ReferenceElement,
11
+ } from '@floating-ui/dom'
12
+ import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
13
+
14
+ // Internal: not exported from the package entry points.
15
+
16
+ export interface UseFloatingPanelOptions {
17
+ /** Default `'bottom-start'`. */
18
+ placement?: MaybeRefOrGetter<Placement>
19
+ /** Gap to the reference in px. Default 4. */
20
+ offset?: number
21
+ /** At least the reference width, at most 28rem (selects). */
22
+ matchWidth?: boolean
23
+ /** Re-place on scroll, resize and layout shift while open. Default `true`. */
24
+ follow?: boolean
25
+ }
26
+
27
+ export interface UseFloatingPanelReturn {
28
+ /** Bind to the panel: `position: fixed`, `left`, `top`, and `visibility: hidden` while the reference is scrolled out of view. */
29
+ style: Ref<Record<string, string>>
30
+ /** The placement after flip, for arrows and transitions. */
31
+ placement: Ref<Placement>
32
+ update: () => Promise<void>
33
+ }
34
+
35
+ /**
36
+ * Places `floating` next to `reference` with fixed positioning (offset, flip,
37
+ * shift, size, hide). A fixed panel escapes the `overflow` of its ancestors (a
38
+ * modal body) unless one of them has a transform, so it can stay in the DOM right
39
+ * after its trigger: Tab order, focus traps and outside-click checks are those of
40
+ * an in-place panel. The size middleware publishes the free height as
41
+ * `--mint-floating-available-height`; a panel whose list scrolls caps its
42
+ * `max-height` with it.
43
+ */
44
+ export function useFloatingPanel(
45
+ reference: MaybeRefOrGetter<ReferenceElement | null>,
46
+ floating: Ref<HTMLElement | null>,
47
+ open: Ref<boolean>,
48
+ options: UseFloatingPanelOptions = {},
49
+ ): UseFloatingPanelReturn {
50
+ const style = ref<Record<string, string>>({ position: 'fixed', left: '0px', top: '0px' })
51
+ const placement = ref<Placement>(toValue(options.placement) ?? 'bottom-start')
52
+
53
+ async function update(): Promise<void> {
54
+ const referenceEl = toValue(reference)
55
+ const floatingEl = floating.value
56
+ if (!referenceEl || !floatingEl) return
57
+ const result = await computePosition(referenceEl, floatingEl, {
58
+ placement: toValue(options.placement) ?? 'bottom-start',
59
+ strategy: 'fixed',
60
+ middleware: [
61
+ offset(options.offset ?? 4),
62
+ flip(),
63
+ shift({ padding: 8 }),
64
+ size({
65
+ padding: 8,
66
+ // Written to the element, not the reactive style: floating-ui measures again right after.
67
+ apply({ availableHeight, rects, elements }) {
68
+ const target = elements.floating.style
69
+ target.setProperty('--mint-floating-available-height', `${Math.max(0, availableHeight)}px`)
70
+ if (options.matchWidth) {
71
+ target.minWidth = `${rects.reference.width}px`
72
+ target.maxWidth = '28rem'
73
+ }
74
+ },
75
+ }),
76
+ hide(),
77
+ ],
78
+ })
79
+ const next: Record<string, string> = { position: 'fixed', left: `${result.x}px`, top: `${result.y}px` }
80
+ // The trigger scrolled out of its scroll container: keep the panel open, but not floating detached.
81
+ if (result.middlewareData.hide?.referenceHidden) next.visibility = 'hidden'
82
+ // autoUpdate fires on every scroll; re-render only when the panel changes.
83
+ const current = style.value
84
+ if (current.left !== next.left || current.top !== next.top || current.visibility !== next.visibility) style.value = next
85
+ placement.value = result.placement
86
+ }
87
+
88
+ let stop: (() => void) | undefined
89
+ function release(): void {
90
+ stop?.()
91
+ stop = undefined
92
+ }
93
+
94
+ watch(
95
+ [open, () => toValue(reference), floating],
96
+ ([isOpen, referenceEl, floatingEl]) => {
97
+ release()
98
+ if (!isOpen || !referenceEl || !floatingEl) return
99
+ if (options.follow ?? true) stop = autoUpdate(referenceEl, floatingEl, () => void update())
100
+ else void update()
101
+ },
102
+ { flush: 'post', immediate: true },
103
+ )
104
+ onScopeDispose(release)
105
+
106
+ return { style, placement, update }
107
+ }
@@ -5,20 +5,26 @@ import {
5
5
  ref,
6
6
  shallowRef,
7
7
  toValue,
8
+ useId,
9
+ watch,
8
10
  type ComputedRef,
9
11
  type MaybeRefOrGetter,
10
12
  type Ref,
11
13
  type ShallowRef,
12
14
  } from 'vue'
13
15
 
14
- import { isActiveJobStatus, normalizeJobState } from '../jobs'
16
+ import { isActiveJobStatus, jobStatusLabel, normalizeJobPercent, normalizeJobState } from '../jobs'
15
17
  import type {
16
18
  JobListPayload,
17
19
  JobState,
18
20
  JobStateInput,
21
+ JobStatus,
22
+ TopBarJob,
19
23
  } from '../types/jobs'
24
+ import { usePlatform } from './usePlatform'
20
25
 
21
- export type JobsStatusTrayTab = 'active' | 'history'
26
+ /** How long a job that just completed stays in the top-bar run slot. */
27
+ const FINISHED_HOLD_MS = 6000
22
28
 
23
29
  /**
24
30
  * The canonical owner of a collection of plugin jobs.
@@ -143,9 +149,41 @@ function duplicatePluginJobId(jobId: string): Error {
143
149
  export interface UseJobCenterOptions {
144
150
  source?: MaybeRefOrGetter<PluginJobCenterSource | undefined>
145
151
  loadOnMount?: boolean
152
+ /**
153
+ * Maps the job that the top-bar chip shows. It is called for the chosen own
154
+ * active job AND for the held just-completed job, so check `job.status`.
155
+ * `null` keeps the default mapping.
156
+ */
157
+ topBarJob?: (job: JobState) => TopBarJob | null
158
+ /**
159
+ * Overrides how a tray row looks: `status` (icon, awaiting-input tint and
160
+ * pinning), `percent` (progress bar and value) and `message` (detail line).
161
+ * A missing or `undefined` field, or a `null` status or message, keeps the
162
+ * job's own; `percent: null` hides the bar and shows the time, and a status
163
+ * alone keeps the job's own percent. The job's section, actions and counts
164
+ * do not change.
165
+ */
166
+ rowJob?: (job: JobState) => JobRowOverride | null
167
+ }
168
+
169
+ /** A `rowJob` result; see `UseJobCenterOptions.rowJob`. */
170
+ export interface JobRowOverride {
171
+ status?: JobStatus | null
172
+ percent?: number | null
173
+ message?: string | null
174
+ }
175
+
176
+ /** How a tray row shows a job; see `UseJobCenterReturn.rowView`. */
177
+ export interface JobRowView {
178
+ status: JobStatus
179
+ /** 0-100, rounded; `null` hides the progress bar and shows the time instead. */
180
+ percent: number | null
181
+ message: string
146
182
  }
147
183
 
148
184
  export interface UseJobCenterReturn {
185
+ /** The current source; a new identity is a new job list. */
186
+ source: ComputedRef<PluginJobCenterSource | undefined>
149
187
  jobs: ComputedRef<JobState[]>
150
188
  activeJobs: ComputedRef<JobState[]>
151
189
  historyJobs: ComputedRef<JobState[]>
@@ -155,14 +193,23 @@ export interface UseJobCenterReturn {
155
193
  canCancel: ComputedRef<boolean>
156
194
  canDelete: ComputedRef<boolean>
157
195
  canClearFinished: ComputedRef<boolean>
196
+ /** DOM id of the JobsStatusTray panel, for `AppTopBar`'s `jobsPanelId` prop. */
197
+ panelId: string
198
+ /** The row view of a job, with the `rowJob` option applied. */
199
+ rowView: (job: JobState) => JobRowView
200
+ /** The viewer's own job for `AppTopBar`'s `job` prop. */
201
+ topBarJob: ComputedRef<TopBarJob | null>
202
+ /** Jobs in the list with status `completed`, for `AppTopBar`'s `jobsDone` prop. */
203
+ completedCount: ComputedRef<number>
158
204
  isOpen: Ref<boolean>
159
- activeTab: Ref<JobsStatusTrayTab>
160
205
  isLoading: Ref<boolean>
161
206
  error: ShallowRef<string | null>
162
207
  pendingJobIds: Ref<Set<string>>
163
208
  open: () => void
164
209
  close: () => void
165
210
  toggle: () => void
211
+ /** The top-bar chip: with the tray closed and none of the viewer's jobs active, selects the held just-completed job; else toggles. */
212
+ handleTopBarClick: (onSelect: (job: JobState) => void) => void
166
213
  refresh: () => Promise<boolean>
167
214
  cancelJob: (job: JobState) => Promise<boolean>
168
215
  deleteJob: (job: JobState) => Promise<boolean>
@@ -170,6 +217,35 @@ export interface UseJobCenterReturn {
170
217
  isPending: (job: JobState) => boolean
171
218
  }
172
219
 
220
+ /** The stored seen failures, or null when nothing is stored or storage is blocked. */
221
+ function readSeenFailed(key: string): Set<string> | null {
222
+ try {
223
+ const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(key) ?? 'null')
224
+ return Array.isArray(parsed) ? new Set(parsed.filter((id): id is string => typeof id === 'string')) : null
225
+ } catch {
226
+ return null
227
+ }
228
+ }
229
+
230
+ function writeSeenFailed(key: string, seen: Set<string>): void {
231
+ try {
232
+ globalThis.localStorage?.setItem(key, JSON.stringify([...seen]))
233
+ } catch {
234
+ // Private windows and full storage: the state lasts until reload.
235
+ }
236
+ }
237
+
238
+ function jobMessage(job: JobState): string {
239
+ if (job.error) return job.error
240
+ if (job.status === 'awaiting_input') {
241
+ return job.progress.message || 'Waiting for external input'
242
+ }
243
+ if (job.status === 'queued' && job.queue_position > 0) {
244
+ return `Queue position ${job.queue_position}`
245
+ }
246
+ return job.progress.message || job.progress.stage || jobStatusLabel(job.status)
247
+ }
248
+
173
249
  function errorMessage(error: unknown, fallback: string): string {
174
250
  return error instanceof Error ? error.message : fallback
175
251
  }
@@ -184,7 +260,6 @@ export function useJobCenter(
184
260
  options: UseJobCenterOptions = {},
185
261
  ): UseJobCenterReturn {
186
262
  const isOpen = ref(false)
187
- const activeTab = ref<JobsStatusTrayTab>('active')
188
263
  const isLoading = ref(false)
189
264
  const error = shallowRef<string | null>(null)
190
265
  const pendingJobIds = ref(new Set<string>())
@@ -200,6 +275,103 @@ export function useJobCenter(
200
275
  const historyJobs = computed(() => jobs.value.filter(job => !isActiveJobStatus(job.status)))
201
276
  const deletableJobs = computed(() => historyJobs.value.filter(job => job.can_delete))
202
277
  const featuredJob = computed(() => activeJobs.value[0] ?? historyJobs.value[0] ?? null)
278
+ const completedCount = computed(() => jobs.value.filter(job => job.status === 'completed').length)
279
+
280
+ // Top-bar run slot: the viewer's own jobs only. Standalone or with auth off
281
+ // (no signed-in user), every job is the viewer's.
282
+ const { integrated, user, plugin, isAdmin } = usePlatform()
283
+ const isOwnJob = (job: JobState) => {
284
+ if (!integrated.value) return true
285
+ // With no user, isAdmin is true only with auth off.
286
+ if (user.value == null) return isAdmin.value
287
+ return (job.owner_user_id ?? job.user_id) === String(user.value.id)
288
+ }
289
+ const ownActiveJobs = computed(() => activeJobs.value.filter(isOwnJob))
290
+ const justFinished = shallowRef<JobState | null>(null)
291
+ // The live held job; it drops out once the job leaves the list.
292
+ const heldJob = computed(() => {
293
+ const held = justFinished.value
294
+ return held ? jobs.value.find(job => job.job_id === held.job_id) ?? null : null
295
+ })
296
+ let finishedTimer: ReturnType<typeof setTimeout> | undefined
297
+ // Compare active jobs within one source only: a replaced source starts over.
298
+ watch([source, ownActiveJobs], ([currentSource, now], [previousSource, before]) => {
299
+ if (currentSource !== previousSource) {
300
+ justFinished.value = null
301
+ return
302
+ }
303
+ const nowIds = new Set(now.map(job => job.job_id))
304
+ const left = new Set(before.filter(job => !nowIds.has(job.job_id)).map(job => job.job_id))
305
+ const finished = jobs.value.find(job => left.has(job.job_id) && job.status === 'completed')
306
+ if (!finished) return
307
+ justFinished.value = finished
308
+ clearTimeout(finishedTimer)
309
+ finishedTimer = setTimeout(() => { justFinished.value = null }, FINISHED_HOLD_MS)
310
+ })
311
+ // ponytail: the seen set only grows; prune to listed jobs if the stored array ever matters.
312
+ const seenFailedKey = `mint.jobs.${plugin.value?.id ?? 'standalone'}.seen-failed`
313
+ const storedSeenFailed = readSeenFailed(seenFailedKey)
314
+ const seenFailed = shallowRef(storedSeenFailed ?? new Set<string>())
315
+ const unseenFailedJobs = computed(() => historyJobs.value.filter(job => (
316
+ job.status === 'failed' && isOwnJob(job) && !seenFailed.value.has(job.job_id)
317
+ )))
318
+ function markFailedSeen(): void {
319
+ seenFailed.value = new Set([...seenFailed.value, ...unseenFailedJobs.value.map(job => job.job_id)])
320
+ writeSeenFailed(seenFailedKey, seenFailed.value)
321
+ }
322
+ // Nothing stored (new browser, cleared or blocked storage): the failures at the
323
+ // initial load are the baseline, so only later failures alert. Without a load
324
+ // step, the first non-empty list stands in for it.
325
+ let hasBaseline = storedSeenFailed !== null
326
+ let awaitingLoad = options.loadOnMount !== false && Boolean(source.value?.refresh)
327
+ function takeBaseline(): void {
328
+ if (hasBaseline || !active) return
329
+ hasBaseline = true
330
+ markFailedSeen()
331
+ }
332
+ watch(jobs, (list) => {
333
+ if (!awaitingLoad && list.length > 0) takeBaseline()
334
+ }, { immediate: true })
335
+ // The open tray shows every failure, including one that happens while it is open.
336
+ watch([isOpen, unseenFailedJobs], ([open, unseen]) => {
337
+ if (open && unseen.length > 0) markFailedSeen()
338
+ })
339
+ const topBarJob = computed<TopBarJob | null>(() => {
340
+ const own = ownActiveJobs.value
341
+ const shown = own.find(job => job.status === 'awaiting_input') ?? own[0]
342
+ if (shown) {
343
+ return options.topBarJob?.(shown) ?? {
344
+ status: shown.status,
345
+ label: shown.progress.message || `${shown.name} ${jobStatusLabel(shown.status).toLowerCase()}`,
346
+ percent: shown.status === 'running' ? normalizeJobPercent(shown.progress.percent) : null,
347
+ }
348
+ }
349
+ const held = heldJob.value
350
+ if (held) return options.topBarJob?.(held) ?? { status: 'completed', label: `${held.name} completed` }
351
+ const failed = unseenFailedJobs.value.length
352
+ return failed > 0 ? { status: 'failed', label: `${failed} failed` } : null
353
+ })
354
+
355
+ function rowView(job: JobState): JobRowView {
356
+ const override = options.rowJob?.(job) ?? {}
357
+ const status = override.status ?? job.status
358
+ // Only an explicit percent changes the bar; a status override keeps the job's own.
359
+ const percent = override.percent !== undefined
360
+ ? override.percent
361
+ : job.status === 'running' ? job.progress.percent : null
362
+ return {
363
+ status,
364
+ percent: percent == null ? null : Math.round(normalizeJobPercent(percent)),
365
+ message: override.message ?? jobMessage(job),
366
+ }
367
+ }
368
+
369
+ function handleTopBarClick(onSelect: (job: JobState) => void): void {
370
+ const finished = heldJob.value
371
+ if (!isOpen.value && ownActiveJobs.value.length === 0 && finished) onSelect(finished)
372
+ else isOpen.value = !isOpen.value
373
+ }
374
+
203
375
  const canRefresh = computed(() => Boolean(source.value?.refresh))
204
376
  const canCancel = computed(() => Boolean(source.value?.cancel))
205
377
  const canDelete = computed(() => Boolean(source.value?.delete))
@@ -290,14 +462,20 @@ export function useJobCenter(
290
462
  }
291
463
 
292
464
  onMounted(() => {
293
- if (options.loadOnMount !== false) void refresh()
465
+ if (options.loadOnMount === false) return
466
+ void refresh().then((loaded) => {
467
+ awaitingLoad = false
468
+ if (loaded || jobs.value.length > 0) takeBaseline()
469
+ })
294
470
  })
295
471
  onUnmounted(() => {
296
472
  active = false
297
473
  requestGeneration += 1
474
+ clearTimeout(finishedTimer)
298
475
  })
299
476
 
300
477
  return {
478
+ source,
301
479
  jobs,
302
480
  activeJobs,
303
481
  historyJobs,
@@ -307,14 +485,18 @@ export function useJobCenter(
307
485
  canCancel,
308
486
  canDelete,
309
487
  canClearFinished,
488
+ panelId: `mint-jobs-tray-${useId()}`,
489
+ rowView,
490
+ topBarJob,
491
+ completedCount,
310
492
  isOpen,
311
- activeTab,
312
493
  isLoading,
313
494
  error,
314
495
  pendingJobIds,
315
496
  open: () => { isOpen.value = true },
316
497
  close: () => { isOpen.value = false },
317
498
  toggle: () => { isOpen.value = !isOpen.value },
499
+ handleTopBarClick,
318
500
  refresh,
319
501
  cancelJob,
320
502
  deleteJob,