@cat-factory/app 0.147.1 → 0.147.2

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.
@@ -23,6 +23,7 @@ import type {
23
23
  import { subtaskIconClass } from '~/utils/pipelineRender'
24
24
  import { activeChunkLabels, chunkReviewPercent, isSlicingChunks } from '~/utils/prReviewProgress'
25
25
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
26
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
26
27
 
27
28
  const execution = useExecutionStore()
28
29
  const board = useBoardStore()
@@ -253,446 +254,468 @@ async function onDismiss(id: string): Promise<void> {
253
254
  </a>
254
255
  </template>
255
256
 
256
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
257
- <!-- Reviewing: the read-only reviewer is still working. The phase is told apart precisely —
257
+ <div class="flex min-h-0 flex-1">
258
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
259
+ <!-- Reviewing: the read-only reviewer is still working. The phase is told apart precisely —
258
260
  SLICING (still grouping the diff into chunks, no plan yet) vs REVIEWING (slicing done,
259
261
  working through the chunks) — so the copy never claims "reviewing" while it's slicing. -->
260
- <div
261
- v-if="status === 'reviewing'"
262
- data-testid="pr-review-reviewing"
263
- class="flex h-full flex-col"
264
- >
265
- <!-- SLICING: no todo list yet — the reviewer is still grouping the diff into chunks. -->
266
262
  <div
267
- v-if="slicing"
268
- data-testid="pr-review-slicing"
269
- class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
263
+ v-if="status === 'reviewing'"
264
+ data-testid="pr-review-reviewing"
265
+ class="flex h-full flex-col"
270
266
  >
271
- <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
272
- <p class="text-sm text-slate-200">{{ t('prReview.reviewing.slicing.title') }}</p>
273
- <p class="max-w-sm text-[11px] text-slate-500">
274
- {{ t('prReview.reviewing.slicing.hint') }}
275
- </p>
276
- </div>
277
-
278
- <!-- REVIEWING: slicing is done — show every chunk with its status + which are active now. -->
279
- <div v-else data-testid="pr-review-reviewing-chunks" class="py-2">
280
- <div class="mb-1 flex items-center gap-2 text-sm text-slate-200">
281
- <UIcon
282
- name="i-lucide-loader-circle"
283
- class="h-4 w-4 shrink-0 animate-spin text-indigo-300"
284
- />
285
- <span>{{ t('prReview.reviewing.reviewingChunks.title') }}</span>
286
- </div>
287
- <p class="mb-3 text-[11px] text-slate-500">
288
- {{ t('prReview.reviewing.reviewingChunks.hint') }}
289
- </p>
290
-
291
- <div class="flex items-center justify-between text-[11px] text-slate-400">
292
- <span data-testid="pr-review-chunk-count">
293
- {{
294
- t('prReview.reviewing.chunks', {
295
- completed: subtasks!.completed,
296
- total: subtasks!.total,
297
- })
298
- }}
299
- </span>
300
- <span v-if="subtasks!.inProgress > 0" class="text-indigo-300">
301
- {{ t('prReview.reviewing.inProgress', { count: subtasks!.inProgress }) }}
302
- </span>
303
- </div>
304
- <div class="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700/60">
305
- <div
306
- class="h-full rounded-full bg-indigo-400 transition-all duration-500"
307
- :style="{ width: `${chunkPercent}%` }"
308
- />
309
- </div>
310
-
311
- <!-- The chunk(s) being actively reviewed right now, called out on their own. -->
267
+ <!-- SLICING: no todo list yet — the reviewer is still grouping the diff into chunks. -->
312
268
  <div
313
- v-if="activeChunks.length"
314
- data-testid="pr-review-active-chunks"
315
- class="mt-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5 px-2.5 py-2"
269
+ v-if="slicing"
270
+ data-testid="pr-review-slicing"
271
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
316
272
  >
317
- <p class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-indigo-300">
318
- {{ t('prReview.reviewing.activeHeading') }}
273
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
274
+ <p class="text-sm text-slate-200">{{ t('prReview.reviewing.slicing.title') }}</p>
275
+ <p class="max-w-sm text-[11px] text-slate-500">
276
+ {{ t('prReview.reviewing.slicing.hint') }}
319
277
  </p>
320
- <ul class="space-y-1">
321
- <li
322
- v-for="(label, i) in activeChunks"
323
- :key="i"
324
- class="flex items-start gap-1.5 text-[12px] text-slate-100"
325
- >
326
- <UIcon
327
- name="i-lucide-loader-circle"
328
- class="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-indigo-300"
329
- />
330
- <span class="min-w-0">{{ label }}</span>
331
- </li>
332
- </ul>
333
278
  </div>
334
279
 
335
- <!-- Every chunk with its explicit status (Reviewed / Reviewing… / Queued). -->
336
- <template v-if="subtasks!.items?.length">
337
- <p class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
338
- {{ t('prReview.reviewing.chunksHeading') }}
280
+ <!-- REVIEWING: slicing is done — show every chunk with its status + which are active now. -->
281
+ <div v-else data-testid="pr-review-reviewing-chunks" class="py-2">
282
+ <div class="mb-1 flex items-center gap-2 text-sm text-slate-200">
283
+ <UIcon
284
+ name="i-lucide-loader-circle"
285
+ class="h-4 w-4 shrink-0 animate-spin text-indigo-300"
286
+ />
287
+ <span>{{ t('prReview.reviewing.reviewingChunks.title') }}</span>
288
+ </div>
289
+ <p class="mb-3 text-[11px] text-slate-500">
290
+ {{ t('prReview.reviewing.reviewingChunks.hint') }}
339
291
  </p>
340
- <ul class="space-y-1.5" data-testid="pr-review-chunks">
341
- <li
342
- v-for="(item, i) in subtasks!.items"
343
- :key="i"
344
- data-testid="pr-review-chunk"
345
- class="flex items-center gap-1.5 text-[12px]"
346
- :class="
347
- item.status === 'completed'
348
- ? 'text-slate-500'
349
- : item.status === 'in_progress'
350
- ? 'text-slate-100'
351
- : 'text-slate-400'
352
- "
353
- >
354
- <UIcon
355
- :name="ITEM_ICON[item.status]"
356
- class="h-3.5 w-3.5 shrink-0"
357
- :class="subtaskIconClass(item.status, false)"
358
- />
359
- <span
360
- class="min-w-0 flex-1 truncate"
361
- :class="item.status === 'completed' ? 'line-through' : ''"
292
+
293
+ <div class="flex items-center justify-between text-[11px] text-slate-400">
294
+ <span data-testid="pr-review-chunk-count">
295
+ {{
296
+ t('prReview.reviewing.chunks', {
297
+ completed: subtasks!.completed,
298
+ total: subtasks!.total,
299
+ })
300
+ }}
301
+ </span>
302
+ <span v-if="subtasks!.inProgress > 0" class="text-indigo-300">
303
+ {{ t('prReview.reviewing.inProgress', { count: subtasks!.inProgress }) }}
304
+ </span>
305
+ </div>
306
+ <div class="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700/60">
307
+ <div
308
+ class="h-full rounded-full bg-indigo-400 transition-all duration-500"
309
+ :style="{ width: `${chunkPercent}%` }"
310
+ />
311
+ </div>
312
+
313
+ <!-- The chunk(s) being actively reviewed right now, called out on their own. -->
314
+ <div
315
+ v-if="activeChunks.length"
316
+ data-testid="pr-review-active-chunks"
317
+ class="mt-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5 px-2.5 py-2"
318
+ >
319
+ <p class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-indigo-300">
320
+ {{ t('prReview.reviewing.activeHeading') }}
321
+ </p>
322
+ <ul class="space-y-1">
323
+ <li
324
+ v-for="(label, i) in activeChunks"
325
+ :key="i"
326
+ class="flex items-start gap-1.5 text-[12px] text-slate-100"
362
327
  >
363
- {{ item.label }}
364
- </span>
365
- <span
366
- data-testid="pr-review-chunk-status"
367
- class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase"
368
- :class="CHUNK_STATUS_CLASS[item.status]"
328
+ <UIcon
329
+ name="i-lucide-loader-circle"
330
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-indigo-300"
331
+ />
332
+ <span class="min-w-0">{{ label }}</span>
333
+ </li>
334
+ </ul>
335
+ </div>
336
+
337
+ <!-- Every chunk with its explicit status (Reviewed / Reviewing… / Queued). -->
338
+ <template v-if="subtasks!.items?.length">
339
+ <p
340
+ class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-slate-400"
341
+ >
342
+ {{ t('prReview.reviewing.chunksHeading') }}
343
+ </p>
344
+ <ul class="space-y-1.5" data-testid="pr-review-chunks">
345
+ <li
346
+ v-for="(item, i) in subtasks!.items"
347
+ :key="i"
348
+ data-testid="pr-review-chunk"
349
+ class="flex items-center gap-1.5 text-[12px]"
350
+ :class="
351
+ item.status === 'completed'
352
+ ? 'text-slate-500'
353
+ : item.status === 'in_progress'
354
+ ? 'text-slate-100'
355
+ : 'text-slate-400'
356
+ "
369
357
  >
370
- {{ chunkStatusLabel(item.status) }}
371
- </span>
372
- </li>
373
- </ul>
374
- </template>
358
+ <UIcon
359
+ :name="ITEM_ICON[item.status]"
360
+ class="h-3.5 w-3.5 shrink-0"
361
+ :class="subtaskIconClass(item.status, false)"
362
+ />
363
+ <span
364
+ class="min-w-0 flex-1 truncate"
365
+ :class="item.status === 'completed' ? 'line-through' : ''"
366
+ >
367
+ {{ item.label }}
368
+ </span>
369
+ <span
370
+ data-testid="pr-review-chunk-status"
371
+ class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase"
372
+ :class="CHUNK_STATUS_CLASS[item.status]"
373
+ >
374
+ {{ chunkStatusLabel(item.status) }}
375
+ </span>
376
+ </li>
377
+ </ul>
378
+ </template>
379
+ </div>
375
380
  </div>
376
- </div>
377
-
378
- <!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
379
- <div
380
- v-else-if="working"
381
- data-testid="pr-review-working"
382
- class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
383
- >
384
- <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
385
- <p class="text-sm">
386
- {{ status === 'fixing' ? t('prReview.fixing.title') : t('prReview.posting.title') }}
387
- </p>
388
- <p class="max-w-sm text-[11px] text-slate-500">
389
- {{ status === 'fixing' ? t('prReview.fixing.hint') : t('prReview.posting.hint') }}
390
- </p>
391
- </div>
392
-
393
- <template v-else>
394
- <p
395
- v-if="prReview.error"
396
- class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
397
- >
398
- {{ prReview.error }}
399
- </p>
400
381
 
401
- <!-- The outcome of the most recent `post` attempt: how many of how many comments landed,
402
- which failed + why, and how many findings were folded into the summary because their
403
- line isn't in the PR diff. Surfaced so a partial/failed post is legible + retryable. -->
382
+ <!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
404
383
  <div
405
- v-if="postReport"
406
- data-testid="pr-review-post-report"
407
- class="mb-3 rounded-lg border px-3 py-2 text-[12px]"
408
- :class="
409
- postReport.failures.length > 0 || postReport.bodyPosted === false
410
- ? 'border-amber-500/40 bg-amber-500/10 text-amber-200'
411
- : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'
412
- "
384
+ v-else-if="working"
385
+ data-testid="pr-review-working"
386
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
413
387
  >
414
- <div class="mb-1 flex items-center gap-1.5 font-medium">
415
- <UIcon
416
- :name="
417
- postReport.failures.length > 0 || postReport.bodyPosted === false
418
- ? 'i-lucide-alert-triangle'
419
- : 'i-lucide-check-circle-2'
420
- "
421
- class="h-4 w-4 shrink-0"
422
- />
423
- <span>{{ t('prReview.postReport.heading') }}</span>
424
- </div>
425
- <p data-testid="pr-review-post-count">
426
- {{
427
- t('prReview.postReport.posted', {
428
- posted: postReport.posted,
429
- attempted: postReport.attempted,
430
- })
431
- }}
432
- </p>
433
- <p v-if="postReport.folded > 0" class="mt-0.5 opacity-90">
434
- {{ t('prReview.postReport.folded', { count: postReport.folded }) }}
388
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
389
+ <p class="text-sm">
390
+ {{ status === 'fixing' ? t('prReview.fixing.title') : t('prReview.posting.title') }}
435
391
  </p>
436
- <template v-if="postReport.failures.length > 0">
437
- <p class="mt-1.5 font-medium">{{ t('prReview.postReport.failuresHeading') }}</p>
438
- <ul class="mt-0.5 space-y-0.5" data-testid="pr-review-post-failures">
439
- <li v-for="f in postReport.failures" :key="f.findingId" class="flex gap-1.5">
440
- <code class="shrink-0 text-amber-100"
441
- >{{ f.path }}<template v-if="f.line != null">:{{ f.line }}</template></code
442
- >
443
- <span class="opacity-90">— {{ f.reason }}</span>
444
- </li>
445
- </ul>
446
- </template>
447
- <p v-if="postReport.bodyPosted === false && postReport.bodyError" class="mt-1.5">
448
- {{ t('prReview.postReport.bodyError', { error: postReport.bodyError }) }}
392
+ <p class="max-w-sm text-[11px] text-slate-500">
393
+ {{ status === 'fixing' ? t('prReview.fixing.hint') : t('prReview.posting.hint') }}
449
394
  </p>
450
395
  </div>
451
396
 
452
- <!-- The reviewer's overall assessment. -->
453
- <p
454
- v-if="state?.summary"
455
- class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
456
- >
457
- <span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
458
- {{ state.summary }}
459
- </p>
460
-
461
- <!-- A clean PR / resolved review with no findings. -->
462
- <div
463
- v-if="findings.length === 0"
464
- class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
465
- >
466
- {{ t('prReview.noFindings') }}
467
- </div>
468
-
469
397
  <template v-else>
470
- <!-- A challenge is in flight: the Challenge Investigator is re-examining a finding. -->
398
+ <p
399
+ v-if="prReview.error"
400
+ class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
401
+ >
402
+ {{ prReview.error }}
403
+ </p>
404
+
405
+ <!-- The outcome of the most recent `post` attempt: how many of how many comments landed,
406
+ which failed + why, and how many findings were folded into the summary because their
407
+ line isn't in the PR diff. Surfaced so a partial/failed post is legible + retryable. -->
471
408
  <div
472
- v-if="challenging"
473
- data-testid="pr-review-challenging"
474
- class="mb-3 flex items-center gap-2 rounded-md border border-indigo-500/40 bg-indigo-500/10 px-3 py-2 text-[12px] text-indigo-200"
409
+ v-if="postReport"
410
+ data-testid="pr-review-post-report"
411
+ class="mb-3 rounded-lg border px-3 py-2 text-[12px]"
412
+ :class="
413
+ postReport.failures.length > 0 || postReport.bodyPosted === false
414
+ ? 'border-amber-500/40 bg-amber-500/10 text-amber-200'
415
+ : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'
416
+ "
475
417
  >
476
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
477
- <span>{{ t('prReview.challenge.investigatingBanner') }}</span>
418
+ <div class="mb-1 flex items-center gap-1.5 font-medium">
419
+ <UIcon
420
+ :name="
421
+ postReport.failures.length > 0 || postReport.bodyPosted === false
422
+ ? 'i-lucide-alert-triangle'
423
+ : 'i-lucide-check-circle-2'
424
+ "
425
+ class="h-4 w-4 shrink-0"
426
+ />
427
+ <span>{{ t('prReview.postReport.heading') }}</span>
428
+ </div>
429
+ <p data-testid="pr-review-post-count">
430
+ {{
431
+ t('prReview.postReport.posted', {
432
+ posted: postReport.posted,
433
+ attempted: postReport.attempted,
434
+ })
435
+ }}
436
+ </p>
437
+ <p v-if="postReport.folded > 0" class="mt-0.5 opacity-90">
438
+ {{ t('prReview.postReport.folded', { count: postReport.folded }) }}
439
+ </p>
440
+ <template v-if="postReport.failures.length > 0">
441
+ <p class="mt-1.5 font-medium">{{ t('prReview.postReport.failuresHeading') }}</p>
442
+ <ul class="mt-0.5 space-y-0.5" data-testid="pr-review-post-failures">
443
+ <li v-for="f in postReport.failures" :key="f.findingId" class="flex gap-1.5">
444
+ <code class="shrink-0 text-amber-100"
445
+ >{{ f.path }}<template v-if="f.line != null">:{{ f.line }}</template></code
446
+ >
447
+ <span class="opacity-90">— {{ f.reason }}</span>
448
+ </li>
449
+ </ul>
450
+ </template>
451
+ <p v-if="postReport.bodyPosted === false && postReport.bodyError" class="mt-1.5">
452
+ {{ t('prReview.postReport.bodyError', { error: postReport.bodyError }) }}
453
+ </p>
478
454
  </div>
479
455
 
480
- <!-- Selection toolbar -->
481
- <div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
482
- <span data-testid="pr-review-selected-count">
483
- {{ t('prReview.selectedCount', { count: activeSelectedIds.length }) }}
484
- </span>
485
- <button class="text-indigo-300 hover:underline" @click="selectAll">
486
- {{ t('prReview.selectAll') }}
487
- </button>
488
- <button class="text-indigo-300 hover:underline" @click="clearAll">
489
- {{ t('prReview.clear') }}
490
- </button>
456
+ <!-- The reviewer's overall assessment. -->
457
+ <p
458
+ v-if="state?.summary"
459
+ class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
460
+ >
461
+ <span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
462
+ {{ state.summary }}
463
+ </p>
464
+
465
+ <!-- A clean PR / resolved review with no findings. -->
466
+ <div
467
+ v-if="findings.length === 0"
468
+ class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
469
+ >
470
+ {{ t('prReview.noFindings') }}
491
471
  </div>
492
472
 
493
- <!-- Findings grouped by slice -->
494
- <section v-for="g in groups" :key="g.id" class="mb-4">
495
- <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
496
- {{ g.title }}
497
- </h3>
498
- <p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
499
- {{ g.rationale }}
500
- </p>
501
- <article
502
- v-for="f in g.items"
503
- :key="f.id"
504
- data-testid="pr-review-finding"
505
- class="mb-1.5 rounded-xl border px-3 py-2 transition"
506
- :class="[
507
- awaiting && selected.has(f.id) && !isRetracted(f)
508
- ? 'border-indigo-500/60 bg-indigo-500/5'
509
- : 'border-slate-800 bg-slate-900/60',
510
- isRetracted(f) ? 'opacity-60' : '',
511
- ]"
473
+ <template v-else>
474
+ <!-- A challenge is in flight: the Challenge Investigator is re-examining a finding. -->
475
+ <div
476
+ v-if="challenging"
477
+ data-testid="pr-review-challenging"
478
+ class="mb-3 flex items-center gap-2 rounded-md border border-indigo-500/40 bg-indigo-500/10 px-3 py-2 text-[12px] text-indigo-200"
512
479
  >
513
- <div class="flex items-start gap-2">
514
- <input
515
- v-if="awaiting || challenging"
516
- type="checkbox"
517
- class="mt-1 accent-indigo-500"
518
- data-testid="pr-review-finding-toggle"
519
- :checked="selected.has(f.id) && !isRetracted(f)"
520
- :disabled="!awaiting || isRetracted(f)"
521
- @change="toggle(f.id)"
522
- />
523
- <div class="min-w-0 flex-1">
524
- <div class="flex flex-wrap items-center gap-1.5">
525
- <span
526
- class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
527
- :class="SEVERITY_CLASS[f.severity]"
528
- >
529
- {{ t(`prReview.severity.${f.severity}`) }}
530
- </span>
531
- <span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
532
- {{ t(`prReview.category.${f.category}`) }}
533
- </span>
534
- <span
535
- v-if="postedIds.has(f.id)"
536
- data-testid="pr-review-finding-posted"
537
- class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-300 ring-1 ring-emerald-500/30"
538
- >
539
- {{ t('prReview.postReport.postedBadge') }}
540
- </span>
541
- <!-- Challenge outcome badges -->
542
- <span
543
- v-if="isRetracted(f)"
544
- data-testid="pr-review-finding-retracted"
545
- class="rounded bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-rose-300 ring-1 ring-rose-500/30"
546
- >
547
- {{ t('prReview.challenge.retractedBadge') }}
548
- </span>
549
- <span
550
- v-else-if="isAmended(f)"
551
- data-testid="pr-review-finding-amended"
552
- class="rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-sky-300 ring-1 ring-sky-500/30"
553
- >
554
- {{ t('prReview.challenge.strengthenedBadge') }}
555
- </span>
556
- <span
557
- v-else-if="isUpheld(f)"
558
- data-testid="pr-review-finding-upheld"
559
- class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-300 ring-1 ring-emerald-500/30"
560
- >
561
- {{ t('prReview.challenge.upheldBadge') }}
562
- </span>
563
- <span
564
- v-else-if="isChallengeFailed(f)"
565
- data-testid="pr-review-finding-challenge-failed"
566
- class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-300 ring-1 ring-amber-500/30"
567
- >
568
- {{ t('prReview.challenge.failedBadge') }}
569
- </span>
570
- <span
571
- v-else-if="isInvestigating(f)"
572
- data-testid="pr-review-finding-investigating"
573
- class="flex items-center gap-1 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-indigo-300 ring-1 ring-indigo-500/30"
574
- >
575
- <UIcon name="i-lucide-loader-circle" class="h-3 w-3 animate-spin" />
576
- {{ t('prReview.challenge.investigatingBadge') }}
577
- </span>
578
- <h4
579
- class="min-w-0 flex-1 text-[13px] font-medium text-slate-100"
480
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
481
+ <span>{{ t('prReview.challenge.investigatingBanner') }}</span>
482
+ </div>
483
+
484
+ <!-- Selection toolbar -->
485
+ <div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
486
+ <span data-testid="pr-review-selected-count">
487
+ {{ t('prReview.selectedCount', { count: activeSelectedIds.length }) }}
488
+ </span>
489
+ <button class="text-indigo-300 hover:underline" @click="selectAll">
490
+ {{ t('prReview.selectAll') }}
491
+ </button>
492
+ <button class="text-indigo-300 hover:underline" @click="clearAll">
493
+ {{ t('prReview.clear') }}
494
+ </button>
495
+ </div>
496
+
497
+ <!-- Findings grouped by slice -->
498
+ <section v-for="g in groups" :key="g.id" class="mb-4">
499
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
500
+ {{ g.title }}
501
+ </h3>
502
+ <p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
503
+ {{ g.rationale }}
504
+ </p>
505
+ <article
506
+ v-for="f in g.items"
507
+ :key="f.id"
508
+ data-testid="pr-review-finding"
509
+ class="mb-1.5 rounded-xl border px-3 py-2 transition"
510
+ :class="[
511
+ awaiting && selected.has(f.id) && !isRetracted(f)
512
+ ? 'border-indigo-500/60 bg-indigo-500/5'
513
+ : 'border-slate-800 bg-slate-900/60',
514
+ isRetracted(f) ? 'opacity-60' : '',
515
+ ]"
516
+ >
517
+ <div class="flex items-start gap-2">
518
+ <input
519
+ v-if="awaiting || challenging"
520
+ type="checkbox"
521
+ class="mt-1 accent-indigo-500"
522
+ data-testid="pr-review-finding-toggle"
523
+ :checked="selected.has(f.id) && !isRetracted(f)"
524
+ :disabled="!awaiting || isRetracted(f)"
525
+ @change="toggle(f.id)"
526
+ />
527
+ <div class="min-w-0 flex-1">
528
+ <div class="flex flex-wrap items-center gap-1.5">
529
+ <span
530
+ class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
531
+ :class="SEVERITY_CLASS[f.severity]"
532
+ >
533
+ {{ t(`prReview.severity.${f.severity}`) }}
534
+ </span>
535
+ <span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
536
+ {{ t(`prReview.category.${f.category}`) }}
537
+ </span>
538
+ <span
539
+ v-if="postedIds.has(f.id)"
540
+ data-testid="pr-review-finding-posted"
541
+ class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-300 ring-1 ring-emerald-500/30"
542
+ >
543
+ {{ t('prReview.postReport.postedBadge') }}
544
+ </span>
545
+ <!-- Challenge outcome badges -->
546
+ <span
547
+ v-if="isRetracted(f)"
548
+ data-testid="pr-review-finding-retracted"
549
+ class="rounded bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-rose-300 ring-1 ring-rose-500/30"
550
+ >
551
+ {{ t('prReview.challenge.retractedBadge') }}
552
+ </span>
553
+ <span
554
+ v-else-if="isAmended(f)"
555
+ data-testid="pr-review-finding-amended"
556
+ class="rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-sky-300 ring-1 ring-sky-500/30"
557
+ >
558
+ {{ t('prReview.challenge.strengthenedBadge') }}
559
+ </span>
560
+ <span
561
+ v-else-if="isUpheld(f)"
562
+ data-testid="pr-review-finding-upheld"
563
+ class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-300 ring-1 ring-emerald-500/30"
564
+ >
565
+ {{ t('prReview.challenge.upheldBadge') }}
566
+ </span>
567
+ <span
568
+ v-else-if="isChallengeFailed(f)"
569
+ data-testid="pr-review-finding-challenge-failed"
570
+ class="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-300 ring-1 ring-amber-500/30"
571
+ >
572
+ {{ t('prReview.challenge.failedBadge') }}
573
+ </span>
574
+ <span
575
+ v-else-if="isInvestigating(f)"
576
+ data-testid="pr-review-finding-investigating"
577
+ class="flex items-center gap-1 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-indigo-300 ring-1 ring-indigo-500/30"
578
+ >
579
+ <UIcon name="i-lucide-loader-circle" class="h-3 w-3 animate-spin" />
580
+ {{ t('prReview.challenge.investigatingBadge') }}
581
+ </span>
582
+ <h4
583
+ class="min-w-0 flex-1 text-[13px] font-medium text-slate-100"
584
+ :class="isRetracted(f) ? 'line-through' : ''"
585
+ >
586
+ {{ f.title }}
587
+ </h4>
588
+ </div>
589
+ <p class="mt-0.5 text-[11px] text-slate-500">
590
+ {{ f.path
591
+ }}<template v-if="f.line != null">
592
+ · {{ t('prReview.line', { line: f.line }) }}</template
593
+ >
594
+ </p>
595
+ <p
596
+ class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300"
580
597
  :class="isRetracted(f) ? 'line-through' : ''"
