@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.
@@ -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 next to its reference design (paired by
6
- // view), and drives the human actions: approve (advance), request a fix from findings (the
7
- // Tester's fixer), or recapture (refresh the pairs). It also lets the human upload reference
8
- // design images for the task.
9
- import { onUnmounted, reactive, ref, watch } from 'vue'
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
- // Release the cached screenshot/reference object URLs when the window goes away, so the
18
- // (potentially large) blob bytes don't linger in memory for the rest of the session.
19
- onUnmounted(() => visualConfirm.revokeBlobs())
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 each pair's artifact ids to object URLs for the <img>s (cached in the store).
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 resolveUrl(p.actualArtifactId)
56
- void resolveUrl(p.referenceArtifactId)
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
- const findings = ref('')
63
- const showFindings = ref(false)
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
- // When the gate flags its screenshots as an unreliable basis (`degradedReason` no capture
66
- // happened, a fix failed, or a fix landed AFTER these shots were taken), approving is no longer
67
- // a safe one-click: require the human to explicitly acknowledge they reviewed the change another
68
- // way (or recaptured) first. Re-armed whenever the reason changes so a fresh warning re-gates.
69
- const ackDegraded = ref(false)
70
- watch(
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
- const needsAck = computed(() => !!vc.value?.degradedReason)
77
- const canApprove = computed(
78
- () => awaitingHuman.value && !busy.value && (!needsAck.value || ackDegraded.value),
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 || !findings.value.trim()) return
88
- await visualConfirm.requestFix(blockId.value, findings.value.trim())
89
- findings.value = ''
90
- showFindings.value = false
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
- if (!file || !blockId.value) return
104
- await visualConfirm.uploadReference(blockId.value, file, uploadView.value.trim())
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
- class="m-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
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
- v-for="(p, i) in pairs"
171
- :key="i"
172
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
173
- >
174
- <h3 class="mb-2 text-[12px] font-semibold text-slate-200">{{ p.view }}</h3>
175
- <div class="grid grid-cols-2 gap-3">
176
- <figure class="space-y-1">
177
- <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
178
- Actual
179
- </figcaption>
180
- <img
181
- v-if="p.actualArtifactId && urls[p.actualArtifactId]"
182
- :src="urls[p.actualArtifactId]"
183
- :alt="`${p.view} (actual)`"
184
- class="w-full rounded border border-slate-800"
185
- />
186
- <div
187
- v-else
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
- <div
204
- v-else
205
- class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
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
- {{ p.referenceArtifactId ? 'Loading…' : 'No reference' }}
208
- </div>
209
- </figure>
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
- <!-- Reference upload -->
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
- <div class="flex items-center justify-between">
245
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
246
- Needs changes?
247
- </h3>
248
- <button
249
- class="text-[12px] text-slate-400 hover:text-slate-200"
250
- @click="showFindings = !showFindings"
251
- >
252
- {{ showFindings ? 'Cancel' : 'Request a fix' }}
253
- </button>
254
- </div>
255
- <div v-if="showFindings" class="mt-2 space-y-2">
256
- <textarea
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 || !findings.trim()"
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">{{ r.findings }}</p>
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
+ }
@@ -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,