@cat-factory/app 0.195.0 → 0.195.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/app/components/board/nodes/BlockNode.vue +23 -4
  2. package/app/components/initiative/InitiativePlanDecision.vue +134 -0
  3. package/app/components/initiative/InitiativePlanNotice.vue +69 -0
  4. package/app/components/initiative/InitiativePlanReview.vue +278 -254
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +72 -25
  6. package/app/components/judge/JudgeResultView.vue +5 -1
  7. package/app/components/merge/MergeEffortChips.vue +5 -1
  8. package/app/components/panels/ReportsPanel.vue +5 -2
  9. package/app/components/panels/ReportsSpendBreakdown.vue +5 -1
  10. package/app/components/panels/StepEffortReport.vue +11 -2
  11. package/app/components/panels/StepFragmentAdherence.vue +7 -2
  12. package/app/components/panels/StepMetadataCard.vue +4 -1
  13. package/app/components/pipeline/EstimateThresholdFields.vue +74 -0
  14. package/app/components/pipeline/OutputBudgetInput.vue +1 -0
  15. package/app/components/pipeline/PipelineBuilder.vue +48 -96
  16. package/app/components/settings/ConsensusGroupsSection.vue +12 -3
  17. package/app/utils/estimateGating.spec.ts +44 -0
  18. package/app/utils/estimateGating.ts +60 -0
  19. package/app/utils/initiative.spec.ts +43 -0
  20. package/app/utils/initiative.ts +23 -0
  21. package/i18n/locales/de.json +28 -4
  22. package/i18n/locales/en.json +28 -4
  23. package/i18n/locales/es.json +28 -4
  24. package/i18n/locales/fr.json +28 -4
  25. package/i18n/locales/he.json +28 -4
  26. package/i18n/locales/it.json +28 -4
  27. package/i18n/locales/ja.json +28 -4
  28. package/i18n/locales/pl.json +28 -4
  29. package/i18n/locales/tr.json +28 -4
  30. package/i18n/locales/uk.json +28 -4
  31. package/package.json +1 -1
@@ -23,10 +23,12 @@ import {
23
23
  INITIATIVE_STATUS_LABEL_KEYS,
24
24
  initiativeProgress,
25
25
  pendingCheckpointPhase,
26
+ planReviewDocument,
26
27
  } from '~/utils/initiative'
27
28
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
28
29
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
29
30
  import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
31
+ import InitiativePlanNotice from '~/components/initiative/InitiativePlanNotice.vue'
30
32
 
31
33
  const board = useBoardStore()
32
34
  const initiatives = useInitiativesStore()
@@ -61,6 +63,24 @@ const {
61
63
  stepIndex: () => stepIndex.value,
62
64
  })
63
65
 
66
+ /**
67
+ * The `StepRunMeta` prop bundle, or null when no step speaks for this window. Bound as one object
68
+ * because it has two homes — the tracker's end-side column, and the plan review's sidebar while the
69
+ * review owns the window — and the run details must read identically in both.
70
+ */
71
+ const runMeta = computed(() =>
72
+ metaStep.value
73
+ ? {
74
+ step: metaStep.value,
75
+ instanceId: runId.value,
76
+ stepNumber: position.value,
77
+ totalSteps: totalSteps.value,
78
+ runFailed: runFailed.value,
79
+ failureAt: failureAt.value,
80
+ }
81
+ : null,
82
+ )
83
+
64
84
  const phases = computed(() => initiative.value?.phases ?? [])