581
598
  >
582
- {{ f.title }}
583
- </h4>
584
- </div>
585
- <p class="mt-0.5 text-[11px] text-slate-500">
586
- {{ f.path
587
- }}<template v-if="f.line != null">
588
- · {{ t('prReview.line', { line: f.line }) }}</template
599
+ {{ f.detail }}
600
+ </p>
601
+ <p
602
+ v-if="f.suggestedFix"
603
+ class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
589
604
  >
590
- </p>
591
- <p
592
- class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300"
593
- :class="isRetracted(f) ? 'line-through' : ''"
594
- >
595
- {{ f.detail }}
596
- </p>
597
- <p
598
- v-if="f.suggestedFix"
599
- class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
600
- >
601
- <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
602
- {{ f.suggestedFix }}
603
- </p>
605
+ <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
606
+ {{ f.suggestedFix }}
607
+ </p>
604
608
 
605
- <!-- The investigator's justification (why the finding holds up / was retracted),
609
+ <!-- The investigator's justification (why the finding holds up / was retracted),
606
610
  or the reason the challenge investigation failed. -->
607
- <p
608
- v-if="f.challenge?.justification"
609
- data-testid="pr-review-finding-justification"
610
- class="mt-1.5 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
611
- :class="
612
- isRetracted(f)
613
- ? 'bg-rose-500/10 text-rose-200'
614
- : isChallengeFailed(f)
615
- ? 'bg-amber-500/10 text-amber-200'
616
- : 'bg-sky-500/10 text-sky-200'
617
- "
618
- >
619
- <span class="font-medium">{{
620
- isChallengeFailed(f)
621
- ? t('prReview.challenge.failedLabel')
622
- : t('prReview.challenge.verdictLabel')
623
- }}</span>
624
- {{ f.challenge.justification }}
625
- </p>
626
-
627
- <!-- Per-finding actions: Challenge + Dismiss (only while awaiting a selection). -->
628
- <div
629
- v-if="awaiting && !isInvestigating(f)"
630
- class="mt-1.5 flex items-center gap-3 text-[11px]"
631
- >
632
- <button
633
- v-if="!isRetracted(f)"
634
- data-testid="pr-review-finding-challenge"
635
- class="flex items-center gap-1 text-indigo-300 hover:underline disabled:opacity-50"
636
- :disabled="!canResolve || !access.canExecuteRuns.value"
637
- @click="openChallenge(f.id)"
611
+ <p
612
+ v-if="f.challenge?.justification"
613
+ data-testid="pr-review-finding-justification"
614
+ class="mt-1.5 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
615
+ :class="
616
+ isRetracted(f)
617
+ ? 'bg-rose-500/10 text-rose-200'
618
+ : isChallengeFailed(f)
619
+ ? 'bg-amber-500/10 text-amber-200'
620
+ : 'bg-sky-500/10 text-sky-200'
621
+ "
638
622
  >
