@cat-factory/app 0.137.2 → 0.138.1

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.
@@ -13,9 +13,8 @@
13
13
  import { computed, onUnmounted, ref, watch } from 'vue'
14
14
  import type { TestConcern, TestOutcome, TestReport, TestScreenshot } from '~/types/domain'
15
15
  import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
16
- import { useFocusTrap } from '~/composables/useFocusTrap'
17
16
  import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
18
- import StepRestartControl from '~/components/panels/StepRestartControl.vue'
17
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
19
18
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
20
19
  import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
21
20
  import AttemptEntryHeader from '~/components/panels/AttemptEntryHeader.vue'
@@ -30,10 +29,17 @@ const { t, d, n } = useI18n()
30
29
  const blobs = useArtifactBlobs()
31
30
  onUnmounted(() => blobs.revokeAll())
32
31
 
33
- // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: this window reads
34
- // its report straight off the execution step, so there's nothing to fetch on open.
35
- const { open, blockId, instanceId, stepIndex, close } = useResultView('tester')
32
+ // Shared seam contract (open/blockId/close). No `onOpen` loader: this window reads its report
33
+ // straight off the execution step, so there's nothing to fetch on open. `manageEscape: false`
34
+ // `ResultWindowShell` owns Escape (and the focus trap + scroll lock + stacking) via the
35
+ // shared overlay behaviour; the nested lightbox layers above it on the same stack.
36
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('tester', {
37
+ manageEscape: false,
38
+ })
36
39
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
40
+ const headerTitle = computed(
41
+ () => `${t('testing.title')}${block.value ? ` — ${block.value.title}` : ''}`,
42
+ )
37
43
 