65
85
  function itemsOf(phaseId: string): InitiativeItem[] {
66
86
  return (initiative.value?.items ?? []).filter((i) => i.phaseId === phaseId)
@@ -103,9 +123,19 @@ async function checkpointControl(action: 'resume' | 'cancel') {
103
123
  // ---- Plan review: the planner step's human gate, resolved right here -----------------------
104
124
  // Derived from the BLOCK (via the shared planning composable), not from this window's own
105
125
  // `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
106
- // human parked on the gate actually uses. So the rail appears on every route into the window.
126
+ // human parked on the gate actually uses. So the review appears on every route into the window.
107
127
  const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
128
 
129
+ /**
130
+ * The plan document the parked gate offers, or `''` — which is also the layout decision. A rendered
131
+ * plan is a REPLACEMENT for the tracker body rather than a card above it: the render reads the
132
+ * ingested entity, so the sections below would be a second copy of what the reviewer is reading,
133
+ * and everything the tracker adds on top (PR links, item curation, checkpoints, follow-ups) is
134
+ * execution-time state that cannot exist until the plan is committed. With no document, the tracker
135
+ * body IS the plan, so the gate takes the compact notice above it instead.
136
+ */
137
+ const planDocument = computed(() => planReviewDocument(planApproval.value))
138
+
109
139
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
110
140
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
111
141
  const axes = [
@@ -236,30 +266,54 @@ async function savePolicy() {
236
266
  </UBadge>
237
267
  </template>
238
268
 
239
- <div class="flex min-h-0 flex-1">
269
+ <!-- The planner's human gate, with the plan rendered as a document. This window is where the
270
+ park ROUTES (the planner's archetype declares this result view), so it is the only surface
271
+ that can resolve it — and while it is parked the review OWNS the window: an outline sidebar,
272
+ the plan at full height, per-block commenting and the commands in an end-side rail, the same
273
+ tools and the same shape the step reader gives the architect's prose. The tracker body it
274
+ replaces would only repeat the plan (see `planDocument`). -->
275
+ <InitiativePlanReview
276
+ v-if="planApproval && planDocument"
277
+ :approval="planApproval.approval"
278
+ :instance-id="planApproval.instanceId"
279
+ :can-execute="access.canExecuteRuns.value"
280
+ :plan-document="planDocument"
281
+ >
282
+ <template v-if="runMeta" #run-details>
283
+ <StepRunMeta v-bind="runMeta" />
284
+ </template>
285
+ </InitiativePlanReview>
286
+
287
+ <div v-else class="flex min-h-0 flex-1">
240
288
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
241
- <!-- No entity yet (module unwired / still creating) -->
289
+ <!-- A parked gate whose step rendered no plan: the commands, plus a notice pointing at the
290
+ sections below — which in that case are the only rendering of the plan there is.
291
+ Deliberately OUTSIDE the entity branch: the gate lives on the RUN, so it is parked
292
+ before `initiatives.load()` has resolved (and stays parked if it fails), and a window
293
+ that answered such a gate with the empty state alone would leave it unresolvable from
294
+ the UI. `hasSections` is what keeps the notice honest about whether the sections it
295
+ points at are actually rendered underneath. -->
296
+ <InitiativePlanNotice
297
+ v-if="planApproval"
298
+ :approval="planApproval.approval"
299
+ :instance-id="planApproval.instanceId"
300
+ :can-execute="access.canExecuteRuns.value"
301
+ :has-sections="!!initiative"
302
+ />
303
+
304
+ <!-- No entity yet (module unwired / still creating). Centred in the column when it is the
305
+ only thing in it; merely inset when the notice above it means `h-full` would overflow
306
+ the scroller by the notice's own height. -->
242
307
  <div
243
308
  v-if="!initiative"
244
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
309
+ class="flex flex-col items-center justify-center gap-2 text-center text-slate-400"
310
+ :class="planApproval ? 'py-16' : 'h-full'"
245
311
  >
246
312
  <UIcon name="i-lucide-milestone" class="h-8 w-8 opacity-40" />
247
313
  <p class="text-sm">{{ t('initiative.tracker.empty') }}</p>
248
314
  </div>
249
315
 
250
316
  <template v-else>
251
- <!-- The planner's human gate. This window is where the park ROUTES (the planner's
252
- archetype declares this result view), so it is the only surface that can resolve
253
- it — and the plan it judges is rendered as a navigable document with per-block
254
- commenting, the same tools the step reader gives the architect's prose. -->
255
- <InitiativePlanReview
256
- v-if="planApproval"
257
- :approval="planApproval.approval"
258
- :instance-id="planApproval.instanceId"
259
- :can-execute="access.canExecuteRuns.value"
260
- :output-is-rendered="planApproval.outputIsRendered"
261
- />
262
-
263
317
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
264
318
  review before the next phase spawns. Read the phase's artifacts/PRs below,
265
319
  then resume (continue) or cancel (stop) the initiative right here. -->
@@ -646,18 +700,11 @@ async function savePolicy() {
646
700
  through `useResultViewRunMeta`, so it is present on the card / inspector entry point
647
701
  too — where this window carries no step index of its own. -->
648
702
  <aside
649
- v-if="metaStep"
703
+ v-if="runMeta"
650
704
  data-testid="initiative-tracker-run-meta"
651
705
  class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
652
706
  >
653
- <StepRunMeta
654
- :step="metaStep"
655
- :instance-id="runId"
656
- :step-number="position"
657
- :total-steps="totalSteps"
658
- :run-failed="runFailed"
659
- :failure-at="failureAt"
660
- />
707
+ <StepRunMeta v-bind="runMeta" />
661
708
  </aside>
662
709
  </div>
663
710
  </ResultWindowShell>
@@ -151,7 +151,11 @@ async function act(choice: 'proceed' | 'bounce' | 'stop') {
151
151
  <span class="text-[13px] font-semibold text-slate-100">
152
152
  {{ score === null ? t('judge.notScored') : n(score, 'percent') }}
153
153
  </span>
154
- <span v-if="threshold !== null" class="text-[12px] text-slate-400">
154
+ <span
155
+ v-if="threshold !== null"
156
+ class="text-[12px] text-slate-400"
157
+ :title="t('judge.thresholdHint')"
158
+ >
155
159
  {{ t('judge.threshold', { threshold: n(threshold, 'percent') }) }}
156
160
  </span>
157
161
  <span v-if="judge.disposition" class="text-[12px] text-slate-400">
@@ -87,7 +87,10 @@ function pick(effort: ReviewEffort) {
87
87
  <template>
88
88
  <div data-testid="merge-effort-chips" class="mt-2">
89
89
  <div class="flex items-center gap-1.5">
90
- <span class="text-[10px] uppercase tracking-wide text-slate-500">
90
+ <span
91
+ class="text-[10px] uppercase tracking-wide text-slate-500"
92
+ :title="t('merge.effort.promptHint')"
93
+ >
91
94
  {{ t('merge.effort.prompt') }}
92
95
  </span>
93
96
  <UBadge
@@ -96,6 +99,7 @@ function pick(effort: ReviewEffort) {
96
99
  color="neutral"
97
100
  variant="subtle"
98
101
  size="sm"
102
+ :title="t('merge.changeClass.hint')"
99
103
  >
100
104
  {{ classLabel }}
101
105
  </UBadge>
@@ -309,12 +309,15 @@ watch(
309
309
  </div>
310
310
  </div>
311
311
  <div class="mt-2 flex items-center gap-4 text-[11px] text-slate-500">
312
- <span class="flex items-center gap-1">
312
+ <span class="flex items-center gap-1" :title="t('reports.legend.meteredHint')">
313
313
  <span class="h-2 w-2 rounded-sm bg-violet-500" />{{
314
314
  t('reports.legend.metered')
315
315
  }}
316
316
  </span>
317
- <span class="flex items-center gap-1">
317
+ <span
318
+ class="flex items-center gap-1"
319
+ :title="t('reports.legend.subscriptionHint')"
320
+ >
318
321
  <span class="h-2 w-2 rounded-sm bg-amber-600" />{{
319
322
  t('reports.legend.subscription')
320
323
  }}
@@ -40,7 +40,11 @@ const max = computed(() => maxOf(props.rows, spendMagnitude))
40
40
  figure would report money that was never billed. -->
41
41
  <span class="shrink-0 tabular-nums text-slate-400">
42
42
  {{ money(row.meteredCost) }}
43
- <span v-if="row.subscriptionCost > 0" class="text-amber-400">
43
+ <span
44
+ v-if="row.subscriptionCost > 0"
45
+ class="text-amber-400"
46
+ :title="t('reports.legend.subscriptionHint')"
47
+ >
44
48
  {{ t('reports.spend.subscriptionAside', { value: money(row.subscriptionCost) }) }}
45
49
  </span>
46
50
  </span>
@@ -42,7 +42,12 @@ const difficultyClass = computed(() => BAR_CLASS[effortBand(props.report.difficu
42
42
  </div>
43
43
 
44
44
  <div class="flex items-center gap-2">
45
- <span class="text-[12px] text-slate-300">{{ t('panels.stepDetail.effort.difficulty') }}</span>
45
+ <span
46
+ class="text-[12px] text-slate-300"
47
+ :title="t('panels.stepDetail.effort.difficultyHint')"
48
+ >
49
+ {{ t('panels.stepDetail.effort.difficulty') }}
50
+ </span>
46
51
  <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-700/60">
47
52
  <div
48
53
  class="h-full rounded-full"
@@ -50,7 +55,11 @@ const difficultyClass = computed(() => BAR_CLASS[effortBand(props.report.difficu
50
55
  :style="{ width: `${difficultyPct}%` }"
51
56
  />
52
57
  </div>
53
- <span data-testid="step-effort-difficulty" class="text-[12px] font-medium text-slate-200">
58
+ <span
59
+ data-testid="step-effort-difficulty"
60
+ class="text-[12px] font-medium text-slate-200"
61
+ :title="t('panels.stepDetail.effort.difficultyHint')"
62
+ >
54
63
  {{ t('panels.stepDetail.effort.outOfTen', { value: report.difficulty }) }}
55
64
  </span>
56
65
  </div>
@@ -33,7 +33,9 @@ function label(item: FragmentAdherence[number]): string {
33
33
  class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
34
34
  >
35
35
  <UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5" />
36
- <span>{{ t('panels.stepDetail.adherence.heading') }}</span>
36
+ <span :title="t('panels.stepDetail.adherence.headingHint')">
37
+ {{ t('panels.stepDetail.adherence.heading') }}
38
+ </span>
37
39
  </div>
38
40
 
39
41
  <div class="space-y-2.5">
@@ -54,7 +56,10 @@ function label(item: FragmentAdherence[number]): string {
54
56
  :style="{ width: `${ratingPct(item.rating)}%` }"
55
57
  />
56
58
  </div>
57
- <span class="shrink-0 text-[12px] font-medium text-slate-200">
59
+ <span
60
+ class="shrink-0 text-[12px] font-medium text-slate-200"
61
+ :title="t('panels.stepDetail.adherence.ratingHint')"
62
+ >
58
63
  {{ t('panels.stepDetail.adherence.outOfTen', { value: item.rating }) }}
59
64
  </span>
60
65
  </div>
@@ -207,7 +207,10 @@ async function copyRunId() {
207
207
 
208
208
  <!-- standards (prompt fragments) folded into this step -->
209
209
  <div v-if="step.selectedFragmentIds && step.selectedFragmentIds.length" class="mt-4">
210
- <div class="text-[11px] uppercase tracking-wide text-slate-500">
210
+ <div
211
+ class="text-[11px] uppercase tracking-wide text-slate-500"
212
+ :title="t('panels.stepMeta.standardsAppliedHint')"
213
+ >
211
214
  {{ t('panels.stepMeta.standardsApplied') }}
212
215
  </div>
213
216
  <div class="mt-1 flex flex-wrap gap-1">
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ // The estimate-gate threshold row, shared by every gate editor in the pipeline builder: a
3
+ // companion step's own gate, a consensus step's escalation gate, and the Tester QC companion's
4
+ // gate. One component because the three rendered the same 0..1 inputs from three copies of the
5
+ // markup — and, more to the point, because the explanation these numbers need has to be worded
6
+ // once. A bare "risk >=" label says nothing about where the score comes from, what its scale is,
7
+ // or how the axes combine, and the scale is the trap: every other score the product shows a user
8
+ // (judge verdicts, effort difficulty, adherence ratings) is out of ten, so an unlabelled 0..1
9
+ // field reads as "risk 1 out of 10" and gates a step that was meant to run on everything.
10
+ //
11
+ // The gate itself is edited by the parent (the builder mutates store drafts in place), so this
12
+ // component only renders and reports: it never writes through the `gating` prop.
13
+ import type { EstimateAxis } from '~/utils/estimateGating'
14
+ import {
15
+ ESTIMATE_AXIS_FIELD,
16
+ ESTIMATE_AXIS_HINT_KEYS,
17
+ ESTIMATE_AXIS_LABEL_KEYS,
18
+ parseAxisThreshold,
19
+ } from '~/utils/estimateGating'
20
+
21
+ const props = defineProps<{
22
+ /** The gate being edited. Read-only here — changes go back through `update`. */
23
+ gating: { minComplexity?: number; minRisk?: number; minImpact?: number }
24
+ /** Which axes this gate exposes, in render order. */
25
+ axes: readonly EstimateAxis[]
26
+ /** What clearing the gate DOES, which differs per gate and is half the explanation. */
27
+ outcome: 'step' | 'consensus'
28
+ }>()
29
+
30
+ const emit = defineEmits<{ update: [axis: EstimateAxis, value: number | undefined] }>()
31
+
32
+ const { t } = useI18n()
33
+
34
+ // Exhaustive Record over the two outcomes with LITERAL keys, so the typed-message-key check sees
35
+ // them and a third outcome fails the typecheck rather than falling back to the step wording.
36
+ const OUTCOME_HINT_KEYS: Record<'step' | 'consensus', string> = {
37
+ step: 'pipeline.builder.gatingRunWhenAnyHint',
38
+ consensus: 'pipeline.builder.gatingRunWhenAnyConsensusHint',
39
+ }
40
+
41
+ const fields = computed(() =>
42
+ props.axes.map((axis) => ({
43
+ axis,
44
+ label: t(ESTIMATE_AXIS_LABEL_KEYS[axis]),
45
+ hint: t(ESTIMATE_AXIS_HINT_KEYS[axis]),
46
+ value: props.gating[ESTIMATE_AXIS_FIELD[axis]],
47
+ })),
48
+ )
49
+
50
+ function commit(axis: EstimateAxis, raw: string) {
51
+ emit('update', axis, parseAxisThreshold(raw))
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2">
57
+ <span class="text-[10px] text-slate-500" :title="t(OUTCOME_HINT_KEYS[outcome])">
58
+ {{ t('pipeline.builder.runWhenAny') }}
59
+ </span>
60
+ <template v-for="f in fields" :key="f.axis">
61
+ <label class="text-slate-400" :title="f.hint">{{ f.label }}</label>
62
+ <input
63
+ :value="f.value"
64
+ :title="f.hint"
65
+ type="number"
66
+ min="0"
67
+ max="1"
68
+ step="0.1"
69
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
70
+ @change="commit(f.axis, ($event.target as HTMLInputElement).value)"
71
+ />
72
+ </template>
73
+ </div>
74
+ </template>
@@ -63,6 +63,7 @@ function commit(raw: string | number) {
63
63
  :min="MIN_AGENT_MAX_OUTPUT_TOKENS"
64
64
  :max="MAX_AGENT_MAX_OUTPUT_TOKENS"
65
65
  :placeholder="placeholder"
66
+ :title="t('pipeline.outputBudget.hint')"
66
67
  :disabled="disabled"
67
68
  @change="commit(($event.target as HTMLInputElement).value)"
68
69
  />
@@ -5,7 +5,9 @@ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
5
5
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
6
6
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
7
7
  import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
8
+ import EstimateThresholdFields from '~/components/pipeline/EstimateThresholdFields.vue'
8
9
  import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
10
+ import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
9
11
  import { showOverrideField } from '~/utils/uiMode'
10
12
  import {
11
13
  agentKindMeta,
@@ -47,6 +49,31 @@ function addParticipant(i: number) {
47
49
  function removeParticipant(i: number, pIdx: number) {
48
50
  pipelines.draftConsensus[i]?.participants.splice(pIdx, 1)
49
51
  }
52
+ /**
53
+ * The consensus escalation gate exposes only the two axes its toggle seeds (risk + impact) —
54
+ * complexity is a fine reason to run a deeper TEST audit but a poor reason to convene a panel,
55
+ * which is about disagreement risk. A module constant rather than a template literal so the
56
+ * array identity is stable across re-renders.
57
+ */
58
+ const CONSENSUS_ESTIMATE_AXES: readonly EstimateAxis[] = ['risk', 'impact']
59
+
60
+ // Writing one axis floor back onto each of the three draft gates. These are functions rather
61
+ // than assignments inlined in the template because a template `v-if` does not narrow inside an
62
+ // event handler's closure, so the inline form needs a non-null assertion at every call site —
63
+ // and a `!` on a gate that has since been toggled off is the one shape that throws.
64
+ function setCompanionGatingAxis(i: number | null, axis: EstimateAxis, value: number | undefined) {
65
+ const gating = i === null ? undefined : pipelines.draftGating[i]
66
+ if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
67
+ }
68
+ function setConsensusGatingAxis(i: number, axis: EstimateAxis, value: number | undefined) {
69
+ const gating = pipelines.draftConsensus[i]?.gating
70
+ if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
71
+ }
72
+ function setTesterQualityGatingAxis(i: number, axis: EstimateAxis, value: number | undefined) {
73
+ const gating = pipelines.draftTesterQuality[i]?.gating
74
+ if (gating) gating[ESTIMATE_AXIS_FIELD[axis]] = value
75
+ }
76
+
50
77
  /** Toggle gating on/off for a draft step's consensus config. */
51
78
  function toggleGating(i: number) {
52
79
  const cfg = pipelines.draftConsensus[i]
@@ -786,43 +813,15 @@ async function clone(p: Pipeline) {
786
813
  @click="pipelines.toggleDraftGating(unit.companionIndex)"
787
814
  />
788
815
  </div>
789
- <div
816
+ <EstimateThresholdFields
790
817
  v-if="pipelines.draftGating[unit.companionIndex]?.enabled"
791
- class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2"
792
- >
793
- <span class="text-[10px] text-slate-500">{{
794
- t('pipeline.builder.runWhenAny')
795
- }}</span>
796
- <label class="text-slate-400">{{
797
- t('pipeline.builder.complexityThreshold')
798
- }}</label>
799
- <input
800
- v-model.number="pipelines.draftGating[unit.companionIndex]!.minComplexity"
801
- type="number"
802
- min="0"
803
- max="1"
804
- step="0.1"
805
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
806
- />
807
- <label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
808
- <input
809
- v-model.number="pipelines.draftGating[unit.companionIndex]!.minRisk"
810
- type="number"
811
- min="0"
812
- max="1"
813
- step="0.1"
814
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
815
- />
816
- <label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
817
- <input
818
- v-model.number="pipelines.draftGating[unit.companionIndex]!.minImpact"
819
- type="number"
820
- min="0"
821
- max="1"
822
- step="0.1"
823
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
824
- />
825
- </div>
818
+ :gating="pipelines.draftGating[unit.companionIndex]!"
819
+ :axes="ESTIMATE_AXES"
820
+ outcome="step"
821
+ @update="
822
+ (axis, value) => setCompanionGatingAxis(unit.companionIndex, axis, value)
823
+ "
824
+ />
826
825
  </div>
827
826
 
828
827
  <!-- Consensus config (shown when the step is consensus-enabled). -->
@@ -953,31 +952,14 @@ async function clone(p: Pipeline) {
953
952
  :title="t('pipeline.builder.consensusGateTooltip')"
954
953
  @click="toggleGating(unit.index)"
955
954
  />
956
- <template v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled">
957
- <label class="text-slate-400">{{
958
- t('pipeline.builder.riskThreshold')
959
- }}</label>
960
- <input
961
- v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minRisk"
962
- type="number"
963
- min="0"
964
- max="1"
965
- step="0.1"
966
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
967
- />
968
- <label class="text-slate-400">{{
969
- t('pipeline.builder.impactThreshold')
970
- }}</label>
971
- <input
972
- v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minImpact"
973
- type="number"
974
- min="0"
975
- max="1"
976
- step="0.1"
977
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
978
- />
979
- </template>
980
955
  </div>
956
+ <EstimateThresholdFields
957
+ v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled"
958
+ :gating="pipelines.draftConsensus[unit.index]!.gating!"
959
+ :axes="CONSENSUS_ESTIMATE_AXES"
960
+ outcome="consensus"
961
+ @update="(axis, value) => setConsensusGatingAxis(unit.index, axis, value)"
962
+ />
981
963
  </template>
982
964
  </div>
983
965
 
@@ -1013,43 +995,13 @@ async function clone(p: Pipeline) {
1013
995
  @click="pipelines.toggleDraftTesterQualityGating(unit.index)"
1014
996
  />
1015
997
  </div>
1016
- <div
998
+ <EstimateThresholdFields
1017
999
  v-if="pipelines.draftTesterQuality[unit.index]?.gating?.enabled"
1018
- class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2"
1019
- >
1020
- <span class="text-[10px] text-slate-500">{{
1021
- t('pipeline.builder.runWhenAny')
1022
- }}</span>
1023
- <label class="text-slate-400">{{
1024
- t('pipeline.builder.complexityThreshold')
1025
- }}</label>
1026
- <input
1027
- v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minComplexity"
1028
- type="number"
1029
- min="0"
1030
- max="1"
1031
- step="0.1"
1032
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
1033
- />
1034
- <label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
1035
- <input
1036
- v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minRisk"
1037
- type="number"
1038
- min="0"
1039
- max="1"
1040
- step="0.1"
1041
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
1042
- />
1043
- <label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
1044
- <input
1045
- v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minImpact"
1046
- type="number"
1047
- min="0"
1048
- max="1"
1049
- step="0.1"
1050
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
1051
- />
1052
- </div>
1000
+ :gating="pipelines.draftTesterQuality[unit.index]!.gating!"
1001
+ :axes="ESTIMATE_AXES"
1002
+ outcome="step"
1003
+ @update="(axis, value) => setTesterQualityGatingAxis(unit.index, axis, value)"
1004
+ />
1053
1005
  </div>
1054
1006
  </li>
1055
1007
  </ol>
@@ -427,7 +427,10 @@ function fail(title: string, e: unknown) {
427
427
  {{ t('settings.consensusGroups.editor.gatedHint') }}
428
428
  </p>
429
429
  <div v-if="editor.gated" class="flex flex-wrap items-center gap-3 text-xs">
430
- <label class="flex items-center gap-1.5 text-slate-400">
430
+ <label
431
+ class="flex items-center gap-1.5 text-slate-400"
432
+ :title="t('pipeline.builder.riskThresholdHint')"
433
+ >
431
434
  {{ t('pipeline.builder.riskThreshold') }}
432
435
  <UInput
433
436
  v-model.number="editor.minRisk"
@@ -439,7 +442,10 @@ function fail(title: string, e: unknown) {
439
442
  class="w-20"
440
443
  />
441
444
  </label>
442
- <label class="flex items-center gap-1.5 text-slate-400">
445
+ <label
446
+ class="flex items-center gap-1.5 text-slate-400"
447
+ :title="t('pipeline.builder.impactThresholdHint')"
448
+ >
443
449
  {{ t('pipeline.builder.impactThreshold') }}
444
450
  <UInput
445
451
  v-model.number="editor.minImpact"
@@ -451,7 +457,10 @@ function fail(title: string, e: unknown) {
451
457
  class="w-20"
452
458
  />
453
459
  </label>
454
- <label class="flex items-center gap-1.5 text-slate-400">
460
+ <label
461
+ class="flex items-center gap-1.5 text-slate-400"
462
+ :title="t('pipeline.builder.complexityThresholdHint')"
463
+ >
455
464
  {{ t('settings.consensusGroups.editor.complexityThreshold') }}
456
465
  <UInput
457
466
  v-model.number="editor.minComplexity"
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ ESTIMATE_AXES,
4
+ ESTIMATE_AXIS_FIELD,
5
+ ESTIMATE_AXIS_HINT_KEYS,
6
+ ESTIMATE_AXIS_LABEL_KEYS,
7
+ parseAxisThreshold,
8
+ } from './estimateGating'
9
+
10
+ describe('parseAxisThreshold', () => {
11
+ it('clears the axis on an empty or unparseable field rather than storing a zero floor', () => {
12
+ // The two are opposites: an unset axis is not considered, where a 0 floor always passes.
13
+ expect(parseAxisThreshold('')).toBeUndefined()
14
+ expect(parseAxisThreshold(' ')).toBeUndefined()
15
+ expect(parseAxisThreshold('abc')).toBeUndefined()
16
+ expect(parseAxisThreshold('0')).toBe(0)
17
+ })
18
+
19
+ it('keeps a value inside the estimator scale', () => {
20
+ expect(parseAxisThreshold('0.6')).toBe(0.6)
21
+ expect(parseAxisThreshold(' 0.25 ')).toBe(0.25)
22
+ })
23
+
24
+ it('clamps out-of-range input instead of rejecting it', () => {
25
+ // A fat-fingered extra digit lands on the ceiling rather than failing the save with a 422.
26
+ expect(parseAxisThreshold('10')).toBe(1)
27
+ expect(parseAxisThreshold('-3')).toBe(0)
28
+ })
29
+ })
30
+
31
+ describe('estimate axis vocabulary', () => {
32
+ it('covers every axis in both key maps and the field map', () => {
33
+ for (const axis of ESTIMATE_AXES) {
34
+ expect(ESTIMATE_AXIS_LABEL_KEYS[axis]).toBeTruthy()
35
+ expect(ESTIMATE_AXIS_HINT_KEYS[axis]).toBeTruthy()
36
+ expect(ESTIMATE_AXIS_FIELD[axis]).toBeTruthy()
37
+ }
38
+ })
39
+
40
+ it('gives each axis its own hint, so no two axes explain the same thing', () => {
41
+ const hints = ESTIMATE_AXES.map((axis) => ESTIMATE_AXIS_HINT_KEYS[axis])
42
+ expect(new Set(hints).size).toBe(ESTIMATE_AXES.length)
43
+ })
44
+ })