@cat-factory/app 0.164.0 → 0.166.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/app/components/board/AddTaskModal.vue +12 -36
- package/app/components/panels/inspector/TaskRunSettings.vue +40 -32
- package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +145 -0
- package/app/components/requirements/RequirementsReviewWindow.logic.ts +105 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +303 -215
- package/app/components/riskPolicy/RiskPolicyPicker.logic.spec.ts +75 -0
- package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +47 -0
- package/app/components/riskPolicy/RiskPolicyPicker.vue +159 -0
- package/app/components/riskPolicy/RiskPolicyPreview.vue +83 -0
- package/app/components/settings/RiskPolicyPanel.vue +37 -54
- package/app/utils/riskPolicy.spec.ts +53 -0
- package/app/utils/riskPolicy.ts +35 -14
- package/i18n/locales/de.json +29 -4
- package/i18n/locales/en.json +29 -4
- package/i18n/locales/es.json +29 -4
- package/i18n/locales/fr.json +29 -4
- package/i18n/locales/he.json +29 -4
- package/i18n/locales/it.json +29 -4
- package/i18n/locales/ja.json +29 -4
- package/i18n/locales/pl.json +29 -4
- package/i18n/locales/tr.json +29 -4
- package/i18n/locales/uk.json +29 -4
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
|
27
27
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
28
28
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
29
29
|
import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
|
|
30
|
-
import
|
|
30
|
+
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
31
31
|
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
32
32
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
33
33
|
|
|
@@ -334,33 +334,13 @@ const riskPolicyId = ref('')
|
|
|
334
334
|
const modelPresetId = ref('')
|
|
335
335
|
const pipelineId = ref('')
|
|
336
336
|
|
|
337
|
+
// The "pick nothing" row names the default policy it resolves to; the picker's detail pane
|
|
338
|
+
// explains what that policy does, so the row itself stays a bare name.
|
|
337
339
|
const defaultPresetLabel = computed(() =>
|
|
338
340
|
riskPolicies.defaultPreset
|
|
339
|
-
? t('board.addTask.defaultPreset', {
|
|
340
|
-
name: riskPolicies.defaultPreset.name,
|
|
341
|
-
thresholds: riskPolicySummary(riskPolicies.defaultPreset),
|
|
342
|
-
})
|
|
341
|
+
? t('board.addTask.defaultPreset', { name: riskPolicies.defaultPreset.name })
|
|
343
342
|
: t('board.addTask.workspaceDefault'),
|
|
344
343
|
)
|
|
345
|
-
const presetMenu = computed(() => [
|
|
346
|
-
[
|
|
347
|
-
{
|
|
348
|
-
label: defaultPresetLabel.value,
|
|
349
|
-
icon: 'i-lucide-rotate-ccw',
|
|
350
|
-
onSelect: () => (riskPolicyId.value = ''),
|
|
351
|
-
},
|
|
352
|
-
...riskPolicies.presets.map((p) => ({
|
|
353
|
-
label: riskPolicyOptionLabel(p),
|
|
354
|
-
icon: 'i-lucide-git-merge',
|
|
355
|
-
onSelect: () => (riskPolicyId.value = p.id),
|
|
356
|
-
})),
|
|
357
|
-
],
|
|
358
|
-
])
|
|
359
|
-
const selectedPresetLabel = computed(() => {
|
|
360
|
-
if (!riskPolicyId.value) return defaultPresetLabel.value
|
|
361
|
-
const picked = riskPolicies.presets.find((p) => p.id === riskPolicyId.value)
|
|
362
|
-
return picked ? riskPolicyOptionLabel(picked) : t('board.addTask.workspaceDefault')
|
|
363
|
-
})
|
|
364
344
|
|
|
365
345
|
// Model preset: which model each agent runs on. Empty = workspace default preset.
|
|
366
346
|
const defaultModelPresetLabel = computed(() =>
|
|
@@ -1146,18 +1126,14 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1146
1126
|
|
|
1147
1127
|
<!-- A review task merges nothing, so its risk (merge) policy is meaningless — omit it. -->
|
|
1148
1128
|
<UFormField v-if="!isReview" :label="t('board.addTask.mergePolicy')">
|
|
1149
|
-
<
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
>
|
|
1158
|
-
{{ selectedPresetLabel }}
|
|
1159
|
-
</UButton>
|
|
1160
|
-
</UDropdownMenu>
|
|
1129
|
+
<RiskPolicyPicker
|
|
1130
|
+
:model-value="riskPolicyId"
|
|
1131
|
+
:options="riskPolicies.presets"
|
|
1132
|
+
:default-policy="riskPolicies.defaultPreset"
|
|
1133
|
+
:none-label="defaultPresetLabel"
|
|
1134
|
+
trigger-class="w-full justify-between"
|
|
1135
|
+
@update:model-value="riskPolicyId = $event"
|
|
1136
|
+
/>
|
|
1161
1137
|
</UFormField>
|
|
1162
1138
|
|
|
1163
1139
|
<UFormField :label="t('board.addTask.modelPreset')">
|
|
@@ -3,9 +3,9 @@ import { computed, onMounted } from 'vue'
|
|
|
3
3
|
import { connectionNeighborIds } from '@cat-factory/contracts'
|
|
4
4
|
import type { Block } from '~/types/domain'
|
|
5
5
|
import type { WritebackOverride } from '~/types/tracker'
|
|
6
|
-
import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
|
|
7
6
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
8
7
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
8
|
+
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
9
9
|
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
10
10
|
|
|
11
11
|
const props = defineProps<{ block: Block }>()
|
|
@@ -67,25 +67,13 @@ function setAutoStartDependents(value: boolean) {
|
|
|
67
67
|
// budget. None selected → the workspace default preset. (The old confidence-based
|
|
68
68
|
// auto-merge threshold is gone; the `merger` step gates on this policy instead.)
|
|
69
69
|
const selectedPreset = computed(() => riskPolicies.resolve(props.block.riskPolicyId))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
: t('inspector.runSettings.workspaceDefault'),
|
|
79
|
-
icon: 'i-lucide-rotate-ccw',
|
|
80
|
-
onSelect: () => setPreset(''),
|
|
81
|
-
},
|
|
82
|
-
...riskPolicies.presets.map((p) => ({
|
|
83
|
-
label: riskPolicyOptionLabel(p),
|
|
84
|
-
icon: 'i-lucide-git-merge',
|
|
85
|
-
onSelect: () => setPreset(p.id),
|
|
86
|
-
})),
|
|
87
|
-
],
|
|
88
|
-
])
|
|
70
|
+
// The "pick nothing" row names the default policy it resolves to; the picker's detail pane
|
|
71
|
+
// explains what that policy does, so the row itself stays a bare name.
|
|
72
|
+
const defaultPresetLabel = computed(() =>
|
|
73
|
+
riskPolicies.defaultPreset
|
|
74
|
+
? t('inspector.runSettings.defaultRiskPolicy', { name: riskPolicies.defaultPreset.name })
|
|
75
|
+
: t('inspector.runSettings.workspaceDefault'),
|
|
76
|
+
)
|
|
89
77
|
function setPreset(id: string) {
|
|
90
78
|
board.updateBlock(props.block.id, { riskPolicyId: id })
|
|
91
79
|
}
|
|
@@ -111,7 +99,7 @@ const modelPresetMenu = computed(() => [
|
|
|
111
99
|
[
|
|
112
100
|
{
|
|
113
101
|
label: modelPresets.defaultPreset
|
|
114
|
-
? t('inspector.runSettings.
|
|
102
|
+
? t('inspector.runSettings.defaultModelPreset', { name: modelPresets.defaultPreset.name })
|
|
115
103
|
: t('inspector.runSettings.workspaceDefault'),
|
|
116
104
|
icon: 'i-lucide-rotate-ccw',
|
|
117
105
|
onSelect: () => setModelPreset(''),
|
|
@@ -308,24 +296,44 @@ const technicalLabel = computed(() => {
|
|
|
308
296
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
309
297
|
{{ t('inspector.runSettings.mergePolicy') }}
|
|
310
298
|
</span>
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
299
|
+
<RiskPolicyPicker
|
|
300
|
+
:model-value="block.riskPolicyId ?? ''"
|
|
301
|
+
:options="riskPolicies.presets"
|
|
302
|
+
:default-policy="riskPolicies.defaultPreset"
|
|
303
|
+
:none-label="defaultPresetLabel"
|
|
304
|
+
@update:model-value="setPreset"
|
|
305
|
+
>
|
|
306
|
+
<template #trigger>
|
|
307
|
+
<UButton
|
|
308
|
+
size="xs"
|
|
309
|
+
variant="ghost"
|
|
310
|
+
color="neutral"
|
|
311
|
+
icon="i-lucide-git-merge"
|
|
312
|
+
trailing-icon="i-lucide-chevron-down"
|
|
313
|
+
/>
|
|
314
|
+
</template>
|
|
315
|
+
</RiskPolicyPicker>
|
|
320
316
|
</div>
|
|
321
317
|
<div v-if="selectedPreset" class="text-[11px] text-slate-400">
|
|
322
|
-
<i18n-t
|
|
318
|
+
<i18n-t
|
|
319
|
+
v-if="selectedPreset.autoMergeEnabled"
|
|
320
|
+
keypath="inspector.runSettings.riskPolicyDetail"
|
|
321
|
+
tag="span"
|
|
322
|
+
scope="global"
|
|
323
|
+
>
|
|
323
324
|
<template #name>
|
|
324
325
|
<span class="text-slate-300">{{ selectedPreset.name }}</span>
|
|
325
326
|
</template>
|
|
326
|
-
<template #complexity>{{ n(selectedPreset.maxComplexity, { key: 'percent' }) }}</template>
|
|
327
327
|
<template #risk>{{ n(selectedPreset.maxRisk, { key: 'percent' }) }}</template>
|
|
328
328
|
<template #impact>{{ n(selectedPreset.maxImpact, { key: 'percent' }) }}</template>
|
|
329
|
+
<template #complexity>{{ n(selectedPreset.maxComplexity, { key: 'percent' }) }}</template>
|
|
330
|
+
<template #attempts>{{ selectedPreset.ciMaxAttempts }}</template>
|
|
331
|
+
</i18n-t>
|
|
332
|
+
<!-- Auto-merge off: the ceilings never apply, so quoting them would misdescribe it. -->
|
|
333
|
+
<i18n-t v-else keypath="inspector.runSettings.riskPolicyManual" tag="span" scope="global">
|
|
334
|
+
<template #name>
|
|
335
|
+
<span class="text-slate-300">{{ selectedPreset.name }}</span>
|
|
336
|
+
</template>
|
|
329
337
|
<template #attempts>{{ selectedPreset.ciMaxAttempts }}</template>
|
|
330
338
|
</i18n-t>
|
|
331
339
|
<span v-if="!block.riskPolicyId" class="text-slate-500">{{
|
|
@@ -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
|
+
}
|