639
- <UIcon name="i-lucide-gavel" class="h-3.5 w-3.5" />
640
- {{
641
- f.challenge
642
- ? t('prReview.challenge.reChallenge')
643
- : t('prReview.challenge.action')
644
- }}
645
- </button>
646
- <button
647
- data-testid="pr-review-finding-dismiss"
648
- class="flex items-center gap-1 text-slate-400 hover:text-rose-300 hover:underline disabled:opacity-50"
649
- :disabled="!canResolve || !access.canExecuteRuns.value"
650
- @click="onDismiss(f.id)"
651
- >
652
- <UIcon name="i-lucide-trash-2" class="h-3.5 w-3.5" />
653
- {{ t('prReview.challenge.dismiss') }}
654
- </button>
655
- </div>
656
-
657
- <!-- The inline challenge box: an OPTIONAL specific concern for the investigator. -->
658
- <div
659
- v-if="challengeForId === f.id"
660
- data-testid="pr-review-challenge-box"
661
- class="mt-2 rounded-md border border-indigo-500/40 bg-slate-900/80 p-2"
662
- >
663
- <textarea
664
- v-model="challengeText"
665
- data-testid="pr-review-challenge-input"
666
- rows="2"
667
- :placeholder="t('prReview.challenge.placeholder')"
668
- class="w-full resize-y rounded border border-slate-700 bg-slate-950/60 px-2 py-1 text-[12px] text-slate-200 outline-none focus:border-indigo-500"
669
- />
670
- <p class="mt-1 text-[10px] text-slate-500">
671
- {{ t('prReview.challenge.hint') }}
623
+ <span class="font-medium">{{
624
+ isChallengeFailed(f)
625
+ ? t('prReview.challenge.failedLabel')
626
+ : t('prReview.challenge.verdictLabel')
627
+ }}</span>
628
+ {{ f.challenge.justification }}
672
629
  </p>
