@morscherlab/mint-sdk 1.4.0-beta.9 → 1.4.0
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.
- package/README.md +14 -3
- package/dist/{AlertBox-B2Bgr3ei.js → AlertBox-DwgNTEqf.js} +3 -3
- package/dist/{AlertBox-B2Bgr3ei.js.map → AlertBox-DwgNTEqf.js.map} +1 -1
- package/dist/{AppSidebar-Dq9W1jE2.js → AppSidebar-CZZaxni0.js} +1103 -144
- package/dist/AppSidebar-CZZaxni0.js.map +1 -0
- package/dist/{ConfirmDialog-nbJj51is.js → ConfirmDialog-CjWoxAD1.js} +2 -2
- package/dist/{ConfirmDialog-nbJj51is.js.map → ConfirmDialog-CjWoxAD1.js.map} +1 -1
- package/dist/{ExperimentPopover-5T1fAq2L.js → ExperimentPopover-DujiuQIO.js} +5 -5
- package/dist/{ExperimentPopover-5T1fAq2L.js.map → ExperimentPopover-DujiuQIO.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-65peG4Up.js → ExperimentSelectorModal-B4j4eUOJ.js} +114 -98
- package/dist/ExperimentSelectorModal-B4j4eUOJ.js.map +1 -0
- package/dist/{SegmentedControl-BtnnjWzs.js → SegmentedControl-DyUPH3I5.js} +2 -2
- package/dist/{SegmentedControl-BtnnjWzs.js.map → SegmentedControl-DyUPH3I5.js.map} +1 -1
- package/dist/{SettingsModal-483nyI6b.js → SettingsModal-D2MOOtuO.js} +26 -25
- package/dist/SettingsModal-D2MOOtuO.js.map +1 -0
- package/dist/__stories__/experiment-helpers.d.ts +10 -0
- package/dist/__tests__/components/AppTopBarOnboarding.test.d.ts +1 -0
- package/dist/__tests__/components/FloatingPanelsInModal.test.d.ts +1 -0
- package/dist/__tests__/composables/useFloatingPanel.test.d.ts +1 -0
- package/dist/__tests__/composables/useOnboarding.test.d.ts +1 -0
- package/dist/__tests__/styles/paletteTokens.test.d.ts +1 -0
- package/dist/__tests__/styles/v2AuditRules.test.d.ts +1 -0
- package/dist/components/AppTopBar.vue.d.ts +20 -5
- package/dist/components/BaseButton.vue.d.ts +4 -2
- package/dist/components/ExperimentSelectorModal.vue.d.ts +15 -1
- package/dist/components/JobsStatusTray.vue.d.ts +15 -4
- package/dist/components/PluginWorkspaceView.props.d.ts +3 -0
- package/dist/components/PluginWorkspaceView.vue.d.ts +3 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -3
- package/dist/components/internal/AppTopBarOnboardingInternal.vue.d.ts +7 -0
- package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -1
- package/dist/components/internal/AutoGroupModalInternal.vue.d.ts +2 -2
- package/dist/components/internal/OnboardingLayerInternal.vue.d.ts +7 -0
- package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +8 -0
- package/dist/composables/pluginWorkspaceModel.d.ts +2 -2
- package/dist/composables/useFileImport.d.ts +7 -0
- package/dist/composables/useFloatingPanel.d.ts +29 -0
- package/dist/composables/useJobCenter.d.ts +41 -3
- package/dist/composables/useOnboarding.d.ts +119 -0
- package/dist/{controlSchemaModel-B8CTXagP.js → controlSchemaModel-Bsg0t5Ab.js} +2 -2
- package/dist/{controlSchemaModel-B8CTXagP.js.map → controlSchemaModel-Bsg0t5Ab.js.map} +1 -1
- package/dist/{data-CgILxDLW.js → data-Boeh4uPI.js} +13 -12
- package/dist/{data-CgILxDLW.js.map → data-Boeh4uPI.js.map} +1 -1
- package/dist/data.d.ts +1 -1
- package/dist/data.js +9 -9
- package/dist/{experiment-43OJtH7R.js → experiment-DyO54FQB.js} +25 -61
- package/dist/experiment-DyO54FQB.js.map +1 -0
- package/dist/{experimentStatus-Cqp6qNrR.js → experimentStatus-DCn2QKG7.js} +2 -2
- package/dist/{experimentStatus-Cqp6qNrR.js.map → experimentStatus-DCn2QKG7.js.map} +1 -1
- package/dist/index.js +20 -20
- package/dist/{install-BtLWTk6H.js → install-BEHp7Mo-.js} +15 -15
- package/dist/{install-BtLWTk6H.js.map → install-BEHp7Mo-.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-MHzp-_J8.js → lab-D61eaX6f.js} +68 -61
- package/dist/{lab-MHzp-_J8.js.map → lab-D61eaX6f.js.map} +1 -1
- package/dist/lab.js +4 -4
- package/dist/{records-B6gpM_3e.js → records-D4aQXyZu.js} +193 -133
- package/dist/records-D4aQXyZu.js.map +1 -0
- package/dist/{runtime-7vfl_bI3.js → runtime-q-R0CNLh.js} +400 -33
- package/dist/runtime-q-R0CNLh.js.map +1 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +7 -7
- package/dist/{shell-Cs9OJMnm.js → shell-6CGFvUUw.js} +10 -5
- package/dist/{shell-Cs9OJMnm.js.map → shell-6CGFvUUw.js.map} +1 -1
- package/dist/shell.d.ts +1 -0
- package/dist/shell.js +6 -6
- package/dist/stores/index.d.ts +1 -1
- package/dist/stores/index.js +2 -2
- package/dist/stores/settings.d.ts +4 -12
- package/dist/styles.css +1191 -479
- package/dist/types/components.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/jobs.d.ts +9 -0
- package/dist/types/platform.d.ts +1 -1
- package/dist/{useDropdownState-8yWMu-7_.js → useDropdownState-uFIz6Yv8.js} +16 -4
- package/dist/useDropdownState-uFIz6Yv8.js.map +1 -0
- package/dist/{useExperimentSamples-DHnxKFAF.js → useExperimentSamples-QEFoq4tf.js} +48 -6
- package/dist/useExperimentSamples-QEFoq4tf.js.map +1 -0
- package/dist/{useListReorder-BitdUhxn.js → useListReorder-DsuzwWGS.js} +122 -172
- package/dist/useListReorder-DsuzwWGS.js.map +1 -0
- package/dist/{useRackEditor-DiwCt77M.js → useRackEditor-KpZ4iJ6o.js} +22 -32
- package/dist/useRackEditor-KpZ4iJ6o.js.map +1 -0
- package/dist/useRealtime-B58Lo-Tm.js +508 -0
- package/dist/useRealtime-B58Lo-Tm.js.map +1 -0
- package/dist/{useRequest-D506eHi8.js → useRequest-BuDmW8Jc.js} +8 -37
- package/dist/useRequest-BuDmW8Jc.js.map +1 -0
- package/package.json +3 -2
- package/src/__stories__/experiment-helpers.ts +3 -3
- package/src/__tests__/components/ActionMenu.test.ts +2 -2
- package/src/__tests__/components/AppTopBar.test.ts +206 -1
- package/src/__tests__/components/AppTopBarOnboarding.test.ts +160 -0
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +125 -9
- package/src/__tests__/components/BaseButton.test.ts +19 -0
- package/src/__tests__/components/BaseModal.test.ts +13 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +34 -1
- package/src/__tests__/components/FloatingPanelsInModal.test.ts +94 -0
- package/src/__tests__/components/GeneratedPluginView.test.ts +1 -1
- package/src/__tests__/components/JobsStatusTray.test.ts +373 -71
- package/src/__tests__/components/PluginWorkspaceView.test.ts +63 -1
- package/src/__tests__/components/SampleSelector.test.ts +20 -0
- package/src/__tests__/components/SettingsModal.test.ts +9 -2
- package/src/__tests__/composables/useFileImport.test.ts +36 -0
- package/src/__tests__/composables/useFloatingPanel.test.ts +68 -0
- package/src/__tests__/composables/useJobCenter.test.ts +262 -3
- package/src/__tests__/composables/useOnboarding.test.ts +317 -0
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +4 -3
- package/src/__tests__/fixtures/table-import/cells.xlsx +0 -0
- package/src/__tests__/fixtures/table-import/samples.xlsx +0 -0
- package/src/__tests__/stores/settings.test.ts +21 -21
- package/src/__tests__/styles/cssTokens.test.ts +4 -1
- package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
- package/src/__tests__/styles/paletteTokens.test.ts +44 -0
- package/src/__tests__/styles/v2AuditRules.test.ts +104 -0
- package/src/components/ActionMenu.vue +6 -37
- package/src/components/AppTopBar.onboarding.story.vue +69 -0
- package/src/components/AppTopBar.story.vue +59 -4
- package/src/components/AppTopBar.vue +132 -10
- package/src/components/BaseButton.vue +16 -2
- package/src/components/BaseModal.vue +2 -1
- package/src/components/ExperimentSelectorModal.story.vue +38 -0
- package/src/components/ExperimentSelectorModal.vue +20 -1
- package/src/components/GeneratedPluginView.vue +4 -9
- package/src/components/GeneratedResultRenderer.vue +3 -4
- package/src/components/JobsStatusTray.story.vue +97 -43
- package/src/components/JobsStatusTray.vue +287 -290
- package/src/components/LayoutResizeHandle.story.vue +65 -4
- package/src/components/PluginWorkspaceView.props.ts +3 -0
- package/src/components/PluginWorkspaceView.vue +17 -0
- package/src/components/SampleSelector.story.vue +21 -6
- package/src/components/SampleSelector.vue +10 -3
- package/src/components/SectionCard.story.vue +33 -0
- package/src/components/SettingsModal.vue +7 -12
- package/src/components/Tooltip.vue +9 -32
- package/src/components/internal/AppTopBarOnboardingInternal.vue +172 -0
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +140 -72
- package/src/components/internal/DatePickerDateInternal.vue +7 -45
- package/src/components/internal/DatePickerDateTimeInternal.vue +7 -1
- package/src/components/internal/DatePickerTimeInternal.vue +8 -1
- package/src/components/internal/NumberUnitInputInternal.vue +4 -18
- package/src/components/internal/OnboardingLayerInternal.vue +173 -0
- package/src/components/internal/SelectMultipleInternal.vue +6 -1
- package/src/components/internal/SelectSearchableInternal.vue +6 -1
- package/src/components/internal/WellEditPopupInternal.vue +11 -22
- package/src/components/internal/WorkspaceShellInternal.vue +12 -0
- package/src/composables/pluginWorkspaceModel.ts +2 -2
- package/src/composables/useFileImport.ts +51 -1
- package/src/composables/useFloatingPanel.ts +107 -0
- package/src/composables/useJobCenter.ts +188 -6
- package/src/composables/useOnboarding.ts +465 -0
- package/src/composables/usePluginWorkspace.ts +1 -1
- package/src/data.ts +1 -0
- package/src/runtime.ts +2 -1
- package/src/shell.ts +17 -0
- package/src/stores/index.ts +0 -2
- package/src/stores/settings.ts +20 -50
- package/src/styles/components/action-menu.css +1 -1
- package/src/styles/components/app-layout.css +6 -3
- package/src/styles/components/app-page-selector.css +120 -45
- package/src/styles/components/app-top-bar.css +165 -3
- package/src/styles/components/button.css +8 -0
- package/src/styles/components/dataframe.css +1 -1
- package/src/styles/components/date-picker.css +4 -2
- package/src/styles/components/datetime-picker.css +4 -4
- package/src/styles/components/experiment-selector-modal.css +13 -0
- package/src/styles/components/jobs-status-tray.css +134 -296
- package/src/styles/components/layout-resize-handle.css +59 -16
- package/src/styles/components/modal.css +3 -3
- package/src/styles/components/number-input.css +1 -1
- package/src/styles/components/onboarding.css +368 -0
- package/src/styles/components/progress-bar.css +3 -3
- package/src/styles/components/sample-selector.css +43 -0
- package/src/styles/components/searchable-select.css +3 -7
- package/src/styles/components/section-card.css +30 -6
- package/src/styles/components/settings-modal.css +7 -2
- package/src/styles/components/time-picker.css +3 -6
- package/src/styles/components/toast.css +1 -1
- package/src/styles/components/tooltip.css +2 -2
- package/src/styles/components/unit-input.css +1 -1
- package/src/styles/components/well-edit-popup.css +2 -2
- package/src/styles/index.css +1 -0
- package/src/styles/variables.css +153 -15
- package/src/types/components.ts +2 -0
- package/src/types/index.ts +1 -0
- package/src/types/jobs.ts +10 -0
- package/src/types/platform.ts +1 -1
- package/dist/AppSidebar-Dq9W1jE2.js.map +0 -1
- package/dist/ExperimentSelectorModal-65peG4Up.js.map +0 -1
- package/dist/SettingsModal-483nyI6b.js.map +0 -1
- package/dist/experiment-43OJtH7R.js.map +0 -1
- package/dist/records-B6gpM_3e.js.map +0 -1
- package/dist/runtime-7vfl_bI3.js.map +0 -1
- package/dist/useDropdownState-8yWMu-7_.js.map +0 -1
- package/dist/useExperimentSamples-DHnxKFAF.js.map +0 -1
- package/dist/useListReorder-BitdUhxn.js.map +0 -1
- package/dist/useRackEditor-DiwCt77M.js.map +0 -1
- package/dist/useRealtime-C3iy9QkU.js +0 -699
- package/dist/useRealtime-C3iy9QkU.js.map +0 -1
- 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.
|
|
161
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
/**
|
|
54
|
-
title
|
|
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
|
-
|
|
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
|
|
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,
|