@morscherlab/mint-sdk 1.0.56 → 1.0.58

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 (44) hide show
  1. package/dist/__tests__/components/ArtifactSaveDialog.test.d.ts +1 -0
  2. package/dist/__tests__/components/ArtifactSelectorModal.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useAnalysisArtifacts.test.d.ts +1 -0
  4. package/dist/analysisArtifactTypes-Cu40dzSG.js +7 -0
  5. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +1 -0
  6. package/dist/components/ArtifactSaveDialog.vue.d.ts +59 -0
  7. package/dist/components/ArtifactSelectorModal.vue.d.ts +41 -0
  8. package/dist/components/index.d.ts +2 -0
  9. package/dist/components/index.js +2 -2
  10. package/dist/{components-BQwOeyiy.js → components-CpnOaPbP.js} +1774 -1144
  11. package/dist/components-CpnOaPbP.js.map +1 -0
  12. package/dist/composables/index.d.ts +1 -0
  13. package/dist/composables/index.js +4 -3
  14. package/dist/composables/useAnalysisArtifacts.d.ts +59 -0
  15. package/dist/{composables-TpXyhRZZ.js → composables-OYTD0Y3r.js} +2 -2
  16. package/dist/{composables-TpXyhRZZ.js.map → composables-OYTD0Y3r.js.map} +1 -1
  17. package/dist/index.js +5 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/install.js +1 -1
  20. package/dist/styles.css +412 -0
  21. package/dist/types/analysisArtifactTypes.d.ts +133 -0
  22. package/dist/types/index.d.ts +2 -0
  23. package/dist/types/index.js +2 -0
  24. package/dist/{useJobsStatusTray-CVecN6Ao.js → useAnalysisArtifacts-Ob8UtVwl.js} +157 -2
  25. package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/__tests__/components/ArtifactSaveDialog.test.ts +566 -0
  28. package/src/__tests__/components/ArtifactSelectorModal.test.ts +178 -0
  29. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +667 -0
  30. package/src/components/ArtifactSaveDialog.story.vue +319 -0
  31. package/src/components/ArtifactSaveDialog.vue +434 -0
  32. package/src/components/ArtifactSelectorModal.story.vue +295 -0
  33. package/src/components/ArtifactSelectorModal.vue +326 -0
  34. package/src/components/index.ts +2 -0
  35. package/src/composables/index.ts +6 -0
  36. package/src/composables/useAnalysisArtifacts.ts +305 -0
  37. package/src/styles/components/artifact-save-dialog.css +17 -0
  38. package/src/styles/components/artifact-selector-modal.css +216 -0
  39. package/src/styles/index.css +2 -0
  40. package/src/types/analysisArtifactTypes.ts +167 -0
  41. package/src/types/index.ts +24 -0
  42. package/dist/components-BQwOeyiy.js.map +0 -1
  43. package/dist/useJobsStatusTray-CVecN6Ao.js.map +0 -1
  44. /package/dist/{ExperimentPopover-8A4Rhffp.js → ExperimentPopover-Ryusjrhv.js} +0 -0