673
- <div class="mt-1.5 flex justify-end gap-2">
630
+
631
+ <!-- Per-finding actions: Challenge + Dismiss (only while awaiting a selection). -->
632
+ <div
633
+ v-if="awaiting && !isInvestigating(f)"
634
+ class="mt-1.5 flex items-center gap-3 text-[11px]"
635
+ >
674
636
  <button
675
- class="rounded px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200"
676
- @click="cancelChallenge"
637
+ v-if="!isRetracted(f)"
638
+ data-testid="pr-review-finding-challenge"
639
+ class="flex items-center gap-1 text-indigo-300 hover:underline disabled:opacity-50"
640
+ :disabled="!canResolve || !access.canExecuteRuns.value"
641
+ @click="openChallenge(f.id)"
677
642
  >
678
- {{ t('common.cancel') }}
643
+ <UIcon name="i-lucide-gavel" class="h-3.5 w-3.5" />
644
+ {{
645
+ f.challenge
646
+ ? t('prReview.challenge.reChallenge')
647
+ : t('prReview.challenge.action')
648
+ }}
679
649
  </button>
680
650
  <button
681
- data-testid="pr-review-challenge-submit"
682
- class="rounded bg-indigo-500/80 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
683
- :disabled="!canResolve"
684
- @click="submitChallenge(f.id)"
651
+ data-testid="pr-review-finding-dismiss"
652
+ class="flex items-center gap-1 text-slate-400 hover:text-rose-300 hover:underline disabled:opacity-50"
653
+ :disabled="!canResolve || !access.canExecuteRuns.value"
654
+ @click="onDismiss(f.id)"
685
655
  >
