@cat-factory/app 0.109.5 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/app/components/board/AddTaskModal.vue +15 -15
  2. package/app/components/panels/inspector/TaskRunSettings.vue +12 -12
  3. package/app/components/pipeline/PipelineBuilder.vue +22 -0
  4. package/app/components/requirements/RequirementsReviewWindow.vue +300 -203
  5. package/app/components/settings/{MergePresetHealthModal.vue → RiskPolicyHealthModal.vue} +19 -19
  6. package/app/components/settings/{MergeThresholdsPanel.vue → RiskPolicyPanel.vue} +46 -46
  7. package/app/components/settings/UsageSettings.vue +142 -0
  8. package/app/components/settings/WorkspaceSettingsPanel.vue +14 -2
  9. package/app/composables/api/board.ts +1 -1
  10. package/app/composables/api/execution.ts +5 -0
  11. package/app/composables/api/presets.ts +18 -18
  12. package/app/composables/api/reviews.ts +7 -6
  13. package/app/composables/{useMergePresetHealth.ts → useRiskPolicyHealth.ts} +10 -10
  14. package/app/pages/index.vue +9 -9
  15. package/app/stores/board.ts +2 -2
  16. package/app/stores/pipelines.ts +41 -1
  17. package/app/stores/requirements.ts +9 -13
  18. package/app/stores/{mergePresets.ts → riskPolicies.ts} +13 -13
  19. package/app/stores/ui.ts +17 -17
  20. package/app/stores/usage.ts +60 -0
  21. package/app/stores/workspace.spec.ts +1 -1
  22. package/app/stores/workspace.ts +4 -4
  23. package/app/types/merge.ts +3 -3
  24. package/app/types/requirements.ts +1 -0
  25. package/app/utils/{mergePreset.ts → riskPolicy.ts} +4 -4
  26. package/i18n/locales/de.json +52 -27
  27. package/i18n/locales/en.json +52 -27
  28. package/i18n/locales/es.json +52 -27
  29. package/i18n/locales/fr.json +53 -28
  30. package/i18n/locales/he.json +53 -28
  31. package/i18n/locales/it.json +53 -28
  32. package/i18n/locales/ja.json +52 -27
  33. package/i18n/locales/pl.json +53 -28
  34. package/i18n/locales/tr.json +52 -27
  35. package/i18n/locales/uk.json +53 -28
  36. package/package.json +2 -2
@@ -32,9 +32,14 @@ const drafts = ref<Record<string, string>>({})
32
32
  // a draft when the recorded reply changes server-side (e.g. accepting a recommendation sets the
33
33
  // finding's answer) WITHOUT clobbering a reply the human is actively editing.
34
34
  const seededReply = ref<Record<string, string>>({})
35
- // Findings the human marked for a Requirement-Writer recommendation, batched until they
36
- // click "Request recommendations" (so the Writer runs once over the whole batch).
37
- const markedForRecommend = ref<Set<string>>(new Set())
35
+ // Findings the human switched to "recommend" mode (the per-finding 3-way selector's third
36
+ // option), batched until they click "Request recommendations" so the Writer runs once over the
37
+ // whole batch.
38
+ const recommendMode = ref<Set<string>>(new Set())
39
+ // Per-finding guidance for the Writer, keyed by finding id: whatever the human typed in the
40
+ // answer box before choosing "recommend" is carried here as steering guidance for THAT finding's
41
+ // recommendation (it steers the suggestion, it is NOT the answer itself).
42
+ const guidanceDrafts = ref<Record<string, string>>({})
38
43
  // Re-request "do it differently" notes, keyed by recommendation id.
39
44
  const reRequestNotes = ref<Record<string, string>>({})
40
45
  // Freeform "do it differently" comment when redoing a merge the human was unhappy with.
