@cat-factory/app 0.227.0 → 0.228.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.
@@ -0,0 +1,614 @@
1
+ <script setup lang="ts">
2
+ // The OUTCOME summary — the non-code answer to "what did this run change, and what backs that
3
+ // up", and the surface "read the result" lands on in basic mode.
4
+ //
5
+ // Reading a finished run meant reading a pull request: a branch, a title, a diff. Everything a
6
+ // person who does not read diffs needs was already captured and each piece sat behind its own
7
+ // step-keyed window, so it was reachable only by someone who had learned the pipeline. This
8
+ // window is the one place they come together, keyed by the RUN: what was asked, which
9
+ // requirements were verified, how it was tested, what it looks like, and which checks ran. The
10
+ // diff is one click from the top of the card rather than the thing you start on.
11
+ //
12
+ // It composes NOTHING itself: `composeRunOutcome` (`~/utils/runOutcome`) is the pure reduction,
13
+ // so the rules that matter (a regression is an `established` requirement observed to fail; an
14
+ // absent producer never renders as a clean result) are unit-tested without mounting this. What
15
+ // lives here is presentation only, plus the ONE fetch the card owns: the enclosing service's
16
+ // spec, which turns the tester's requirement IDS into the requirement TITLES a reader came for.
17
+ import { computed, onUnmounted, ref, watch } from 'vue'
18
+ import type {
19
+ OutcomeCheckKind,
20
+ OutcomeCheckState,
21
+ OutcomeDisposition,
22
+ OutcomeSpecJoin,
23
+ OutcomeVisual,
24
+ RequirementsGap,
25
+ TestsGap,
26
+ TestsVerdict,
27
+ VisualsGap,
28
+ } from '~/utils/runOutcome'
29
+ import { composeRunOutcome } from '~/utils/runOutcome'
30
+ import { REPRODUCTION_STATUS_KEYS } from '~/utils/reproduction'
31
+ import type { RequirementVerdictStatus, TestConcernSeverity } from '~/types/domain'
32
+ import type { TestEnvironment } from '@cat-factory/contracts'
33
+ import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
34
+ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
35
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
36
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
37
+ import EmptyState from '~/components/common/EmptyState.vue'
38
+
39
+ const board = useBoardStore()
40
+ const execution = useExecutionStore()
41
+ const serviceSpec = useServiceSpecStore()
42
+ const ui = useUiStore()
43
+ const { t } = useI18n()
44
+
45
+ // Per-window blob cache for the captured views; revoked on unmount so the (large) image bytes
46
+ // don't outlive the card.
47
+ const blobs = useArtifactBlobs()
48
+ onUnmounted(() => blobs.revokeAll())
49
+
50
+ // The shared seam contract. The `onOpen` loader fetches the ENCLOSING SERVICE's spec: the
51
+ // requirement verdicts are keyed by the spec's own ids, and without it the coverage section can
52
+ // only show ids (which it then says, rather than letting an id read as a title).
53
+ const { open, blockId, instanceId, close } = useResultView('outcome', {
54
+ onOpen: (view) => {
55
+ const block = board.getBlock(view.blockId)
56
+ const service = block ? board.serviceOf(block) : undefined
57
+ if (service) void serviceSpec.load(service.id)
58
+ },
59
+ })
60
+
61
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
62
+ const service = computed(() => (block.value ? board.serviceOf(block.value) : undefined))
63
+ const instance = computed(() => {
64
+ // The run carried by the opener, else the block's own live run: a card opened from a
65
+ // notification names the run, one opened from the board does not.
66
+ const id = instanceId.value ?? block.value?.executionId ?? null
67
+ return id ? (execution.getInstance(id) ?? null) : null
68
+ })
69
+
70
+ const outcome = computed(() =>
71
+ block.value
72
+ ? composeRunOutcome({
73
+ block: block.value,
74
+ instance: instance.value,
75
+ spec: service.value ? serviceSpec.viewFor(service.value.id) : null,
76
+ })
77
+ : null,
78
+ )
79
+
80
+ // ---- Presentation maps (exhaustive over the closed unions, so a new member fails the
81
+ // typecheck here rather than rendering blank on the one surface whose job is to say what is
82
+ // known and what is not).
83
+
84
+ const DISPOSITION_KEYS: Record<OutcomeDisposition, string> = {
85
+ merged: 'outcome.disposition.merged',
86
+ awaiting_merge: 'outcome.disposition.awaiting_merge',
87
+ in_flight: 'outcome.disposition.in_flight',
88
+ needs_attention: 'outcome.disposition.needs_attention',
89
+ not_run: 'outcome.disposition.not_run',
90
+ unknown: 'outcome.disposition.unknown',
91
+ }
92
+ /** The badge palette, named once so every colour map below is checked against it. */
93
+ type BadgeColor = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
94
+
95
+ const DISPOSITION_COLOR: Record<OutcomeDisposition, BadgeColor> = {
96
+ merged: 'success',
97
+ awaiting_merge: 'info',
98
+ in_flight: 'primary',
99
+ needs_attention: 'error',
100
+ not_run: 'neutral',
101
+ unknown: 'neutral',
102
+ }
103
+
104
+ // A run this card could not resolve is the SAME fact in every section, so all three name one
105
+ // key: it is about the read, not about what any particular producer did or did not do.
106
+ const RUN_UNAVAILABLE_KEY = 'outcome.gap.run_unavailable'
107
+
108
+ const REQUIREMENTS_GAP_KEYS: Record<RequirementsGap, string> = {
109
+ run_unavailable: RUN_UNAVAILABLE_KEY,
110
+ no_tester_step: 'outcome.requirements.gap.no_tester_step',
111
+ tester_not_reported: 'outcome.requirements.gap.tester_not_reported',
112
+ no_verdicts: 'outcome.requirements.gap.no_verdicts',
113
+ }
114
+ const TESTS_GAP_KEYS: Record<TestsGap, string> = {
115
+ run_unavailable: RUN_UNAVAILABLE_KEY,
116
+ no_tester_step: 'outcome.tests.gap.no_tester_step',
117
+ tester_not_reported: 'outcome.tests.gap.tester_not_reported',
118
+ }
119
+ const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
120
+ run_unavailable: RUN_UNAVAILABLE_KEY,
121
+ no_visual_step: 'outcome.visuals.gap.no_visual_step',
122
+ none_captured: 'outcome.visuals.gap.none_captured',
123
+ }
124
+ /**
125
+ * Why the rows carry no spec titles. `joined` is excluded rather than mapped to an empty
126
+ * string, so the note renders only where there is one to make and a new join state cannot ship
127
+ * without copy of its own.
128
+ */
129
+ const SPEC_JOIN_KEYS: Record<Exclude<OutcomeSpecJoin, 'joined'>, string> = {
130
+ not_read: 'outcome.requirements.spec.not_read',
131
+ unmatched: 'outcome.requirements.spec.unmatched',
132
+ }
133
+
134
+ const VERDICT_META: Record<RequirementVerdictStatus, { color: string; key: string }> = {
135
+ met: { color: '#22c55e', key: 'outcome.requirements.verdict.met' },
136
+ not_met: { color: '#ef4444', key: 'outcome.requirements.verdict.not_met' },
137
+ not_covered: { color: '#64748b', key: 'outcome.requirements.verdict.not_covered' },
138
+ }
139
+ const SEVERITY_KEYS: Record<TestConcernSeverity, string> = {
140
+ low: 'outcome.tests.severity.low',
141
+ medium: 'outcome.tests.severity.medium',
142
+ high: 'outcome.tests.severity.high',
143
+ critical: 'outcome.tests.severity.critical',
144
+ }
145
+ const SEVERITY_COLOR: Record<TestConcernSeverity, BadgeColor> = {
146
+ low: 'neutral',
147
+ medium: 'warning',
148
+ high: 'warning',
149
+ critical: 'error',
150
+ }
151
+ const ENVIRONMENT_KEYS: Record<TestEnvironment, string> = {
152
+ local: 'outcome.tests.environment.local',
153
+ ephemeral: 'outcome.tests.environment.ephemeral',
154
+ }
155
+ const CHECK_KEYS: Record<OutcomeCheckKind, string> = {
156
+ ci: 'outcome.checks.kind.ci',
157
+ validation: 'outcome.checks.kind.validation',
158
+ reproduction: 'outcome.checks.kind.reproduction',
159
+ }
160
+ const CHECK_STATE_KEYS: Record<OutcomeCheckState, string> = {
161
+ pass: 'outcome.checks.state.pass',
162
+ fail: 'outcome.checks.state.fail',
163
+ pending: 'outcome.checks.state.pending',
164
+ inconclusive: 'outcome.checks.state.inconclusive',
165
+ }
166
+ const CHECK_STATE_COLOR: Record<OutcomeCheckState, BadgeColor> = {
167
+ pass: 'success',
168
+ fail: 'error',
169
+ pending: 'info',
170
+ inconclusive: 'warning',
171
+ }
172
+ const TESTS_VERDICT_KEYS: Record<TestsVerdict, string> = {
173
+ greenlit: 'outcome.tests.verdict.greenlit',
174
+ concerns: 'outcome.tests.verdict.concerns',
175
+ could_not_run: 'outcome.tests.verdict.could_not_run',
176
+ }
177
+ const TESTS_VERDICT_COLOR: Record<TestsVerdict, BadgeColor> = {
178
+ greenlit: 'success',
179
+ concerns: 'warning',
180
+ could_not_run: 'error',
181
+ }
182
+
183
+ // ---- Derived view state ----------------------------------------------------
184
+
185
+ const headerTitle = computed(() => outcome.value?.title ?? t('outcome.title'))
186
+ const disposition = computed(() => outcome.value?.disposition ?? 'not_run')
187
+
188
+ /**
189
+ * The note under the requirement counts when the rows carry no spec titles, null when they do.
190
+ * Resolved here so the `joined` exclusion is checked by the compiler once, rather than by a
191
+ * template condition that would silently render nothing if the union grew.
192
+ */
193
+ const specNote = computed(() => {
194
+ const requirements = outcome.value?.requirements
195
+ if (!requirements || requirements.status !== 'reported' || requirements.spec === 'joined') {
196
+ return null
197
+ }
198
+ return t(SPEC_JOIN_KEYS[requirements.spec])
199
+ })
200
+
201
+ /**
202
+ * The requirement rows, each carrying whether its id is standing in for a title it has no way
203
+ * to show. Marked per row ONLY where the section as a whole joined: an id sitting unmarked
204
+ * between two named requirements reads as a requirement someone named after a slug, while a
205
+ * marker on every row of a section the note above already explains is just noise.
206
+ */
207
+ const requirementRows = computed(() => {
208
+ const requirements = outcome.value?.requirements
209
+ if (!requirements || requirements.status !== 'reported') return []
210
+ return requirements.entries.map((entry) => ({
211
+ ...entry,
212
+ idOnly: requirements.spec === 'joined' && entry.title === null,
213
+ }))
214
+ })
215
+
216
+ /** The captured views, resolved to blobs as they arrive (the card shows them inline). */
217
+ const views = computed(() =>
218
+ outcome.value?.visuals.status === 'reported' ? outcome.value.visuals.views : [],
219
+ )
220
+ watch(
221
+ views,
222
+ (next) => {
223
+ for (const v of next) if (v.artifactId) void blobs.resolve(v.artifactId)
224
+ },
225
+ { immediate: true },
226
+ )
227
+
228
+ const lightboxItems = computed(() =>
229
+ views.value.flatMap((v) =>
230
+ v.artifactId
231
+ ? [
232
+ {
233
+ artifactId: v.artifactId,
234
+ label: v.view,
235
+ alt: t('outcome.visuals.shotAlt', { view: v.view }),
236
+ },
237
+ ]
238
+ : [],
239
+ ),
240
+ )
241
+ const lightboxOpen = ref(false)
242
+ const lightboxIndex = ref(0)
243
+ /**
244
+ * Open the zoom viewer on a captured view. A view whose capture is missing has nothing to open.
245
+ *
246
+ * The viewer's index is counted over the views that HAVE a capture, in order, rather than
247
+ * looked up by artifact id: two views of one artifact (a gate that captured a shared reference,
248
+ * a re-captured view) are distinct rows that would otherwise both open the first of them.
249
+ */
250
+ function openShot(view: OutcomeVisual, position: number) {
251
+ if (!view.artifactId) return
252
+ lightboxIndex.value = views.value.slice(0, position).filter((v) => v.artifactId).length
253
+ lightboxOpen.value = true
254
+ }
255
+
256
+ /**
257
+ * The recorded machine checks as chips. The reproduction row names its OWN verdict through the
258
+ * shared presentation map instead of the generic state word: `inconclusive` and
259
+ * `declared_infeasible` are both "not proof" and call for different reactions, so collapsing
260
+ * them onto one label is the reporting failure this card exists to avoid.
261
+ */
262
+ const checkRows = computed(() =>
263
+ (outcome.value?.checks ?? []).map((check) => ({
264
+ kind: check.kind,
265
+ color: CHECK_STATE_COLOR[check.state],
266
+ label: t('outcome.checks.row', {
267
+ kind: t(CHECK_KEYS[check.kind]),
268
+ state: check.reproduction
269
+ ? t(REPRODUCTION_STATUS_KEYS[check.reproduction].chip)
270
+ : t(CHECK_STATE_KEYS[check.state]),
271
+ }),
272
+ })),
273
+ )
274
+
275
+ /** Drill into the full test report (this card is the summary, never a replacement for it). */
276
+ function openTestReport() {
277
+ if (instance.value) ui.openTestEvidence(instance.value.id)
278
+ }
279
+ </script>
280
+
281
+ <template>
282
+ <ResultWindowShell
283
+ :open="open"
284
+ icon="i-lucide-clipboard-check"
285
+ icon-class="bg-sky-500/15 text-sky-300"
286
+ :title="headerTitle"
287
+ :subtitle="t('outcome.subtitle')"
288
+ width="3xl"
289
+ testid="outcome-window"
290
+ @close="close"
291
+ >
292
+ <div v-if="outcome" class="min-h-0 flex-1 overflow-y-auto px-5 py-4" data-testid="outcome-body">
293
+ <!-- Where the work stands, and the way to the diff. The pull requests sit at the TOP so
294
+ the code is exactly one click from the summary rather than the thing you start on. -->
295
+ <div class="mb-4 flex flex-wrap items-center gap-2">
296
+ <UBadge
297
+ :color="DISPOSITION_COLOR[disposition]"
298
+ variant="subtle"
299
+ size="md"
300
+ data-testid="outcome-disposition"
301
+ :data-disposition="disposition"
302
+ >
303
+ {{ t(DISPOSITION_KEYS[disposition]) }}
304
+ </UBadge>
305
+ <UButton
306
+ v-for="pr in outcome.pullRequests"
307
+ :key="pr.url"
308
+ :to="pr.url"
309
+ target="_blank"
310
+ rel="noopener"
311
+ external
312
+ color="neutral"
313
+ variant="soft"
314
+ size="xs"
315
+ icon="i-lucide-git-pull-request"
316
+ trailing-icon="i-lucide-external-link"
317
+ data-testid="outcome-pr-link"
318
+ >
319
+ {{
320
+ pr.repo
321
+ ? t('outcome.peerDiff', { repo: pr.repo })
322
+ : pr.number
323
+ ? t('outcome.diffNumbered', { number: pr.number })
324
+ : t('outcome.diff')
325
+ }}
326
+ </UButton>
327
+ </div>
328
+
329
+ <!-- What was asked, in the requester's own words. -->
330
+ <section class="mb-5">
331
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
332
+ {{ t('outcome.ask.title') }}
333
+ </h3>
334
+ <MarkdownProse
335
+ v-if="outcome.ask"
336
+ :text="outcome.ask"
337
+ class="text-[13px] leading-relaxed text-slate-300"
338
+ data-testid="outcome-ask"
339
+ />
340
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
341
+ {{ t('outcome.ask.none') }}
342
+ </p>
343
+ </section>
344
+
345
+ <!-- Requirement coverage: which required behaviours were checked, and what was seen. -->
346
+ <section class="mb-5" data-testid="outcome-requirements">
347
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
348
+ {{ t('outcome.requirements.title') }}
349
+ </h3>
350
+ <template v-if="outcome.requirements.status === 'reported'">
351
+ <div class="mb-2 flex flex-wrap items-center gap-1.5">
352
+ <UBadge color="success" variant="subtle" size="sm">
353
+ {{ t('outcome.requirements.met', { count: outcome.requirements.met }) }}
354
+ </UBadge>
355
+ <UBadge color="error" variant="subtle" size="sm">
356
+ {{ t('outcome.requirements.notMet', { count: outcome.requirements.notMet }) }}
357
+ </UBadge>
358
+ <UBadge color="neutral" variant="subtle" size="sm">
359
+ {{ t('outcome.requirements.notCovered', { count: outcome.requirements.notCovered }) }}
360
+ </UBadge>
361
+ <UBadge
362
+ v-if="outcome.requirements.regressions > 0"
363
+ color="error"
364
+ variant="solid"
365
+ size="sm"
366
+ icon="i-lucide-triangle-alert"
367
+ data-testid="outcome-regressions"
368
+ >
369
+ {{
370
+ t('outcome.requirements.regressions', {
371
+ count: outcome.requirements.regressions,
372
+ })
373
+ }}
374
+ </UBadge>
375
+ </div>
376
+ <!-- The ids are all there is: say WHICH reason, rather than letting a slug read as
377
+ the name of a requirement (never read, versus read and naming none of these). -->
378
+ <p
379
+ v-if="specNote"
380
+ class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
381
+ data-testid="outcome-spec-note"
382
+ >
383
+ {{ specNote }}
384
+ </p>
385
+ <ul class="space-y-1.5">
386
+ <li
387
+ v-for="req in requirementRows"
388
+ :key="req.id"
389
+ class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-950/40 p-2"
390
+ data-testid="outcome-requirement"
391
+ >
392
+ <span
393
+ class="mt-1.5 h-2 w-2 shrink-0 rounded-full"
394
+ :style="{ backgroundColor: VERDICT_META[req.verdict].color }"
395
+ />
396
+ <div class="min-w-0">
397
+ <div class="flex flex-wrap items-center gap-1.5">
398
+ <span class="text-[13px] text-slate-200">{{ req.title ?? req.id }}</span>
399
+ <!-- This row's id is standing in for a title the spec does not have for it,
400
+ beside rows that DO carry one. -->
401
+ <UBadge
402
+ v-if="req.idOnly"
403
+ color="neutral"
404
+ variant="subtle"
405
+ size="sm"
406
+ :title="t('outcome.requirements.idOnlyHint')"
407
+ data-testid="outcome-requirement-id-only"
408
+ >
409
+ {{ t('outcome.requirements.idOnly') }}
410
+ </UBadge>
411
+ <UBadge
412
+ v-if="req.regression"
413
+ color="error"
414
+ variant="subtle"
415
+ size="sm"
416
+ data-testid="outcome-requirement-regression"
417
+ >
418
+ {{ t('outcome.requirements.regressionTag') }}
419
+ </UBadge>
420
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">
421
+ {{ t(VERDICT_META[req.verdict].key) }}
422
+ </span>
423
+ </div>
424
+ <p v-if="req.detail" class="mt-0.5 text-[12px] leading-relaxed text-slate-400">
425
+ {{ req.detail }}
426
+ </p>
427
+ </div>
428
+ </li>
429
+ </ul>
430
+ </template>
431
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
432
+ {{ t(REQUIREMENTS_GAP_KEYS[outcome.requirements.gap]) }}
433
+ </p>
434
+ </section>
435
+
436
+ <!-- How it was tested: the tester's own verdict and prose, attributed as its account. -->
437
+ <section class="mb-5" data-testid="outcome-tests">
438
+ <div class="mb-1.5 flex flex-wrap items-center gap-2">
439
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
440
+ {{ t('outcome.tests.title') }}
441
+ </h3>
442
+ <UBadge
443
+ v-if="outcome.tests.status === 'reported'"
444
+ :color="TESTS_VERDICT_COLOR[outcome.tests.verdict]"
445
+ variant="subtle"
446
+ size="sm"
447
+ data-testid="outcome-tests-verdict"
448
+ >
449
+ {{ t(TESTS_VERDICT_KEYS[outcome.tests.verdict]) }}
450
+ </UBadge>
451
+ <UBadge
452
+ v-if="outcome.tests.status === 'reported' && outcome.tests.environment"
453
+ color="neutral"
454
+ variant="subtle"
455
+ size="sm"
456
+ >
457
+ {{ t(ENVIRONMENT_KEYS[outcome.tests.environment]) }}
458
+ </UBadge>
459
+ <UButton
460
+ v-if="outcome.tests.status === 'reported' && instance"
461
+ color="neutral"
462
+ variant="ghost"
463
+ size="xs"
464
+ icon="i-lucide-flask-conical"
465
+ data-testid="outcome-open-test-report"
466
+ @click="openTestReport"
467
+ >
468
+ {{ t('outcome.tests.openReport') }}
469
+ </UButton>
470
+ </div>
471
+ <template v-if="outcome.tests.status === 'reported'">
472
+ <p
473
+ v-if="outcome.tests.abortReason"
474
+ class="mb-2 rounded-md border border-rose-900/70 bg-rose-500/10 p-2 text-[13px] leading-relaxed text-rose-200"
475
+ data-testid="outcome-tests-abort"
476
+ >
477
+ {{ t('outcome.tests.abort', { reason: outcome.tests.abortReason }) }}
478
+ </p>
479
+ <p v-if="outcome.tests.summary" class="text-[13px] leading-relaxed text-slate-300">
480
+ {{ t('outcome.tests.summary', { summary: outcome.tests.summary }) }}
481
+ </p>
482
+ <p class="mt-1.5 text-[12px] text-slate-400">
483
+ {{
484
+ t('outcome.tests.counts', {
485
+ passed: outcome.tests.passed,
486
+ failed: outcome.tests.failed,
487
+ skipped: outcome.tests.skipped,
488
+ })
489
+ }}
490
+ </p>
491
+ <ul v-if="outcome.tests.concerns.length" class="mt-2 space-y-1">
492
+ <li
493
+ v-for="(concern, i) in outcome.tests.concerns"
494
+ :key="i"
495
+ class="flex items-start gap-2 text-[12px] text-slate-300"
496
+ data-testid="outcome-concern"
497
+ >
498
+ <UBadge :color="SEVERITY_COLOR[concern.severity]" variant="subtle" size="sm">
499
+ {{ t(SEVERITY_KEYS[concern.severity]) }}
500
+ </UBadge>
501
+ <span class="min-w-0">{{ concern.title }}</span>
502
+ </li>
503
+ </ul>
504
+ </template>
505
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
506
+ {{ t(TESTS_GAP_KEYS[outcome.tests.gap]) }}
507
+ </p>
508
+ </section>
509
+
510
+ <!-- What it looks like: the captured views, and whether a human was asked about them. -->
511
+ <section class="mb-5" data-testid="outcome-visuals">
512
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
513
+ {{ t('outcome.visuals.title') }}
514
+ </h3>
515
+ <template v-if="outcome.visuals.status === 'reported'">
516
+ <p class="mb-2 text-[12px] leading-relaxed text-slate-400">
517
+ {{
518
+ outcome.visuals.source === 'visual_confirm'
519
+ ? t('outcome.visuals.source.visual_confirm')
520
+ : t('outcome.visuals.source.tester')
521
+ }}
522
+ </p>
523
+ <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
524
+ <button
525
+ v-for="(view, position) in outcome.visuals.views"
526
+ :key="`${position}:${view.view}`"
527
+ type="button"
528
+ class="group overflow-hidden rounded-md border border-slate-800 bg-slate-950/60 text-start transition hover:border-slate-600"
529
+ :disabled="!view.artifactId"
530
+ data-testid="outcome-shot"
531
+ @click="openShot(view, position)"
532
+ >
533
+ <img
534
+ v-if="view.artifactId && blobs.urlFor(view.artifactId)"
535
+ :src="blobs.urlFor(view.artifactId)"
536
+ :alt="t('outcome.visuals.shotAlt', { view: view.view })"
537
+ class="h-24 w-full object-cover object-top"
538
+ />
539
+ <div v-else class="flex h-24 w-full items-center justify-center text-slate-600">
540
+ <UIcon
541
+ :name="
542
+ view.artifactId && blobs.statusFor(view.artifactId) === 'error'
543
+ ? 'i-lucide-image-off'
544
+ : 'i-lucide-image'
545
+ "
546
+ class="h-5 w-5"
547
+ />
548
+ </div>
549
+ <span
550
+ class="flex items-center gap-1 truncate px-1.5 py-1 text-[11px] text-slate-300"
551
+ :title="view.view"
552
+ >
553
+ <UIcon
554
+ v-if="view.referenceArtifactId"
555
+ name="i-lucide-images"
556
+ class="h-3 w-3 shrink-0 text-sky-300"
557
+ :title="t('outcome.visuals.hasReference')"
558
+ />
559
+ {{ view.view }}
560
+ </span>
561
+ </button>
562
+ </div>
563
+ </template>
564
+ <template v-else>
565
+ <p class="text-[13px] italic leading-relaxed text-slate-500">
566
+ {{ t(VISUALS_GAP_KEYS[outcome.visuals.gap]) }}
567
+ </p>
568
+ <p
569
+ v-if="outcome.visuals.detail"
570
+ class="mt-1 text-[12px] leading-relaxed text-slate-500"
571
+ data-testid="outcome-visuals-detail"
572
+ >
573
+ {{ outcome.visuals.detail }}
574
+ </p>
575
+ </template>
576
+ </section>
577
+
578
+ <!-- The machine checks, listed only where one actually recorded a verdict. -->
579
+ <section v-if="checkRows.length" data-testid="outcome-checks">
580
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
581
+ {{ t('outcome.checks.title') }}
582
+ </h3>
583
+ <div class="flex flex-wrap items-center gap-1.5">
584
+ <UBadge
585
+ v-for="check in checkRows"
586
+ :key="check.kind"
587
+ :color="check.color"
588
+ variant="subtle"
589
+ size="sm"
590
+ data-testid="outcome-check"
591
+ :data-check="check.kind"
592
+ >
593
+ {{ check.label }}
594
+ </UBadge>
595
+ </div>
596
+ </section>
597
+ </div>
598
+
599
+ <EmptyState
600
+ v-else
601
+ icon="i-lucide-clipboard-check"
602
+ :title="t('outcome.empty.title')"
603
+ :description="t('outcome.empty.body')"
604
+ />
605
+ </ResultWindowShell>
606
+
607
+ <!-- Shared zoom/pan viewer, layered above this window on the shared modal stack. -->
608
+ <ArtifactLightbox
609
+ v-model:open="lightboxOpen"
610
+ v-model:index="lightboxIndex"
611
+ :items="lightboxItems"
612
+ :blobs="blobs"
613
+ />
614
+ </template>
@@ -59,6 +59,10 @@ const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
59
59
  why: 'tracker column + run-metadata rail, and it hands its whole body to the three-column plan review while a plan is parked',
