@cat-factory/app 0.164.0 → 0.165.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.
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ findingAttention,
4
+ orderFindings,
5
+ reconcileFindingOrder,
6
+ type FindingRecommendationState,
7
+ type OrderedFinding,
8
+ } from './RequirementsReviewWindow.logic'
9
+ import type { RequirementReviewItem } from '~/types/requirements'
10
+
11
+ /**
12
+ * The findings list's floating order. The point of the feature is that a human working a long
13
+ * review never has to hunt for what is still on them, so these pin the bucketing (including the
14
+ * cases where the recommendation state disagrees with the finding's own status), the tie-breaking
15
+ * that keeps the sort stable, and the pinning rule that stops the list re-sorting under a cursor
16
+ * without ever letting a stale pin hide a newly-raised finding.
17
+ */
18
+ const NO_REC: FindingRecommendationState = { pending: false, ready: false }
19
+
20
+ const item = (
21
+ id: string,
22
+ status: RequirementReviewItem['status'],
23
+ severity: RequirementReviewItem['severity'] = 'medium',
24
+ ): RequirementReviewItem => ({
25
+ id,
26
+ category: 'question',
27
+ severity,
28
+ title: id,
29
+ detail: `detail for ${id}`,
30
+ status,
31
+ reply: null,
32
+ createdAt: 0,
33
+ updatedAt: 0,
34
+ })
35
+
36
+ const order = (
37
+ items: RequirementReviewItem[],
38
+ recs: Record<string, FindingRecommendationState> = {},
39
+ ) => orderFindings(items, (i) => recs[i.id] ?? NO_REC)
40
+
41
+ describe('findingAttention', () => {
42
+ it('puts an unanswered finding on the human', () => {
43
+ expect(findingAttention(item('a', 'open'), NO_REC)).toBe('action')
44
+ })
45
+
46
+ it('settles a finding the human already reacted to', () => {
47
+ expect(findingAttention(item('a', 'answered'), NO_REC)).toBe('settled')
48
+ expect(findingAttention(item('a', 'dismissed'), NO_REC)).toBe('settled')
49
+ expect(findingAttention(item('a', 'resolved'), NO_REC)).toBe('settled')
50
+ })
51
+
52
+ it('parks a finding whose suggestion is still being generated', () => {
53
+ expect(
54
+ findingAttention(item('a', 'recommend_requested'), { pending: true, ready: false }),
55
+ ).toBe('waiting')
56
+ // Even one the human never settled: there is nothing to react to until the Writer lands.
57
+ expect(findingAttention(item('a', 'open'), { pending: true, ready: false })).toBe('waiting')
58
+ })
59
+
60
+ it('returns a finding to the human the moment its suggestion is ready to accept or reject', () => {
61
+ expect(
62
+ findingAttention(item('a', 'recommend_requested'), { pending: false, ready: true }),
63
+ ).toBe('action')
64
+ // A ready suggestion outranks even an answer already recorded — accept/reject is still owed.
65
+ expect(findingAttention(item('a', 'answered'), { pending: false, ready: true })).toBe('action')
66
+ })
67
+
68
+ it('returns a recommend-requested finding with nothing in flight to the human', () => {
69
+ // The suggestion was rejected / never landed: nothing more is coming, so it is back on them.
70
+ expect(findingAttention(item('a', 'recommend_requested'), NO_REC)).toBe('action')
71
+ })
72
+ })
73
+
74
+ describe('orderFindings', () => {
75
+ it('floats what needs a reaction above what is waiting, above what is handled', () => {
76
+ const items = [
77
+ item('handled', 'answered'),
78
+ item('waiting', 'recommend_requested'),
79
+ item('needs-me', 'open'),
80
+ ]
81
+ expect(
82
+ order(items, { waiting: { pending: true, ready: false } }).map((entry) => entry.id),
83
+ ).toEqual(['needs-me', 'waiting', 'handled'])
84
+ })
85
+
86
+ it('keeps severity as the order within a bucket', () => {
87
+ const items = [item('low', 'open', 'low'), item('high', 'open', 'high'), item('mid', 'open')]
88
+ expect(order(items).map((entry) => entry.id)).toEqual(['high', 'mid', 'low'])
89
+ })
90
+
91
+ it('breaks ties on the reviewer ordering, so the sort is stable', () => {
92
+ const items = [item('first', 'open'), item('second', 'open'), item('third', 'open')]
93
+ expect(order(items).map((entry) => entry.id)).toEqual(['first', 'second', 'third'])
94
+ })
95
+
96
+ it('outranks severity by attention, so a low open finding beats a high handled one', () => {
97
+ const items = [item('high-handled', 'dismissed', 'high'), item('low-open', 'open', 'low')]
98
+ expect(order(items).map((entry) => entry.id)).toEqual(['low-open', 'high-handled'])
99
+ })
100
+
101
+ it('tags each entry with the bucket its position came from', () => {
102
+ const items = [item('a', 'open'), item('b', 'dismissed')]
103
+ expect(order(items)).toEqual([
104
+ { id: 'a', attention: 'action' },
105
+ { id: 'b', attention: 'settled' },
106
+ ])
107
+ })
108
+
109
+ it('handles an empty review', () => {
110
+ expect(order([])).toEqual([])
111
+ })
112
+ })
113
+
114
+ describe('reconcileFindingOrder', () => {
115
+ const desired: OrderedFinding[] = [
116
+ { id: 'a', attention: 'action' },
117
+ { id: 'b', attention: 'settled' },
118
+ ]
119
+
120
+ it('falls back to the computed order when nothing is pinned', () => {
121
+ expect(reconcileFindingOrder(desired, null)).toEqual(desired)
122
+ })
123
+
124
+ it('holds the pinned order while it covers the same findings', () => {
125
+ // `b` has since been answered and would now sink, but the pin keeps the list still.
126
+ const pinned: OrderedFinding[] = [
127
+ { id: 'b', attention: 'action' },
128
+ { id: 'a', attention: 'action' },
129
+ ]
130
+ expect(reconcileFindingOrder(desired, pinned)).toBe(pinned)
131
+ })
132
+
133
+ it('drops a pin that no longer covers every finding, so a new one can never be hidden', () => {
134
+ const pinned: OrderedFinding[] = [{ id: 'a', attention: 'action' }]
135
+ expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
136
+ })
137
+
138
+ it('drops a pin naming a finding the review no longer has', () => {
139
+ const pinned: OrderedFinding[] = [
140
+ { id: 'a', attention: 'action' },
141
+ { id: 'gone', attention: 'action' },
142
+ ]
143
+ expect(reconcileFindingOrder(desired, pinned)).toEqual(desired)
144
+ })
145
+ })
@@ -0,0 +1,105 @@
1
+ // Pure ordering for the requirements-review window's findings list.
2
+ //
3
+ // A reviewer pass raises its findings in whatever order it produced them, and the human then
4
+ // works down the list answering, dismissing or handing findings to the Requirement Writer. A
5
+ // finding they have dealt with keeps its slot, so on a long review (and on every re-review, which
6
+ // re-raises what is still unresolved alongside what is new) the ones still waiting on a reaction
7
+ // end up scattered between settled ones. This floats what still needs the human to the top and
8
+ // sinks what is already handled, so "what is left for me?" is answered by looking at the top of
9
+ // the list instead of scrolling it end to end.
10
+ //
11
+ // Kept out of the SFC so the bucketing, the comparator and the pinning rule are unit-testable
12
+ // without mounting Nuxt.
13
+ import type { RequirementReviewItem, ReviewItemSeverity } from '~/types/requirements'
14
+
15
+ /**
16
+ * How much of the human's attention a finding still wants.
17
+ * - `action` — the ball is in THEIR court: an unanswered finding, or a Writer suggestion sitting
18
+ * there awaiting accept/reject.
19
+ * - `waiting` — the ball is in the MACHINE's court: the Requirement Writer is still producing a
20
+ * suggestion for this finding, so there is nothing to react to yet.
21
+ * - `settled` — answered, dismissed or resolved; nothing further is owed per-finding (the review
22
+ * as a whole still needs the incorporate/proceed decision, which lives in the action rail).
23
+ */
24
+ export type FindingAttention = 'action' | 'waiting' | 'settled'
25
+
26
+ /** What the review's recommendation collection currently holds for one finding. */
27
+ export interface FindingRecommendationState {
28
+ /** A requested suggestion is still being generated by the Writer. */
29
+ pending: boolean
30
+ /** A generated suggestion is waiting on the human's accept/reject. */
31
+ ready: boolean
32
+ }
33
+
34
+ /** One finding in the rendered order, tagged with the bucket its position came from. */
35
+ export interface OrderedFinding {
36
+ id: string
37
+ attention: FindingAttention
38
+ }
39
+
40
+ const ATTENTION_RANK: Record<FindingAttention, number> = { action: 0, waiting: 1, settled: 2 }
41
+ const SEVERITY_RANK: Record<ReviewItemSeverity, number> = { high: 0, medium: 1, low: 2 }
42
+
43
+ /**
44
+ * Which bucket a finding belongs in. The recommendation state OUTRANKS the finding's own status,
45
+ * because that is where the actionability actually moved: a finding parked in
46
+ * `recommend_requested` is the human's problem again the moment a suggestion lands (accept or
47
+ * reject), and is nobody's problem while the Writer is still thinking. A `recommend_requested`
48
+ * finding with NEITHER a pending nor a ready suggestion has had its recommendation declined or
49
+ * lost, so nothing more is coming and it is back on the human — the opposite of what its status
50
+ * alone would suggest.
51
+ */
52
+ export function findingAttention(
53
+ item: Pick<RequirementReviewItem, 'status'>,
54
+ recommendation: FindingRecommendationState,
55
+ ): FindingAttention {
56
+ if (recommendation.ready) return 'action'
57
+ if (recommendation.pending) return 'waiting'
58
+ if (item.status === 'open' || item.status === 'recommend_requested') return 'action'
59
+ return 'settled'
60
+ }
61
+
62
+ /**
63
+ * The findings in the order the window should render them: unreacted-to first, then whatever the
64
+ * Writer is still working on, then everything already handled. Severity stays the secondary key
65
+ * (the order the window has always used within a bucket), and the reviewer's own ordering breaks
66
+ * the remaining ties so the sort is total and stable.
67
+ */
68
+ export function orderFindings(
69
+ items: readonly RequirementReviewItem[],
70
+ recommendationFor: (item: RequirementReviewItem) => FindingRecommendationState,
71
+ ): OrderedFinding[] {
72
+ return items
73
+ .map((item, index) => ({
74
+ id: item.id,
75
+ attention: findingAttention(item, recommendationFor(item)),
76
+ severity: item.severity,
77
+ index,
78
+ }))
79
+ .sort(
80
+ (a, b) =>
81
+ ATTENTION_RANK[a.attention] - ATTENTION_RANK[b.attention] ||
82
+ SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
83
+ a.index - b.index,
84
+ )
85
+ .map(({ id, attention }) => ({ id, attention }))
86
+ }
87
+
88
+ /**
89
+ * Which order to actually render: the PINNED one while it still covers exactly the same findings,
90
+ * otherwise the freshly computed one.
91
+ *
92
+ * The window pins the order while the human is typing in a finding's box and re-pins once they
93
+ * leave it, because an answer auto-saves on blur: re-sorting at that exact moment would slide the
94
+ * card they just clicked into out from under the cursor. Pinning is only ever allowed to hold a
95
+ * position STALE, never to hide a finding — a reviewer pass that adds or drops findings therefore
96
+ * invalidates the pin outright rather than rendering a list missing the new ones.
97
+ */
98
+ export function reconcileFindingOrder(
99
+ desired: readonly OrderedFinding[],
100
+ pinned: readonly OrderedFinding[] | null,
101
+ ): readonly OrderedFinding[] {
102
+ if (!pinned || pinned.length !== desired.length) return desired
103
+ const ids = new Set(desired.map((entry) => entry.id))
104
+ return pinned.every((entry) => ids.has(entry.id)) ? pinned : desired
105
+ }
@@ -10,6 +10,12 @@
10
10
  import { parseOutputOutline } from '~/utils/agentOutput'