38
44
  const instance = computed(() =>
39
45
  instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
@@ -262,13 +268,6 @@ function openShot(artifactId: string) {
262
268
  lightboxOpen.value = true
263
269
  }
264
270
 
265
- // Focus management for the modal panel; hands the Tab trap off to the lightbox while it's open.
266
- const dialogRoot = ref<HTMLElement | null>(null)
267
- useFocusTrap(
268
- dialogRoot,
269
- computed(() => open.value && !lightboxOpen.value),
270
- )
271
-
272
271
  const sortedConcerns = computed<TestConcern[]>(() => {
273
272
  const r = report.value
274
273
  if (!r) return []
@@ -309,508 +308,419 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
309
308
  </script>
310
309
 
311
310
  <template>
312
- <Teleport to="body">
313
- <div
314
- v-if="open"
315
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
316
- @click.self="close"
317
- >
318
- <div
319
- ref="dialogRoot"
320
- tabindex="-1"
321
- role="dialog"
322
- aria-modal="true"
323
- data-testid="tester-report-window"
324
- :aria-label="t('testing.title')"
325
- 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"
311
+ <ResultWindowShell
312
+ :open="open"
313
+ icon="i-lucide-flask-conical"
314
+ icon-class="bg-amber-500/15 text-amber-300"
315
+ :title="headerTitle"
316
+ :subtitle="t('testing.subtitle')"
317
+ :step-ref="{ instanceId, stepIndex }"
318
+ width="5xl"
319
+ testid="tester-report-window"
320
+ @close="close"
321
+ >
322
+ <template #header-extras>
323
+ <UBadge
324
+ v-if="report"
325
+ :color="report.greenlight ? 'success' : 'warning'"
326
+ variant="subtle"
327
+ size="sm"
326
328
  >
327
- <!-- Header -->
328
- <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
329
- <span
330
- class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/15 text-amber-300"
331
- >
332
- <UIcon name="i-lucide-flask-conical" class="h-4 w-4" />
333
- </span>
334
- <div class="min-w-0 flex-1">
335
- <h2 class="truncate text-sm font-semibold text-slate-100">
336
- {{ t('testing.title') }}{{ block ? ` — ${block.title}` : '' }}
337
- </h2>
338
- <p class="truncate text-[11px] text-slate-400">
339
- {{ t('testing.subtitle') }}
340
- </p>
341
- </div>
342
- <UBadge
343
- v-if="report"
344
- :color="report.greenlight ? 'success' : 'warning'"
345
- variant="subtle"
346
- size="sm"
347
- >
348
- {{ report.greenlight ? t('testing.badge.greenlit') : t('testing.badge.needsFixes') }}
349
- </UBadge>
350
- <span
351
- v-if="testState && testState.attempts > 0"
352
- class="text-[11px] text-slate-400"
353
- :title="t('testing.fixerAttempts')"
354
- >
355
- {{
356
- t('testing.fixCount', { attempts: testState.attempts, max: testState.maxAttempts })
357
- }}
358
- <template v-if="testState.phase === 'fixing'">
359
- {{ t('testing.fixingSuffix') }}</template
360
- >
361
- </span>
362
- <StepRestartControl
363
- :instance-id="instanceId"
364
- :step-index="stepIndex"
365
- @restarted="close"
366
- />
367
- <button
368
- class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
369
- @click="close"
370
- >
371
- <UIcon name="i-lucide-x" class="h-4 w-4" />
372
- </button>
373
- </header>
374
-
375
- <div class="flex min-h-0 flex-1">
376
- <!-- Main: infrastructure observability + scenarios → outcomes → concerns tree -->
377
- <div class="min-w-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
378
- <!-- Infrastructure: container lifecycle (where + what it's doing), the
329
+ {{ report.greenlight ? t('testing.badge.greenlit') : t('testing.badge.needsFixes') }}
330
+ </UBadge>
331
+ <span
332
+ v-if="testState && testState.attempts > 0"
333
+ class="text-[11px] text-slate-400"
334
+ :title="t('testing.fixerAttempts')"
335
+ >
336
+ {{ t('testing.fixCount', { attempts: testState.attempts, max: testState.maxAttempts }) }}
337
+ <template v-if="testState.phase === 'fixing'"> {{ t('testing.fixingSuffix') }}</template>
338
+ </span>
339
+ </template>
340
+
341
+ <div class="flex min-h-0 flex-1">
342
+ <!-- Main: infrastructure observability + scenarios → outcomes → concerns tree -->
343
+ <div class="min-w-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
344
+ <!-- Infrastructure: container lifecycle (where + what it's doing), the
379
345
  ephemeral environment, and the run's infra attempts + logs — parity with
380
346
  the Coder's step detail. Shown even before a report lands, so the infra
381
347
  spin-up is visible WHILE the Tester is still standing it up. -->
382
- <!-- Only when there's genuine infrastructure to show — a container or an ephemeral
348
+ <!-- Only when there's genuine infrastructure to show — a container or an ephemeral
383
349
  environment. A no-infra tester (no container, no env) has no infra attempts
384
350
  either, so we don't render an empty header + a log toggle over nothing. -->
385
- <section
386
- v-if="step && (step.container || stepEnvironment || infraSetup)"
387
- data-testid="tester-infrastructure"
388
- class="space-y-3"
389
- >
390
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
391
- {{ t('testing.infrastructure') }}
392
- </h3>
393
- <StepContainerStatus :step="step" :run-failed="runFailed" />
394
- <EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
395
-
396
- <!-- In-container docker-compose dependency stand-up (local-infra tester): the
351
+ <section
352
+ v-if="step && (step.container || stepEnvironment || infraSetup)"
353
+ data-testid="tester-infrastructure"
354
+ class="space-y-3"
355
+ >
356
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
357
+ {{ t('testing.infrastructure') }}
358
+ </h3>
359
+ <StepContainerStatus :step="step" :run-failed="runFailed" />
360
+ <EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
361
+
362
+ <!-- In-container docker-compose dependency stand-up (local-infra tester): the
397
363
  outcome + the captured `docker compose up` logs. This is the stand-up that
398
364
  runs INSIDE the container, so its output isn't in the provisioning drawer
399
365
  below — it's the highest-signal artifact when local infra fails to start. -->
400
- <div
401
- v-if="infraSetup"
402
- data-testid="tester-infra-setup"
403
- class="rounded-lg border px-3 py-2"
404
- :class="
405
- infraSetup.started
406
- ? 'border-slate-800 bg-slate-900/60'
407
- : 'border-rose-500/40 bg-rose-500/10'
366
+ <div
367
+ v-if="infraSetup"
368
+ data-testid="tester-infra-setup"
369
+ class="rounded-lg border px-3 py-2"
370
+ :class="
371
+ infraSetup.started
372
+ ? 'border-slate-800 bg-slate-900/60'
373
+ : 'border-rose-500/40 bg-rose-500/10'
374
+ "
375
+ >
376
+ <div class="flex items-center gap-2">
377
+ <UIcon
378
+ :name="infraSetup.started ? 'i-lucide-container' : 'i-lucide-circle-x'"
379
+ class="h-3.5 w-3.5 shrink-0"
380
+ :class="infraSetup.started ? 'text-emerald-400' : 'text-rose-400'"
381
+ />
382
+ <span class="text-[13px] font-medium text-slate-200">
383
+ {{ infraSetup.started ? t('testing.standup.up') : t('testing.standup.failed') }}
384
+ </span>
385
+ <span v-if="infraSetup.durationMs != null" class="ms-auto text-[11px] text-slate-500">
386
+ {{
387
+ t('testing.standup.took', {
388
+ seconds: n(infraSetup.durationMs / 1000, 'decimal'),
389
+ })
390
+ }}
391
+ </span>
392
+ </div>
393
+ <p v-if="infraSetup.composePath" class="mt-1 font-mono text-[11px] text-slate-500">
394
+ {{ infraSetup.composePath }}
395
+ </p>
396
+ <p
397
+ v-if="infraSetup.error"
398
+ class="mt-1 text-[12px] leading-snug text-rose-300"
399
+ data-testid="tester-infra-setup-error"
400
+ >
401
+ {{ infraSetup.error }}
402
+ </p>
403
+ <template v-if="infraSetup.logs">
404
+ <UButton
405
+ :icon="showInfraSetupLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
406
+ variant="ghost"
407
+ size="xs"
408
+ class="mt-1.5"
409
+ data-testid="tester-infra-setup-logs-toggle"
410
+ @click="
411
+ () => {
412
+ showInfraSetupLogs = !showInfraSetupLogs
413
+ }
408
414
  "
409
415
  >
410
- <div class="flex items-center gap-2">
411
- <UIcon
412
- :name="infraSetup.started ? 'i-lucide-container' : 'i-lucide-circle-x'"
413
- class="h-3.5 w-3.5 shrink-0"
414
- :class="infraSetup.started ? 'text-emerald-400' : 'text-rose-400'"
415
- />
416
- <span class="text-[13px] font-medium text-slate-200">
417
- {{ infraSetup.started ? t('testing.standup.up') : t('testing.standup.failed') }}
418
- </span>
419
- <span
420
- v-if="infraSetup.durationMs != null"
421
- class="ms-auto text-[11px] text-slate-500"
422
- >
423
- {{
424
- t('testing.standup.took', {
425
- seconds: n(infraSetup.durationMs / 1000, 'decimal'),
426
- })
427
- }}
428
- </span>
429
- </div>
430
- <p v-if="infraSetup.composePath" class="mt-1 font-mono text-[11px] text-slate-500">
431
- {{ infraSetup.composePath }}
432
- </p>
433
- <p
434
- v-if="infraSetup.error"
435
- class="mt-1 text-[12px] leading-snug text-rose-300"
436
- data-testid="tester-infra-setup-error"
437
- >
438
- {{ infraSetup.error }}
439
- </p>
440
- <template v-if="infraSetup.logs">
441
- <UButton
442
- :icon="showInfraSetupLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
443
- variant="ghost"
444
- size="xs"
445
- class="mt-1.5"
446
- data-testid="tester-infra-setup-logs-toggle"
447
- @click="
448
- () => {
449
- showInfraSetupLogs = !showInfraSetupLogs
450
- }
451
- "
452
- >
453
- {{
454
- showInfraSetupLogs
455
- ? t('testing.standup.hideLogs')
456
- : t('testing.standup.showLogs')
457
- }}
458
- </UButton>
459
- <pre
460
- v-if="showInfraSetupLogs"
461
- data-testid="tester-infra-setup-logs"
462
- class="mt-2 max-h-64 overflow-auto rounded bg-slate-950/70 p-2 font-mono text-[11px] leading-relaxed text-slate-300"
463
- >{{ infraSetup.logs }}</pre
464
- >
465
- </template>
466
- </div>
416
+ {{
417
+ showInfraSetupLogs ? t('testing.standup.hideLogs') : t('testing.standup.showLogs')
418
+ }}
419
+ </UButton>
420
+ <pre
421
+ v-if="showInfraSetupLogs"
422
+ data-testid="tester-infra-setup-logs"
423
+ class="mt-2 max-h-64 overflow-auto rounded bg-slate-950/70 p-2 font-mono text-[11px] leading-relaxed text-slate-300"
424
+ >{{ infraSetup.logs }}</pre
425
+ >
426
+ </template>
427
+ </div>
467
428
 
468
- <!-- Explicit confirmation that every piece of the tester's infrastructure is up
429
+ <!-- Explicit confirmation that every piece of the tester's infrastructure is up
469
430
  (container running, the ephemeral environment ready, any in-container
470
431
  dependency stand-up done) and the agent is now starting its work — so the
471
432
  details don't jump silently from "provisioning" into a blank working state. -->
472
- <div
473
- v-if="infraReady"
474
- data-testid="tester-env-ready"
475
- class="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[13px] text-emerald-200"
476
- >
477
- <UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0 text-emerald-400" />
478
- <span>{{ t('testing.readyBanner') }}</span>
479
- </div>
433
+ <div
434
+ v-if="infraReady"
435
+ data-testid="tester-env-ready"
436
+ class="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[13px] text-emerald-200"
437
+ >
438
+ <UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0 text-emerald-400" />
439
+ <span>{{ t('testing.readyBanner') }}</span>
440
+ </div>
480
441
 
481
- <div v-if="executionId">
482
- <UButton
483
- :icon="showProvisioning ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
484
- variant="ghost"
485
- size="xs"
486
- data-testid="tester-infra-attempts-toggle"
487
- @click="
488
- () => {
489
- showProvisioning = !showProvisioning
490
- }
491
- "
492
- >
493
- {{
494
- showProvisioning
495
- ? t('panels.stepDetail.hideInfraAttempts')
496
- : t('panels.stepDetail.infraAttempts')
497
- }}
498
- </UButton>
499
- <ProvisioningLogsDrawer
500
- v-if="showProvisioning"
501
- class="mt-2"
502
- :execution-id="executionId"
503
- :live="runLive"
504
- />
505
- </div>
506
- </section>
442
+ <div v-if="executionId">
443
+ <UButton
444
+ :icon="showProvisioning ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
445
+ variant="ghost"
446
+ size="xs"
447
+ data-testid="tester-infra-attempts-toggle"
448
+ @click="
449
+ () => {
450
+ showProvisioning = !showProvisioning
451
+ }
452
+ "
453
+ >
454
+ {{
455
+ showProvisioning
456
+ ? t('panels.stepDetail.hideInfraAttempts')
457
+ : t('panels.stepDetail.infraAttempts')
458
+ }}
459
+ </UButton>
460
+ <ProvisioningLogsDrawer
461
+ v-if="showProvisioning"
462
+ class="mt-2"
463
+ :execution-id="executionId"
464
+ :live="runLive"
465
+ />
466
+ </div>
467
+ </section>
507
468
 
508
- <!-- Fixer timeline: one inspectable entry per fixer round (what it was handed and
469
+ <!-- Fixer timeline: one inspectable entry per fixer round (what it was handed and
509
470
  how it ended), so the otherwise-opaque fixer sub-jobs have a surface — the
510
471
  analogue of the polling gate's attempt history. -->
511
- <section
512
- v-if="fixerAttempts.length"
513
- data-testid="tester-fixer-attempts"
514
- class="space-y-2"
472
+ <section v-if="fixerAttempts.length" data-testid="tester-fixer-attempts" class="space-y-2">
473
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
474
+ {{ t('testing.fixerAttempts') }}
475
+ </h3>
476
+ <ol class="space-y-2">
477
+ <li
478
+ v-for="a in fixerAttempts"
479
+ :key="a.attempt"
480
+ data-testid="tester-fixer-attempt"
481
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
515
482
  >
516
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
517
- {{ t('testing.fixerAttempts') }}
518
- </h3>
519
- <ol class="space-y-2">
520
- <li
521
- v-for="a in fixerAttempts"
522
- :key="a.attempt"
523
- data-testid="tester-fixer-attempt"
524
- class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
525
- >
526
- <AttemptEntryHeader
527
- :label="t('testing.fixerTimeline.attempt', { n: a.attempt })"
528
- :outcome="a.outcome"
529
- :outcome-label="
530
- a.outcome === 'completed'
531
- ? t('testing.fixerTimeline.completed')
532
- : t('testing.fixerTimeline.failed')
533
- "
534
- :at="a.at"
535
- :icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
536
- :icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
537
- />
538
- <p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
539
- {{ a.summary }}
540
- </p>
541
- <div v-if="a.concerns && a.concerns.length" class="mt-1.5">
542
- <p class="text-[11px] text-slate-500">
543
- {{ t('testing.fixerTimeline.addressed') }}
544
- </p>
545
- <ul class="mt-1 space-y-0.5">
546
- <li
547
- v-for="(c, ci) in a.concerns"
548
- :key="`fa${a.attempt}-c${ci}`"
549
- class="flex items-center gap-1.5 text-[12px] text-slate-300"
550
- >
551
- <span
552
- class="rounded px-1 text-[10px] uppercase"
553
- :class="SEVERITY_META[c.severity].chip"
554
- >{{ SEVERITY_LABELS[c.severity] }}</span
555
- >
556
- <span class="truncate">{{ c.title }}</span>
557
- </li>
558
- </ul>
559
- </div>
560
- </li>
561
- </ol>
562
- </section>
483
+ <AttemptEntryHeader
484
+ :label="t('testing.fixerTimeline.attempt', { n: a.attempt })"
485
+ :outcome="a.outcome"
486
+ :outcome-label="
487
+ a.outcome === 'completed'
488
+ ? t('testing.fixerTimeline.completed')
489
+ : t('testing.fixerTimeline.failed')
490
+ "
491
+ :at="a.at"
492
+ :icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
493
+ :icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
494
+ />
495
+ <p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
496
+ {{ a.summary }}
497
+ </p>
498
+ <div v-if="a.concerns && a.concerns.length" class="mt-1.5">
499
+ <p class="text-[11px] text-slate-500">
500
+ {{ t('testing.fixerTimeline.addressed') }}
501
+ </p>
502
+ <ul class="mt-1 space-y-0.5">
503
+ <li
504
+ v-for="(c, ci) in a.concerns"
505
+ :key="`fa${a.attempt}-c${ci}`"
506
+ class="flex items-center gap-1.5 text-[12px] text-slate-300"
507
+ >
508
+ <span
509
+ class="rounded px-1 text-[10px] uppercase"
510
+ :class="SEVERITY_META[c.severity].chip"
511
+ >{{ SEVERITY_LABELS[c.severity] }}</span
512
+ >
513
+ <span class="truncate">{{ c.title }}</span>
514
+ </li>
515
+ </ul>
516
+ </div>
517
+ </li>
518
+ </ol>
519
+ </section>
563
520
 
564
- <!-- Test quality-control companion: the coverage audit(s) the QC reviewer ran on
521
+ <!-- Test quality-control companion: the coverage audit(s) the QC reviewer ran on
565
522
  the report before the greenlight/fixer decision. Each verdict says whether the
566
523
  report adequately covered what the task needed tested, with the gaps that
567
524
  looped the Tester for a focused additional pass. -->
568
- <section
569
- v-if="quality && qualityVerdicts.length"
570
- data-testid="tester-quality"
571
- class="space-y-2"
525
+ <section
526
+ v-if="quality && qualityVerdicts.length"
527
+ data-testid="tester-quality"
528
+ class="space-y-2"
529
+ >
530
+ <div class="flex items-center gap-2">
531
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
532
+ {{ t('testing.quality.heading') }}
533
+ </h3>
534
+ <span
535
+ v-if="quality.attempts"
536
+ class="text-[11px] text-slate-400"
537
+ :title="t('testing.quality.reruns')"
538
+ >
539
+ {{
540
+ t('testing.quality.rerunCount', {
541
+ attempts: quality.attempts,
542
+ max: quality.maxAttempts,
543
+ })
544
+ }}
545
+ </span>
546
+ <UBadge
547
+ v-if="quality.exceeded"
548
+ color="warning"
549
+ variant="subtle"
550
+ size="sm"
551
+ data-testid="tester-quality-exceeded"
552
+ >
553
+ {{ t('testing.quality.exceeded') }}
554
+ </UBadge>
555
+ </div>
556
+ <ol class="space-y-2">
557
+ <li
558
+ v-for="(vd, vi) in qualityVerdicts"
559
+ :key="`qc${vi}`"
560
+ data-testid="tester-quality-verdict"
561
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
572
562
  >
573
563
  <div class="flex items-center gap-2">
574
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
575
- {{ t('testing.quality.heading') }}
576
- </h3>
577
- <span
578
- v-if="quality.attempts"
579
- class="text-[11px] text-slate-400"
580
- :title="t('testing.quality.reruns')"
581
- >
564
+ <UIcon
565
+ :name="vd.adequate ? 'i-lucide-shield-check' : 'i-lucide-shield-alert'"
566
+ class="h-3.5 w-3.5 shrink-0"
567
+ :class="vd.adequate ? 'text-emerald-400' : 'text-amber-300'"
568
+ />
569
+ <span class="text-[13px] font-medium text-slate-200">
582
570
  {{
583
- t('testing.quality.rerunCount', {
584
- attempts: quality.attempts,
585
- max: quality.maxAttempts,
586
- })
571
+ vd.adequate ? t('testing.quality.adequate') : t('testing.quality.inadequate')
587
572
  }}
588
573
  </span>
589
- <UBadge
590
- v-if="quality.exceeded"
591
- color="warning"
592
- variant="subtle"
593
- size="sm"
594
- data-testid="tester-quality-exceeded"
595
- >
596
- {{ t('testing.quality.exceeded') }}
597
- </UBadge>
574
+ <span v-if="vd.model" class="ms-auto font-mono text-[10px] text-slate-500">{{
575
+ vd.model
576
+ }}</span>
577
+ <span class="text-[11px] text-slate-500" :class="{ 'ms-auto': !vd.model }">{{
578
+ d(new Date(vd.at), 'short')
579
+ }}</span>
598
580
  </div>
599
- <ol class="space-y-2">
600
- <li
601
- v-for="(vd, vi) in qualityVerdicts"
602
- :key="`qc${vi}`"
603
- data-testid="tester-quality-verdict"
604
- class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
605
- >
606
- <div class="flex items-center gap-2">
607
- <UIcon
608
- :name="vd.adequate ? 'i-lucide-shield-check' : 'i-lucide-shield-alert'"
609
- class="h-3.5 w-3.5 shrink-0"
610
- :class="vd.adequate ? 'text-emerald-400' : 'text-amber-300'"
611
- />
612
- <span class="text-[13px] font-medium text-slate-200">
613
- {{
614
- vd.adequate
615
- ? t('testing.quality.adequate')
616
- : t('testing.quality.inadequate')
617
- }}
618
- </span>
619
- <span v-if="vd.model" class="ms-auto font-mono text-[10px] text-slate-500">{{
620
- vd.model
621
- }}</span>
622
- <span class="text-[11px] text-slate-500" :class="{ 'ms-auto': !vd.model }">{{
623
- d(new Date(vd.at), 'short')
624
- }}</span>
625
- </div>
626
- <p v-if="vd.feedback" class="mt-1 text-[12px] leading-snug text-slate-400">
627
- {{ vd.feedback }}
628
- </p>
629
- <div v-if="vd.gaps.length" class="mt-1.5">
630
- <p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
631
- <ul class="mt-1 space-y-0.5">
632
- <li
633
- v-for="(gap, gi) in vd.gaps"
634
- :key="`qc${vi}-g${gi}`"
635
- class="flex items-start gap-1.5 text-[12px] text-slate-300"
636
- >
637
- <UIcon
638
- name="i-lucide-dot"
639
- class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400"
640
- />
641
- <span>{{ gap }}</span>
642
- </li>
643
- </ul>
644
- </div>
645
- </li>
646
- </ol>
647
- </section>
648
-
649
- <div
650
- v-if="!report"
651
- class="flex flex-col items-center justify-center gap-2 py-12 text-center text-slate-400"
652
- >
653
- <UIcon name="i-lucide-flask-conical" class="h-8 w-8 opacity-40" />
654
- <p class="text-sm">{{ t('testing.empty.title') }}</p>
655
- <p class="max-w-sm text-[11px] text-slate-500">
656
- {{ t('testing.empty.hint') }}
581
+ <p v-if="vd.feedback" class="mt-1 text-[12px] leading-snug text-slate-400">
582
+ {{ vd.feedback }}
657
583
  </p>
658
- </div>
659
-
660
- <template v-else>
661
- <!-- Summary -->
662
- <p v-if="report.summary" class="mb-4 text-[13px] leading-relaxed text-slate-300">
663
- {{ report.summary }}
664
- </p>
665
-
666
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
667
- {{ t('testing.scenariosOutcomes') }}
668
- </h3>
669
- <ul class="space-y-2">
670
- <li
671
- v-for="g in groups"
672
- :key="g.key"
673
- class="overflow-hidden rounded-lg border border-slate-800 bg-slate-900/60"
674
- >
675
- <button
676
- class="flex w-full items-center gap-2 px-3 py-2 text-start hover:bg-slate-800/40"
677
- @click="toggle(g.key)"
584
+ <div v-if="vd.gaps.length" class="mt-1.5">
585
+ <p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
586
+ <ul class="mt-1 space-y-0.5">
587
+ <li
588
+ v-for="(gap, gi) in vd.gaps"
589
+ :key="`qc${vi}-g${gi}`"
590
+ class="flex items-start gap-1.5 text-[12px] text-slate-300"
678
591
  >
679
- <UIcon
680
- :name="
681
- collapsed.has(g.key) ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'
682
- "
683
- class="h-3.5 w-3.5 shrink-0 text-slate-500"
684
- />
685
- <UIcon
686
- :name="GROUP_STATUS_META[g.status].icon"
687
- class="h-4 w-4 shrink-0"
688
- :class="GROUP_STATUS_META[g.status].text"
689
- />
690
- <span
691
- class="min-w-0 flex-1 truncate text-[13px]"
692
- :class="g.other ? 'text-slate-400' : 'font-medium text-slate-200'"
693
- >
694
- {{ g.title }}
695
- </span>
696
- <UIcon
697
- v-if="g.screenshots.length"
698
- name="i-lucide-camera"
699
- class="h-3.5 w-3.5 shrink-0 text-slate-500"
700
- :title="
701
- t(
702
- 'testing.screenshotCount',
703
- { count: g.screenshots.length },
704
- g.screenshots.length,
705
- )
706
- "
707
- />
708
- <span class="shrink-0 text-[11px] text-slate-500">
709
- {{ t('testing.checkCount', { count: g.outcomes.length }, g.outcomes.length) }}
710
- <template v-if="g.concerns.length">
711
- ·
712
- {{
713
- t('testing.concernCount', { count: g.concerns.length }, g.concerns.length)
714
- }}
715
- </template>
716
- </span>
717
- </button>
592
+ <UIcon name="i-lucide-dot" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400" />
593
+ <span>{{ gap }}</span>
594
+ </li>
595
+ </ul>
596
+ </div>
597
+ </li>
598
+ </ol>
599
+ </section>
600
+
601
+ <div
602
+ v-if="!report"
603
+ class="flex flex-col items-center justify-center gap-2 py-12 text-center text-slate-400"
604
+ >
605
+ <UIcon name="i-lucide-flask-conical" class="h-8 w-8 opacity-40" />
606
+ <p class="text-sm">{{ t('testing.empty.title') }}</p>
607
+ <p class="max-w-sm text-[11px] text-slate-500">
608
+ {{ t('testing.empty.hint') }}
609
+ </p>
610
+ </div>
718
611
 
719
- <div v-if="!collapsed.has(g.key)" class="space-y-1 px-3 pb-3 ps-9">
720
- <!-- Outcomes -->
721
- <div
722
- v-for="(o, oi) in g.outcomes"
723
- :key="`o${oi}`"
724
- class="flex items-start gap-2 py-0.5"
725
- >
726
- <UIcon
727
- :name="STATUS_META[o.status].icon"
728
- class="mt-0.5 h-3.5 w-3.5 shrink-0"
729
- :class="STATUS_META[o.status].text"
730
- />
731
- <div class="min-w-0">
732
- <span class="text-[13px] text-slate-200">{{ o.name }}</span>
733
- <p v-if="o.detail" class="text-[12px] leading-snug text-slate-400">
734
- {{ o.detail }}
735
- </p>
736
- </div>
737
- </div>
738
- <p v-if="!g.outcomes.length" class="py-0.5 text-[12px] italic text-slate-500">
739
- {{ t('testing.noDiscreteCheck') }}
612
+ <template v-else>
613
+ <!-- Summary -->
614
+ <p v-if="report.summary" class="mb-4 text-[13px] leading-relaxed text-slate-300">
615
+ {{ report.summary }}
616
+ </p>
617
+
618
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
619
+ {{ t('testing.scenariosOutcomes') }}
620
+ </h3>
621
+ <ul class="space-y-2">
622
+ <li
623
+ v-for="g in groups"
624
+ :key="g.key"
625
+ class="overflow-hidden rounded-lg border border-slate-800 bg-slate-900/60"
626
+ >
627
+ <button
628
+ class="flex w-full items-center gap-2 px-3 py-2 text-start hover:bg-slate-800/40"
629
+ @click="toggle(g.key)"
630
+ >
631
+ <UIcon
632
+ :name="collapsed.has(g.key) ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
633
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
634
+ />
635
+ <UIcon
636
+ :name="GROUP_STATUS_META[g.status].icon"
637
+ class="h-4 w-4 shrink-0"
638
+ :class="GROUP_STATUS_META[g.status].text"
639
+ />
640
+ <span
641
+ class="min-w-0 flex-1 truncate text-[13px]"
642
+ :class="g.other ? 'text-slate-400' : 'font-medium text-slate-200'"
643
+ >
644
+ {{ g.title }}
645
+ </span>
646
+ <UIcon
647
+ v-if="g.screenshots.length"
648
+ name="i-lucide-camera"
649
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
650
+ :title="
651
+ t(
652
+ 'testing.screenshotCount',
653
+ { count: g.screenshots.length },
654
+ g.screenshots.length,
655
+ )
656
+ "
657
+ />
658
+ <span class="shrink-0 text-[11px] text-slate-500">
659
+ {{ t('testing.checkCount', { count: g.outcomes.length }, g.outcomes.length) }}
660
+ <template v-if="g.concerns.length">
661
+ ·
662
+ {{ t('testing.concernCount', { count: g.concerns.length }, g.concerns.length) }}
663
+ </template>
664
+ </span>
665
+ </button>
666
+
667
+ <div v-if="!collapsed.has(g.key)" class="space-y-1 px-3 pb-3 ps-9">
668
+ <!-- Outcomes -->
669
+ <div
670
+ v-for="(o, oi) in g.outcomes"
671
+ :key="`o${oi}`"
672
+ class="flex items-start gap-2 py-0.5"
673
+ >
674
+ <UIcon
675
+ :name="STATUS_META[o.status].icon"
676
+ class="mt-0.5 h-3.5 w-3.5 shrink-0"
677
+ :class="STATUS_META[o.status].text"
678
+ />
679
+ <div class="min-w-0">
680
+ <span class="text-[13px] text-slate-200">{{ o.name }}</span>
681
+ <p v-if="o.detail" class="text-[12px] leading-snug text-slate-400">
682
+ {{ o.detail }}
740
683
  </p>
684
+ </div>
685
+ </div>
686
+ <p v-if="!g.outcomes.length" class="py-0.5 text-[12px] italic text-slate-500">
687
+ {{ t('testing.noDiscreteCheck') }}
688
+ </p>
741
689
 
742
- <!-- Concerns linked to this scenario -->
743
- <div
744
- v-for="(c, ci) in g.concerns"
745
- :key="`c${ci}`"
746
- class="mt-1 flex items-start gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-2 py-1.5"
747
- >
748
- <UIcon
749
- name="i-lucide-alert-triangle"
750
- class="mt-0.5 h-3.5 w-3.5 shrink-0"
751
- :class="SEVERITY_META[c.severity].text"
752
- />
753
- <div class="min-w-0">
754
- <div class="flex items-center gap-1.5">
755
- <span class="text-[12px] font-medium text-slate-200">{{ c.title }}</span>
756
- <span
757
- class="rounded px-1 text-[10px] uppercase"
758
- :class="SEVERITY_META[c.severity].chip"
759
- >
760
- {{ SEVERITY_LABELS[c.severity] }}
761
- </span>
762
- </div>
763
- <p v-if="c.detail" class="text-[12px] leading-snug text-slate-400">
764
- {{ c.detail }}
765
- </p>
766
- </div>
767
- </div>
768
-
769
- <!-- Screenshots captured for this scenario -->
770
- <div v-if="g.screenshots.length" class="mt-2 flex flex-wrap gap-2">
771
- <button
772
- v-for="(s, si) in g.screenshots"
773
- :key="`shot${si}`"
774
- class="group relative h-20 w-28 shrink-0 overflow-hidden rounded border border-slate-800 bg-slate-950/60 hover:border-slate-600"
775
- :title="s.view"
776
- @click="openShot(s.artifactId)"
690
+ <!-- Concerns linked to this scenario -->
691
+ <div
692
+ v-for="(c, ci) in g.concerns"
693
+ :key="`c${ci}`"
694
+ class="mt-1 flex items-start gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-2 py-1.5"
695
+ >
696
+ <UIcon
697
+ name="i-lucide-alert-triangle"
698
+ class="mt-0.5 h-3.5 w-3.5 shrink-0"
699
+ :class="SEVERITY_META[c.severity].text"
700
+ />
701
+ <div class="min-w-0">
702
+ <div class="flex items-center gap-1.5">
703
+ <span class="text-[12px] font-medium text-slate-200">{{ c.title }}</span>
704
+ <span
705
+ class="rounded px-1 text-[10px] uppercase"
706
+ :class="SEVERITY_META[c.severity].chip"
777
707
  >
778
- <img
779
- v-if="blobs.urlFor(s.artifactId)"
780
- :src="blobs.urlFor(s.artifactId)"
781
- :alt="t('testing.screenshotAlt', { view: s.view })"
782
- class="h-full w-full object-cover object-top"
783
- />
784
- <span
785
- v-else
786
- class="flex h-full w-full items-center justify-center text-[10px] text-slate-600"
787
- >
788
- {{
789
- blobs.statusFor(s.artifactId) === 'error'
790
- ? t('testing.shot.failed')
791
- : t('testing.shot.loading')
792
- }}
793
- </span>
794
- <span
795
- class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1 py-0.5 text-[9px] text-slate-300"
796
- >{{ s.view }}</span
797
- >
798
- </button>
708
+ {{ SEVERITY_LABELS[c.severity] }}
709
+ </span>
799
710
  </div>
711
+ <p v-if="c.detail" class="text-[12px] leading-snug text-slate-400">
712
+ {{ c.detail }}
713
+ </p>
800
714
  </div>
801
- </li>
802
- </ul>
803
-
804
- <!-- Standalone gallery: any captures not mapped to a scenario above -->
805
- <section v-if="ungroupedScreenshots.length" class="mt-5">
806
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
807
- {{ t('testing.screenshots') }}
808
- </h3>
809
- <div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
715
+ </div>
716
+
717
+ <!-- Screenshots captured for this scenario -->
718
+ <div v-if="g.screenshots.length" class="mt-2 flex flex-wrap gap-2">
810
719
  <button
811
- v-for="(s, si) in ungroupedScreenshots"
812
- :key="`gal${si}`"
813
- class="group relative aspect-video overflow-hidden rounded-lg border border-slate-800 bg-slate-950/60 hover:border-slate-600"
720
+ v-for="(s, si) in g.screenshots"
721
+ :key="`shot${si}`"
722
+ data-testid="tester-screenshot"
723
+ class="group relative h-20 w-28 shrink-0 overflow-hidden rounded border border-slate-800 bg-slate-950/60 hover:border-slate-600"
814
724
  :title="s.view"
815
725
  @click="openShot(s.artifactId)"
816
726
  >
@@ -822,110 +732,146 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
822
732
  />
823
733
  <span
824
734
  v-else
825
- class="flex h-full w-full items-center justify-center text-[11px] text-slate-600"
735
+ class="flex h-full w-full items-center justify-center text-[10px] text-slate-600"
826
736
  >
827
737
  {{
828
738
  blobs.statusFor(s.artifactId) === 'error'
829
- ? t('testing.shot.failedToLoad')
739
+ ? t('testing.shot.failed')
830
740
  : t('testing.shot.loading')
831
741
  }}
832
742
  </span>
833
743
  <span
834
- class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300"
744
+ class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1 py-0.5 text-[9px] text-slate-300"
835
745
  >{{ s.view }}</span
836
746
  >
837
747
  </button>
838
748
  </div>
839
- </section>
840
- </template>
841
- </div>
842
-
843
- <!-- Sidebar: metadata -->
844
- <aside
845
- class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
846
- >
847
- <div v-if="report">
848
- <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
849
- {{ t('testing.verdict.heading') }}
850
- </h4>
851
- <div class="flex items-center gap-2 text-[13px]">
852
- <UIcon
853
- :name="report.greenlight ? 'i-lucide-circle-check' : 'i-lucide-circle-x'"
854
- class="h-4 w-4"
855
- :class="report.greenlight ? 'text-emerald-400' : 'text-rose-400'"
749
+ </div>
750
+ </li>
751
+ </ul>
752
+
753
+ <!-- Standalone gallery: any captures not mapped to a scenario above -->
754
+ <section v-if="ungroupedScreenshots.length" class="mt-5">
755
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
756
+ {{ t('testing.screenshots') }}
757
+ </h3>
758
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
759
+ <button
760
+ v-for="(s, si) in ungroupedScreenshots"
761
+ :key="`gal${si}`"
762
+ class="group relative aspect-video overflow-hidden rounded-lg border border-slate-800 bg-slate-950/60 hover:border-slate-600"
763
+ :title="s.view"
764
+ @click="openShot(s.artifactId)"
765
+ >
766
+ <img
767
+ v-if="blobs.urlFor(s.artifactId)"
768
+ :src="blobs.urlFor(s.artifactId)"
769
+ :alt="t('testing.screenshotAlt', { view: s.view })"
770
+ class="h-full w-full object-cover object-top"
856
771
  />
857
- <span :class="report.greenlight ? 'text-emerald-300' : 'text-rose-300'">
772
+ <span
773
+ v-else
774
+ class="flex h-full w-full items-center justify-center text-[11px] text-slate-600"
775
+ >
858
776
  {{
859
- report.greenlight ? t('testing.verdict.safe') : t('testing.verdict.withheld')
777
+ blobs.statusFor(s.artifactId) === 'error'
778
+ ? t('testing.shot.failedToLoad')
779
+ : t('testing.shot.loading')
860
780
  }}
861
781
  </span>
862
- </div>
782
+ <span
783
+ class="absolute inset-x-0 bottom-0 truncate bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300"
784
+ >{{ s.view }}</span
785
+ >
786
+ </button>
863
787
  </div>
788
+ </section>
789
+ </template>
790
+ </div>
864
791
 
865
- <div v-if="report">
866
- <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
867
- {{ t('testing.outcomes.heading') }}
868
- </h4>
869
- <dl class="space-y-1 text-[12px]">
870
- <div class="flex items-center justify-between">
871
- <dt class="text-slate-400">{{ t('testing.outcomes.passed') }}</dt>
872
- <dd class="text-emerald-300">{{ counts.passed }}</dd>
873
- </div>
874
- <div class="flex items-center justify-between">
875
- <dt class="text-slate-400">{{ t('testing.outcomes.failed') }}</dt>
876
- <dd class="text-rose-300">{{ counts.failed }}</dd>
877
- </div>
878
- <div class="flex items-center justify-between">
879
- <dt class="text-slate-400">{{ t('testing.outcomes.skipped') }}</dt>
880
- <dd class="text-slate-300">{{ counts.skipped }}</dd>
881
- </div>
882
- <div class="flex items-center justify-between border-t border-slate-800 pt-1">
883
- <dt class="text-slate-400">{{ t('testing.outcomes.concerns') }}</dt>
884
- <dd class="text-amber-300">
885
- {{ counts.concerns
886
- }}<template v-if="counts.blocking">
887
- {{
888
- t('testing.outcomes.blocking', { count: counts.blocking }, counts.blocking)
889
- }}</template
890
- >
891
- </dd>
892
- </div>
893
- </dl>
894
- </div>
792
+ <!-- Sidebar: metadata -->
793
+ <aside
794
+ class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
795
+ >
796
+ <div v-if="report">
797
+ <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
798
+ {{ t('testing.verdict.heading') }}
799
+ </h4>
800
+ <div class="flex items-center gap-2 text-[13px]">
801
+ <UIcon
802
+ :name="report.greenlight ? 'i-lucide-circle-check' : 'i-lucide-circle-x'"
803
+ class="h-4 w-4"
804
+ :class="report.greenlight ? 'text-emerald-400' : 'text-rose-400'"
805
+ />
806
+ <span :class="report.greenlight ? 'text-emerald-300' : 'text-rose-300'">
807
+ {{ report.greenlight ? t('testing.verdict.safe') : t('testing.verdict.withheld') }}
808
+ </span>
809
+ </div>
810
+ </div>
895
811
 
896
- <div v-if="report?.environment">
897
- <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
898
- {{ t('testing.environment') }}
899
- </h4>
900
- <p class="text-[12px] capitalize text-slate-300">{{ report.environment }}</p>
812
+ <div v-if="report">
813
+ <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
814
+ {{ t('testing.outcomes.heading') }}
815
+ </h4>
816
+ <dl class="space-y-1 text-[12px]">
817
+ <div class="flex items-center justify-between">
818
+ <dt class="text-slate-400">{{ t('testing.outcomes.passed') }}</dt>
819
+ <dd class="text-emerald-300">{{ counts.passed }}</dd>
901
820
  </div>
821
+ <div class="flex items-center justify-between">
822
+ <dt class="text-slate-400">{{ t('testing.outcomes.failed') }}</dt>
823
+ <dd class="text-rose-300">{{ counts.failed }}</dd>
824
+ </div>
825
+ <div class="flex items-center justify-between">
826
+ <dt class="text-slate-400">{{ t('testing.outcomes.skipped') }}</dt>
827
+ <dd class="text-slate-300">{{ counts.skipped }}</dd>
828
+ </div>
829
+ <div class="flex items-center justify-between border-t border-slate-800 pt-1">
830
+ <dt class="text-slate-400">{{ t('testing.outcomes.concerns') }}</dt>
831
+ <dd class="text-amber-300">
832
+ {{ counts.concerns
833
+ }}<template v-if="counts.blocking">
834
+ {{
835
+ t('testing.outcomes.blocking', { count: counts.blocking }, counts.blocking)
836
+ }}</template
837
+ >
838
+ </dd>
839
+ </div>
840
+ </dl>
841
+ </div>
902
842
 
903
- <!-- Shared run metadata + embedded observability (model, run id, timing,
904
- model-activity rollup) identical to the gate and agent step detail. -->
905
- <StepRunMeta
906
- v-if="step"
907
- :step="step"
908
- :instance-id="instanceId ?? undefined"
909
- :step-number="stepIndex === null ? undefined : stepIndex + 1"
910
- :total-steps="instance?.steps.length"
911
- :run-failed="instance?.status === 'failed'"
912
- :failure-at="instance?.failure?.occurredAt"
913
- />
914
-
915
- <p class="mt-auto text-[10px] leading-relaxed text-slate-600">
916
- {{ t('testing.footer') }}
917
- </p>
918
- </aside>
843
+ <div v-if="report?.environment">
844
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
845
+ {{ t('testing.environment') }}
846
+ </h4>
847
+ <p class="text-[12px] capitalize text-slate-300">{{ report.environment }}</p>
919
848
  </div>
920
- </div>
921
- </div>
922
849
 
923
- <!-- Shared zoom/pan viewer for the captured screenshots. -->
924
- <ArtifactLightbox
925
- v-model:open="lightboxOpen"
926
- v-model:index="lightboxIndex"
927
- :items="lightboxItems"
928
- :blobs="blobs"
929
- />
930
- </Teleport>
850
+ <!-- Shared run metadata + embedded observability (model, run id, timing,
851
+ model-activity rollup) — identical to the gate and agent step detail. -->
852
+ <StepRunMeta
853
+ v-if="step"
854
+ :step="step"
855
+ :instance-id="instanceId ?? undefined"
856
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
857
+ :total-steps="instance?.steps.length"
858
+ :run-failed="instance?.status === 'failed'"
859
+ :failure-at="instance?.failure?.occurredAt"
860
+ />
861
+
862
+ <p class="mt-auto text-[10px] leading-relaxed text-slate-600">
863
+ {{ t('testing.footer') }}
864
+ </p>
865
+ </aside>
866
+ </div>
867
+ </ResultWindowShell>
868
+
869
+ <!-- Shared zoom/pan viewer for the captured screenshots — a sibling overlay that layers
870
+ above this window on the shared modal stack while open. -->
871
+ <ArtifactLightbox
872
+ v-model:open="lightboxOpen"
873
+ v-model:index="lightboxIndex"
874
+ :items="lightboxItems"
875
+ :blobs="blobs"
876
+ />
931
877
  </template>