@cat-factory/app 0.163.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.
package/README.md CHANGED
@@ -77,7 +77,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
77
77
  structure, dependencies, model + fragment picker, live execution, and linked
78
78
  docs/issues/scenarios. Decisions resolve via `DecisionModal`.
79
79
  - **Pipeline builder** (`components/pipeline`) — assemble/edit agent chains and
80
- watch `PipelineProgress`.
80
+ watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
81
+ single way a pipeline is chosen anywhere — add-task, run settings, the recurring
82
+ schedule, the focus view's Run menu — so every surface explains a pipeline by the
83
+ ordered steps it will run rather than by its name alone.
81
84
  - **Integrations** — modals/panels for `github` (the source-control panel, shared
82
85
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
83
86
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
@@ -70,17 +70,7 @@ function defaultRecurrence(): Recurrence {
70
70
  const selectablePipelines = computed(() =>
71
71
  pipelines.pipelines.filter((p) => pipelineAllowedForSchedule(p, frame.value, board.blocks)),
72
72
  )
73
- const pipelineMenu = computed(() => [
74
- selectablePipelines.value.map((p) => ({
75
- label: p.name,
76
- icon: 'i-lucide-workflow',
77
- onSelect: () => (pipelineId.value = p.id),
78
- })),
79
- ])
80
73
  const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
81
- const selectedPipelineLabel = computed(
82
- () => selectedPipeline.value?.name ?? t('board.recurring.pickPipeline'),
83
- )
84
74
 
85
75
  // Infer the template from the picked pipeline so the backend seeds the right block
86
76
  // description (and so we know to show the tracker config).
@@ -269,19 +259,16 @@ async function add() {
269
259
  />
270
260
  </UFormField>
271
261
 
262
+ <!-- The rich picker, not a name-only menu: which steps a schedule will run unattended,
263
+ every time it fires, is exactly what the person setting it up needs to see. A schedule
264
+ must name a pipeline, so there is no "none" row. -->
272
265
  <UFormField :label="t('board.recurring.pipeline')" required>
273
- <UDropdownMenu :items="pipelineMenu" class="w-full">
274
- <UButton
275
- color="neutral"
276
- variant="subtle"
277
- size="sm"
278
- icon="i-lucide-workflow"
279
- trailing-icon="i-lucide-chevron-down"
280
- class="w-full justify-between"
281
- >
282
- {{ selectedPipelineLabel }}
283
- </UButton>
284
- </UDropdownMenu>
266
+ <PipelinePicker
267
+ v-model="pipelineId"
268
+ :options="selectablePipelines"
269
+ :placeholder="t('board.recurring.pickPipeline')"
270
+ trigger-class="w-full justify-between"
271
+ />
285
272
  </UFormField>
286
273
 
287
274
  <UFormField :label="t('board.recurring.prompt')">
@@ -31,17 +31,19 @@ const deps = computed(() =>
31
31
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
32
32
  // pipelines (a manual run of one is refused server-side), and — for a `document` task — every
33
33
  // non-document pipeline (per the `purpose` classifier) — see the backend gate.
34
- const runMenu = computed(() => {
34
+ const runOptions = computed(() => {
35
35
  const frame = block.value ? board.serviceOf(block.value) : undefined
36
- return pipelines.pipelines
37
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType))
38
- .map((p) => ({
39
- label: p.name,
40
- icon: 'i-lucide-play',
41
- onSelect: () => block.value && execution.start(block.value.id, p),
42
- }))
36
+ return pipelines.pipelines.filter((p) =>
37
+ pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType),
38
+ )
43
39
  })
44
40
 
41
+ /** Start the picked pipeline immediately — the Run menu chooses an ACTION, it stores no default. */
42
+ function runPipeline(id: string) {
43
+ const pipeline = pipelines.getPipeline(id)
44
+ if (pipeline && block.value) void execution.start(block.value.id, pipeline)
45
+ }
46
+
45
47
  function close() {
46
48
  ui.focus(null)
47
49
  }
@@ -97,17 +99,21 @@ function openApprovalFor(approvalId: string) {
97
99
  {{ statusMeta.label }}
98
100
  </UBadge>
99
101
  <div class="ms-auto flex items-center gap-2">
100
- <UDropdownMenu :items="runMenu">
101
- <UButton
102
- color="primary"
103
- variant="soft"
104
- size="sm"
105
- icon="i-lucide-play"
106
- trailing-icon="i-lucide-chevron-down"
107
- >
108
- {{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
109
- </UButton>
110
- </UDropdownMenu>
102
+ <!-- The rich picker rather than a list of names: the run starts the moment a row is
103
+ clicked, so the preview is the only chance to see which agents it will run. -->
104
+ <PipelinePicker model-value="" :options="runOptions" @update:model-value="runPipeline">
105
+ <template #trigger>
106
+ <UButton
107
+ color="primary"
108
+ variant="soft"
109
+ size="sm"
110
+ icon="i-lucide-play"
111
+ trailing-icon="i-lucide-chevron-down"
112
+ >
113
+ {{ instance ? t('focus.rerunPipeline') : t('focus.runPipeline') }}
114
+ </UButton>
115
+ </template>
116
+ </PipelinePicker>
111
117
  <IconButton
112
118
  icon="i-lucide-x"
113
119
  color="neutral"
@@ -1,25 +1,36 @@
1
1
  <script setup lang="ts">
2
- // The rich pipeline picker used wherever a pipeline is chosen (add-task modal, inspector run
3
- // settings). A master–detail popover: the left column lists the selectable pipelines (plus a
4
- // "none / choose at run time" row), and hovering a row reveals that pipeline's full preview — its
5
- // description + the ordered agent steps in the right column, so a user sees exactly what a
6
- // pipeline does before picking it. The trigger is customizable via the `#trigger` slot (the
7
- // inspector uses a bare icon button; the modal a full-width labelled one).
2
+ // The rich pipeline picker used wherever a pipeline is chosen — as a stored default (add-task
3
+ // modal, inspector run settings, the recurring-schedule modal) or as an immediate action (the
4
+ // focus view's Run menu). A master–detail popover: the left column lists the selectable pipelines
5
+ // (plus an optional "none / choose at run time" row), and hovering a row reveals that pipeline's
6
+ // full preview its description and every step it will run, in order in the right column, so a
7
+ // user sees exactly what a pipeline does before picking it. The trigger is customizable via the
8
+ // `#trigger` slot (the inspector uses a bare icon button; the modal a full-width labelled one).
8
9
  import { computed, ref } from 'vue'
9
10
  import type { Pipeline } from '~/types/domain'
10
11
 
11
12
  const props = withDefaults(
12
13
  defineProps<{
13
- /** Selected pipeline id, or '' for the "none" option. */
14
+ /** Selected pipeline id, or '' for none. An action-mode caller passes a constant ''. */
14
15
  modelValue: string
15
16
  /** The pipelines offered (already filtered for the surface, e.g. manual-start allowed). */
16
17
  options: Pipeline[]
17
- /** Label for the "none" row (e.g. "Choose at run time" / "No default"). */
18
- noneLabel: string
18
+ /**
19
+ * Label for the "none" row (e.g. "Choose at run time" / "No default"). Omit where picking
20
+ * nothing is not a valid outcome — a schedule needs a pipeline, and a Run menu acts on the
21
+ * chosen row — and the row is left out entirely.
22
+ */
23
+ noneLabel?: string
24
+ /**
25
+ * Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
26
+ * correctly when the "none" row IS the empty state; a surface without that row supplies its
27
+ * own prompt ("Pick a pipeline").
28
+ */
29
+ placeholder?: string
19
30
  /** Extra classes for the default trigger button (e.g. full-width in the modal). */
20
31
  triggerClass?: string
21
32
  }>(),
22
- { triggerClass: '' },
33
+ { noneLabel: undefined, placeholder: undefined, triggerClass: '' },
23
34
  )
24
35
 
25
36
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
@@ -31,7 +42,9 @@ const open = ref(false)
31
42
  const hoverId = ref<string | undefined>(undefined)
32
43
 
33
44
  const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
34
- const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
45
+ const triggerLabel = computed(
46
+ () => selected.value?.name ?? props.placeholder ?? props.noneLabel ?? '',
47
+ )
35
48
 
36
49
  /** The pipeline the right pane previews: the hovered row, else the current selection. */
37
50
  const previewPipeline = computed<Pipeline | null>(() => {
@@ -39,6 +52,17 @@ const previewPipeline = computed<Pipeline | null>(() => {
39
52
  return id ? (props.options.find((p) => p.id === id) ?? null) : null
40
53
  })
41
54
 
55
+ /**
56
+ * With no pipeline to preview the pane explains WHY it's empty, and the two reasons differ: the
57
+ * "none" row is a real choice worth describing, while a picker without one is simply waiting for
58
+ * a hover. Sharing one hint would tell a Run menu it has no default pipeline, which is nonsense.
59
+ */
60
+ const emptyHint = computed(() =>
61
+ props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
62
+ ? t('pipeline.picker.noneHint')
63
+ : t('pipeline.picker.hoverHint'),
64
+ )
65
+
42
66
  function choose(id: string) {
43
67
  emit('update:modelValue', id)
44
68
  open.value = false
@@ -63,13 +87,13 @@ function choose(id: string) {
63
87
 
64
88
  <template #content>
65
89
  <div
66
- class="flex max-h-[24rem] w-[min(44rem,94vw)]"
90
+ class="flex max-h-[28rem] w-[min(44rem,94vw)]"
67
91
  data-testid="pipeline-picker-panel"
68
92
  @mouseleave="hoverId = undefined"
69
93
  >
70
94
  <!-- left: selectable options -->
71
95
  <ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
72
- <li>
96
+ <li v-if="noneLabel !== undefined">
73
97
  <button
74
98
  type="button"
75
99
  class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
@@ -110,9 +134,7 @@ function choose(id: string) {
110
134
  <!-- right: preview of the hovered (or selected) pipeline -->
111
135
  <div class="w-1/2 overflow-y-auto p-3">
112
136
  <PipelinePreview v-if="previewPipeline" :pipeline="previewPipeline" />
113
- <div v-else class="text-[12px] leading-snug text-slate-500">
114
- {{ t('pipeline.picker.noneHint') }}
115
- </div>
137
+ <div v-else class="text-[12px] leading-snug text-slate-500">{{ emptyHint }}</div>
116
138
  </div>
117
139
  </div>
118
140
  </template>
@@ -1,17 +1,28 @@
1
1
  <script setup lang="ts">
2
2
  // A compact, read-only summary of a pipeline: its name, prose description (when authored), and the
3
- // ordered list of enabled agent steps rendered as icon+label chips (a human-gated step is flagged).
4
- // Shared by the pipeline pickers' hover preview so "what a pipeline consists of" is explained the
5
- // same way everywhere. Resolves each step's display metadata through the single `agentKindMeta`
6
- // path (via <AgentKindIcon>), so a system/custom kind can never blow up the renderer.
3
+ // ordered list of steps it will actually run each a numbered row carrying the agent's icon, label
4
+ // and catalog description, with the human approval gates flagged. The FULL list, not a step count:
5
+ // "what does this pipeline actually do" is the only question a picker's preview pane exists to
6
+ // answer, and a row of interchangeable chips answers just "how many". Shared by the pipeline
7
+ // pickers everywhere a pipeline is chosen, so the explanation is identical on every surface.
8
+ // Resolves each step's display metadata through the single `agentKindMeta` path (via
9
+ // <AgentKindIcon> and {@link stepDescription}), so a system/custom kind can never blow up the
10
+ // renderer.
7
11
  import { computed } from 'vue'
8
12
  import type { Pipeline } from '~/types/domain'
9
- import { pipelineDisplaySteps } from '~/utils/pipeline'
13
+ import { agentKindMeta } from '~/utils/catalog'
14
+ import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
10
15
 
11
16
  const props = defineProps<{ pipeline: Pipeline }>()
12
17
  const { t } = useI18n()
13
18
 
14
19
  const steps = computed(() => pipelineDisplaySteps(props.pipeline))
20
+ const gateCount = computed(() => pipelineGateCount(props.pipeline))
21
+
22
+ /** What the agent at this step does — the same catalog prose the palette and step tooltips use. */
23
+ function stepDescription(kind: string): string {
24
+ return agentKindMeta(kind).description
25
+ }
15
26
  </script>
16
27
 
17
28
  <template>
@@ -24,26 +35,57 @@ const steps = computed(() => pipelineDisplaySteps(props.pipeline))
24
35
  >
25
36
  {{ pipeline.description }}
26
37
  </p>
27
- <div>
28
- <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
38
+
39
+ <div
40
+ class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] uppercase tracking-wide text-slate-500"
41
+ >
42
+ <span class="inline-flex items-center gap-1">
29
43
  <UIcon name="i-lucide-workflow" class="h-3 w-3" />
30
44
  {{ t('pipeline.preview.stepCount', { count: steps.length }, steps.length) }}
31
- </div>
32
- <ol class="flex flex-wrap items-center gap-1">
33
- <li
34
- v-for="(s, i) in steps"
35
- :key="i"
36
- class="inline-flex items-center gap-1 rounded bg-slate-800/70 px-1.5 py-0.5"
37
- >
38
- <AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
39
- <UIcon
40
- v-if="s.gated"
41
- name="i-lucide-shield-check"
42
- class="h-3 w-3 text-amber-400"
43
- :title="t('pipeline.preview.gated')"
44
- />
45
- </li>
46
- </ol>
45
+ </span>
46
+ <!-- Gates are the reason a run stops for a human, so they earn a headline of their own
47
+ rather than only the per-step marker below. -->
48
+ <span v-if="gateCount" class="inline-flex items-center gap-1 text-amber-500">
49
+ <UIcon name="i-lucide-shield-check" class="h-3 w-3" />
50
+ {{ t('pipeline.preview.gateCount', { count: gateCount }, gateCount) }}
51
+ </span>
47
52
  </div>
53
+
54
+ <!-- The ordered steps. The number column doubles as the flow connector (a rule drawn between
55
+ consecutive numbers), so the list reads as a sequence rather than an unordered set. -->
56
+ <ol>
57
+ <li
58
+ v-for="(s, i) in steps"
59
+ :key="i"
60
+ class="flex gap-2"
61
+ data-testid="pipeline-preview-step"
62
+ :data-step-kind="s.kind"
63
+ >
64
+ <div class="flex flex-col items-center">
65
+ <span
66
+ class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-slate-800 font-mono text-[9px] tabular-nums text-slate-400"
67
+ >
68
+ {{ i + 1 }}
69
+ </span>
70
+ <span v-if="i < steps.length - 1" class="w-px flex-1 bg-slate-800" />
71
+ </div>
72
+ <div class="min-w-0 flex-1 pb-2">
73
+ <div class="flex items-center gap-1">
74
+ <AgentKindIcon :kind="s.kind" show-label icon-class="h-3.5 w-3.5" />
75
+ <UIcon
76
+ v-if="s.gated"
77
+ name="i-lucide-shield-check"
78
+ class="h-3 w-3 shrink-0 text-amber-400"
79
+ :title="t('pipeline.preview.gated')"
80
+ />
81
+ </div>
82
+ <!-- Clamped: the catalog prose runs long for some kinds, and <AgentKindIcon> already
83
+ carries the full text in its hover tooltip. -->
84
+ <p class="line-clamp-2 text-[11px] leading-snug text-slate-500">
85
+ {{ stepDescription(s.kind) }}
86
+ </p>
87
+ </div>
88
+ </li>
89
+ </ol>
48
90
  </div>
49
91
  </template>
@@ -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
+ }