11
11
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
12
12
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
13
+ import {
14
+ orderFindings,
15
+ reconcileFindingOrder,
16
+ type FindingAttention,
17
+ type OrderedFinding,
18
+ } from './RequirementsReviewWindow.logic'
13
19
  import type {
14
20
  RequirementRecommendation,
15
21
  RequirementReview,
@@ -48,6 +54,11 @@ const showRedo = ref(false)
48
54
  // Human's explicit collapse choice for the whole incorporated-requirements section; null = follow
49
55
  // the default (collapse it while there's still work to do so it doesn't dominate the window).
50
56
  const docCollapsedOverride = ref<boolean | null>(null)
57
+ // State of the floating findings order (the section that computes it, further down, explains the
58
+ // pinning rule). Declared up here because `onOpen` fires SYNCHRONOUSLY during setup and resets
59
+ // them — a ref declared below would be in its temporal dead zone at that point.
60
+ const pinnedOrder = ref<OrderedFinding[] | null>(null)
61
+ const editingFinding = ref(false)
51
62
 
52
63
  // The seam contract (open/blockId/close + Escape handling + load-on-open) lives in
53
64
  // `useResultView`, so this window can't drift from the others. Declaring `onOpen` makes the
@@ -64,6 +75,10 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
64
75
  redoComment.value = ''
65
76
  showRedo.value = false
66
77
  docCollapsedOverride.value = null
78
+ // Re-open is the settle point that matters most: whatever is still outstanding floats back to
79
+ // the top rather than inheriting the order the previous session was pinned to.
80
+ pinnedOrder.value = null
81
+ editingFinding.value = false
67
82
  void requirements.load(blockId)
68
83
  },
