@cat-factory/app 0.42.1 → 0.44.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/app/components/board/RecurringPipelineModal.vue +16 -1
- package/app/components/layout/IntegrationsHub.vue +2 -1
- package/app/components/media/ArtifactLightbox.vue +273 -0
- package/app/components/media/ImageCompare.vue +305 -0
- package/app/components/settings/IssueTrackerPanel.vue +96 -6
- package/app/components/testing/TestReportWindow.vue +149 -8
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +190 -104
- package/app/composables/useArtifactBlobs.ts +120 -0
- package/app/composables/useFocusTrap.ts +72 -0
- package/app/stores/tracker.ts +2 -0
- package/app/stores/visualConfirm.ts +6 -35
- package/app/types/domain.ts +1 -0
- package/package.json +2 -2
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
// Visual-confirmation gate window — the dedicated surface for a `visual-confirmation` step
|
|
3
3
|
// (opened via the universal result-view host, the same seam the human-test / tester windows
|
|
4
4
|
// use). It reads the gate's live state off the execution step (`step.visualConfirm`, pushed
|
|
5
|
-
// over the stream), renders each captured screenshot
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
5
|
+
// over the stream), renders each captured screenshot against its reference design (via the
|
|
6
|
+
// reusable <ImageCompare>: side-by-side / overlay / swipe / diff, with click-to-zoom into the
|
|
7
|
+
// shared <ArtifactLightbox>), and drives the human actions: approve (advance), request a fix
|
|
8
|
+
// (per-view notes + a freeform box, composed into the Tester's fixer findings), or recapture.
|
|
9
|
+
// References can be dropped straight onto a pair, or uploaded for any view below.
|
|
10
|
+
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
|
10
11
|
import type { VisualConfirmStepState } from '~/types/execution'
|
|
12
|
+
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
13
|
+
import { useFocusTrap } from '~/composables/useFocusTrap'
|
|
14
|
+
import ImageCompare from '~/components/media/ImageCompare.vue'
|
|
15
|
+
import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
11
16
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
12
17
|
|
|
13
18
|
const board = useBoardStore()
|
|
14
19
|
const execution = useExecutionStore()
|
|
15
20
|
const visualConfirm = useVisualConfirmStore()
|
|
16
21
|
|
|
17
|
-
//
|
|
18
|
-
// (potentially large) blob bytes don't linger
|
|
19
|
-
|
|
22
|
+
// Per-window blob cache; release the cached screenshot/reference object URLs when the window
|
|
23
|
+
// goes away, so the (potentially large) blob bytes don't linger for the rest of the session.
|
|
24
|
+
const blobs = useArtifactBlobs()
|
|
25
|
+
onUnmounted(() => blobs.revokeAll())
|
|
20
26
|
|
|
21
27
|
const { open, blockId, instanceId, stepIndex, close } = useResultView('visual-confirm')
|
|
22
28
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
@@ -41,70 +47,134 @@ const PHASE_LABEL: Record<NonNullable<VisualConfirmStepState['phase']>, string>
|
|
|
41
47
|
approved: 'Approved',
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
// Resolve
|
|
45
|
-
const urls = reactive<Record<string, string>>({})
|
|
46
|
-
async function resolveUrl(id: string | null | undefined) {
|
|
47
|
-
if (!id || urls[id]) return
|
|
48
|
-
const url = await visualConfirm.blobUrl(id)
|
|
49
|
-
if (url) urls[id] = url
|
|
50
|
-
}
|
|
50
|
+
// Resolve every pair's artifacts (the gallery + the lightbox share this one cache).
|
|
51
51
|
watch(
|
|
52
52
|
pairs,
|
|
53
53
|
(next) => {
|
|
54
54
|
for (const p of next) {
|
|
55
|
-
void
|
|
56
|
-
void
|
|
55
|
+
void blobs.resolve(p.actualArtifactId)
|
|
56
|
+
void blobs.resolve(p.referenceArtifactId)
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
{ immediate: true },
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
const
|
|
62
|
+
// Flat list of all images (actual then reference, per pair) for the lightbox + its index.
|
|
63
|
+
const lightboxItems = computed(() => {
|
|
64
|
+
const items: { artifactId: string; label: string; alt: string }[] = []
|
|
65
|
+
for (const p of pairs.value) {
|
|
66
|
+
if (p.actualArtifactId)
|
|
67
|
+
items.push({
|
|
68
|
+
artifactId: p.actualArtifactId,
|
|
69
|
+
label: `${p.view} — actual`,
|
|
70
|
+
alt: `${p.view} (actual)`,
|
|
71
|
+
})
|
|
72
|
+
if (p.referenceArtifactId)
|
|
73
|
+
items.push({
|
|
74
|
+
artifactId: p.referenceArtifactId,
|
|
75
|
+
label: `${p.view} — reference`,
|
|
76
|
+
alt: `${p.view} (reference)`,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
return items
|
|
80
|
+
})
|
|
81
|
+
const lightboxOpen = ref(false)
|
|
82
|
+
const lightboxIndex = ref(0)
|
|
83
|
+
function expand(artifactId: string) {
|
|
84
|
+
const i = lightboxItems.value.findIndex((it) => it.artifactId === artifactId)
|
|
85
|
+
lightboxIndex.value = i < 0 ? 0 : i
|
|
86
|
+
lightboxOpen.value = true
|
|
87
|
+
}
|
|
64
88
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
() => vc.value?.degradedReason ?? null,
|
|
72
|
-
() => {
|
|
73
|
-
ackDegraded.value = false
|
|
74
|
-
},
|
|
89
|
+
// Focus management for the modal panel. While the lightbox is open it owns the trap, so the
|
|
90
|
+
// window hands off (active = open && !lightbox) to avoid two Tab traps fighting.
|
|
91
|
+
const dialogRoot = ref<HTMLElement | null>(null)
|
|
92
|
+
useFocusTrap(
|
|
93
|
+
dialogRoot,
|
|
94
|
+
computed(() => open.value && !lightboxOpen.value),
|
|
75
95
|
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
|
|
97
|
+
// --- Request a fix: per-view notes + a freeform box, composed into one findings string. ---
|
|
98
|
+
const perViewNotes = reactive<Record<string, string>>({})
|
|
99
|
+
const noteOpen = reactive<Record<string, boolean>>({})
|
|
100
|
+
const globalFindings = ref('')
|
|
101
|
+
|
|
102
|
+
const hasFindings = computed(
|
|
103
|
+
() => globalFindings.value.trim() !== '' || pairs.value.some((p) => perViewNotes[p.view]?.trim()),
|
|
79
104
|
)
|
|
80
105
|
|
|
106
|
+
/** Compose the per-view notes + freeform text into the fixer's findings (and a structured
|
|
107
|
+
* mirror, so a future structured-findings contract is a one-line swap). */
|
|
108
|
+
function buildFindings(): { text: string; structured: { view?: string; note: string }[] } {
|
|
109
|
+
const structured: { view?: string; note: string }[] = []
|
|
110
|
+
const blocks: string[] = []
|
|
111
|
+
for (const p of pairs.value) {
|
|
112
|
+
const note = perViewNotes[p.view]?.trim()
|
|
113
|
+
if (note) {
|
|
114
|
+
structured.push({ view: p.view, note })
|
|
115
|
+
blocks.push(`### ${p.view}\n${note}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const general = globalFindings.value.trim()
|
|
119
|
+
if (general) {
|
|
120
|
+
structured.push({ note: general })
|
|
121
|
+
blocks.push(`### General\n${general}`)
|
|
122
|
+
}
|
|
123
|
+
return { text: blocks.join('\n\n'), structured }
|
|
124
|
+
}
|
|
125
|
+
|
|
81
126
|
async function approve() {
|
|
82
127
|
if (!blockId.value || !canApprove.value) return
|
|
83
128
|
await visualConfirm.approve(blockId.value)
|
|
84
129
|
close()
|
|
85
130
|
}
|
|
86
131
|
async function submitFix() {
|
|
87
|
-
if (!blockId.value || !
|
|
88
|
-
await visualConfirm.requestFix(blockId.value,
|
|
89
|
-
|
|
90
|
-
|
|
132
|
+
if (!blockId.value || !hasFindings.value) return
|
|
133
|
+
await visualConfirm.requestFix(blockId.value, buildFindings().text)
|
|
134
|
+
globalFindings.value = ''
|
|
135
|
+
for (const k of Object.keys(perViewNotes)) delete perViewNotes[k]
|
|
136
|
+
for (const k of Object.keys(noteOpen)) delete noteOpen[k]
|
|
91
137
|
}
|
|
92
138
|
async function recapture() {
|
|
93
139
|
if (!blockId.value) return
|
|
94
140
|
await visualConfirm.recapture(blockId.value)
|
|
95
141
|
}
|
|
96
142
|
|
|
97
|
-
// Reference upload.
|
|
143
|
+
// --- Reference upload (per-pair drop, plus a free "any view" picker below). ---
|
|
144
|
+
async function uploadFor(view: string, file: File) {
|
|
145
|
+
if (!blockId.value) return
|
|
146
|
+
await visualConfirm.uploadReference(blockId.value, file, view)
|
|
147
|
+
}
|
|
98
148
|
const uploadView = ref('')
|
|
99
149
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
100
150
|
async function onFilePicked(e: Event) {
|
|
101
151
|
const input = e.target as HTMLInputElement
|
|
102
152
|
const file = input.files?.[0]
|
|
103
|
-
|
|
104
|
-
|
|
153
|
+
const view = uploadView.value.trim()
|
|
154
|
+
// Require a view name: a reference with no view can't pair with any captured screenshot,
|
|
155
|
+
// so it would be silently orphaned. The input is also disabled until a view is entered.
|
|
156
|
+
if (!file || !blockId.value || !view) {
|
|
157
|
+
if (fileInput.value) fileInput.value.value = ''
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
await visualConfirm.uploadReference(blockId.value, file, view)
|
|
105
161
|
uploadView.value = ''
|
|
106
162
|
if (fileInput.value) fileInput.value.value = ''
|
|
107
163
|
}
|
|
164
|
+
|
|
165
|
+
// Degraded-basis approval guard (no capture / a fix landed after these shots): require an
|
|
166
|
+
// explicit "I reviewed this another way" acknowledgement before the one-click approve.
|
|
167
|
+
const ackDegraded = ref(false)
|
|
168
|
+
watch(
|
|
169
|
+
() => vc.value?.degradedReason ?? null,
|
|
170
|
+
() => {
|
|
171
|
+
ackDegraded.value = false
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
const needsAck = computed(() => !!vc.value?.degradedReason)
|
|
175
|
+
const canApprove = computed(
|
|
176
|
+
() => awaitingHuman.value && !busy.value && (!needsAck.value || ackDegraded.value),
|
|
177
|
+
)
|
|
108
178
|
</script>
|
|
109
179
|
|
|
110
180
|
<template>
|
|
@@ -115,7 +185,12 @@ async function onFilePicked(e: Event) {
|
|
|
115
185
|
@click.self="close"
|
|
116
186
|
>
|
|
117
187
|
<div
|
|
118
|
-
|
|
188
|
+
ref="dialogRoot"
|
|
189
|
+
tabindex="-1"
|
|
190
|
+
role="dialog"
|
|
191
|
+
aria-modal="true"
|
|
192
|
+
aria-label="Visual confirmation"
|
|
193
|
+
class="m-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl focus:outline-none"
|
|
119
194
|
>
|
|
120
195
|
<header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
|
|
121
196
|
<span
|
|
@@ -164,57 +239,52 @@ async function onFilePicked(e: Event) {
|
|
|
164
239
|
{{ phase ? PHASE_LABEL[phase] : '' }}
|
|
165
240
|
</p>
|
|
166
241
|
|
|
167
|
-
<!-- Actual-vs-reference gallery
|
|
242
|
+
<!-- Actual-vs-reference gallery. Keyed by `view` (the contract's unique per-pair
|
|
243
|
+
identity) so a pair's note/expand state stays bound to its view across recaptures. -->
|
|
168
244
|
<section v-if="pairs.length" class="space-y-4">
|
|
169
|
-
<div
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
|
|
189
|
-
>
|
|
190
|
-
{{ p.actualArtifactId ? 'Loading…' : 'Not captured' }}
|
|
191
|
-
</div>
|
|
192
|
-
</figure>
|
|
193
|
-
<figure class="space-y-1">
|
|
194
|
-
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
195
|
-
Reference
|
|
196
|
-
</figcaption>
|
|
197
|
-
<img
|
|
198
|
-
v-if="p.referenceArtifactId && urls[p.referenceArtifactId]"
|
|
199
|
-
:src="urls[p.referenceArtifactId]"
|
|
200
|
-
:alt="`${p.view} (reference)`"
|
|
201
|
-
class="w-full rounded border border-slate-800"
|
|
245
|
+
<div v-for="p in pairs" :key="p.view" class="space-y-2">
|
|
246
|
+
<ImageCompare
|
|
247
|
+
:view="p.view"
|
|
248
|
+
:actual-id="p.actualArtifactId"
|
|
249
|
+
:reference-id="p.referenceArtifactId"
|
|
250
|
+
:blobs="blobs"
|
|
251
|
+
:busy="busy"
|
|
252
|
+
@expand="expand"
|
|
253
|
+
@upload-reference="(file: File) => uploadFor(p.view, file)"
|
|
254
|
+
/>
|
|
255
|
+
<!-- Per-view note (folded into the fixer findings) -->
|
|
256
|
+
<div v-if="awaitingHuman" class="px-1">
|
|
257
|
+
<button
|
|
258
|
+
class="flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-200"
|
|
259
|
+
@click="noteOpen[p.view] = !noteOpen[p.view]"
|
|
260
|
+
>
|
|
261
|
+
<UIcon
|
|
262
|
+
:name="noteOpen[p.view] ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
|
|
263
|
+
class="h-3 w-3"
|
|
202
264
|
/>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
265
|
+
Note an issue with {{ p.view }}
|
|
266
|
+
<span
|
|
267
|
+
v-if="perViewNotes[p.view]?.trim()"
|
|
268
|
+
class="rounded-full bg-amber-500/15 px-1.5 text-[9px] text-amber-300"
|
|
269
|
+
>noted</span
|
|
206
270
|
>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
271
|
+
</button>
|
|
272
|
+
<textarea
|
|
273
|
+
v-if="noteOpen[p.view]"
|
|
274
|
+
v-model="perViewNotes[p.view]"
|
|
275
|
+
rows="2"
|
|
276
|
+
:placeholder="`What looks wrong on ${p.view}?`"
|
|
277
|
+
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-[12px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
|
|
278
|
+
/>
|
|
210
279
|
</div>
|
|
211
280
|
</div>
|
|
212
281
|
</section>
|
|
213
282
|
<p v-else class="text-[12px] italic text-slate-500">
|
|
214
|
-
No screenshots were captured — review the change manually
|
|
283
|
+
No screenshots were captured — review the change manually, or upload a reference
|
|
284
|
+
below.
|
|
215
285
|
</p>
|
|
216
286
|
|
|
217
|
-
<!--
|
|
287
|
+
<!-- Upload a reference for any view -->
|
|
218
288
|
<section class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
|
219
289
|
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
220
290
|
Upload a reference design
|
|
@@ -222,18 +292,29 @@ async function onFilePicked(e: Event) {
|
|
|
222
292
|
<div class="flex flex-wrap items-center gap-2">
|
|
223
293
|
<input
|
|
224
294
|
v-model="uploadView"
|
|
295
|
+
list="vc-views"
|
|
225
296
|
placeholder="View name (e.g. login)"
|
|
226
297
|
class="rounded-md border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200 placeholder:text-slate-600"
|
|
227
298
|
/>
|
|
299
|
+
<datalist id="vc-views">
|
|
300
|
+
<option v-for="p in pairs" :key="p.view" :value="p.view" />
|
|
301
|
+
</datalist>
|
|
228
302
|
<input
|
|
229
303
|
ref="fileInput"
|
|
230
304
|
type="file"
|
|
231
305
|
accept="image/png,image/jpeg"
|
|
232
|
-
:disabled="busy"
|
|
233
|
-
class="text-[12px] text-slate-300 file:mr-2 file:rounded file:border-0 file:bg-slate-800 file:px-2 file:py-1 file:text-slate-200"
|
|
306
|
+
:disabled="busy || !uploadView.trim()"
|
|
307
|
+
class="text-[12px] text-slate-300 file:mr-2 file:rounded file:border-0 file:bg-slate-800 file:px-2 file:py-1 file:text-slate-200 disabled:opacity-40"
|
|
234
308
|
@change="onFilePicked"
|
|
235
309
|
/>
|
|
236
310
|
</div>
|
|
311
|
+
<p class="mt-1.5 text-[10px] text-slate-600">
|
|
312
|
+
{{
|
|
313
|
+
uploadView.trim()
|
|
314
|
+
? 'Tip: drop an image straight onto a pair above to set its reference.'
|
|
315
|
+
: 'Enter a view name first, then choose a file. Or drop an image straight onto a pair above.'
|
|
316
|
+
}}
|
|
317
|
+
</p>
|
|
237
318
|
</section>
|
|
238
319
|
|
|
239
320
|
<!-- Request fix -->
|
|
@@ -241,30 +322,25 @@ async function onFilePicked(e: Event) {
|
|
|
241
322
|
v-if="awaitingHuman"
|
|
242
323
|
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
243
324
|
>
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
v-model="findings"
|
|
258
|
-
rows="4"
|
|
259
|
-
placeholder="Describe what looks wrong — the Fixer agent gets this as context."
|
|
260
|
-
class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
|
|
261
|
-
/>
|
|
325
|
+
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
326
|
+
Request a fix
|
|
327
|
+
</h3>
|
|
328
|
+
<textarea
|
|
329
|
+
v-model="globalFindings"
|
|
330
|
+
rows="3"
|
|
331
|
+
placeholder="Anything else the Fixer should know (in addition to any per-view notes above)."
|
|
332
|
+
class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
|
|
333
|
+
/>
|
|
334
|
+
<div class="mt-2 flex items-center justify-between">
|
|
335
|
+
<span class="text-[11px] text-slate-500">
|
|
336
|
+
Per-view notes are folded in automatically.
|
|
337
|
+
</span>
|
|
262
338
|
<UButton
|
|
263
339
|
size="sm"
|
|
264
340
|
color="warning"
|
|
265
341
|
icon="i-lucide-wrench"
|
|
266
342
|
:loading="busy"
|
|
267
|
-
:disabled="busy || !
|
|
343
|
+
:disabled="busy || !hasFindings"
|
|
268
344
|
@click="submitFix"
|
|
269
345
|
>
|
|
270
346
|
Send to Fixer
|
|
@@ -300,7 +376,9 @@ async function onFilePicked(e: Event) {
|
|
|
300
376
|
>
|
|
301
377
|
{{ r.outcome ?? 'in progress' }}
|
|
302
378
|
</span>
|
|
303
|
-
<p v-if="r.findings" class="leading-snug text-slate-400">
|
|
379
|
+
<p v-if="r.findings" class="whitespace-pre-wrap leading-snug text-slate-400">
|
|
380
|
+
{{ r.findings }}
|
|
381
|
+
</p>
|
|
304
382
|
</div>
|
|
305
383
|
</li>
|
|
306
384
|
</ol>
|
|
@@ -353,5 +431,13 @@ async function onFilePicked(e: Event) {
|
|
|
353
431
|
</footer>
|
|
354
432
|
</div>
|
|
355
433
|
</div>
|
|
434
|
+
|
|
435
|
+
<!-- Shared zoom/pan viewer for any screenshot in the gallery. -->
|
|
436
|
+
<ArtifactLightbox
|
|
437
|
+
v-model:open="lightboxOpen"
|
|
438
|
+
v-model:index="lightboxIndex"
|
|
439
|
+
:items="lightboxItems"
|
|
440
|
+
:blobs="blobs"
|
|
441
|
+
/>
|
|
356
442
|
</Teleport>
|
|
357
443
|
</template>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-component cache for resolving stored binary artifacts (screenshots / reference
|
|
6
|
+
* designs) into `<img>`-ready object URLs.
|
|
7
|
+
*
|
|
8
|
+
* The artifact bytes are served behind an authed endpoint (`GET /workspaces/:ws/
|
|
9
|
+
* artifacts/:id/blob`), so the browser can't point an `<img src>` straight at them — they
|
|
10
|
+
* have to be fetched as a `Blob` and turned into an `URL.createObjectURL`. That object URL
|
|
11
|
+
* pins the blob in memory until it's explicitly revoked, so this composable is a FACTORY
|
|
12
|
+
* (one cache per calling component), and the caller MUST `revokeAll()` on unmount. Making
|
|
13
|
+
* it a global singleton would mean one window's unmount frees another window's images.
|
|
14
|
+
*
|
|
15
|
+
* Both the visual-confirmation gate and the test-report window use this, so neither has to
|
|
16
|
+
* own blob plumbing or depend on the other's Pinia store.
|
|
17
|
+
*/
|
|
18
|
+
export type ArtifactBlobStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
19
|
+
|
|
20
|
+
export function useArtifactBlobs() {
|
|
21
|
+
const ws = useWorkspaceStore()
|
|
22
|
+
const api = useApi()
|
|
23
|
+
|
|
24
|
+
/** artifactId → object URL (reactive so templates re-render when a blob resolves). */
|
|
25
|
+
const urls = reactive<Record<string, string>>({})
|
|
26
|
+
/** artifactId → fetch status, drives loading / error / retry affordances. */
|
|
27
|
+
const status = reactive<Record<string, ArtifactBlobStatus>>({})
|
|
28
|
+
/** In-flight promises, so concurrent `resolve(id)` calls share one fetch + one blob. */
|
|
29
|
+
const inFlight = new Map<string, Promise<string | null>>()
|
|
30
|
+
/**
|
|
31
|
+
* Set once `revokeAll()` has run (the owning component unmounted). A fetch already in
|
|
32
|
+
* flight at that point still creates its object URL when it settles; without this guard
|
|
33
|
+
* that URL would be written into the now-cleared cache and never revoked (a leak), and
|
|
34
|
+
* we'd be mutating reactive state for a dead component.
|
|
35
|
+
*/
|
|
36
|
+
let disposed = false
|
|
37
|
+
|
|
38
|
+
function urlFor(id: string | null | undefined): string | undefined {
|
|
39
|
+
return id ? urls[id] : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusFor(id: string | null | undefined): ArtifactBlobStatus {
|
|
43
|
+
return id ? (status[id] ?? 'idle') : 'idle'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve an artifact to an object URL (cached + deduped). Returns null on failure. */
|
|
47
|
+
function resolve(id: string | null | undefined): Promise<string | null> {
|
|
48
|
+
if (!id || disposed) return Promise.resolve(null)
|
|
49
|
+
const cached = urls[id]
|
|
50
|
+
if (cached) return Promise.resolve(cached)
|
|
51
|
+
const pending = inFlight.get(id)
|
|
52
|
+
if (pending) return pending
|
|
53
|
+
|
|
54
|
+
status[id] = 'loading'
|
|
55
|
+
const p = api
|
|
56
|
+
.fetchArtifactBlobUrl(ws.requireId(), id)
|
|
57
|
+
.then((url) => {
|
|
58
|
+
// The owner unmounted while this was in flight: revoke the freshly-minted URL
|
|
59
|
+
// instead of stranding it in the cleared cache.
|
|
60
|
+
if (disposed) {
|
|
61
|
+
try {
|
|
62
|
+
URL.revokeObjectURL(url)
|
|
63
|
+
} catch {
|
|
64
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
urls[id] = url
|
|
69
|
+
status[id] = 'ready'
|
|
70
|
+
return url
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
status[id] = 'error'
|
|
74
|
+
return null
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
inFlight.delete(id)
|
|
78
|
+
})
|
|
79
|
+
inFlight.set(id, p)
|
|
80
|
+
return p
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Force a re-fetch of a previously-failed artifact (clears its cached error state). */
|
|
84
|
+
function retry(id: string): Promise<string | null> {
|
|
85
|
+
const stale = urls[id]
|
|
86
|
+
if (stale) {
|
|
87
|
+
try {
|
|
88
|
+
URL.revokeObjectURL(stale)
|
|
89
|
+
} catch {
|
|
90
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
delete urls[id]
|
|
94
|
+
status[id] = 'idle'
|
|
95
|
+
inFlight.delete(id)
|
|
96
|
+
return resolve(id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Revoke every cached object URL and clear the cache. Call on `onUnmounted` — otherwise
|
|
101
|
+
* the (potentially large) screenshot bytes linger in memory for the session's lifetime.
|
|
102
|
+
*/
|
|
103
|
+
function revokeAll(): void {
|
|
104
|
+
disposed = true
|
|
105
|
+
for (const url of Object.values(urls)) {
|
|
106
|
+
try {
|
|
107
|
+
URL.revokeObjectURL(url)
|
|
108
|
+
} catch {
|
|
109
|
+
// Already revoked / unsupported environment — nothing to do.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const k of Object.keys(urls)) delete urls[k]
|
|
113
|
+
for (const k of Object.keys(status)) delete status[k]
|
|
114
|
+
inFlight.clear()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { urls, status, urlFor, statusFor, resolve, retry, revokeAll }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ArtifactBlobs = ReturnType<typeof useArtifactBlobs>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { nextTick, onScopeDispose, watch, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight focus management for a modal surface (the screenshot review windows + the
|
|
5
|
+
* shared lightbox). While `active`, it:
|
|
6
|
+
* · moves focus into the container on open (so keyboard / screen-reader users land inside
|
|
7
|
+
* the dialog instead of staying on the background),
|
|
8
|
+
* · traps Tab / Shift+Tab within the container's focusable elements, and
|
|
9
|
+
* · restores focus to whatever was focused before, on close.
|
|
10
|
+
*
|
|
11
|
+
* Nested surfaces (a lightbox opened over a review window) hand off cleanly because each
|
|
12
|
+
* caller scopes its own `active` — the window passes `open && !lightboxOpen`, so exactly one
|
|
13
|
+
* trap is live at a time and they never fight over Tab.
|
|
14
|
+
*/
|
|
15
|
+
export function useFocusTrap(container: Ref<HTMLElement | null>, active: Ref<boolean>): void {
|
|
16
|
+
let previouslyFocused: HTMLElement | null = null
|
|
17
|
+
|
|
18
|
+
function focusables(): HTMLElement[] {
|
|
19
|
+
const root = container.value
|
|
20
|
+
if (!root) return []
|
|
21
|
+
const nodes = root.querySelectorAll<HTMLElement>(
|
|
22
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
23
|
+
)
|
|
24
|
+
// Skip elements that aren't actually rendered (e.g. inside a `v-if`/`hidden` branch).
|
|
25
|
+
return Array.from(nodes).filter(
|
|
26
|
+
(el) => el.offsetParent !== null || el === document.activeElement,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
31
|
+
if (!active.value || e.key !== 'Tab') return
|
|
32
|
+
const els = focusables()
|
|
33
|
+
if (!els.length) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
container.value?.focus()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const first = els[0]!
|
|
39
|
+
const last = els[els.length - 1]!
|
|
40
|
+
const current = document.activeElement as HTMLElement | null
|
|
41
|
+
const inside = !!container.value?.contains(current)
|
|
42
|
+
if (e.shiftKey) {
|
|
43
|
+
if (!inside || current === first) {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
last.focus()
|
|
46
|
+
}
|
|
47
|
+
} else if (!inside || current === last) {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
first.focus()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
watch(
|
|
54
|
+
active,
|
|
55
|
+
(on) => {
|
|
56
|
+
if (on) {
|
|
57
|
+
previouslyFocused = document.activeElement as HTMLElement | null
|
|
58
|
+
window.addEventListener('keydown', onKeydown, true)
|
|
59
|
+
void nextTick(() => {
|
|
60
|
+
;(focusables()[0] ?? container.value)?.focus()
|
|
61
|
+
})
|
|
62
|
+
} else {
|
|
63
|
+
window.removeEventListener('keydown', onKeydown, true)
|
|
64
|
+
previouslyFocused?.focus?.()
|
|
65
|
+
previouslyFocused = null
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ immediate: true },
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
onScopeDispose(() => window.removeEventListener('keydown', onKeydown, true))
|
|
72
|
+
}
|
package/app/stores/tracker.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
|
|
14
14
|
const settings = ref<TrackerSettings>({
|
|
15
15
|
tracker: null,
|
|
16
16
|
jiraProjectKey: null,
|
|
17
|
+
linearTeamId: null,
|
|
17
18
|
writebackCommentOnPrOpen: false,
|
|
18
19
|
writebackResolveOnMerge: false,
|
|
19
20
|
updatedAt: 0,
|
|
@@ -23,6 +24,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
|
|
23
24
|
settings.value = value ?? {
|
|
24
25
|
tracker: null,
|
|
25
26
|
jiraProjectKey: null,
|
|
27
|
+
linearTeamId: null,
|
|
26
28
|
writebackCommentOnPrOpen: false,
|
|
27
29
|
writebackResolveOnMerge: false,
|
|
28
30
|
updatedAt: 0,
|