60
60
  },
61
61
  'judge/JudgeResultView.vue': { width: '3xl', why: 'a rubric verdict — one column, short' },
62
+ 'outcome/OutcomeSummaryWindow.vue': {
63
+ width: '3xl',
64
+ why: 'the run outcome summary: one column of short evidence sections, no rail to lay out beside it',
65
+ },
62
66
  'panels/GenericStructuredResultView.vue': {
63
67
  width: '4xl',
64
68
  why: 'the fallback structured-result reader, one column of sections',
@@ -19,6 +19,7 @@ import type { ChangeClass, ReviewEffort } from '~/types/merge'
19
19
  import MergeEffortChips from '~/components/merge/MergeEffortChips.vue'
20
20
  import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
21
21
  import { inputGateNoticeFor } from '~/utils/inputGate'
22
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
22
23
 
23
24
  const props = defineProps<{ block: Block }>()
24
25
 
@@ -87,6 +88,22 @@ const failedRun = computed(() => {
87
88
  // CURRENT status, so the error trail stays viewable after a restart clears the top banner.
88
89
  const failureHistory = computed(() => agentRuns.byBlock[props.block.id]?.failureHistory ?? [])
89
90
 
91
+ /**
92
+ * Whether this task has a result worth reading in product terms: a pull request, or a step that
93
+ * recorded evidence. Asked of the shared reduction rather than re-derived here, so the panel and
94
+ * the board card can never disagree about which runs have an outcome to open.
95
+ *
96
+ * The spec is deliberately not loaded for this check: it only ever adds TITLES to requirement
97
+ * rows, never sections, so nothing about whether there is something to show depends on it. The
98
+ * window loads it on open.
99
+ */
100
+ const outcomeReadable = computed(() =>
101
+ hasOutcomeToShow(composeRunOutcome({ block: props.block, instance: instance.value ?? null })),
102
+ )
103
+ function openOutcome() {
104
+ ui.openOutcome(props.block.id, instance.value?.id ?? null)
105
+ }
106
+
90
107
  const pr = computed(() => props.block.pullRequest)
91
108
  /** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
92
109
  const prMerged = computed(() => props.block.status === 'done')
@@ -605,6 +622,22 @@ async function mergePr() {
605
622
  <!-- error trail of prior attempts (survives a retry/restart that cleared the banner) -->
606
623
  <AgentFailureHistory :failures="failureHistory" />
607
624
 
625
+ <!-- Read the result: the outcome summary is the way in, and the pull request below is the
626
+ way to the diff. Offered whenever there is evidence or a PR to read, which includes a
627
+ merged task whose run instance is long gone. -->
628
+ <UButton
629
+ v-if="outcomeReadable"
630
+ color="primary"
631
+ variant="soft"
632
+ size="sm"
633
+ icon="i-lucide-clipboard-check"
634
+ block
635
+ data-testid="inspector-open-outcome"
636
+ @click="openOutcome"
637
+ >
638
+ {{ t('inspector.execution.readOutcome') }}
639
+ </UButton>
640
+
608
641
  <!-- Open PR: link straight to it on GitHub -->
609
642
  <div v-if="pr" class="space-y-2">
610
643
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">