@cat-factory/app 0.147.0 → 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.
@@ -17,10 +17,13 @@ import type {
17
17
  PrReviewResolution,
18
18
  PrReviewSeverity,
19
19
  PrReviewStepState,
20
+ StepSubtaskItem,
20
21
  StepSubtasks,
21
22
  } from '~/types/execution'
22
23
  import { subtaskIconClass } from '~/utils/pipelineRender'
24
+ import { activeChunkLabels, chunkReviewPercent, isSlicingChunks } from '~/utils/prReviewProgress'
23
25
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
26
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
24
27
 
25
28
  const execution = useExecutionStore()
26
29
  const board = useBoardStore()
@@ -51,17 +54,46 @@ const awaiting = computed(() => status.value === 'awaiting_selection')
51
54
  // selection controls + per-finding actions are disabled until the verdict lands and it re-parks.
52
55
  const challenging = computed(() => status.value === 'challenging')
53
56
  // The reviewer's live todo list while it works, streamed onto the step. Its entries are the
54
- // cohesive slices/chunks the agent grouped the diff into (plus a final "aggregate" step), so it
55
- // surfaces slices-reviewed-so-far progress during the `reviewing` phase richer than a spinner.
57
+ // cohesive slices/chunks the agent grouped the diff into (plus a final "aggregate" step). The
58
+ // derivation of the two `reviewing`-phase sub-states from it lives in `~/utils/prReviewProgress`
59
+ // (pure + unit-tested); see that module for the slicing-vs-reviewing signal.
56
60
  const subtasks = computed<StepSubtasks | null>(() => step.value?.subtasks ?? null)
57
- const hasProgress = computed(() => (subtasks.value?.total ?? 0) > 0)
61
+
62
+ /**
63
+ * Slicing sub-phase: the reviewer is still grouping the diff into chunks (no todo list yet).
64
+ * Once the list exists, slicing is done and we render the per-chunk status list instead.
65
+ */
66
+ const slicing = computed(() => status.value === 'reviewing' && isSlicingChunks(subtasks.value))
67
+
68
+ /** Chunk-review completion, clamped 0..100 for the progress bar. */
69
+ const chunkPercent = computed(() => chunkReviewPercent(subtasks.value))
70
+
71
+ /** The chunks the reviewer is actively working through right now (their labels), for the callout. */
72
+ const activeChunks = computed<string[]>(() => activeChunkLabels(subtasks.value))
58
73
 
59
74
  /** Icon per todo-item status (matches the pipeline timeline's live subtask breakdown). */
