@cat-factory/app 0.165.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.
@@ -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 { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
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
- <UDropdownMenu :items="presetMenu" class="w-full">
1150
- <UButton
1151
- color="neutral"
1152
- variant="subtle"
1153
- size="sm"
1154
- icon="i-lucide-git-merge"
1155
- trailing-icon="i-lucide-chevron-down"
1156
- class="w-full justify-between"
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
- const presetMenu = computed(() => [
71
- [
72
- {
73
- label: riskPolicies.defaultPreset
74
- ? t('inspector.runSettings.defaultPresetThresholds', {
75
- name: riskPolicies.defaultPreset.name,
76
- thresholds: riskPolicySummary(riskPolicies.defaultPreset),
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.defaultPreset', { name: modelPresets.defaultPreset.name })
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
- <UDropdownMenu :items="presetMenu">
312
- <UButton
313
- size="xs"
314
- variant="ghost"
315
- color="neutral"
316
- icon="i-lucide-git-merge"
317
- trailing-icon="i-lucide-chevron-down"
318
- />
319
- </UDropdownMenu>
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 keypath="inspector.runSettings.riskPolicyDetail" tag="span" scope="global">
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,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RiskPolicy } from '~/types/merge'
3
+ import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
4
+
5
+ // The resolver reads ids only, so the fixture carries just enough to be identifiable.
6
+ const policy = (id: string, name: string) => ({ id, name }) as unknown as RiskPolicy
7
+
8
+ const balanced = policy('mp_balanced', 'Balanced')
9
+ const strict = policy('mp_strict', 'Strict')
10
+ const options = [balanced, strict]
11
+
12
+ describe('resolveRiskPolicyPicker', () => {
13
+ it('previews the active row over the selection', () => {
14
+ const state = resolveRiskPolicyPicker({
15
+ options,
16
+ defaultPolicy: balanced,
17
+ modelValue: 'mp_balanced',
18
+ activeId: 'mp_strict',
19
+ })
20
+ expect(state).toEqual({ policy: strict, viaWorkspaceDefault: false })
21
+ })
22
+
23
+ it('falls back to the selection when no row is active', () => {
24
+ const state = resolveRiskPolicyPicker({
25
+ options,
26
+ defaultPolicy: balanced,
27
+ modelValue: 'mp_strict',
28
+ activeId: undefined,
29
+ })
30
+ expect(state).toEqual({ policy: strict, viaWorkspaceDefault: false })
31
+ })
32
+
33
+ it('previews the workspace default for the "pick nothing" row, captioned', () => {
34
+ const state = resolveRiskPolicyPicker({
35
+ options,
36
+ defaultPolicy: balanced,
37
+ modelValue: 'mp_strict',
38
+ activeId: '',
39
+ })
40
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
41
+ })
42
+
43
+ it('previews the workspace default when nothing is selected at all', () => {
44
+ const state = resolveRiskPolicyPicker({
45
+ options,
46
+ defaultPolicy: balanced,
47
+ modelValue: '',
48
+ activeId: undefined,
49
+ })
50
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
51
+ })
52
+
53
+ it('resolves a DELETED policy to the workspace default, as the run engine does', () => {
54
+ // A task can hold a riskPolicyId whose policy was since removed from the library. The
55
+ // store's resolve() hands that task the default, so the pane must not claim there is no
56
+ // policy — that would describe the run wrongly on the one surface explaining it.
57
+ const state = resolveRiskPolicyPicker({
58
+ options,
59
+ defaultPolicy: balanced,
60
+ modelValue: 'mp_deleted',
61
+ activeId: undefined,
62
+ })
63
+ expect(state).toEqual({ policy: balanced, viaWorkspaceDefault: true })
64
+ })
65
+
66
+ it('reports nothing to preview only when the workspace has no default at all', () => {
67
+ const state = resolveRiskPolicyPicker({
68
+ options: [],
69
+ defaultPolicy: null,
70
+ modelValue: '',
71
+ activeId: '',
72
+ })
73
+ expect(state).toEqual({ policy: null, viaWorkspaceDefault: false })
74
+ })
75
+ })
@@ -0,0 +1,47 @@
1
+ // Pure resolution behind <RiskPolicyPicker>'s detail pane.
2
+ //
3
+ // Kept out of the SFC because the interesting case is not the happy path: a task can hold a
4
+ // `riskPolicyId` naming a policy that has since been DELETED from the workspace library, and
5
+ // what the pane shows then has to agree with what the run engine would actually do. The
6
+ // riskPolicies store's `resolve()` falls back to the workspace default for both an empty id
7
+ // and a dangling one; this mirrors that, so the picker can never tell the user "no risk policy
8
+ // configured" about a task the default is quietly governing.
9
+ import type { RiskPolicy } from '~/types/merge'
10
+
11
+ export interface RiskPolicyPickerState {
12
+ /** The policy the detail pane renders, or `null` when the workspace has none at all. */
13
+ policy: RiskPolicy | null
14
+ /**
15
+ * True when `policy` is the workspace default standing in for a row that names no live
16
+ * policy of its own — the pane captions it, so the user knows why they are looking at a
17
+ * policy they did not point at.
18
+ */
19
+ viaWorkspaceDefault: boolean
20
+ }
21
+
22
+ export interface RiskPolicyPickerInput {
23
+ /** The policies offered (the workspace's library). */
24
+ options: readonly RiskPolicy[]
25
+ /** The workspace default, resolved by the caller. `null` when the workspace has none. */
26
+ defaultPolicy: RiskPolicy | null
27
+ /** The selected policy id, or `''` for the "workspace default" row. */
28
+ modelValue: string
29
+ /**
30
+ * The row the pointer or keyboard focus is on: an id, `''` for the "workspace default"
31
+ * row, or `undefined` when neither is on a row (fall back to the selection).
32
+ */
33
+ activeId: string | undefined
34
+ }
35
+
36
+ /**
37
+ * Resolve what the detail pane shows: the active row's policy, else the selected one, else
38
+ * the workspace default. An id that resolves to nothing — empty (the explicit "workspace
39
+ * default" row) or dangling (a deleted policy) — takes the default, since that is the policy
40
+ * the task is governed by either way.
41
+ */
42
+ export function resolveRiskPolicyPicker(input: RiskPolicyPickerInput): RiskPolicyPickerState {
43
+ const id = input.activeId ?? input.modelValue
44
+ const named = id ? input.options.find((p) => p.id === id) : undefined
45
+ if (named) return { policy: named, viaWorkspaceDefault: false }
46
+ return { policy: input.defaultPolicy, viaWorkspaceDefault: !!input.defaultPolicy }
47
+ }
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ // The rich risk-policy picker used wherever a task's merge policy is chosen (add-task modal,
3
+ // inspector run settings). A master-detail popover mirroring <PipelinePicker>: the left column
4
+ // lists the policies by NAME ONLY (plus a "workspace default" row), and hovering a row reveals
5
+ // that policy's ceilings and what they mean in the right column. Cramming the thresholds into
6
+ // the option line itself made every row an unreadable run of abbreviated percentages; the
7
+ // detail pane is where they belong. The trigger is customizable via the `#trigger` slot (the
8
+ // inspector uses a bare icon button; the modal a full-width labelled one).
9
+ //
10
+ // The detail pane follows FOCUS as well as the pointer. Moving the numbers off the option
11
+ // line would otherwise put them out of reach of anyone driving the list from the keyboard,
12
+ // who never fires a `mouseenter` — the thresholds are the whole point of the pane, so they
13
+ // cannot be pointer-only.
14
+ import { computed, ref } from 'vue'
15
+ import type { RiskPolicy } from '~/types/merge'
16
+ import RiskPolicyPreview from '~/components/riskPolicy/RiskPolicyPreview.vue'
17
+ import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** Selected policy id, or '' for the "workspace default" option. */
22
+ modelValue: string
23
+ /** The policies offered (the workspace's library). */
24
+ options: RiskPolicy[]
25
+ /**
26
+ * The workspace default a task picking nothing is governed by, resolved by the consumer
27
+ * (which already reads it off the store to build `noneLabel`) so the two can't disagree.
28
+ */
29
+ defaultPolicy: RiskPolicy | null
30
+ /** Label for the "workspace default" row (the consumer names the resolved default). */
31
+ noneLabel: string
32
+ /** Extra classes for the default trigger button (e.g. full-width in the modal). */
33
+ triggerClass?: string
34
+ }>(),
35
+ { triggerClass: '' },
36
+ )
37
+
38
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
39
+ const { t } = useI18n()
40
+
41
+ const open = ref(false)
42
+ // The row the pointer or the keyboard is currently on, driving the right-column preview.
43
+ // `undefined` ⇒ fall back to the selected policy; the sentinel '' means the "workspace
44
+ // default" row is active.
45
+ const activeId = ref<string | undefined>(undefined)
46
+
47
+ const selected = computed(() => props.options.find((p) => p.id === props.modelValue))
48
+ const triggerLabel = computed(() => selected.value?.name ?? props.noneLabel)
49
+
50
+ const preview = computed(() =>
51
+ resolveRiskPolicyPicker({
52
+ options: props.options,
53
+ defaultPolicy: props.defaultPolicy,
54
+ modelValue: props.modelValue,
55
+ activeId: activeId.value,
56
+ }),
57
+ )
58
+
59
+ /**
60
+ * Tabbing BETWEEN two rows fires `focusout` on the one being left, so only focus leaving the
61
+ * panel altogether may drop the preview back to the selection.
62
+ */
63
+ function onPanelFocusOut(event: FocusEvent) {
64
+ const panel = event.currentTarget
65
+ const next = event.relatedTarget
66
+ if (panel instanceof Node && next instanceof Node && panel.contains(next)) return
67
+ activeId.value = undefined
68
+ }
69
+
70
+ function choose(id: string) {
71
+ emit('update:modelValue', id)
72
+ open.value = false
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <UPopover v-model:open="open" :content="{ align: 'start' }">
78
+ <slot name="trigger" :label="triggerLabel">
79
+ <UButton
80
+ color="neutral"
81
+ variant="subtle"
82
+ size="sm"
83
+ icon="i-lucide-git-merge"
84
+ trailing-icon="i-lucide-chevron-down"
85
+ :class="triggerClass"
86
+ data-testid="risk-policy-picker-trigger"
87
+ >
88
+ {{ triggerLabel }}
89
+ </UButton>
90
+ </slot>
91
+
92
+ <template #content>
93
+ <div
94
+ class="flex max-h-[24rem] w-[min(40rem,94vw)]"
95
+ data-testid="risk-policy-picker-panel"
96
+ @mouseleave="activeId = undefined"
97
+ @focusout="onPanelFocusOut"
98
+ >
99
+ <!-- left: selectable options, NAME ONLY -->
100
+ <ul class="w-1/2 shrink-0 overflow-y-auto border-e border-slate-800 p-1">
101
+ <li>
102
+ <button
103
+ type="button"
104
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
105
+ :class="modelValue ? 'text-slate-300' : 'text-slate-100'"
106
+ data-testid="risk-policy-option-none"
107
+ @mouseenter="activeId = ''"
108
+ @focus="activeId = ''"
109
+ @click="choose('')"
110
+ >
111
+ <UIcon name="i-lucide-rotate-ccw" class="h-4 w-4 shrink-0 text-slate-400" />
112
+ <span class="flex-1 truncate">{{ noneLabel }}</span>
113
+ <UIcon
114
+ v-if="!modelValue"
115
+ name="i-lucide-check"
116
+ class="h-4 w-4 shrink-0 text-primary-400"
117
+ />
118
+ </button>
119
+ </li>
120
+ <li v-for="p in options" :key="p.id">
121
+ <button
122
+ type="button"
123
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
124
+ :class="modelValue === p.id ? 'text-slate-100' : 'text-slate-300'"
125
+ :data-testid="`risk-policy-option-${p.id}`"
126
+ @mouseenter="activeId = p.id"
127
+ @focus="activeId = p.id"
128
+ @click="choose(p.id)"
129
+ >
130
+ <UIcon name="i-lucide-git-merge" class="h-4 w-4 shrink-0 text-slate-400" />
131
+ <span class="flex-1 truncate">{{ p.name }}</span>
132
+ <UIcon
133
+ v-if="modelValue === p.id"
134
+ name="i-lucide-check"
135
+ class="h-4 w-4 shrink-0 text-primary-400"
136
+ />
137
+ </button>
138
+ </li>
139
+ </ul>
140
+
141
+ <!-- right: what the active (or selected) policy actually does -->
142
+ <div class="w-1/2 overflow-y-auto p-3">
143
+ <template v-if="preview.policy">
144
+ <p
145
+ v-if="preview.viaWorkspaceDefault"
146
+ class="mb-2 text-[11px] leading-snug text-slate-500"
147
+ >
148
+ {{ t('riskPolicy.picker.workspaceDefaultCaption') }}
149
+ </p>
150
+ <RiskPolicyPreview :policy="preview.policy" />
151
+ </template>
152
+ <div v-else class="text-[12px] leading-snug text-slate-500">
153
+ {{ t('riskPolicy.picker.noneHint') }}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </template>
158
+ </UPopover>
159
+ </template>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // A compact, read-only summary of a risk policy: the three auto-merge ceilings grouped
3
+ // together, plus prose saying what clearing them actually DOES (the pull request merges
4
+ // without waiting for a human review). Shared by the risk-policy picker's hover preview so
5
+ // "what this policy means" is explained the same way everywhere — the <PipelinePreview>
6
+ // counterpart for merge policy.
7
+ //
8
+ // A policy with auto-merge off has no thresholds to explain (the master switch wins before
9
+ // any score is compared), so it says so instead of listing ceilings that never apply.
10
+ import { computed } from 'vue'
11
+ import type { RiskPolicy } from '~/types/merge'
12
+ import { riskPolicyCeilings, type RiskPolicyAxis } from '~/utils/riskPolicy'
13
+
14
+ const props = defineProps<{ policy: RiskPolicy }>()
15
+ const { t, n } = useI18n()
16
+
17
+ // Exhaustive over the axis union with LITERAL keys, so both i18n drift guards apply: the
18
+ // typed-key check catches a renamed key, and the Record catches a new axis.
19
+ const AXIS_LABEL: Record<RiskPolicyAxis, () => string> = {
20
+ risk: () => t('riskPolicy.preview.axis.risk'),
21
+ impact: () => t('riskPolicy.preview.axis.impact'),
22
+ complexity: () => t('riskPolicy.preview.axis.complexity'),
23
+ }
24
+
25
+ const ceilings = computed(() =>
26
+ riskPolicyCeilings(props.policy).map((c) => ({ ...c, label: AXIS_LABEL[c.axis]() })),
27
+ )
28
+ </script>
29
+
30
+ <template>
31
+ <div class="space-y-3" data-testid="risk-policy-preview">
32
+ <div class="flex items-center gap-1.5">
33
+ <span class="text-sm font-semibold text-slate-100">{{ policy.name }}</span>
34
+ <UBadge v-if="policy.isDefault" size="sm" variant="soft" color="neutral">
35
+ {{ t('riskPolicy.preview.defaultBadge') }}
36
+ </UBadge>
37
+ </div>
38
+
39
+ <!-- the three ceilings, grouped under one heading that names what they are -->
40
+ <div v-if="policy.autoMergeEnabled">
41
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
42
+ <UIcon name="i-lucide-git-merge" class="h-3 w-3" />
43
+ {{ t('riskPolicy.preview.autoMergeHeading') }}
44
+ </div>
45
+ <dl class="space-y-1">
46
+ <div
47
+ v-for="c in ceilings"
48
+ :key="c.axis"
49
+ class="flex items-baseline justify-between gap-3 rounded bg-slate-800/70 px-1.5 py-0.5 text-[12px]"
50
+ >
51
+ <dt class="text-slate-300">{{ c.label }}</dt>
52
+ <dd class="tabular-nums text-slate-100">
53
+ {{ t('riskPolicy.preview.ceiling', { value: n(c.max, { key: 'percent' }) }) }}
54
+ </dd>
55
+ </div>
56
+ </dl>
57
+ <p class="mt-1.5 text-[12px] leading-snug text-slate-400">
58
+ {{ t('riskPolicy.preview.autoMergeExplainer') }}
59
+ </p>
60
+ </div>
61
+ <div v-else>
62
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
63
+ <UIcon name="i-lucide-user-check" class="h-3 w-3" />
64
+ {{ t('riskPolicy.preview.manualHeading') }}
65
+ </div>
66
+ <p class="text-[12px] leading-snug text-slate-400">
67
+ {{ t('riskPolicy.preview.manualExplainer') }}
68
+ </p>
69
+ </div>
70
+
71
+ <div>
72
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
73
+ <UIcon name="i-lucide-wrench" class="h-3 w-3" />
74
+ {{ t('riskPolicy.preview.ciHeading') }}
75
+ </div>
76
+ <p class="text-[12px] leading-snug text-slate-400">
77
+ {{
78
+ t('riskPolicy.preview.ciAttempts', { count: policy.ciMaxAttempts }, policy.ciMaxAttempts)
79
+ }}
80
+ </p>
81
+ </div>
82
+ </div>
83
+ </template>
@@ -7,9 +7,40 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { MergeClassRules, RiskPolicy, RequirementConcernLevel } from '~/types/merge'
9
9
  import type { StepGating } from '@cat-factory/contracts'
10
+ import {
11
+ RISK_POLICY_AXES,
12
+ RISK_POLICY_CEILING_FIELD,
13
+ type RiskPolicyAxis,
14
+ } from '~/utils/riskPolicy'
10
15
 
11
16
  const { t } = useI18n()
12
17
 
18
+ // Both axis groups below iterate the SHARED presentation order rather than hard-coding one,
19
+ // so this editor can't drift from the order the picker's preview and the inspector's summary
20
+ // line use (it used to read complexity-first while they read risk-first).
21
+ const CEILING_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
22
+ risk: 'settings.riskPolicy.field.maxRisk',
23
+ impact: 'settings.riskPolicy.field.maxImpact',
24
+ complexity: 'settings.riskPolicy.field.maxComplexity',
25
+ }
26
+
27
+ // The fork-decision group is the same three axes read as FLOORS (how big an estimate has to
28
+ // be before the coder stops to propose implementations), so it shares the order but not the
29
+ // fields.
30
+ const FORK_FLOOR_FIELD: Record<
31
+ RiskPolicyAxis,
32
+ 'forkMinRisk' | 'forkMinImpact' | 'forkMinComplexity'
33
+ > = {
34
+ risk: 'forkMinRisk',
35
+ impact: 'forkMinImpact',
36
+ complexity: 'forkMinComplexity',
37
+ }
38
+ const FORK_FLOOR_LABEL_KEYS: Record<RiskPolicyAxis, string> = {
39
+ risk: 'settings.riskPolicy.forkDecision.minRisk',
40
+ impact: 'settings.riskPolicy.forkDecision.minImpact',
41
+ complexity: 'settings.riskPolicy.forkDecision.minComplexity',
42
+ }
43
+
13
44
  // Per-concern-level label. An exhaustive Record keyed off the union (a missing member fails
14
45
  // the typecheck); each value is a LITERAL catalog key so the typed-message-keys check sees
15
46
  // it. Leaf keys mirror the enum value verbatim.
@@ -276,36 +307,12 @@ async function create() {
276
307
  </div>
277
308
 
278
309
  <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
279
- <label class="block">
280
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
281
- {{ t('settings.riskPolicy.field.maxComplexity') }}
282
- </span>
283
- <UInput
284
- v-model.number="drafts[p.id]!.maxComplexity"
285
- type="number"
286
- :min="0"
287
- :max="100"
288
- size="sm"
289
- />
290
- </label>
291
- <label class="block">
310
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
292
311
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
293
- {{ t('settings.riskPolicy.field.maxRisk') }}
312
+ {{ t(CEILING_LABEL_KEYS[axis]) }}
294
313
  </span>
295
314
  <UInput
296
- v-model.number="drafts[p.id]!.maxRisk"
297
- type="number"
298
- :min="0"
299
- :max="100"
300
- size="sm"
301
- />
302
- </label>
303
- <label class="block">
304
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
305
- {{ t('settings.riskPolicy.field.maxImpact') }}
306
- </span>
307
- <UInput
308
- v-model.number="drafts[p.id]!.maxImpact"
315
+ v-model.number="drafts[p.id]![RISK_POLICY_CEILING_FIELD[axis]]"
309
316
  type="number"
310
317
  :min="0"
311
318
  :max="100"
@@ -369,36 +376,12 @@ async function create() {
369
376
  :description="t('settings.riskPolicy.forkDecision.hint')"
370
377
  />
371
378
  <div v-if="drafts[p.id]!.forkEnabled" class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
372
- <label class="block">
373
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
374
- {{ t('settings.riskPolicy.forkDecision.minComplexity') }}
375
- </span>
376
- <UInput
377
- v-model.number="drafts[p.id]!.forkMinComplexity"
378
- type="number"
379
- size="sm"
380
- :min="0"
381
- :max="100"
382
- />
383
- </label>
384
- <label class="block">
385
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
386
- {{ t('settings.riskPolicy.forkDecision.minRisk') }}
387
- </span>
388
- <UInput
389
- v-model.number="drafts[p.id]!.forkMinRisk"
390
- type="number"
391
- size="sm"
392
- :min="0"
393
- :max="100"
394
- />
395
- </label>
396
- <label class="block">
379
+ <label v-for="axis in RISK_POLICY_AXES" :key="axis" class="block">
397
380
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
398
- {{ t('settings.riskPolicy.forkDecision.minImpact') }}
381
+ {{ t(FORK_FLOOR_LABEL_KEYS[axis]) }}
399
382
  </span>
400
383
  <UInput
401
- v-model.number="drafts[p.id]!.forkMinImpact"
384
+ v-model.number="drafts[p.id]![FORK_FLOOR_FIELD[axis]]"
402
385
  type="number"
403
386
  size="sm"
404
387
  :min="0"