@@ -53,7 +58,8 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
53
58
  onOpen: (id) => {
54
59
  drafts.value = {}
55
60
  seededReply.value = {}
56
- markedForRecommend.value = new Set()
61
+ recommendMode.value = new Set()
62
+ guidanceDrafts.value = {}
57
63
  reRequestNotes.value = {}
58
64
  redoComment.value = ''
59
65
  showRedo.value = false
@@ -194,6 +200,9 @@ async function flushDrafts() {
194
200
  const r = review.value
195
201
  if (!r) return
196
202
  for (const item of r.items) {
203
+ // A finding switched to "recommend" mode holds GUIDANCE in its box, not an answer — never
204
+ // persist that as the reply.
205
+ if (recommendMode.value.has(item.id)) continue
197
206
  if (item.status === 'open' || item.status === 'answered') await persistDraft(item, r)
198
207
  }
199
208
  }
@@ -262,16 +271,55 @@ const readyRecommendations = computed<RequirementRecommendation[]>(() =>
262
271
  const generatingRecommendations = computed<RequirementRecommendation[]>(() =>
263
272
  (review.value?.recommendations ?? []).filter((r) => r.status === 'pending'),
264
273
  )
265
- // "ready / total" progress for the in-flight batch (null when nothing is generating). Scoped to
266
- // the current wave via `createdAt` (all placeholders in one request share the timestamp), so
267
- // stale `ready` recommendations the human hasn't acted on from an earlier batch don't inflate it.
268
- const recommendationProgress = computed(() => {
269
- const generating = generatingRecommendations.value
270
- if (generating.length === 0) return null
271
- const batchTimes = new Set(generating.map((r) => r.createdAt))
272
- const ready = readyRecommendations.value.filter((r) => batchTimes.has(r.createdAt)).length
273
- return { ready, total: ready + generating.length }
274
+ // Findings whose current answer is an AUTO-generated recommended default (an accepted `auto`
275
+ // recommendation) keyed by finding id. These are pre-filled by the auto-recommendation
276
+ // automation for findings the reviewer judged answerable without a product owner; the human can
277
+ // keep, edit or dismiss them. Matched by the snapshotted itemId first (findings churn across
278
+ // re-reviews), then by title+detail. Precomputed so the template doesn't re-scan per finding.
279
+ const autoDefaults = computed(() => {
280
+ const recs = (review.value?.recommendations ?? []).filter(
281
+ (r) => r.auto === true && r.status === 'accepted',
282
+ )
283
+ const map = new Map<string, RequirementRecommendation>()
284
+ for (const item of review.value?.items ?? []) {
285
+ const rec = recs.find(
286
+ (r) =>
287
+ r.sourceFinding.itemId === item.id ||
288
+ (r.sourceFinding.title === item.title && r.sourceFinding.detail === item.detail),
289
+ )
290
+ if (rec) map.set(item.id, rec)
291
+ }
292
+ return map
274
293
  })
294
+ // True once the automation has pre-answered at least one finding — used to flag the REMAINING
295
+ // open findings (the genuine business decisions the reviewer left for the human) as needing input.
296
+ const hasAutoDefaults = computed(() => autoDefaults.value.size > 0)
297
+ // Recommendations rendered INLINE inside their source finding card (rather than in a separate
298
+ // section): the human-requested ones that are `ready` (awaiting accept/reject) or still `pending`
299
+ // (generating). Keyed by finding id, matched by snapshotted itemId first then title+detail.
300
+ function recFor(item: RequirementReviewItem, status: 'ready' | 'pending') {
301
+ return (review.value?.recommendations ?? []).find(
302
+ (r) =>
303
+ r.status === status &&
304
+ r.auto !== true &&
305
+ (r.sourceFinding.itemId === item.id ||
306
+ (r.sourceFinding.title === item.title && r.sourceFinding.detail === item.detail)),
307
+ )
308
+ }
309
+ function readyRecFor(item: RequirementReviewItem) {
310
+ return recFor(item, 'ready')
311
+ }
312
+ function pendingRecFor(item: RequirementReviewItem) {
313
+ return recFor(item, 'pending')
314
+ }
315
+ // Whether a finding's recorded reply is the human's OWN answer (vs an untouched auto-generated
316
+ // recommended default). Drives the "User answered" marker on the Answer option.
317
+ function isUserAnswered(item: RequirementReviewItem): boolean {
318
+ const reply = (item.reply ?? '').trim()
319
+ if (!reply) return false
320
+ const auto = autoDefaults.value.get(item.id)
321
+ return !auto || auto.recommendedText.trim() !== reply
322
+ }
275
323
  // Whether the human still has something to act on (findings to answer/dismiss or recommendations
276
324
  // to decide). Drives the incorporated-document default collapse so the reference doc doesn't push
277
325
  // the actionable findings/recommendations off-screen while there's still work.
@@ -307,27 +355,77 @@ watch(status, () => {
307
355
  docCollapsedOverride.value = null
308
356
  })
309
357
 
310
- function isMarkedForRecommend(item: RequirementReviewItem): boolean {
311
- return markedForRecommend.value.has(item.id)
358
+ // The per-finding 3-way selector: which of Answer / Dismiss / Recommend is currently active.
359
+ // Derived from the finding's status plus the local `recommendMode` set (so a freshly-toggled
360
+ // finding shows "recommend" before any request has fired).
361
+ type FindingMode = 'answer' | 'dismiss' | 'recommend'
362
+ const FINDING_MODES = [
363
+ { mode: 'answer', labelKey: 'requirements.mode.answer', icon: 'i-lucide-pencil-line' },
364
+ { mode: 'dismiss', labelKey: 'requirements.mode.dismiss', icon: 'i-lucide-x' },
365
+ { mode: 'recommend', labelKey: 'requirements.mode.recommend', icon: 'i-lucide-wand-2' },
366
+ ] as const satisfies ReadonlyArray<{ mode: FindingMode; labelKey: string; icon: string }>
367
+ function modeFor(item: RequirementReviewItem): FindingMode {
368
+ if (item.status === 'dismissed') return 'dismiss'
369
+ if (recommendMode.value.has(item.id) || item.status === 'recommend_requested') return 'recommend'
370
+ return 'answer'
312
371
  }
313
- function toggleRecommend(item: RequirementReviewItem) {
314
- const next = new Set(markedForRecommend.value)
315
- if (next.has(item.id)) next.delete(item.id)
316
- else next.add(item.id)
317
- markedForRecommend.value = next
372
+ async function setMode(item: RequirementReviewItem, mode: FindingMode) {
373
+ if (frozen.value || modeFor(item) === mode) return
374
+ if (mode === 'dismiss') {
375
+ dropRecommendMode(item.id)
376
+ await setStatus(item, 'dismissed')
377
+ return
378
+ }
379
+ if (mode === 'answer') {
380
+ dropRecommendMode(item.id)
381
+ // Re-open a dismissed finding so its answer box is editable again.
382
+ if (item.status === 'dismissed') await setStatus(item, 'open')
383
+ return
384
+ }
385
+ // Recommend: carry whatever the human typed in the answer box over as the starting guidance,
386
+ // then flag the finding for the batch request (fired from the action rail).
387
+ const carried = (drafts.value[item.id] ?? '').trim()
388
+ if (carried && !(guidanceDrafts.value[item.id] ?? '').trim()) {
389
+ guidanceDrafts.value = { ...guidanceDrafts.value, [item.id]: carried }
390
+ }
391
+ recommendMode.value = new Set(recommendMode.value).add(item.id)
318
392
  }
393
+ function dropRecommendMode(id: string) {
394
+ if (!recommendMode.value.has(id)) return
395
+ const next = new Set(recommendMode.value)
396
+ next.delete(id)
397
+ recommendMode.value = next
398
+ }
399
+ // Findings currently flagged for a recommendation request (recommend mode, not yet requested).
400
+ const pendingRecommendRequests = computed(() =>
401
+ (review.value?.items ?? []).filter(
402
+ (i) =>
403
+ recommendMode.value.has(i.id) &&
404
+ i.status !== 'dismissed' &&
405
+ !pendingRecFor(i) &&
406
+ !readyRecFor(i),
407
+ ),
408
+ )
319
409
 
320
410
  // Fire the Writer over the whole marked batch (grounded on the project's best-practice
321
411
  // standards, specs/tech-specs and web search). ASYNCHRONOUS: it returns at once with `pending`
322
412
  // placeholders that fill in live; the user can close the window and is notified when the batch
323
413
  // is ready. Flush any typed-but-unblurred answers first so nothing the human entered is lost.
324
414
  async function requestRecommendations() {
325
- if (!blockId.value || markedForRecommend.value.size === 0) return
326
- const ids = [...markedForRecommend.value]
415
+ if (!blockId.value) return
416
+ const targets = pendingRecommendRequests.value
417
+ if (targets.length === 0) return
418
+ // Each finding carries its own guidance (transformed from what the human typed in its box);
419
+ // an empty guidance is omitted so the Writer falls back to grounding alone.
420
+ const items = targets.map((item) => {
421
+ const note = (guidanceDrafts.value[item.id] ?? '').trim()
422
+ return note ? { itemId: item.id, note } : { itemId: item.id }
423
+ })
424
+ const ids = targets.map((i) => i.id)
327
425
  try {
328
426
  await flushDrafts()
329
- const updated = await requirements.requestRecommendations(blockId.value, ids)
330
- markedForRecommend.value = new Set()
427
+ const updated = await requirements.requestRecommendations(blockId.value, items)
428
+ recommendMode.value = new Set()
331
429
  const n = ids.length
332
430
  // On a parked run the request returns at once with `pending` placeholders the durable driver
333
431
  // fills in the background; off-path (no active pipeline) there is no driver, so the Writer
@@ -594,6 +692,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
594
692
  <UBadge size="xs" variant="outline" color="neutral">
595
693
  {{ CATEGORY_LABELS[item.category] }}
596
694
  </UBadge>
695
+ <!-- Once the automation has pre-answered some findings, flag the ones it
696
+ left open as the genuine business decisions that need the human. -->
697
+ <UBadge
698
+ v-if="
699
+ hasAutoDefaults &&
700
+ item.status === 'open' &&
701
+ item.autoAnswerable === false
702
+ "
703
+ size="xs"
704
+ variant="subtle"
705
+ color="warning"
706
+ >
707
+ {{ t('requirements.needsYourInput') }}
708
+ </UBadge>
597
709
  <UBadge
598
710
  size="xs"
599
711
  variant="soft"
@@ -619,196 +731,179 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
619
731
  <p class="whitespace-pre-line">{{ item.reply }}</p>
620
732
  </div>
621
733
 
622
- <!-- react: answer (relevant) or dismiss (irrelevant). The answer
623
- auto-saves on blur no explicit save button. Disabled once the
624
- requirements are settled / awaiting a higher-level decision. -->
625
- <template v-if="item.status === 'open' || item.status === 'answered'">
626
- <UTextarea
627
- v-model="drafts[item.id]"
628
- :rows="2"
629
- autoresize
630
- size="sm"
631
- class="mt-2 w-full"
632
- :placeholder="t('requirements.answerPlaceholder')"
633
- :disabled="frozen"
634
- @blur="persistDraft(item)"
635
- />
636
- <div class="mt-2 flex flex-wrap items-center gap-2">
734
+ <!-- per-finding 3-way selector: Answer (write it) / Dismiss (irrelevant) /
735
+ Recommend (let the Requirement Writer suggest one). The active mode
736
+ drives the content below, IN PLACE no separate section. Disabled once
737
+ the requirements are settled / a cycle is running; hidden for a
738
+ `resolved` finding (its recorded answer shows above). -->
739
+ <template v-if="item.status !== 'resolved'">
740
+ <div class="mt-2 flex flex-wrap items-center gap-1">
637
741
  <UButton
638
- color="neutral"
639
- variant="ghost"
742
+ v-for="opt in FINDING_MODES"
743
+ :key="opt.mode"
744
+ :color="modeFor(item) === opt.mode ? 'primary' : 'neutral'"
745
+ :variant="modeFor(item) === opt.mode ? 'soft' : 'ghost'"
640
746
  size="xs"
641
- icon="i-lucide-x"
747
+ :icon="opt.icon"
642
748
  :disabled="frozen"
643
- @click="setStatus(item, 'dismissed')"
749
+ @click="setMode(item, opt.mode)"
644
750
  >
645
- {{ t('requirements.dismissIrrelevant') }}
751
+ {{ t(opt.labelKey) }}
752
+ <UIcon
753
+ v-if="opt.mode === 'answer' && isUserAnswered(item)"
754
+ name="i-lucide-check"
755
+ class="h-3.5 w-3.5 text-emerald-400"
756
+ />
646
757
  </UButton>
647
- <UButton
648
- :color="isMarkedForRecommend(item) ? 'primary' : 'neutral'"
649
- :variant="isMarkedForRecommend(item) ? 'soft' : 'ghost'"
650
- size="xs"
651
- icon="i-lucide-wand-2"
758
+ </div>
759
+
760
+ <!-- ANSWER: type the answer directly (auto-saves on blur) -->
761
+ <template v-if="modeFor(item) === 'answer'">
762
+ <!-- Auto-generated recommended default: the automation pre-filled this
763
+ answer; the human can keep it, edit it, or switch modes. -->
764
+ <div
765
+ v-if="autoDefaults.get(item.id)"
766
+ class="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-indigo-300"
767
+ >
768
+ <UIcon name="i-lucide-sparkles" class="h-3.5 w-3.5 shrink-0" />
769
+ <span>{{ t('requirements.recommendedDefault') }}</span>
770
+ <UBadge
771
+ v-if="autoDefaults.get(item.id)!.groundedInFragment"
772
+ size="xs"
773
+ variant="subtle"
774
+ color="primary"
775
+ >
776
+ {{
777
+ t('requirements.currentStandard', {
778
+ title: autoDefaults.get(item.id)!.groundedInFragment!.title,
779
+ })
780
+ }}
781
+ </UBadge>
782
+ </div>
783
+ <UTextarea
784
+ v-model="drafts[item.id]"
785
+ :rows="2"
786
+ autoresize
787
+ size="sm"
788
+ class="mt-2 w-full"
789
+ :placeholder="t('requirements.answerPlaceholder')"
652
790
  :disabled="frozen"
653
- @click="toggleRecommend(item)"
791
+ @blur="persistDraft(item)"
792
+ />
793
+ <p
794
+ v-if="isUserAnswered(item)"
795
+ class="mt-1 flex items-center gap-1 text-[11px] text-emerald-400"
654
796
  >
655
- {{
656
- isMarkedForRecommend(item)
657
- ? t('requirements.markedForRecommendation')
658
- : t('requirements.recommendSomething')
659
- }}
660
- </UButton>
661
- </div>
662
- </template>
797
+ <UIcon name="i-lucide-check" class="h-3 w-3 shrink-0" />
798
+ {{ t('requirements.userAnswered') }}
799
+ </p>
800
+ </template>
663
801
 
664
- <!-- finding awaiting a recommendation batch -->
665
- <div
666
- v-else-if="item.status === 'recommend_requested'"
667
- class="mt-2 flex items-center gap-1.5 text-xs text-indigo-300"
668
- >
669
- <UIcon name="i-lucide-wand-2" class="h-3.5 w-3.5" />
670
- {{ t('requirements.recommendationRequested') }}
671
- </div>
802
+ <!-- RECOMMEND: generating / the ready suggestion / a guidance box, all
803
+ rendered inline where the question was asked. -->
804
+ <template v-else-if="modeFor(item) === 'recommend'">
805
+ <div
806
+ v-if="pendingRecFor(item)"
807
+ class="mt-2 flex items-center gap-1.5 text-xs text-indigo-300"
808
+ >
809
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
810
+ {{ t('requirements.generatingSuggestion') }}
811
+ </div>
812
+ <template v-else-if="readyRecFor(item)">
813
+ <div
814
+ v-for="rec in [readyRecFor(item)!]"
815
+ :key="rec.id"
816
+ class="mt-2 rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
817
+ >
818
+ <UBadge
819
+ v-if="rec.groundedInFragment"
820
+ size="xs"
821
+ variant="subtle"
822
+ color="success"
823
+ icon="i-lucide-badge-check"
824
+ >
825
+ {{
826
+ t('requirements.currentStandard', {
827
+ title: rec.groundedInFragment.title,
828
+ })
829
+ }}
830
+ </UBadge>
831
+ <p class="mt-1 whitespace-pre-line text-sm text-slate-300">
832
+ {{ rec.recommendedText }}
833
+ </p>
834
+ <div class="mt-2 flex flex-wrap items-center gap-2">
835
+ <UButton
836
+ color="primary"
837
+ variant="soft"
838
+ size="xs"
839
+ icon="i-lucide-check"
840
+ :disabled="frozen"
841
+ @click="acceptRecommendation(rec)"
842
+ >
843
+ {{ t('requirements.accept') }}
844
+ </UButton>
845
+ <UButton
846
+ color="neutral"
847
+ variant="ghost"
848
+ size="xs"
849
+ icon="i-lucide-x"
850
+ :disabled="frozen"
851
+ @click="rejectRecommendation(rec)"
852
+ >
853
+ {{ t('requirements.reject') }}
854
+ </UButton>
855
+ </div>
856
+ <div class="mt-2 flex items-start gap-2">
857
+ <UTextarea
858
+ v-model="reRequestNotes[rec.id]"
859
+ :rows="1"
860
+ autoresize
861
+ size="sm"
862
+ class="flex-1"
863
+ :placeholder="t('requirements.reRequestPlaceholder')"
864
+ :disabled="frozen || recommending"
865
+ />
866
+ <UButton
867
+ color="neutral"
868
+ variant="soft"
869
+ size="xs"
870
+ icon="i-lucide-rotate-cw"
871
+ :loading="recommending"
872
+ :disabled="!(reRequestNotes[rec.id] ?? '').trim() || frozen"
873
+ @click="reRequestRecommendation(rec)"
874
+ >
875
+ {{ t('requirements.reRequest') }}
876
+ </UButton>
877
+ </div>
878
+ </div>
879
+ </template>
880
+ <template v-else>
881
+ <UTextarea
882
+ v-model="guidanceDrafts[item.id]"
883
+ :rows="2"
884
+ autoresize
885
+ size="sm"
886
+ class="mt-2 w-full"
887
+ :placeholder="t('requirements.guidancePlaceholder')"
888
+ :disabled="frozen"
889
+ />
890
+ <p class="mt-1 flex items-center gap-1 text-[11px] text-indigo-300/80">
891
+ <UIcon name="i-lucide-wand-2" class="h-3 w-3 shrink-0" />
892
+ {{ t('requirements.guidanceHint') }}
893
+ </p>
894
+ </template>
895
+ </template>
672
896
 
673
- <!-- reopen a dismissed finding -->
674
- <div v-else-if="item.status === 'dismissed'" class="mt-2">
675
- <UButton
676
- color="neutral"
677
- variant="ghost"
678
- size="xs"
679
- icon="i-lucide-rotate-ccw"
680
- :disabled="frozen"
681
- @click="setStatus(item, 'open')"
682
- >
683
- {{ t('requirements.reopen') }}
684
- </UButton>
685
- </div>
897
+ <!-- DISMISS: nothing to fill in — a short note explains the effect -->
898
+ <p v-else class="mt-2 text-[11px] text-slate-500">
899
+ {{ t('requirements.dismissedHint') }}
900
+ </p>
901
+ </template>
686
902
  </div>
687
903
  </div>
688
904
  </div>
689
905
  </div>
690
906
 
691
- <!-- Requirement-Writer recommendations: awaiting a human decision (`ready`) and/or
692
- still generating in the background (`pending`) -->
693
- <section
694
- v-if="readyRecommendations.length || generatingRecommendations.length"
695
- class="mt-6 border-t border-slate-800 pt-5"
696
- >
697
- <div class="mb-3 flex items-center gap-2 text-[11px] text-indigo-300">
698
- <UIcon name="i-lucide-wand-2" class="h-3.5 w-3.5" />
699
- <span class="font-semibold uppercase tracking-wide">{{
700
- t('requirements.recommendedAnswers')
701
- }}</span>
702
- <span
703
- v-if="recommendationProgress"
704
- class="ms-auto flex items-center gap-1.5 normal-case text-indigo-300/80"
705
- >
706
- <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
707
- {{
708
- t('requirements.recommendationProgress', {
709
- ready: recommendationProgress.ready,
710
- total: recommendationProgress.total,
711
- })
712
- }}
713
- </span>
714
- </div>
715
-
716
- <!-- still-generating placeholders (one per requested finding) -->
717
- <div v-if="generatingRecommendations.length" class="mb-3 flex flex-col gap-3">
718
- <div
719
- v-for="rec in generatingRecommendations"
720
- :key="rec.id"
721
- class="flex items-start gap-2 rounded-lg border border-dashed border-indigo-900/50 bg-indigo-950/10 p-3"
722
- >
723
- <UIcon
724
- name="i-lucide-loader-circle"
725
- class="mt-0.5 h-4 w-4 shrink-0 animate-spin text-indigo-300"
726
- />
727
- <div class="min-w-0">
728
- <span class="text-sm font-medium text-white">{{
729
- rec.sourceFinding.title
730
- }}</span>
731
- <p class="text-xs text-indigo-300/70">
732
- {{ t('requirements.generatingSuggestion') }}
733
- </p>
734
- </div>
735
- </div>
736
- </div>
737
-
738
- <div class="flex flex-col gap-3">
739
- <div
740
- v-for="rec in readyRecommendations"
741
- :key="rec.id"
742
- class="rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
743
- >
744
- <div class="flex flex-wrap items-center gap-1.5">
745
- <span class="text-sm font-medium text-white">{{
746
- rec.sourceFinding.title
747
- }}</span>
748
- <UBadge
749
- v-if="rec.groundedInFragment"
750
- size="xs"
751
- variant="subtle"
752
- color="success"
753
- icon="i-lucide-badge-check"
754
- >
755
- {{
756
- t('requirements.currentStandard', { title: rec.groundedInFragment.title })
757
- }}
758
- </UBadge>
759
- </div>
760
- <p class="mt-2 whitespace-pre-line text-sm text-slate-300">
761
- {{ rec.recommendedText }}
762
- </p>
763
- <div class="mt-2 flex flex-wrap items-center gap-2">
764
- <UButton
765
- color="primary"
766
- variant="soft"
767
- size="xs"
768
- icon="i-lucide-check"
769
- :disabled="frozen"
770
- @click="acceptRecommendation(rec)"
771
- >
772
- {{ t('requirements.accept') }}
773
- </UButton>
774
- <UButton
775
- color="neutral"
776
- variant="ghost"
777
- size="xs"
778
- icon="i-lucide-x"
779
- :disabled="frozen"
780
- @click="rejectRecommendation(rec)"
781
- >
782
- {{ t('requirements.reject') }}
783
- </UButton>
784
- </div>
785
- <!-- re-request with a note (an alternative to rejecting outright) -->
786
- <div class="mt-2 flex items-start gap-2">
787
- <UTextarea
788
- v-model="reRequestNotes[rec.id]"
789
- :rows="1"
790
- autoresize
791
- size="sm"
792
- class="flex-1"
793
- :placeholder="t('requirements.reRequestPlaceholder')"
794
- :disabled="frozen || recommending"
795
- />
796
- <UButton
797
- color="neutral"
798
- variant="soft"
799
- size="xs"
800
- icon="i-lucide-rotate-cw"
801
- :loading="recommending"
802
- :disabled="!(reRequestNotes[rec.id] ?? '').trim() || frozen"
803
- @click="reRequestRecommendation(rec)"
804
- >
805
- {{ t('requirements.reRequest') }}
806
- </UButton>
807
- </div>
808
- </div>
809
- </div>
810
- </section>
811
-
812
907
  <!-- incorporated document: the standard-format requirements. The whole section
813
908
  collapses as a unit (a long doc otherwise pushes the findings/recommendations
814
909
  off-screen); the per-heading toggles below still work when it's expanded. -->
@@ -918,7 +1013,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
918
1013
  bare `!frozen`, which would also expose it in `exceeded`, where the run is parked
919
1014
  on the cap decision and a fresh recommendation batch has no path to settle). -->
920
1015
  <div
921
- v-if="review && markedForRecommend.size > 0 && (status === 'ready' || merged)"
1016
+ v-if="
1017
+ review && pendingRecommendRequests.length > 0 && (status === 'ready' || merged)
1018
+ "
922
1019
  class="border-t border-slate-800 pt-4"
923
1020
  >
924
1021
  <UButton
@@ -933,8 +1030,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
933
1030
  {{
934
1031
  t(
935
1032
  'requirements.actions.requestRecommendations',
936
- { count: markedForRecommend.size },
937
- markedForRecommend.size,
1033
+ { count: pendingRecommendRequests.length },
1034
+ pendingRecommendRequests.length,
938
1035
  )
939
1036
  }}
940
1037
  </UButton>