69
84
  // Closing the window (X, backdrop, Escape) must not silently drop an answer the user typed
@@ -84,14 +99,6 @@ const reworking = computed(() =>
84
99
  )
85
100
  const acting = ref(false)
86
101
 
87
- const SEVERITY_RANK: Record<ReviewItemSeverity, number> = { high: 0, medium: 1, low: 2 }
88
- const sortedItems = computed<RequirementReviewItem[]>(() => {
89
- if (!review.value) return []
90
- return [...review.value.items].sort(
91
- (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity],
92
- )
93
- })
94
-
95
102
  const openCount = computed(() => (review.value ? requirements.openCount(review.value) : 0))
96
103
  const answeredCount = computed(() => (review.value ? requirements.answeredCount(review.value) : 0))
97
104
  const status = computed(() => review.value?.status ?? null)
@@ -312,6 +319,71 @@ function readyRecFor(item: RequirementReviewItem) {
312
319
  function pendingRecFor(item: RequirementReviewItem) {
313
320
  return recFor(item, 'pending')
314
321
  }
322
+
323
+ // --- Floating findings order ------------------------------------------------
324
+ // Findings the human still owes a reaction float to the top, then the ones the Writer is still
325
+ // thinking about, then everything already handled — so a long review (or a re-review, which
326
+ // re-raises unresolved findings alongside new ones) doesn't leave the outstanding work scattered
327
+ // between settled cards. Severity remains the order WITHIN a bucket. Ranking is pure
328
+ // (`RequirementsReviewWindow.logic.ts`); the window only decides WHEN to apply it.
329
+ const desiredOrder = computed<OrderedFinding[]>(() =>
330
+ orderFindings(review.value?.items ?? [], (item) => ({
331
+ pending: !!pendingRecFor(item),
332
+ ready: !!readyRecFor(item),
333
+ })),
334
+ )
335
+ // `pinnedOrder` (declared at the top of the script, since `onOpen` resets it) holds the order
336
+ // actually rendered; null means "use the computed one". It is pinned while `editingFinding` — focus
337
+ // inside one of a finding's text boxes — is true, because an answer auto-saves on BLUR, which
338
+ // re-buckets that finding: re-sorting right then would slide the card the user just clicked into
339
+ // out from under their cursor. Focus moving straight from one box to the next never lets the flag
340
+ // settle to `false`, so the list holds still for a whole editing burst and the remaining work
341
+ // floats back up the moment they leave it.
342
+ function isTextEntry(target: EventTarget | null): boolean {
343
+ return target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement
344
+ }
345
+ function onFindingFocusIn(event: FocusEvent) {
346
+ if (isTextEntry(event.target)) editingFinding.value = true
347
+ }
348
+ function onFindingFocusOut(event: FocusEvent) {
349
+ if (isTextEntry(event.target)) editingFinding.value = false
350
+ }
351
+ watch(
352
+ [desiredOrder, editingFinding],
353
+ () => {
354
+ if (!editingFinding.value) pinnedOrder.value = desiredOrder.value
355
+ },
356
+ { immediate: true },
357
+ )
358
+ const orderedFindings = computed<{ item: RequirementReviewItem; attention: FindingAttention }[]>(
359
+ () => {
360
+ const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
361
+ return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
362
+ const item = byId.get(entry.id)
363
+ return item ? [{ item, attention: entry.attention }] : []
364
+ })
365
+ },
366
+ )
367
+ // Label the buckets only once the list actually spans more than one — on a fresh review every
368
+ // finding is outstanding, and a lone "Needs your reaction" heading over all of them is noise.
369
+ const attentionGroupsShown = computed(
370
+ () => new Set(orderedFindings.value.map((entry) => entry.attention)).size > 1,
371
+ )
372
+ function startsAttentionGroup(index: number): boolean {
373
+ if (!attentionGroupsShown.value) return false
374
+ const entries = orderedFindings.value
375
+ return index === 0 || entries[index - 1]?.attention !== entries[index]?.attention
376
+ }
377
+ const ATTENTION_LABELS = computed<Record<FindingAttention, string>>(() => ({
378
+ action: t('requirements.group.action'),
379
+ waiting: t('requirements.group.waiting'),
380
+ settled: t('requirements.group.settled'),
381
+ }))
382
+ const ATTENTION_LABEL_COLOR = {
383
+ action: 'text-amber-300',
384
+ waiting: 'text-indigo-300',
385
+ settled: 'text-slate-500',
386
+ } as const satisfies Record<FindingAttention, string>
315
387
  // Whether a finding's recorded reply is the human's OWN answer (vs an untouched auto-generated
