@cat-factory/app 0.147.1 → 0.147.3

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