60
- const ITEM_ICON: Record<string, string> = {
75
+ const ITEM_ICON: Record<StepSubtaskItem['status'], string> = {
61
76
  completed: 'i-lucide-check-circle-2',
62
77
  in_progress: 'i-lucide-loader-circle',
63
78
  pending: 'i-lucide-circle',
64
79
  }
80
+
81
+ // Per-chunk status label + chip styling. The key map is an exhaustive Record over the subtask
82
+ // status union, so adding a status without a label fails the typecheck (the sanctioned dynamic
83
+ // enum→key pattern — tier 1 can't see a runtime-built key).
84
+ const CHUNK_STATUS_KEY: Record<StepSubtaskItem['status'], string> = {
85
+ completed: 'prReview.reviewing.chunkStatus.completed',
86
+ in_progress: 'prReview.reviewing.chunkStatus.in_progress',
87
+ pending: 'prReview.reviewing.chunkStatus.pending',
88
+ }
89
+ const CHUNK_STATUS_CLASS: Record<StepSubtaskItem['status'], string> = {
90
+ completed: 'bg-emerald-500/15 text-emerald-300',
91
+ in_progress: 'bg-indigo-500/15 text-indigo-300',
92
+ pending: 'bg-slate-700/60 text-slate-400',
93
+ }
94
+ function chunkStatusLabel(status: StepSubtaskItem['status']): string {
95
+ return t(CHUNK_STATUS_KEY[status])
96
+ }
65
97
  // A resolution is executing (the Fixer is committing, or comments are being posted) — show a
66
98
  // working state between the human's choice and the run advancing/the stream echoing `done`.
67
99
  const working = computed(() => status.value === 'fixing' || status.value === 'posting')
@@ -222,403 +254,468 @@ async function onDismiss(id: string): Promise<void> {
222
254
  </a>
223
255
  </template>
224
256
 
225
- <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
226
- <!-- Reviewing: the read-only reviewer is still working. Once it starts maintaining its
227
- per-slice todo list, surface the live chunk progress (slices reviewed / total + the
228
- breakdown) instead of a bare spinner. -->
229
- <div
230
- v-if="status === 'reviewing'"
231
- data-testid="pr-review-reviewing"
232
- class="flex h-full flex-col"
233
- >
234
- <!-- Live chunk progress once the reviewer has planned its slices. -->
235
- <div v-if="hasProgress" class="py-2">
236
- <div class="mb-1 flex items-center gap-2 text-sm text-slate-200">
237
- <UIcon
238
- name="i-lucide-loader-circle"
239
- class="h-4 w-4 shrink-0 animate-spin text-indigo-300"
240
- />
241
- <span>{{ t('prReview.reviewing.title') }}</span>
242
- </div>
243
- <p class="mb-3 text-[11px] text-slate-500">{{ t('prReview.reviewing.hint') }}</p>
244
-
245
- <div class="flex items-center justify-between text-[11px] text-slate-400">
246
- <span data-testid="pr-review-chunk-count">
247
- {{
248
- t('prReview.reviewing.chunks', {
249
- completed: subtasks!.completed,
250
- total: subtasks!.total,
251
- })
252
- }}
253
- </span>
254
- <span v-if="subtasks!.inProgress > 0" class="text-indigo-300">
255
- {{ t('prReview.reviewing.inProgress', { count: subtasks!.inProgress }) }}
256
- </span>
257
- </div>
258
- <div class="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700/60">
259
- <div
260
- class="h-full rounded-full bg-indigo-400 transition-all duration-500"
261
- :style="{ width: `${(subtasks!.completed / subtasks!.total) * 100}%` }"
262
- />
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 —
260
+ SLICING (still grouping the diff into chunks, no plan yet) vs REVIEWING (slicing done,
261
+ working through the chunks) — so the copy never claims "reviewing" while it's slicing. -->
262
+ <div
263
+ v-if="status === 'reviewing'"
264
+ data-testid="pr-review-reviewing"
265
+ class="flex h-full flex-col"
266
+ >
267
+ <!-- SLICING: no todo list yet — the reviewer is still grouping the diff into chunks. -->
268
+ <div
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"
272
+ >
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') }}
277
+ </p>
263
278
  </div>
264
279
 
265
- <!-- The slice/todo breakdown the agent is working through. -->
266
- <ul
267
- v-if="subtasks!.items?.length"
268
- class="mt-3 space-y-1.5"
269
- data-testid="pr-review-chunks"
270
- >
271
- <li
272
- v-for="(item, i) in subtasks!.items"
273
- :key="i"
274
- class="flex items-start gap-1.5 text-[12px]"
275
- :class="
276
- item.status === 'completed'
277
- ? 'text-slate-500 line-through'
278
- : item.status === 'in_progress'
279
- ? 'text-slate-100'
280
- : 'text-slate-400'
281
- "
282
- >
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
283
  <UIcon
284
- :name="ITEM_ICON[item.status]"
285
- class="mt-0.5 h-3.5 w-3.5 shrink-0"
286
- :class="subtaskIconClass(item.status, false)"
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') }}
291
+ </p>
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}%` }"
287
310
  />
288
- <span>{{ item.label }}</span>
289
- </li>
290
- </ul>
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"
327
+ >
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
+ "
357
+ >
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>
291
380
  </div>
292
381
 
293
- <!-- Before the reviewer has planned its slices: the cold-start spinner. -->
382
+ <!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
294
383
  <div
295
- v-else
384
+ v-else-if="working"
385
+ data-testid="pr-review-working"
296
386
  class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
297
387
  >
298
388
  <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
299
- <p class="text-sm">{{ t('prReview.reviewing.title') }}</p>
300
- <p class="max-w-sm text-[11px] text-slate-500">{{ t('prReview.reviewing.hint') }}</p>
389
+ <p class="text-sm">
390
+ {{ status === 'fixing' ? t('prReview.fixing.title') : t('prReview.posting.title') }}
391
+ </p>
392
+ <p class="max-w-sm text-[11px] text-slate-500">
393
+ {{ status === 'fixing' ? t('prReview.fixing.hint') : t('prReview.posting.hint') }}
394
+ </p>
301
395
  </div>
302
- </div>
303
-
304
- <!-- A resolution is executing: the Fixer is committing / comments are being posted. -->
305
- <div
306
- v-else-if="working"
307
- data-testid="pr-review-working"
308
- class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
309
- >
310
- <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
311
- <p class="text-sm">
312
- {{ status === 'fixing' ? t('prReview.fixing.title') : t('prReview.posting.title') }}
313
- </p>
314
- <p class="max-w-sm text-[11px] text-slate-500">
315
- {{ status === 'fixing' ? t('prReview.fixing.hint') : t('prReview.posting.hint') }}
316
- </p>
317
- </div>
318
396
 
319
- <template v-else>
320
- <p
321
- v-if="prReview.error"
322
- class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
323
- >
324
- {{ prReview.error }}
325
- </p>
397
+ <template v-else>
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>
326
404
 
327
- <!-- The outcome of the most recent `post` attempt: how many of how many comments landed,
405
+ <!-- The outcome of the most recent `post` attempt: how many of how many comments landed,
328
406
  which failed + why, and how many findings were folded into the summary because their
329
407
  line isn't in the PR diff. Surfaced so a partial/failed post is legible + retryable. -->
330
- <div
331
- v-if="postReport"
332
- data-testid="pr-review-post-report"
333
- class="mb-3 rounded-lg border px-3 py-2 text-[12px]"
334
- :class="
335
- postReport.failures.length > 0 || postReport.bodyPosted === false
336
- ? 'border-amber-500/40 bg-amber-500/10 text-amber-200'
337
- : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'
338
- "
339
- >
340
- <div class="mb-1 flex items-center gap-1.5 font-medium">
341
- <UIcon
342
- :name="
343
- postReport.failures.length > 0 || postReport.bodyPosted === false
344
- ? 'i-lucide-alert-triangle'
345
- : 'i-lucide-check-circle-2'
346
- "
347
- class="h-4 w-4 shrink-0"
348
- />
349
- <span>{{ t('prReview.postReport.heading') }}</span>
408
+ <div
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
+ "
417
+ >
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>
350
454
  </div>
351
- <p data-testid="pr-review-post-count">
352
- {{
353
- t('prReview.postReport.posted', {
354
- posted: postReport.posted,
355
- attempted: postReport.attempted,
356
- })
357
- }}
358
- </p>
359
- <p v-if="postReport.folded > 0" class="mt-0.5 opacity-90">
360
- {{ t('prReview.postReport.folded', { count: postReport.folded }) }}
361
- </p>
362
- <template v-if="postReport.failures.length > 0">
363
- <p class="mt-1.5 font-medium">{{ t('prReview.postReport.failuresHeading') }}</p>
364
- <ul class="mt-0.5 space-y-0.5" data-testid="pr-review-post-failures">
365
- <li v-for="f in postReport.failures" :key="f.findingId" class="flex gap-1.5">
366
- <code class="shrink-0 text-amber-100"
367
- >{{ f.path }}<template v-if="f.line != null">:{{ f.line }}</template></code
368
- >
369
- <span class="opacity-90">— {{ f.reason }}</span>
370
- </li>
371
- </ul>
372
- </template>
373
- <p v-if="postReport.bodyPosted === false && postReport.bodyError" class="mt-1.5">
374
- {{ t('prReview.postReport.bodyError', { error: postReport.bodyError }) }}
375
- </p>
376
- </div>
377
-
378
- <!-- The reviewer's overall assessment. -->
379
- <p
380
- v-if="state?.summary"
381
- class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
382
- >
383
- <span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
384
- {{ state.summary }}
385
- </p>
386
455
 
387
- <!-- A clean PR / resolved review with no findings. -->
388
- <div
389
- v-if="findings.length === 0"
390
- class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
391
- >
392
- {{ t('prReview.noFindings') }}
393
- </div>
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>
394
464
 
395
- <template v-else>
396
- <!-- A challenge is in flight: the Challenge Investigator is re-examining a finding. -->
465
+ <!-- A clean PR / resolved review with no findings. -->
397
466
  <div
398
- v-if="challenging"
399
- data-testid="pr-review-challenging"
400
- 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"
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"
401
469
  >
402
- <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
403
- <span>{{ t('prReview.challenge.investigatingBanner') }}</span>
470
+ {{ t('prReview.noFindings') }}
404
471
  </div>
405
472
 
406
- <!-- Selection toolbar -->
407
- <div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
408
- <span data-testid="pr-review-selected-count">
409
- {{ t('prReview.selectedCount', { count: activeSelectedIds.length }) }}
410
- </span>
411
- <button class="text-indigo-300 hover:underline" @click="selectAll">
412
- {{ t('prReview.selectAll') }}
413
- </button>
414
- <button class="text-indigo-300 hover:underline" @click="clearAll">
415
- {{ t('prReview.clear') }}
416
- </button>
417
- </div>
418
-
419
- <!-- Findings grouped by slice -->
420
- <section v-for="g in groups" :key="g.id" class="mb-4">
421
- <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
422
- {{ g.title }}
423
- </h3>
424
- <p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
425
- {{ g.rationale }}
426
- </p>
427
- <article
428
- v-for="f in g.items"
429
- :key="f.id"
430
- data-testid="pr-review-finding"
431
- class="mb-1.5 rounded-xl border px-3 py-2 transition"
432
- :class="[
433
- awaiting && selected.has(f.id) && !isRetracted(f)
434
- ? 'border-indigo-500/60 bg-indigo-500/5'
435
- : 'border-slate-800 bg-slate-900/60',
436
- isRetracted(f) ? 'opacity-60' : '',
437
- ]"
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"
438
479
  >
439
- <div class="flex items-start gap-2">
440
- <input
441
- v-if="awaiting || challenging"
442
- type="checkbox"
443
- class="mt-1 accent-indigo-500"
444
- data-testid="pr-review-finding-toggle"
445
- :checked="selected.has(f.id) && !isRetracted(f)"
446
- :disabled="!awaiting || isRetracted(f)"
447
- @change="toggle(f.id)"
448
- />
449
- <div class="min-w-0 flex-1">
450
- <div class="flex flex-wrap items-center gap-1.5">
451
- <span
452
- class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
453
- :class="SEVERITY_CLASS[f.severity]"
454
- >
455
- {{ t(`prReview.severity.${f.severity}`) }}
456
- </span>
457
- <span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
458
- {{ t(`prReview.category.${f.category}`) }}
459
- </span>
460
- <span
461
- v-if="postedIds.has(f.id)"
462
- data-testid="pr-review-finding-posted"
463
- 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"
464
- >
465
- {{ t('prReview.postReport.postedBadge') }}
466
- </span>
467
- <!-- Challenge outcome badges -->
468
- <span
469
- v-if="isRetracted(f)"
470
- data-testid="pr-review-finding-retracted"
471
- 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"
472
- >
473
- {{ t('prReview.challenge.retractedBadge') }}
474
- </span>
475
- <span
476
- v-else-if="isAmended(f)"
477
- data-testid="pr-review-finding-amended"
478
- 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"
479
- >
480
- {{ t('prReview.challenge.strengthenedBadge') }}
481
- </span>
482
- <span
483
- v-else-if="isUpheld(f)"
484
- data-testid="pr-review-finding-upheld"
485
- 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"
486
- >
487
- {{ t('prReview.challenge.upheldBadge') }}
488
- </span>
489
- <span
490
- v-else-if="isChallengeFailed(f)"
491
- data-testid="pr-review-finding-challenge-failed"
492
- 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"
493
- >
494
- {{ t('prReview.challenge.failedBadge') }}
495
- </span>
496
- <span
497
- v-else-if="isInvestigating(f)"
498
- data-testid="pr-review-finding-investigating"
499
- 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"
500
- >
501
- <UIcon name="i-lucide-loader-circle" class="h-3 w-3 animate-spin" />
502
- {{ t('prReview.challenge.investigatingBadge') }}
503
- </span>
504
- <h4
505
- 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"
506
597
  :class="isRetracted(f) ? 'line-through' : ''"
507
598
  >
508
- {{ f.title }}
509
- </h4>
510
- </div>
511
- <p class="mt-0.5 text-[11px] text-slate-500">
512
- {{ f.path
513
- }}<template v-if="f.line != null">
514
- · {{ 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"
515
604
  >
516
- </p>
517
- <p
518
- class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300"
519
- :class="isRetracted(f) ? 'line-through' : ''"
520
- >
521
- {{ f.detail }}
522
- </p>
523
- <p
524
- v-if="f.suggestedFix"
525
- class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
526
- >
527
- <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
528
- {{ f.suggestedFix }}
529
- </p>
605
+ <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
606
+ {{ f.suggestedFix }}
607
+ </p>
530
608
 
531
- <!-- The investigator's justification (why the finding holds up / was retracted),
609
+ <!-- The investigator's justification (why the finding holds up / was retracted),
532
610
  or the reason the challenge investigation failed. -->
533
- <p
534
- v-if="f.challenge?.justification"
535
- data-testid="pr-review-finding-justification"
536
- class="mt-1.5 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
537
- :class="
538
- isRetracted(f)
539
- ? 'bg-rose-500/10 text-rose-200'
540
- : isChallengeFailed(f)
541
- ? 'bg-amber-500/10 text-amber-200'
542
- : 'bg-sky-500/10 text-sky-200'
543
- "
544
- >
545
- <span class="font-medium">{{
546
- isChallengeFailed(f)
547
- ? t('prReview.challenge.failedLabel')
548
- : t('prReview.challenge.verdictLabel')
549
- }}</span>
550
- {{ f.challenge.justification }}
551
- </p>
552
-
553
- <!-- Per-finding actions: Challenge + Dismiss (only while awaiting a selection). -->
554
- <div
555
- v-if="awaiting && !isInvestigating(f)"
556
- class="mt-1.5 flex items-center gap-3 text-[11px]"
557
- >
558
- <button
559
- v-if="!isRetracted(f)"
560
- data-testid="pr-review-finding-challenge"
561
- class="flex items-center gap-1 text-indigo-300 hover:underline disabled:opacity-50"
562
- :disabled="!canResolve || !access.canExecuteRuns.value"
563
- @click="openChallenge(f.id)"
564
- >
565
- <UIcon name="i-lucide-gavel" class="h-3.5 w-3.5" />
566
- {{
567
- f.challenge
568
- ? t('prReview.challenge.reChallenge')
569
- : t('prReview.challenge.action')
570
- }}
571
- </button>
572
- <button
573
- data-testid="pr-review-finding-dismiss"
574
- class="flex items-center gap-1 text-slate-400 hover:text-rose-300 hover:underline disabled:opacity-50"
575
- :disabled="!canResolve || !access.canExecuteRuns.value"
576
- @click="onDismiss(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
+ "
577
622
  >
578
- <UIcon name="i-lucide-trash-2" class="h-3.5 w-3.5" />
579
- {{ t('prReview.challenge.dismiss') }}
580
- </button>
581
- </div>
582
-
583
- <!-- The inline challenge box: an OPTIONAL specific concern for the investigator. -->
584
- <div
585
- v-if="challengeForId === f.id"
586
- data-testid="pr-review-challenge-box"
587
- class="mt-2 rounded-md border border-indigo-500/40 bg-slate-900/80 p-2"
588
- >
589
- <textarea
590
- v-model="challengeText"
591
- data-testid="pr-review-challenge-input"
592
- rows="2"
593
- :placeholder="t('prReview.challenge.placeholder')"
594
- 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"
595
- />
596
- <p class="mt-1 text-[10px] text-slate-500">
597
- {{ 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 }}
598
629
  </p>
599
- <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
+ >
600
636
  <button
601
- class="rounded px-2 py-1 text-[11px] text-slate-400 hover:text-slate-200"
602
- @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)"
603
642
  >
604
- {{ 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
+ }}
605
649
  </button>
606
650
  <button
607
- data-testid="pr-review-challenge-submit"
608
- class="rounded bg-indigo-500/80 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
609
- :disabled="!canResolve"
610
- @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)"
611
655
  >
612
- {{ t('prReview.challenge.submit') }}
656
+ <UIcon name="i-lucide-trash-2" class="h-3.5 w-3.5" />
657
+ {{ t('prReview.challenge.dismiss') }}
613
658
  </button>
614
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>
615
694
  </div>
616
695
  </div>
617
- </div>
618
- </article>
619
- </section>
696
+ </article>
697
+ </section>
698
+ </template>
620
699
  </template>
621
- </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>
622
719
  </div>
623
720
 
624
721
  <!-- Footer -->