316
388
  // recommended default). Drives the "User answered" marker on the Answer option.
317
389
  function isUserAnswered(item: RequirementReviewItem): boolean {
@@ -643,249 +715,265 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
643
715
  </span>
644
716
  </div>
645
717
 
646
- <!-- findings to react to -->
647
- <div v-if="review.items.length" class="flex flex-col gap-3">
648
- <div
649
- v-for="item in sortedItems"
650
- :key="item.id"
651
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
652
- :class="{ 'opacity-60': item.status === 'dismissed' }"
653
- >
654
- <div class="flex items-start gap-2">
655
- <UIcon
656
- :name="CATEGORY_ICON[item.category]"
657
- class="mt-0.5 h-4 w-4 shrink-0 text-slate-400"
658
- />
659
- <div class="min-w-0 flex-1">
660
- <div class="flex flex-wrap items-center gap-1.5">
661
- <span class="text-sm font-medium text-white">{{ item.title }}</span>
662
- <UBadge size="xs" variant="subtle" :color="SEVERITY_COLOR[item.severity]">
663
- {{ SEVERITY_LABELS[item.severity] }}
664
- </UBadge>
665
- <UBadge size="xs" variant="outline" color="neutral">
666
- {{ CATEGORY_LABELS[item.category] }}
667
- </UBadge>
668
- <!-- Once the automation has pre-answered some findings, flag the ones it
718
+ <!-- findings to react to — ordered so anything still owed a reaction sits at the
719
+ top (see the floating-order section in the script), with the buckets labelled
720
+ once the list spans more than one of them -->
721
+ <div
722
+ v-if="review.items.length"
723
+ class="flex flex-col gap-3"
724
+ @focusin="onFindingFocusIn"
725
+ @focusout="onFindingFocusOut"
726
+ >
727
+ <template v-for="({ item, attention }, index) in orderedFindings" :key="item.id">
728
+ <div v-if="startsAttentionGroup(index)" class="flex items-center gap-2 pt-1">
729
+ <span
730
+ class="text-[11px] font-semibold uppercase tracking-wide"
731
+ :class="ATTENTION_LABEL_COLOR[attention]"
732
+ >
733
+ {{ ATTENTION_LABELS[attention] }}
734
+ </span>
735
+ <span class="h-px flex-1 bg-slate-800" />
736
+ </div>
737
+ <div
738
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
739
+ :class="{ 'opacity-60': item.status === 'dismissed' }"
740
+ >
741
+ <div class="flex items-start gap-2">
742
+ <UIcon
743
+ :name="CATEGORY_ICON[item.category]"
744
+ class="mt-0.5 h-4 w-4 shrink-0 text-slate-400"
745
+ />
746
+ <div class="min-w-0 flex-1">
747
+ <div class="flex flex-wrap items-center gap-1.5">
748
+ <span class="text-sm font-medium text-white">{{ item.title }}</span>
749
+ <UBadge size="xs" variant="subtle" :color="SEVERITY_COLOR[item.severity]">
750
+ {{ SEVERITY_LABELS[item.severity] }}
751
+ </UBadge>
752
+ <UBadge size="xs" variant="outline" color="neutral">
753
+ {{ CATEGORY_LABELS[item.category] }}
754
+ </UBadge>
755
+ <!-- Once the automation has pre-answered some findings, flag the ones it
669
756
  left open as the genuine business decisions that need the human. -->
670
- <UBadge
671
- v-if="
672
- hasAutoDefaults && item.status === 'open' && item.autoAnswerable === false
673
- "
674
- size="xs"
675
- variant="subtle"
676
- color="warning"
677
- >
678
- {{ t('requirements.needsYourInput') }}
679
- </UBadge>
680
- <UBadge
681
- size="xs"
682
- variant="soft"
683
- :color="STATUS_COLOR[item.status]"
684
- class="ms-auto"
685
- >
686
- {{ STATUS_LABELS[item.status] }}
687
- </UBadge>
688
- </div>
689
- <p class="mt-1 whitespace-pre-line text-sm text-slate-400">
690
- {{ item.detail }}
691
- </p>
757
+ <UBadge
758
+ v-if="
759
+ hasAutoDefaults && item.status === 'open' && item.autoAnswerable === false
760
+ "
761
+ size="xs"
762
+ variant="subtle"
763
+ color="warning"
764
+ >
765
+ {{ t('requirements.needsYourInput') }}
766
+ </UBadge>
767
+ <UBadge
768
+ size="xs"
769
+ variant="soft"
770
+ :color="STATUS_COLOR[item.status]"
771
+ class="ms-auto"
772
+ >
773
+ {{ STATUS_LABELS[item.status] }}
774
+ </UBadge>
775
+ </div>
776
+ <p class="mt-1 whitespace-pre-line text-sm text-slate-400">
777
+ {{ item.detail }}
778
+ </p>
692
779
 
693
- <!-- recorded answer (only for non-editable findings — for editable
780
+ <!-- recorded answer (only for non-editable findings — for editable
694
781
  ones the answer lives in the textarea below, seeded from the reply) -->
695
- <div
696
- v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
697
- class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
698
- >
699
- <span class="text-[10px] uppercase tracking-wide text-slate-500">
700
- {{ t('requirements.answerLabel') }}
701
- </span>
702
- <p class="whitespace-pre-line">{{ item.reply }}</p>
703
- </div>
782
+ <div
783
+ v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
784
+ class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
785
+ >
786
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">
787
+ {{ t('requirements.answerLabel') }}
788
+ </span>
789
+ <p class="whitespace-pre-line">{{ item.reply }}</p>
790
+ </div>
704
791
 
705
- <!-- per-finding 3-way selector: Answer (write it) / Dismiss (irrelevant) /
792
+ <!-- per-finding 3-way selector: Answer (write it) / Dismiss (irrelevant) /
706
793
  Recommend (let the Requirement Writer suggest one). The active mode
707
794
  drives the content below, IN PLACE — no separate section. Disabled once
708
795
  the requirements are settled / a cycle is running; hidden for a
709
796
  `resolved` finding (its recorded answer shows above). -->
710
- <template v-if="item.status !== 'resolved'">
711
- <div class="mt-2 flex flex-wrap items-center gap-1">
712
- <UButton
713
- v-for="opt in FINDING_MODES"
714
- :key="opt.mode"
715
- :color="modeFor(item) === opt.mode ? 'primary' : 'neutral'"
716
- :variant="modeFor(item) === opt.mode ? 'soft' : 'ghost'"
717
- size="xs"
718
- :icon="opt.icon"
719
- :disabled="frozen"
720
- @click="setMode(item, opt.mode)"
721
- >
722
- {{ t(opt.labelKey) }}
723
- <UIcon
724
- v-if="opt.mode === 'answer' && isUserAnswered(item)"
725
- name="i-lucide-check"
726
- class="h-3.5 w-3.5 text-emerald-400"
727
- />
728
- </UButton>
729
- </div>
730
-
731
- <!-- ANSWER: type the answer directly (auto-saves on blur) -->
732
- <template v-if="modeFor(item) === 'answer'">
733
- <!-- Auto-generated recommended default: the automation pre-filled this
734
- answer; the human can keep it, edit it, or switch modes. -->
735
- <div
736
- v-if="autoDefaults.get(item.id)"
737
- class="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-indigo-300"
738
- >
739
- <UIcon name="i-lucide-sparkles" class="h-3.5 w-3.5 shrink-0" />
740
- <span>{{ t('requirements.recommendedDefault') }}</span>
741
- <UBadge
742
- v-if="autoDefaults.get(item.id)!.groundedInFragment"
797
+ <template v-if="item.status !== 'resolved'">
798
+ <div class="mt-2 flex flex-wrap items-center gap-1">
799
+ <UButton
800
+ v-for="opt in FINDING_MODES"
801
+ :key="opt.mode"
802
+ :color="modeFor(item) === opt.mode ? 'primary' : 'neutral'"
803
+ :variant="modeFor(item) === opt.mode ? 'soft' : 'ghost'"
743
804
  size="xs"
744
- variant="subtle"
745
- color="primary"
805
+ :icon="opt.icon"
806
+ :disabled="frozen"
807
+ @click="setMode(item, opt.mode)"
746
808
  >
747
- {{
748
- t('requirements.currentStandard', {
749
- title: autoDefaults.get(item.id)!.groundedInFragment!.title,
750
- })
751
- }}
752
- </UBadge>
809
+ {{ t(opt.labelKey) }}
810
+ <UIcon
811
+ v-if="opt.mode === 'answer' && isUserAnswered(item)"
812
+ name="i-lucide-check"
813
+ class="h-3.5 w-3.5 text-emerald-400"
814
+ />
815
+ </UButton>
753
816
  </div>
754
- <UTextarea
755
- v-model="drafts[item.id]"
756
- :rows="2"
757
- autoresize
758
- size="sm"
759
- class="mt-2 w-full"
760
- :placeholder="t('requirements.answerPlaceholder')"
761
- :disabled="frozen"
762
- @blur="persistDraft(item)"
763
- />
764
- <p
765
- v-if="isUserAnswered(item)"
766
- class="mt-1 flex items-center gap-1 text-[11px] text-emerald-400"
767
- >
768
- <UIcon name="i-lucide-check" class="h-3 w-3 shrink-0" />
769
- {{ t('requirements.userAnswered') }}
770
- </p>
771
- </template>
772
817
 
773
- <!-- RECOMMEND: generating / the ready suggestion / a guidance box, all
774
- rendered inline where the question was asked. -->
775
- <template v-else-if="modeFor(item) === 'recommend'">
776
- <div
777
- v-if="pendingRecFor(item)"
778
- class="mt-2 flex items-center gap-1.5 text-xs text-indigo-300"
779
- >
780
- <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
781
- {{ t('requirements.generatingSuggestion') }}
782
- </div>
783
- <template v-else-if="readyRecFor(item)">
818
+ <!-- ANSWER: type the answer directly (auto-saves on blur) -->
819
+ <template v-if="modeFor(item) === 'answer'">
820
+ <!-- Auto-generated recommended default: the automation pre-filled this
821
+ answer; the human can keep it, edit it, or switch modes. -->
784
822
  <div
785
- v-for="rec in [readyRecFor(item)!]"
786
- :key="rec.id"
787
- class="mt-2 rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
823
+ v-if="autoDefaults.get(item.id)"
824
+ class="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-indigo-300"
788
825
  >
826
+ <UIcon name="i-lucide-sparkles" class="h-3.5 w-3.5 shrink-0" />
827
+ <span>{{ t('requirements.recommendedDefault') }}</span>
789
828
  <UBadge
790
- v-if="rec.groundedInFragment"
829
+ v-if="autoDefaults.get(item.id)!.groundedInFragment"
791
830
  size="xs"
792
831
  variant="subtle"
793
- color="success"
794
- icon="i-lucide-badge-check"
832
+ color="primary"
795
833
  >
796
834
  {{
797
835
  t('requirements.currentStandard', {
798
- title: rec.groundedInFragment.title,
836
+ title: autoDefaults.get(item.id)!.groundedInFragment!.title,
799
837
  })
800
838
  }}
801
839
  </UBadge>
802
- <p class="mt-1 whitespace-pre-line text-sm text-slate-300">
803
- {{ rec.recommendedText }}
804
- </p>
805
- <div class="mt-2 flex flex-wrap items-center gap-2">
806
- <UButton
807
- color="primary"
808
- variant="soft"
809
- size="xs"
810
- icon="i-lucide-check"
811
- :disabled="frozen || !access.canExecuteRuns.value"
812
- :title="
813
- access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
814
- "
815
- @click="acceptRecommendation(rec)"
816
- >
817
- {{ t('requirements.accept') }}
818
- </UButton>
819
- <UButton
820
- color="neutral"
821
- variant="ghost"
822
- size="xs"
823
- icon="i-lucide-x"
824
- :disabled="frozen || !access.canExecuteRuns.value"
825
- :title="
826
- access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
827
- "
828
- @click="rejectRecommendation(rec)"
829
- >
830
- {{ t('requirements.reject') }}
831
- </UButton>
832
- </div>
833
- <div class="mt-2 flex items-start gap-2">
834
- <UTextarea
835
- v-model="reRequestNotes[rec.id]"
836
- :rows="1"
837
- autoresize
838
- size="sm"
839
- class="flex-1"
840
- :placeholder="t('requirements.reRequestPlaceholder')"
841
- :disabled="frozen || recommending"
842
- />
843
- <UButton
844
- color="neutral"
845
- variant="soft"
846
- size="xs"
847
- icon="i-lucide-rotate-cw"
848
- :loading="recommending"
849
- :disabled="
850
- !(reRequestNotes[rec.id] ?? '').trim() ||
851
- frozen ||
852
- !access.canExecuteRuns.value
853
- "
854
- :title="
855
- access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
856
- "
857
- @click="reRequestRecommendation(rec)"
858
- >
859
- {{ t('requirements.reRequest') }}
860
- </UButton>
861
- </div>
862
840
  </div>
863
- </template>
864
- <template v-else>
865
841
  <UTextarea
866
- v-model="guidanceDrafts[item.id]"
842
+ v-model="drafts[item.id]"
867
843
  :rows="2"
868
844
  autoresize
869
845
  size="sm"
870
846
  class="mt-2 w-full"
871
- :placeholder="t('requirements.guidancePlaceholder')"
847
+ :placeholder="t('requirements.answerPlaceholder')"
872
848
  :disabled="frozen"
849
+ @blur="persistDraft(item)"
873
850
  />
874
- <p class="mt-1 flex items-center gap-1 text-[11px] text-indigo-300/80">
875
- <UIcon name="i-lucide-wand-2" class="h-3 w-3 shrink-0" />
876
- {{ t('requirements.guidanceHint') }}
851
+ <p
852
+ v-if="isUserAnswered(item)"
853
+ class="mt-1 flex items-center gap-1 text-[11px] text-emerald-400"
854
+ >
855
+ <UIcon name="i-lucide-check" class="h-3 w-3 shrink-0" />
856
+ {{ t('requirements.userAnswered') }}
877
857
  </p>
878
858
  </template>
879
- </template>
880
859
 
881
- <!-- DISMISS: nothing to fill in a short note explains the effect -->
882
- <p v-else class="mt-2 text-[11px] text-slate-500">
883
- {{ t('requirements.dismissedHint') }}
884
- </p>
885
- </template>
860
+ <!-- RECOMMEND: generating / the ready suggestion / a guidance box, all
861
+ rendered inline where the question was asked. -->
862
+ <template v-else-if="modeFor(item) === 'recommend'">
863
+ <div
864
+ v-if="pendingRecFor(item)"
865
+ class="mt-2 flex items-center gap-1.5 text-xs text-indigo-300"
866
+ >
867
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
868
+ {{ t('requirements.generatingSuggestion') }}
869
+ </div>
870
+ <template v-else-if="readyRecFor(item)">
871
+ <div
872
+ v-for="rec in [readyRecFor(item)!]"
873
+ :key="rec.id"
874
+ class="mt-2 rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
875
+ >
876
+ <UBadge
877
+ v-if="rec.groundedInFragment"
878
+ size="xs"
879
+ variant="subtle"
880
+ color="success"
881
+ icon="i-lucide-badge-check"
882
+ >
883
+ {{
884
+ t('requirements.currentStandard', {
885
+ title: rec.groundedInFragment.title,
886
+ })
887
+ }}
888
+ </UBadge>
889
+ <p class="mt-1 whitespace-pre-line text-sm text-slate-300">
890
+ {{ rec.recommendedText }}
891
+ </p>
892
+ <div class="mt-2 flex flex-wrap items-center gap-2">
893
+ <UButton
894
+ color="primary"
895
+ variant="soft"
896
+ size="xs"
897
+ icon="i-lucide-check"
898
+ :disabled="frozen || !access.canExecuteRuns.value"
899
+ :title="
900
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
901
+ "
902
+ @click="acceptRecommendation(rec)"
903
+ >
904
+ {{ t('requirements.accept') }}
905
+ </UButton>
906
+ <UButton
907
+ color="neutral"
908
+ variant="ghost"
909
+ size="xs"
910
+ icon="i-lucide-x"
911
+ :disabled="frozen || !access.canExecuteRuns.value"
912
+ :title="
913
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
914
+ "
915
+ @click="rejectRecommendation(rec)"
916
+ >
917
+ {{ t('requirements.reject') }}
918
+ </UButton>
919
+ </div>
920
+ <div class="mt-2 flex items-start gap-2">
921
+ <UTextarea
922
+ v-model="reRequestNotes[rec.id]"
923
+ :rows="1"
924
+ autoresize
925
+ size="sm"
926
+ class="flex-1"
927
+ :placeholder="t('requirements.reRequestPlaceholder')"
928
+ :disabled="frozen || recommending"
929
+ />
930
+ <UButton
931
+ color="neutral"
932
+ variant="soft"
933
+ size="xs"
934
+ icon="i-lucide-rotate-cw"
935
+ :loading="recommending"
936
+ :disabled="
937
+ !(reRequestNotes[rec.id] ?? '').trim() ||
938
+ frozen ||
939
+ !access.canExecuteRuns.value
940
+ "
941
+ :title="
942
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
943
+ "
944
+ @click="reRequestRecommendation(rec)"
945
+ >
946
+ {{ t('requirements.reRequest') }}
947
+ </UButton>
948
+ </div>
949
+ </div>
950
+ </template>
951
+ <template v-else>
952
+ <UTextarea
953
+ v-model="guidanceDrafts[item.id]"
954
+ :rows="2"
955
+ autoresize
956
+ size="sm"
957
+ class="mt-2 w-full"
958
+ :placeholder="t('requirements.guidancePlaceholder')"
959
+ :disabled="frozen"
960
+ />
961
+ <p class="mt-1 flex items-center gap-1 text-[11px] text-indigo-300/80">
962
+ <UIcon name="i-lucide-wand-2" class="h-3 w-3 shrink-0" />
963
+ {{ t('requirements.guidanceHint') }}
964
+ </p>
965
+ </template>
966
+ </template>
967
+
968
+ <!-- DISMISS: nothing to fill in — a short note explains the effect -->
969
+ <p v-else class="mt-2 text-[11px] text-slate-500">
970
+ {{ t('requirements.dismissedHint') }}
971
+ </p>
972
+ </template>
973
+ </div>
886
974
  </div>
887
975
  </div>
888
- </div>
976
+ </template>
889
977
  </div>
890
978
 
891
979
  <!-- incorporated document: the standard-format requirements. The whole section
@@ -3592,6 +3592,11 @@
3592
3592
  "recommendationRequested": "Empfehlung angefordert — prüfen Sie den Vorschlag unten.",
3593
3593
  "recommendedDefault": "Empfohlene Vorgabe: behalten, bearbeiten oder die Feststellung verwerfen.",
3594
3594
  "needsYourInput": "Benötigt Ihre Eingabe",
3595
+ "group": {
3596
+ "action": "Benötigt Ihre Reaktion",
3597
+ "waiting": "Warten auf Vorschläge",
3598
+ "settled": "Erledigt"
3599
+ },
3595
3600
  "mode": {
3596
3601
  "answer": "Beantworten",
3597
3602
  "dismiss": "Verwerfen",
@@ -4145,6 +4145,11 @@
4145
4145
  "recommendationRequested": "Recommendation requested — review the suggestion below.",
4146
4146
  "recommendedDefault": "Recommended default — keep it, edit it, or dismiss the finding.",
4147
4147
  "needsYourInput": "Needs your input",
4148
+ "group": {
4149
+ "action": "Needs your reaction",
4150
+ "waiting": "Waiting on suggestions",
4151
+ "settled": "Handled"
4152
+ },
4148
4153
  "mode": {
4149
4154
  "answer": "Answer",
4150
4155
  "dismiss": "Dismiss",
@@ -3996,6 +3996,11 @@
3996
3996
  "recommendationRequested": "Recomendación solicitada — revisa la sugerencia más abajo.",
3997
3997
  "recommendedDefault": "Valor recomendado por defecto: consérvalo, edítalo o descarta el hallazgo.",
3998
3998
  "needsYourInput": "Necesita tu intervención",
3999
+ "group": {
4000
+ "action": "Necesita tu reacción",
4001
+ "waiting": "Esperando sugerencias",
4002
+ "settled": "Gestionados"
4003
+ },
3999
4004
  "mode": {
4000
4005
  "answer": "Responder",
4001
4006
  "dismiss": "Descartar",
@@ -3996,6 +3996,11 @@
3996
3996
  "recommendationRequested": "Recommandation demandée — consultez la suggestion ci-dessous.",
3997
3997
  "recommendedDefault": "Valeur recommandée par défaut : conservez-la, modifiez-la ou rejetez la constatation.",
3998
3998
  "needsYourInput": "Nécessite votre intervention",
3999
+ "group": {
4000
+ "action": "Nécessite votre réaction",
4001
+ "waiting": "En attente de suggestions",
4002
+ "settled": "Traitées"
4003
+ },
3999
4004
  "mode": {
4000
4005
  "answer": "Répondre",
4001
4006
  "dismiss": "Ignorer",
@@ -4007,6 +4007,11 @@
4007
4007
  "recommendationRequested": "התבקשה המלצה — סקור את ההצעה שלהלן.",
4008
4008
  "recommendedDefault": "ברירת מחדל מומלצת: השאר אותה, ערוך אותה או דחה את הממצא.",
4009
4009
  "needsYourInput": "דורש את הקלט שלך",
4010
+ "group": {
4011
+ "action": "דורש את תגובתך",
4012
+ "waiting": "ממתין להצעות",
4013
+ "settled": "טופלו"
4014
+ },
4010
4015
  "mode": {
4011
4016
  "answer": "ענה",
4012
4017
  "dismiss": "דחה",
@@ -3592,6 +3592,11 @@
3592
3592
  "recommendationRequested": "Raccomandazione richiesta — rivedi il suggerimento qui sotto.",
3593
3593
  "recommendedDefault": "Valore predefinito consigliato: mantienilo, modificalo o ignora il rilievo.",
3594
3594
  "needsYourInput": "Richiede il tuo intervento",
3595
+ "group": {
3596
+ "action": "Richiede la tua reazione",
3597
+ "waiting": "In attesa di suggerimenti",
3598
+ "settled": "Gestiti"
3599
+ },
3595
3600
  "mode": {
3596
3601
  "answer": "Rispondi",
3597
3602
  "dismiss": "Ignora",
@@ -4008,6 +4008,11 @@
4008
4008
  "recommendationRequested": "推奨をリクエストしました。下記の提案を確認してください。",
4009
4009
  "recommendedDefault": "推奨される既定値です。そのまま使うか、編集するか、指摘を破棄してください。",
4010
4010
  "needsYourInput": "あなたの入力が必要です",
4011
+ "group": {
4012
+ "action": "対応が必要",
4013
+ "waiting": "提案を待機中",
4014
+ "settled": "対応済み"
4015
+ },
4011
4016
  "mode": {
4012
4017
  "answer": "回答する",
4013
4018
  "dismiss": "破棄",
@@ -3996,6 +3996,11 @@
3996
3996
  "recommendationRequested": "Poproszono o rekomendację — sprawdź sugestię poniżej.",
3997
3997
  "recommendedDefault": "Zalecana wartość domyślna: zachowaj ją, edytuj lub odrzuć uwagę.",
3998
3998
  "needsYourInput": "Wymaga Twojej reakcji",
3999
+ "group": {
4000
+ "action": "Wymaga Twojej reakcji",
4001
+ "waiting": "Oczekują na sugestie",
4002
+ "settled": "Rozpatrzone"
4003
+ },
3999
4004
  "mode": {
4000
4005
  "answer": "Odpowiedz",
4001
4006
  "dismiss": "Odrzuć",
@@ -4008,6 +4008,11 @@
4008
4008
  "recommendationRequested": "Öneri istendi — aşağıdaki öneriyi inceleyin.",
4009
4009
  "recommendedDefault": "Önerilen varsayılan: koruyun, düzenleyin ya da bulguyu yok sayın.",
4010
4010
  "needsYourInput": "Sizin girdiniz gerekiyor",
4011
+ "group": {
4012
+ "action": "Tepkinizi bekliyor",
4013
+ "waiting": "Öneriler bekleniyor",
4014
+ "settled": "Ele alındı"
4015
+ },
4011
4016
  "mode": {
4012
4017
  "answer": "Yanıtla",
4013
4018
  "dismiss": "Yok say",
@@ -3996,6 +3996,11 @@
3996
3996
  "recommendationRequested": "Запитано рекомендацію — перегляньте пропозицію нижче.",
3997
3997
  "recommendedDefault": "Рекомендоване значення за замовчуванням: залиште його, відредагуйте або відхиліть зауваження.",
3998
3998
  "needsYourInput": "Потрібен ваш внесок",
3999
+ "group": {
4000
+ "action": "Потрібна ваша реакція",
4001
+ "waiting": "Очікують на пропозиції",
4002
+ "settled": "Опрацьовані"
4003
+ },
3999
4004
  "mode": {
4000
4005
  "answer": "Відповісти",
4001
4006
  "dismiss": "Відхилити",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.164.0",
3
+ "version": "0.165.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",