686
- {{ t('prReview.challenge.submit') }}
656
+ <UIcon name="i-lucide-trash-2" class="h-3.5 w-3.5" />
657
+ {{ t('prReview.challenge.dismiss') }}
687
658
  </button>
688
659
  </div>
660
+
661
+ <!-- The inline challenge box: an OPTIONAL specific concern for the investigator. -->
662
+ <div
663
+ v-if="challengeForId === f.id"
664
+ data-testid="pr-review-challenge-box"
665
+ class="mt-2 rounded-md border border-indigo-500/40 bg-slate-900/80 p-2"
666
+ >
667
+ <textarea
668
+ v-model="challengeText"
669
+ data-testid="pr-review-challenge-input"
670
+ rows="2"
671
+ :placeholder="t('prReview.challenge.placeholder')"
672
+ class="w-full resize-y rounded border border-slate-700 bg-slate-950/60 px-2 py-1 text-[12px] text-slate-200 outline-none focus:border-indigo-500"
673
+ />
674
+ <p class="mt-1 text-[10px] text-slate-500">
675
+ {{ t('prReview.challenge.hint') }}
676
+ </p>
677
+ <div class="mt-1.5 flex justify-end gap-2">
678
+ <button
679
+ class="rounded px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200"
680
+ @click="cancelChallenge"
681
+ >
682
+ {{ t('common.cancel') }}
683
+ </button>
684
+ <button
685
+ data-testid="pr-review-challenge-submit"
686
+ class="rounded bg-indigo-500/80 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
687
+ :disabled="!canResolve"
688
+ @click="submitChallenge(f.id)"
689
+ >
690
+ {{ t('prReview.challenge.submit') }}
691
+ </button>
692
+ </div>
693
+ </div>
689
694
  </div>
690
695
  </div>
691
- </div>
692
- </article>
693
- </section>
696
+ </article>
697
+ </section>
698
+ </template>
694
699
  </template>
695
- </template>
700
+ </div>
701
+
702
+ <!-- Run details: the same timing / model / run id + LLM model-activity rollup (calls,
703
+ tokens) every step-backed result window carries, so the reviewer's run reads the
704
+ same "which run is this / how did the model do" facts as the generic step detail. -->
705
+ <aside
706
+ v-if="step"
707
+ data-testid="pr-review-run-meta"
708
+ class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
709
+ >
710
+ <StepRunMeta
711
+ :step="step"
712
+ :instance-id="instanceId ?? undefined"
713
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
714
+ :total-steps="instance?.steps.length"
715
+ :run-failed="instance?.status === 'failed'"
716
+ :failure-at="instance?.failure?.occurredAt"
717
+ />
718
+ </aside>
696
719
  </div>
697
720
 
698
721
  <!-- Footer -->