@@ -0,0 +1,295 @@
1
+ <script setup lang="ts">
2
+ import { ref, nextTick, onMounted, onUnmounted } from 'vue'
3
+ import type { InternalAxiosRequestConfig } from 'axios'
4
+ import ArtifactSelectorModal from './ArtifactSelectorModal.vue'
5
+ import {
6
+ ANALYSIS_FILE_ARTIFACT_SCHEMA,
7
+ type AnalysisArtifactSummary,
8
+ } from '../types/analysisArtifactTypes'
9
+
10
+ // -- Mock data: artifacts from two analysis plugins on one experiment --
11
+ const mockArtifacts: AnalysisArtifactSummary[] = [
12
+ {
13
+ id: 1,
14
+ experiment_id: 42,
15
+ plugin_id: 'drp',
16
+ artifact_key: 'dose-response-fit',
17
+ display_name: 'Doxorubicin IC50 fit',
18
+ note: 'Four-parameter logistic, R² = 0.994',
19
+ status: 'active',
20
+ result_keys: ['curves', 'ic50', 'summary'],
21
+ created_at: '2026-07-01T10:00:00Z',
22
+ updated_at: '2026-07-02T09:15:00Z',
23
+ },
24
+ {
25
+ id: 2,
26
+ experiment_id: 42,
27
+ plugin_id: 'drp',
28
+ artifact_key: 'synergy-matrix',
29
+ display_name: 'DOX + 5-FU synergy matrix',
30
+ status: 'active',
31
+ result_keys: ['bliss', 'hsa'],
32
+ created_at: '2026-07-01T11:30:00Z',
33
+ updated_at: '2026-07-01T11:30:00Z',
34
+ },
35
+ {
36
+ id: 3,
37
+ experiment_id: 42,
38
+ plugin_id: 'drp',
39
+ artifact_key: 'qc-report',
40
+ display_name: 'QC report (PDF)',
41
+ note: 'Generated after plate re-read',
42
+ status: 'active',
43
+ result_keys: ['schema_version', 'kind', 'filename', 'file'],
44
+ created_at: '2026-07-02T14:00:00Z',
45
+ updated_at: '2026-07-02T14:00:00Z',
46
+ },
47
+ {
48
+ id: 4,
49
+ experiment_id: 42,
50
+ plugin_id: 'ms-uploader',
51
+ artifact_key: 'peak-table',
52
+ display_name: 'Peak table (raw)',
53
+ status: 'active',
54
+ result_keys: ['peak_data', 'sample_names', 'upload_info'],
55
+ created_at: '2026-06-28T08:00:00Z',
56
+ updated_at: '2026-06-28T08:00:00Z',
57
+ },
58
+ {
59
+ id: 5,
60
+ experiment_id: 42,
61
+ plugin_id: 'ms-uploader',
62
+ artifact_key: 'chromatogram',
63
+ display_name: 'TIC chromatogram (PNG)',
64
+ status: 'active',
65
+ result_keys: ['schema_version', 'kind', 'filename', 'file'],
66
+ created_at: '2026-06-28T08:05:00Z',
67
+ updated_at: '2026-06-28T08:05:00Z',
68
+ },
69
+ {
70
+ id: 6,
71
+ experiment_id: 42,
72
+ plugin_id: 'drp',
73
+ artifact_key: 'old-fit',
74
+ display_name: 'Superseded IC50 fit',
75
+ note: 'Replaced after outlier removal',
76
+ status: 'archived',
77
+ result_keys: ['curves', 'ic50'],
78
+ created_at: '2026-06-20T09:00:00Z',
79
+ updated_at: '2026-06-25T16:00:00Z',
80
+ archived_at: '2026-06-25T16:00:00Z',
81
+ archived_by: 3,
82
+ },
83
+ ]
84
+
85
+ const mockDetails: Record<number, Record<string, unknown>> = {
86
+ 3: {
87
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
88
+ kind: 'report',
89
+ filename: 'qc-report.pdf',
90
+ file: { uri: 'mint-object://experiments/42/plugins/drp/analysis-artifacts/qc-report/qc-report.pdf' },
91
+ },
92
+ 5: {
93
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
94
+ kind: 'plot',
95
+ filename: 'tic.png',
96
+ file: { uri: 'mint-object://experiments/42/plugins/ms-uploader/analysis-artifacts/chromatogram/tic.png' },
97
+ },
98
+ }
99
+
100
+ // -- Intercept API calls to return mock data --
101
+ // Ejected on unmount so remounts and HMR don't stack interceptors on the shared client.
102
+ let interceptorId: number | null = null
103
+ let ejectInterceptor: (() => void) | null = null
104
+
105
+ onUnmounted(() => {
106
+ ejectInterceptor?.()
107
+ })
108
+
109
+ onMounted(async () => {
110
+ const { useApi } = await import('../composables/useApi')
111
+ const { client } = useApi()
112
+ const state = mockArtifacts.map((artifact) => ({ ...artifact }))
113
+
114
+ function respond(config: InternalAxiosRequestConfig, data: unknown) {
115
+ return () => Promise.resolve({
116
+ data,
117
+ status: 200,
118
+ statusText: 'OK',
119
+ headers: {},
120
+ config,
121
+ })
122
+ }
123
+
124
+ interceptorId = client.interceptors.request.use((config) => {
125
+ const url = config.url ?? ''
126
+ if (!url.includes('/analysis-artifacts')) return config
127
+
128
+ const method = (config.method ?? 'get').toLowerCase()
129
+ const idMatch = url.match(/\/analysis-artifacts\/(\d+)/)
130
+ const artifactId = idMatch ? Number(idMatch[1]) : undefined
131
+ const artifact = state.find((row) => row.id === artifactId)
132
+
133
+ if (method === 'post' && artifact) {
134
+ artifact.status = url.endsWith('/archive') ? 'archived' : 'active'
135
+ artifact.archived_at = url.endsWith('/archive') ? new Date().toISOString() : null
136
+ config.adapter = respond(config, { ...artifact })
137
+ return config
138
+ }
139
+
140
+ if (method === 'patch' && artifact) {
141
+ Object.assign(artifact, config.data ? JSON.parse(String(config.data)) : {})
142
+ config.adapter = respond(config, { ...artifact })
143
+ return config
144
+ }
145
+
146
+ if (artifactId && artifact) {
147
+ config.adapter = respond(config, {
148
+ ...artifact,
149
+ result: mockDetails[artifactId] ?? { summary: { rows: 96 } },
150
+ tree: [],
151
+ summary: null,
152
+ })
153
+ return config
154
+ }
155
+
156
+ const params = new URLSearchParams(url.split('?')[1] ?? '')
157
+ const includeArchived = params.get('include_archived') === 'true'
158
+ const rows = includeArchived ? state : state.filter((row) => row.status === 'active')
159
+ config.adapter = respond(config, { artifacts: rows.map((row) => ({ ...row })) })
160
+ return config
161
+ })
162
+
163
+ ejectInterceptor = () => {
164
+ if (interceptorId !== null) {
165
+ client.interceptors.request.eject(interceptorId)
166
+ interceptorId = null
167
+ }
168
+ }
169
+
170
+ await nextTick()
171
+ isOpen.value = true
172
+ })
173
+
174
+ // -- Story state --
175
+ const isOpen = ref(false)
176
+ const isOpenScoped = ref(false)
177
+ const isOpenArchived = ref(false)
178
+ const isOpenEmpty = ref(false)
179
+ const selectedArtifact = ref<AnalysisArtifactSummary | null>(null)
180
+
181
+ function handleSelect(artifact: AnalysisArtifactSummary) {
182
+ selectedArtifact.value = artifact
183
+ }
184
+ </script>
185
+
186
+ <template>
187
+ <Story title="Experiment/ArtifactSelectorModal">
188
+ <Variant title="Playground">
189
+ <template #default="{ state }">
190
+ <div style="padding: 2rem;">
191
+ <button
192
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
193
+ @click="isOpen = true"
194
+ >
195
+ Open Artifact Selector
196
+ </button>
197
+
198
+ <div
199
+ v-if="selectedArtifact"
200
+ style="margin-top: 1rem; padding: 0.75rem 1rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; display: flex; align-items: center; gap: 0.5rem;"
201
+ >
202
+ <span style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8);">Selected:</span>
203
+ <code style="font-size: 0.75rem; color: var(--text-secondary, #475569);">
204
+ {{ selectedArtifact.plugin_id }}/{{ selectedArtifact.artifact_key }}
205
+ </code>
206
+ <span style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary, #1e293b);">
207
+ {{ selectedArtifact.display_name }}
208
+ </span>
209
+ </div>
210
+
211
+ <ArtifactSelectorModal
212
+ v-model="isOpen"
213
+ :experiment-id="42"
214
+ :title="state.title"
215
+ :include-archived="state.includeArchived"
216
+ :fetch-detail-on-select="state.fetchDetailOnSelect"
217
+ :current-artifact-id="selectedArtifact?.id ?? null"
218
+ @select="handleSelect"
219
+ />
220
+ </div>
221
+ </template>
222
+
223
+ <template #controls="{ state }">
224
+ <HstCheckbox v-model="isOpen" title="Open" />
225
+ <HstText v-model="state.title" title="Title" />
226
+ <HstCheckbox v-model="state.includeArchived" title="Include archived" />
227
+ <HstCheckbox v-model="state.fetchDetailOnSelect" title="Fetch detail on select" />
228
+ </template>
229
+ </Variant>
230
+
231
+ <Variant title="With Archived">
232
+ <div style="padding: 2rem;">
233
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
234
+ Opens with archived artifacts visible. Archived rows are muted and offer Restore.
235
+ </p>
236
+ <button
237
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
238
+ @click="isOpenArchived = true"
239
+ >
240
+ Open With Archived
241
+ </button>
242
+
243
+ <ArtifactSelectorModal
244
+ v-model="isOpenArchived"
245
+ :experiment-id="42"
246
+ :include-archived="true"
247
+ @select="handleSelect"
248
+ />
249
+ </div>
250
+ </Variant>
251
+
252
+ <Variant title="Single Plugin Scoped">
253
+ <div style="padding: 2rem;">
254
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
255
+ Scoped to the <code>ms-uploader</code> plugin, so the plugin filter is hidden.
256
+ </p>
257
+ <button
258
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
259
+ @click="isOpenScoped = true"
260
+ >
261
+ Open Scoped Selector
262
+ </button>
263
+
264
+ <ArtifactSelectorModal
265
+ v-model="isOpenScoped"
266
+ :experiment-id="42"
267
+ plugin-id="ms-uploader"
268
+ title="Select Peak Data"
269
+ @select="handleSelect"
270
+ />
271
+ </div>
272
+ </Variant>
273
+
274
+ <Variant title="Empty State">
275
+ <div style="padding: 2rem;">
276
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
277
+ Scoped to a plugin with no artifacts, showing the empty state.
278
+ </p>
279
+ <button
280
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
281
+ @click="isOpenEmpty = true"
282
+ >
283
+ Open Selector (Empty)
284
+ </button>
285
+
286
+ <ArtifactSelectorModal
287
+ v-model="isOpenEmpty"
288
+ :experiment-id="42"
289
+ plugin-id="nonexistent-plugin"
290
+ @select="handleSelect"
291
+ />
292
+ </div>
293
+ </Variant>
294
+ </Story>
295
+ </template>
@@ -0,0 +1,326 @@
1
+ <script setup lang="ts">
2
+ /** Modal for browsing and selecting an analysis artifact of an experiment, with filters and keyboard nav. */
3
+ import { computed, ref, watch, nextTick } from 'vue'
4
+ import type { ModalSize } from '../types'
5
+ import type {
6
+ AnalysisArtifactDetail,
7
+ AnalysisArtifactStatusFilter,
8
+ AnalysisArtifactSummary,
9
+ } from '../types/analysisArtifactTypes'
10
+ import { useAnalysisArtifacts } from '../composables/useAnalysisArtifacts'
11
+ import { formatExperimentDate } from '../composables/experiment-utils'
12
+ import { getInjectedPlatformContext } from '../composables/platformContextHelpers'
13
+ import { hasAllPermissions } from '../permissions'
14
+ import BaseModal from './BaseModal.vue'
15
+ import BaseInput from './BaseInput.vue'
16
+ import BaseSelect from './BaseSelect.vue'
17
+ import BasePill from './BasePill.vue'
18
+ import Skeleton from './Skeleton.vue'
19
+ import EmptyState from './EmptyState.vue'
20
+
21
+ interface Props {
22
+ modelValue: boolean
23
+ /** Experiment id override; defaults to platform injection or URL. */
24
+ experimentId?: number | null
25
+ /** Scope to a single owning plugin; omit to show all plugins. */
26
+ pluginId?: string | null
27
+ /** Highlights the currently used artifact. */
28
+ currentArtifactId?: number | null
29
+ /** Start with archived artifacts visible. */
30
+ includeArchived?: boolean
31
+ /** Fetch the full detail before emitting selectDetail. */
32
+ fetchDetailOnSelect?: boolean
33
+ title?: string
34
+ size?: ModalSize
35
+ }
36
+
37
+ const props = withDefaults(defineProps<Props>(), {
38
+ experimentId: null,
39
+ pluginId: null,
40
+ currentArtifactId: null,
41
+ includeArchived: false,
42
+ fetchDetailOnSelect: false,
43
+ title: 'Select Analysis Artifact',
44
+ size: 'full',
45
+ })
46
+
47
+ const emit = defineEmits<{
48
+ 'update:modelValue': [value: boolean]
49
+ select: [artifact: AnalysisArtifactSummary]
50
+ selectDetail: [detail: AnalysisArtifactDetail]
51
+ archive: [artifact: AnalysisArtifactSummary]
52
+ restore: [artifact: AnalysisArtifactSummary]
53
+ }>()
54
+
55
+ const {
56
+ artifacts,
57
+ visibleArtifacts,
58
+ isLoading,
59
+ isMutating,
60
+ error,
61
+ search,
62
+ statusFilter,
63
+ pluginFilter,
64
+ list,
65
+ getDetail,
66
+ archive,
67
+ restore,
68
+ } = useAnalysisArtifacts({
69
+ experimentId: () => props.experimentId ?? undefined,
70
+ pluginId: () => props.pluginId,
71
+ includeArchived: () => props.includeArchived,
72
+ })
73
+
74
+ const injectedContext = getInjectedPlatformContext()
75
+ // Without injected user info (standalone/dev mode) the backend stays the authority.
76
+ const canManage = computed(() => {
77
+ const user = injectedContext?.user
78
+ return user ? hasAllPermissions(user, ['experiments.edit']) : true
79
+ })
80
+
81
+ const STATUS_FILTER_OPTIONS: Array<{ value: AnalysisArtifactStatusFilter; label: string }> = [
82
+ { value: 'active', label: 'Active' },
83
+ { value: 'archived', label: 'Archived' },
84
+ { value: 'all', label: 'All statuses' },
85
+ ]
86
+
87
+ const pluginFilterOptions = computed(() => [
88
+ { value: '', label: 'All plugins' },
89
+ ...[...new Set(artifacts.value.map((artifact) => artifact.plugin_id))]
90
+ .sort()
91
+ .map((pluginId) => ({ value: pluginId, label: pluginId })),
92
+ ])
93
+ const canChoosePlugin = computed(
94
+ () => !props.pluginId && pluginFilterOptions.value.length > 2,
95
+ )
96
+
97
+ const activeIndex = ref(-1)
98
+ const listRef = ref<HTMLElement | null>(null)
99
+
100
+ const LISTBOX_ID = 'mint-artifact-selector-listbox'
101
+ function optionId(artifact: AnalysisArtifactSummary): string {
102
+ return `mint-artifact-option-${artifact.id}`
103
+ }
104
+ const activeDescendantId = computed(() => {
105
+ const artifact = visibleArtifacts.value[activeIndex.value]
106
+ return activeIndex.value >= 0 && artifact ? optionId(artifact) : undefined
107
+ })
108
+ const hasResults = computed(
109
+ () => !isLoading.value && !error.value && visibleArtifacts.value.length > 0,
110
+ )
111
+
112
+ function statusVariant(status: string): 'success' | 'warning' {
113
+ return status === 'archived' ? 'warning' : 'success'
114
+ }
115
+
116
+ function formatArtifactDate(artifact: AnalysisArtifactSummary): string {
117
+ const date = artifact.updated_at ?? artifact.created_at
118
+ return date ? formatExperimentDate(date) : ''
119
+ }
120
+
121
+ async function handleSelect(artifact: AnalysisArtifactSummary): Promise<void> {
122
+ if (props.fetchDetailOnSelect) {
123
+ const detail = await getDetail(artifact.id)
124
+ // Stay open on failure: closing would look like a successful pick while the
125
+ // consumer waiting on selectDetail got nothing.
126
+ if (!detail) return
127
+ emit('selectDetail', detail)
128
+ }
129
+ emit('select', artifact)
130
+ emit('update:modelValue', false)
131
+ }
132
+
133
+ // Emit what the backend returned, not the row we started from: the pre-mutation
134
+ // object still carries the old status.
135
+ async function handleArchive(artifact: AnalysisArtifactSummary): Promise<void> {
136
+ const archived = await archive(artifact.id)
137
+ if (archived) emit('archive', archived)
138
+ }
139
+
140
+ async function handleRestore(artifact: AnalysisArtifactSummary): Promise<void> {
141
+ const restored = await restore(artifact.id)
142
+ if (restored) emit('restore', restored)
143
+ }
144
+
145
+ function handleKeydown(event: KeyboardEvent): void {
146
+ const rows = visibleArtifacts.value
147
+ if (!rows.length) return
148
+ // Let the row's own Archive/Restore button handle its keys natively.
149
+ if ((event.target as HTMLElement | null)?.closest('.mint-artifact-selector__action')) return
150
+
151
+ switch (event.key) {
152
+ case 'ArrowDown':
153
+ event.preventDefault()
154
+ activeIndex.value = Math.min(activeIndex.value + 1, rows.length - 1)
155
+ scrollActiveIntoView()
156
+ break
157
+ case 'ArrowUp':
158
+ event.preventDefault()
159
+ activeIndex.value = Math.max(activeIndex.value - 1, 0)
160
+ scrollActiveIntoView()
161
+ break
162
+ case 'Enter':
163
+ event.preventDefault()
164
+ if (activeIndex.value >= 0 && activeIndex.value < rows.length) {
165
+ void handleSelect(rows[activeIndex.value])
166
+ }
167
+ break
168
+ }
169
+ }
170
+
171
+ function scrollActiveIntoView(): void {
172
+ nextTick(() => {
173
+ const row = listRef.value?.querySelector('.mint-artifact-selector__row--focused')
174
+ row?.scrollIntoView({ block: 'nearest' })
175
+ })
176
+ }
177
+
178
+ watch(visibleArtifacts, () => { activeIndex.value = -1 })
179
+
180
+ watch(
181
+ () => props.modelValue,
182
+ (isOpen) => {
183
+ if (isOpen) {
184
+ activeIndex.value = -1
185
+ statusFilter.value = props.includeArchived ? 'all' : 'active'
186
+ void list()
187
+ }
188
+ },
189
+ { immediate: true },
190
+ )
191
+ </script>
192
+
193
+ <template>
194
+ <BaseModal
195
+ :model-value="modelValue"
196
+ :title="title"
197
+ :size="size"
198
+ @update:model-value="emit('update:modelValue', $event)"
199
+ >
200
+ <div class="mint-artifact-selector" @keydown="handleKeydown">
201
+ <!-- Filter row -->
202
+ <div class="mint-artifact-selector__filters-row">
203
+ <div class="mint-artifact-selector__search">
204
+ <BaseInput
205
+ v-model="search"
206
+ placeholder="Search artifacts..."
207
+ size="sm"
208
+ type="search"
209
+ role="combobox"
210
+ aria-label="Search analysis artifacts"
211
+ :aria-expanded="hasResults"
212
+ :aria-controls="LISTBOX_ID"
213
+ :aria-activedescendant="activeDescendantId"
214
+ />
215
+ </div>
216
+ <div class="mint-artifact-selector__filter-select">
217
+ <BaseSelect
218
+ :model-value="statusFilter"
219
+ :options="STATUS_FILTER_OPTIONS"
220
+ size="sm"
221
+ aria-label="Filter by status"
222
+ @update:model-value="v => statusFilter = v as AnalysisArtifactStatusFilter"
223
+ />
224
+ </div>
225
+ <div v-if="canChoosePlugin" class="mint-artifact-selector__filter-select">
226
+ <BaseSelect
227
+ :model-value="pluginFilter ?? ''"
228
+ :options="pluginFilterOptions"
229
+ size="sm"
230
+ aria-label="Filter by plugin"
231
+ @update:model-value="v => pluginFilter = String(v) || null"
232
+ />
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Loading skeleton -->
237
+ <div v-if="isLoading" class="mint-artifact-selector__skeleton">
238
+ <div v-for="n in 4" :key="n" class="mint-artifact-selector__skeleton-row">
239
+ <div class="mint-artifact-selector__skeleton-content">
240
+ <Skeleton :width="120 + n * 20" height="14px" />
241
+ <Skeleton width="80px" height="10px" />
242
+ </div>
243
+ <Skeleton width="60px" height="20px" variant="rounded" />
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Error -->
248
+ <div v-else-if="error" class="mint-artifact-selector__error">
249
+ {{ error }}
250
+ </div>
251
+
252
+ <!-- Empty -->
253
+ <EmptyState
254
+ v-else-if="visibleArtifacts.length === 0"
255
+ title="No analysis artifacts found"
256
+ description="Artifacts appear here once an analysis saves results for this experiment."
257
+ size="sm"
258
+ />
259
+
260
+ <!-- Artifact list -->
261
+ <div
262
+ v-else
263
+ :id="LISTBOX_ID"
264
+ ref="listRef"
265
+ class="mint-artifact-selector__list"
266
+ role="listbox"
267
+ aria-label="Analysis artifacts"
268
+ >
269
+ <div
270
+ v-for="(artifact, idx) in visibleArtifacts"
271
+ :id="optionId(artifact)"
272
+ :key="artifact.id"
273
+ class="mint-artifact-selector__row"
274
+ role="option"
275
+ :aria-selected="artifact.id === currentArtifactId"
276
+ :class="{
277
+ 'mint-artifact-selector__row--active': artifact.id === currentArtifactId,
278
+ 'mint-artifact-selector__row--focused': idx === activeIndex,
279
+ 'mint-artifact-selector__row--archived': artifact.status === 'archived',
280
+ }"
281
+ @click="handleSelect(artifact)"
282
+ @mouseenter="activeIndex = idx"
283
+ >
284
+ <div class="mint-artifact-selector__row-content">
285
+ <div class="mint-artifact-selector__name">
286
+ {{ artifact.display_name }}
287
+ <code class="mint-artifact-selector__key">{{ artifact.artifact_key }}</code>
288
+ </div>
289
+ <div class="mint-artifact-selector__meta">
290
+ <span class="mint-artifact-selector__plugin">{{ artifact.plugin_id }}</span>
291
+ <span>{{ artifact.result_keys.length }} result{{ artifact.result_keys.length === 1 ? '' : 's' }}</span>
292
+ <span>{{ formatArtifactDate(artifact) }}</span>
293
+ </div>
294
+ </div>
295
+ <div class="mint-artifact-selector__row-side">
296
+ <BasePill :variant="statusVariant(artifact.status)" size="sm">
297
+ {{ artifact.status }}
298
+ </BasePill>
299
+ <button
300
+ v-if="canManage && artifact.status === 'active'"
301
+ type="button"
302
+ class="mint-artifact-selector__action"
303
+ :disabled="isMutating"
304
+ @click.stop="handleArchive(artifact)"
305
+ >
306
+ Archive
307
+ </button>
308
+ <button
309
+ v-else-if="canManage && artifact.status === 'archived'"
310
+ type="button"
311
+ class="mint-artifact-selector__action"
312
+ :disabled="isMutating"
313
+ @click.stop="handleRestore(artifact)"
314
+ >
315
+ Restore
316
+ </button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+ </BaseModal>
322
+ </template>
323
+
324
+ <style>
325
+ @import '../styles/components/artifact-selector-modal.css';
326
+ </style>
@@ -126,5 +126,7 @@ export { default as ResourceCard } from './ResourceCard.vue'
126
126
 
127
127
  // Experiment / analysis components
128
128
  export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
129
+ export { default as ArtifactSelectorModal } from './ArtifactSelectorModal.vue'
130
+ export { default as ArtifactSaveDialog } from './ArtifactSaveDialog.vue'
129
131
  export { default as ExperimentPopover } from './ExperimentPopover.vue'
130
132
  export { default as FitPanel } from './FitPanel.vue'
@@ -141,6 +141,12 @@ export {
141
141
  type UseExperimentSelectorOptions,
142
142
  type UseExperimentSelectorReturn,
143
143
  } from './useExperimentSelector'
144
+ export {
145
+ useAnalysisArtifacts,
146
+ ANALYSIS_FILE_ARTIFACT_SCHEMA,
147
+ type UseAnalysisArtifactsOptions,
148
+ type UseAnalysisArtifactsReturn,
149
+ } from './useAnalysisArtifacts'
144
150
  export {
145
151
  formatExperimentDate,
146
152
  formatExperimentStatus,