@cat-factory/app 0.186.0 → 0.188.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.
Files changed (34) hide show
  1. package/README.md +26 -0
  2. package/app/components/consensus/ConsensusSessionWindow.vue +11 -0
  3. package/app/components/fragments/FragmentLibraryManager.vue +39 -2
  4. package/app/components/palettes/AgentPalette.vue +12 -1
  5. package/app/components/palettes/AgentTierSelect.vue +67 -0
  6. package/app/components/pipeline/PipelineBuilder.vue +166 -92
  7. package/app/components/settings/ConsensusGroupsSection.vue +498 -0
  8. package/app/components/settings/ModelConfigurationPanel.vue +34 -2
  9. package/app/composables/api/presets.ts +22 -0
  10. package/app/modular/agent-kinds.spec.ts +9 -3
  11. package/app/modular/agent-kinds.ts +4 -0
  12. package/app/stores/agentTier.spec.ts +24 -0
  13. package/app/stores/agentTier.ts +42 -0
  14. package/app/stores/consensusGroups.ts +77 -0
  15. package/app/stores/pipelines/draftActions.ts +11 -113
  16. package/app/stores/pipelines/draftStepConfig.ts +157 -0
  17. package/app/stores/workspace/hydrate.ts +2 -0
  18. package/app/types/consensus.ts +3 -0
  19. package/app/types/domain.ts +8 -1
  20. package/app/utils/agentTier.spec.ts +59 -0
  21. package/app/utils/agentTier.ts +49 -0
  22. package/app/utils/catalog.spec.ts +12 -0
  23. package/app/utils/catalog.ts +58 -2
  24. package/i18n/locales/de.json +61 -2
  25. package/i18n/locales/en.json +68 -2
  26. package/i18n/locales/es.json +61 -2
  27. package/i18n/locales/fr.json +61 -2
  28. package/i18n/locales/he.json +61 -2
  29. package/i18n/locales/it.json +61 -2
  30. package/i18n/locales/ja.json +61 -2
  31. package/i18n/locales/pl.json +61 -2
  32. package/i18n/locales/tr.json +61 -2
  33. package/i18n/locales/uk.json +61 -2
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -16,6 +16,7 @@ The SPA source lives under `app/` (the Nuxt srcDir).
16
16
  - [Tech stack](#tech-stack)
17
17
  - [Layout](#layout)
18
18
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
19
+ - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
19
20
  - [Key UI surfaces](#key-ui-surfaces)
20
21
  - [Develop & test](#develop--test)
21
22
 
@@ -116,6 +117,31 @@ hoc where it can be avoided:
116
117
  `false` is a choice, not absence), so basic mode can never conceal a setting a run will
117
118
  actually use.
118
119
 
120
+ ## Agent tiers (basic / intermediate / advanced)
121
+
122
+ A separate, narrower axis: how deep into the **agent catalog** a surface reaches. Every agent
123
+ kind carries a `tier` — `basic` (the everyday delivery loop), `intermediate` (reached for
124
+ regularly) or `advanced` (specialist) — and the two surfaces that enumerate the catalog, the
125
+ **pipeline builder's palette** and the **model preset's per-agent override list**, show the
126
+ selected tier and everything below it. They open on `basic`; the `AgentTierSelect` control on
127
+ each widens them, with `advanced` showing the whole catalog. The choice is one shared,
128
+ persisted preference (the `agentTier` store), because picking the agents a pipeline runs and
129
+ picking what each of them runs on are halves of the same job.
130
+
131
+ - The vocabulary, the default and the cumulative predicate live in `@cat-factory/contracts`
132
+ (`AGENT_TIERS` / `DEFAULT_AGENT_TIER` / `agentTierVisibleAt`), beside
133
+ `purposeAllowsAgentCategory` — so a **deployment-registered kind's** declared tier
134
+ (`presentation.tier`, carried in the workspace snapshot) and the SPA's own built-ins are
135
+ read by one rule. A kind that declares no tier is treated as `intermediate`.
136
+ - **This is not the interface mode.** That tier decides which surfaces the whole SPA offers;
137
+ this one decides how much of one surface's catalog is listed. They are independent — an
138
+ advanced-mode user still starts on the basic agent tier — and the tier control is present in
139
+ **both** interface modes, since it is the only route to the kinds it hides.
140
+ - A narrowed catalog states what it is holding back (the "n hidden at this tier" hint), and
141
+ the model preset list **always keeps a kind the edited preset already pins a model for**,
142
+ whatever the tier — the same rule `showOverrideField` states for a single field: a row the
143
+ user can neither read nor clear is worse than a longer list.
144
+
119
145
  ## Extending the layer (consumer modules)
120
146
 
121
147
  A deployment can contribute its own components — result windows, nav entries, inspector
@@ -124,6 +124,17 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
124
124
  @close="close"
125
125
  >
126
126
  <template v-if="session" #header-extras>
127
+ <!-- Which workspace consensus GROUP the run escalated to. Only a tiered step has one, and
128
+ the name is the session's own copy — so it still reads correctly after the library
129
+ entry was renamed or deleted. Without it, a reader asking "why did five models run on
130
+ this task" has the transcript but not the reason. -->
131
+ <span
132
+ v-if="session.groupName"
133
+ class="rounded-full bg-emerald-900/50 px-2.5 py-1 text-xs font-medium text-emerald-200"
134
+ :title="t('consensus.groupTitle')"
135
+ >
136
+ {{ session.groupName }}
137
+ </span>
127
138
  <span
128
139
  class="rounded-full px-2.5 py-1 text-xs font-medium"
129
140
  :class="STATUS_CLASS[session.status] ?? 'bg-slate-700 text-slate-300'"
@@ -14,6 +14,7 @@ import type {
14
14
  ResolvedFragment,
15
15
  } from '~/types/domain'
16
16
  import { useFragmentLibrary, useFragmentLibraryStore } from '~/stores/fragmentLibrary'
17
+ import { showOverrideField } from '~/utils/uiMode'
17
18
  import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
18
19
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
19
20
  import GitHubDocUrlImport from '~/components/fragments/GitHubDocUrlImport.vue'
@@ -131,7 +132,7 @@ const linkingDoc = ref(false)
131
132
  const linkingSource = ref(false)
132
133
 
133
134
  // ---- create a hand-authored fragment --------------------------------------
134
- const draft = ref({ title: '', summary: '', body: '', tags: '' })
135
+ const draft = ref({ title: '', summary: '', body: '', brief: '', tags: '' })
135
136
  const draftValid = computed(
136
137
  () => draft.value.title.trim() && draft.value.summary.trim() && draft.value.body.trim(),
137
138
  )
@@ -167,6 +168,7 @@ const editDraft = ref<{
167
168
  title: string
168
169
  summary: string
169
170
  body: string
171
+ brief: string
170
172
  tags: string
171
173
  } | null>(null)
172
174
  function startEdit(f: (typeof library.fragments)[number]) {
@@ -175,8 +177,10 @@ function startEdit(f: (typeof library.fragments)[number]) {
175
177
  title: f.title,
176
178
  summary: f.summary,
177
179
  body: f.body,
180
+ brief: f.brief ?? '',
178
181
  tags: (f.tags ?? []).join(', '),
179
182
  }
183
+ showEditBrief.value = showOverrideField(uiMode.isAdvanced, f.brief ?? null)
180
184
  }
181
185
  function cancelEdit() {
182
186
  editDraft.value = null
@@ -188,6 +192,19 @@ const editValid = computed(
188
192
  !!editDraft.value.summary.trim() &&
189
193
  !!editDraft.value.body.trim(),
190
194
  )
195
+ // The linked SHORT VERSION is an OVERRIDE of what the platform does by default (condense a
196
+ // long standard automatically, fold a short one in full), so it follows the override rule:
197
+ // hidden in basic mode while unset, revealed as soon as the fragment carries one — a
198
+ // basic-mode curator is never left unable to see or clear a brief a teammate linked.
199
+ //
200
+ // Both flags are LATCHED at the moment the form opens rather than tracking the live draft.
201
+ // Recomputing per keystroke makes the control delete itself the instant a basic-mode curator
202
+ // empties it — mid-edit, under the cursor, on the one interaction (clearing, to hand the
203
+ // standard back to auto-generation) the rule exists to keep reachable.
204
+ const uiMode = useUiModeStore()
205
+ const showEditBrief = ref(false)
206
+ const showDraftBrief = computed(() => showOverrideField(uiMode.isAdvanced, null))
207
+
191
208
  async function saveEdit() {
192
209
  const d = editDraft.value
193
210
  if (!d || !editValid.value) return
@@ -197,6 +214,9 @@ async function saveEdit() {
197
214
  title: d.title.trim(),
198
215
  summary: d.summary.trim(),
199
216
  body: d.body.trim(),
217
+ // Always sent, so clearing the box UNLINKS the short version and hands the
218
+ // standard back to auto-generation (an omitted key would leave the old one).
219
+ brief: d.brief.trim(),
200
220
  tags: d.tags
201
221
  .split(',')
202
222
  .map((t) => t.trim())
@@ -218,12 +238,13 @@ async function createFragment() {
218
238
  title: draft.value.title.trim(),
219
239
  summary: draft.value.summary.trim(),
220
240
  body: draft.value.body.trim(),
241
+ brief: draft.value.brief.trim() || undefined,
221
242
  tags: draft.value.tags
222
243
  .split(',')
223
244
  .map((t) => t.trim())
224
245
  .filter(Boolean),
225
246
  })
226
- draft.value = { title: '', summary: '', body: '', tags: '' }
247
+ draft.value = { title: '', summary: '', body: '', brief: '', tags: '' }
227
248
  toast.add({ title: t('fragments.toast.added'), icon: 'i-lucide-check' })
228
249
  } catch (e) {
229
250
  notifyError(t('fragments.toast.addFailed'), e)
@@ -701,6 +722,14 @@ async function unlinkSource(id: string) {
701
722
  :placeholder="t('fragments.authored.bodyPlaceholder')"
702
723
  :rows="4"
703
724
  />
725
+ <div v-if="showEditBrief" class="flex flex-col gap-1">
726
+ <UTextarea
727
+ v-model="editDraft.brief"
728
+ :placeholder="t('fragments.authored.briefPlaceholder')"
729
+ :rows="2"
730
+ />
731
+ <p class="text-xs text-slate-500">{{ t('fragments.authored.briefHint') }}</p>
732
+ </div>
704
733
  <UInput
705
734
  v-model="editDraft.tags"
706
735
  :placeholder="t('fragments.authored.tagsPlaceholder')"
@@ -799,6 +828,14 @@ async function unlinkSource(id: string) {
799
828
  :placeholder="t('fragments.authored.bodyPlaceholder')"
800
829
  :rows="4"
801
830
  />
831
+ <div v-if="showDraftBrief" class="flex flex-col gap-1">
832
+ <UTextarea
833
+ v-model="draft.brief"
834
+ :placeholder="t('fragments.authored.briefPlaceholder')"
835
+ :rows="2"
836
+ />
837
+ <p class="text-xs text-slate-500">{{ t('fragments.authored.briefHint') }}</p>
838
+ </div>
802
839
  <UInput v-model="draft.tags" :placeholder="t('fragments.authored.tagsPlaceholder')" />
803
840
  <UButton
804
841
  icon="i-lucide-plus"
@@ -3,10 +3,13 @@ import { computed } from 'vue'
3
3
  import { useLocalStorage } from '@vueuse/core'
4
4
  import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
5
5
  import type { AgentKind, PipelinePurpose } from '~/types/domain'
6
+ import AgentTierSelect from '~/components/palettes/AgentTierSelect.vue'
7
+ import { filterByAgentTier } from '~/utils/agentTier'
6
8
  import { AGENT_CATEGORIES, OBSERVABILITY_GATE_ARCHETYPE } from '~/utils/catalog'
7
9
 
8
10
  const { t } = useI18n()
9
11
  const agents = useAgentsStore()
12
+ const agentTier = useAgentTierStore()
10
13
  const releaseHealth = useReleaseHealthStore()
11
14
  defineEmits<{ (e: 'add', kind: AgentKind): void }>()
12
15
  // The purpose of the pipeline being built. When set to a non-`build` classifier, the
@@ -16,7 +19,7 @@ const props = defineProps<{ purpose?: PipelinePurpose | null }>()
16
19
 
17
20
  // The post-release-health gate is only meaningful — and only accepted by the backend —
18
21
  // with an observability integration connected, so it appears in the palette ONLY then.
19
- const palette = computed(() => {
22
+ const offered = computed(() => {
20
23
  const all = releaseHealth.connection.connected
21
24
  ? [...agents.archetypes, OBSERVABILITY_GATE_ARCHETYPE]
22
25
  : agents.archetypes
@@ -25,6 +28,13 @@ const palette = computed(() => {
25
28
  return all.filter((a) => !a.category || purposeAllowsAgentCategory(props.purpose, a.category))
26
29
  })
27
30
 
31
+ // Then narrow to the selected tier. Applied AFTER the purpose gate so the "n hidden" hint
32
+ // counts only what the TIER is holding back — a kind the pipeline's purpose rules out is not
33
+ // something a wider tier would reveal, so counting it would send the user chasing a control
34
+ // that cannot help them.
35
+ const palette = computed(() => filterByAgentTier(offered.value, agentTier.tier))
36
+ const hiddenByTier = computed(() => offered.value.length - palette.value.length)
37
+
28
38
  // Group the palette into the ordered catalog categories, plus a trailing "Custom" bucket
29
39
  // for runtime-added agents that carry no category. Empty groups are dropped.
30
40
  const groups = computed(() => {
@@ -54,6 +64,7 @@ function toggle(id: string) {
54
64
  <template>
55
65
  <div class="space-y-2">
56
66
  <p class="px-1 text-[11px] text-slate-500">{{ t('palette.hint') }}</p>
67
+ <AgentTierSelect :hidden-count="hiddenByTier" />
57
68
  <div class="space-y-2">
58
69
  <section v-for="g in groups" :key="g.id">
59
70
  <button
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ // The agent-tier control, shared by the pipeline builder's palette and the model preset's
3
+ // per-agent override list (the two surfaces that enumerate the agent catalog).
4
+ //
5
+ // Tiers are CUMULATIVE, so this is a level dial rather than a set of independent toggles:
6
+ // `basic` is the everyday delivery loop, `intermediate` adds the kinds reached for regularly,
7
+ // and `advanced` — the widest level — shows the whole catalog. The trailing hint states how
8
+ // many kinds the current level is holding back, so a narrowed catalog never reads as the
9
+ // complete one.
10
+ import { computed } from 'vue'
11
+ import { AGENT_TIERS, type AgentTier } from '@cat-factory/contracts'
12
+
13
+ const { t } = useI18n()
14
+ const agentTier = useAgentTierStore()
15
+
16
+ const props = defineProps<{
17
+ /** How many kinds the current level hides. Renders the "n hidden" hint when > 0. */
18
+ hiddenCount?: number
19
+ }>()
20
+
21
+ // One STATIC literal `t()` key per tier (not a key assembled from the loop variable), so the
22
+ // typed-message-keys check covers them — the same shape the vendor/enum-keyed sets use.
23
+ const TIER_LABELS = computed<Record<AgentTier, string>>(() => ({
24
+ basic: t('agentTier.basic'),
25
+ intermediate: t('agentTier.intermediate'),
26
+ advanced: t('agentTier.advanced'),
27
+ }))
28
+ const TIER_HINTS = computed<Record<AgentTier, string>>(() => ({
29
+ basic: t('agentTier.basicHint'),
30
+ intermediate: t('agentTier.intermediateHint'),
31
+ advanced: t('agentTier.advancedHint'),
32
+ }))
33
+
34
+ const items = computed(() => [
35
+ AGENT_TIERS.map((tier) => ({
36
+ label: TIER_LABELS.value[tier],
37
+ // The hint is what distinguishes "everything below this level" from "only this level",
38
+ // which the bare tier names cannot say.
39
+ description: TIER_HINTS.value[tier],
40
+ icon: tier === agentTier.tier ? 'i-lucide-check' : 'i-lucide-layers',
41
+ onSelect: () => agentTier.setTier(tier),
42
+ })),
43
+ ])
44
+ </script>
45
+
46
+ <template>
47
+ <div class="space-y-1">
48
+ <UDropdownMenu :items="items" :ui="{ content: 'z-[60] max-w-72' }">
49
+ <UButton
50
+ size="xs"
51
+ color="neutral"
52
+ variant="soft"
53
+ icon="i-lucide-layers"
54
+ trailing-icon="i-lucide-chevron-down"
55
+ class="w-full justify-between"
56
+ :title="t('agentTier.tooltip')"
57
+ >
58
+ <span class="truncate">
59
+ {{ t('agentTier.label') }}: {{ TIER_LABELS[agentTier.tier] }}
60
+ </span>
61
+ </UButton>
62
+ </UDropdownMenu>
63
+ <p v-if="props.hiddenCount" class="px-1 text-[10px] text-slate-500">
64
+ {{ t('agentTier.hidden', { count: props.hiddenCount }, props.hiddenCount) }}
65
+ </p>
66
+ </div>
67
+ </template>
@@ -54,6 +54,34 @@ function toggleGating(i: number) {
54
54
  ? { ...cfg.gating, enabled: false }
55
55
  : { enabled: true, minRisk: 0.6, minImpact: 0.6, onMissingEstimate: 'consensus' }
56
56
  }
57
+
58
+ // ---- The workspace consensus-GROUP tier set -------------------------------
59
+ // A step either authors its panel inline (the participants + gating editor below) or names a
60
+ // SET of workspace groups, each with its own estimate bar, and the engine runs the most
61
+ // demanding tier the task clears. The two are mutually exclusive by design — a non-empty tier
62
+ // set takes over — so the builder shows one editor or the other rather than both at once.
63
+ const consensusGroups = useConsensusGroupsStore()
64
+
65
+ /** Whether the draft step at `i` has escalated to the group library. */
66
+ function usesGroups(i: number): boolean {
67
+ return (pipelines.draftConsensus[i]?.groupIds?.length ?? 0) > 0
68
+ }
69
+
70
+ function isGroupSelected(i: number, groupId: string): boolean {
71
+ return pipelines.draftConsensus[i]?.groupIds?.includes(groupId) ?? false
72
+ }
73
+
74
+ /**
75
+ * The bar a group sets, as display text: its highest named threshold, or the "always" label when
76
+ * it is ungated. What a reader needs from the tier list is where each panel sits relative to the
77
+ * others, which is exactly this one number.
78
+ */
79
+ function groupBarLabel(groupId: string): string {
80
+ const group = consensusGroups.groups.find((g) => g.id === groupId)
81
+ if (!group) return ''
82
+ const bar = consensusGroups.barFor(group)
83
+ return bar === null ? t('pipeline.builder.consensusGroupAlways') : `≥ ${bar}`
84
+ }
57
85
  const agents = useAgentsStore()
58
86
  const ui = useUiStore()
59
87
  const uiMode = useUiModeStore()
@@ -760,109 +788,155 @@ async function clone(p: Pipeline) {
760
788
  v-if="pipelines.draftConsensus[unit.index]?.enabled"
761
789
  class="ms-6 space-y-2 rounded-md border border-emerald-800/40 bg-emerald-950/20 p-2 text-xs"
762
790
  >
763
- <div class="flex items-center gap-2">
764
- <label class="text-slate-400">{{ t('pipeline.builder.strategy') }}</label>
765
- <select
766
- v-model="pipelines.draftConsensus[unit.index]!.strategy"
767
- class="rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
768
- >
769
- <option v-for="s in CONSENSUS_STRATEGIES" :key="s.value" :value="s.value">
770
- {{ s.label }}
771
- </option>
772
- </select>
773
- <label
774
- v-if="pipelines.draftConsensus[unit.index]!.strategy === 'debate'"
775
- class="ms-2 text-slate-400"
776
- >{{ t('pipeline.builder.rounds') }}</label
777
- >
778
- <input
779
- v-if="pipelines.draftConsensus[unit.index]!.strategy === 'debate'"
780
- v-model.number="pipelines.draftConsensus[unit.index]!.rounds"
781
- type="number"
782
- min="1"
783
- max="5"
784
- placeholder="2"
785
- class="w-12 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
786
- />
791
+ <!-- The workspace consensus-GROUP tier set: pick which reusable panels this
792
+ step may escalate to. Each group carries its own estimate bar; the engine
793
+ runs the most demanding one the task clears, and none clearing means the
794
+ standard single agent runs. Selecting any group replaces the inline panel
795
+ editor below, so the step has exactly one source of truth for its panel. -->
796
+ <div
797
+ v-if="consensusGroups.hasGroups"
798
+ class="space-y-1 border-b border-slate-800 pb-2"
799
+ >
800
+ <div class="flex items-center gap-1.5">
801
+ <UIcon name="i-lucide-layers" class="h-3.5 w-3.5 text-emerald-400" />
802
+ <span class="text-slate-300">{{ t('pipeline.builder.consensusGroups') }}</span>
803
+ </div>
804
+ <p class="text-[11px] text-slate-500">
805
+ {{ t('pipeline.builder.consensusGroupsHint') }}
806
+ </p>
807
+ <div class="flex flex-wrap gap-1">
808
+ <button
809
+ v-for="group in consensusGroups.groups"
810
+ :key="group.id"
811
+ type="button"
812
+ class="rounded border px-1.5 py-0.5 text-[11px]"
813
+ :class="
814
+ isGroupSelected(unit.index, group.id)
815
+ ? 'border-emerald-600 bg-emerald-900/40 text-emerald-200'
816
+ : 'border-slate-700 bg-slate-900 text-slate-400 hover:text-slate-200'
817
+ "
818
+ :title="group.description"
819
+ @click="pipelines.toggleDraftConsensusGroup(unit.index, group.id)"
820
+ >
821
+ {{ group.name }}
822
+ <span class="ms-1 text-slate-500">{{ groupBarLabel(group.id) }}</span>
823
+ </button>
824
+ </div>
787
825
  </div>
788
826
 
789
- <!-- participants -->
790
- <div class="space-y-1">
791
- <div
792
- v-for="(p, pIdx) in pipelines.draftConsensus[unit.index]!.participants"
793
- :key="p.id"
794
- class="flex items-center gap-1.5"
795
- >
796
- <input
797
- v-model="p.role"
798
- :placeholder="t('pipeline.builder.rolePlaceholder')"
799
- class="w-28 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
800
- />
827
+ <div v-if="usesGroups(unit.index)" class="text-[11px] text-slate-500">
828
+ {{ t('pipeline.builder.consensusGroupsActive') }}
829
+ </div>
830
+
831
+ <template v-else>
832
+ <div class="flex items-center gap-2">
833
+ <label class="text-slate-400">{{ t('pipeline.builder.strategy') }}</label>
834
+ <select
835
+ v-model="pipelines.draftConsensus[unit.index]!.strategy"
836
+ class="rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
837
+ >
838
+ <option v-for="s in CONSENSUS_STRATEGIES" :key="s.value" :value="s.value">
839
+ {{ s.label }}
840
+ </option>
841
+ </select>
842
+ <label
843
+ v-if="pipelines.draftConsensus[unit.index]!.strategy === 'debate'"
844
+ class="ms-2 text-slate-400"
845
+ >{{ t('pipeline.builder.rounds') }}</label
846
+ >
801
847
  <input
802
- v-model="p.modelId"
803
- :placeholder="t('pipeline.builder.modelIdPlaceholder')"
804
- class="flex-1 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-300"
848
+ v-if="pipelines.draftConsensus[unit.index]!.strategy === 'debate'"
849
+ v-model.number="pipelines.draftConsensus[unit.index]!.rounds"
850
+ type="number"
851
+ min="1"
852
+ max="5"
853
+ placeholder="2"
854
+ class="w-12 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
805
855
  />
856
+ </div>
857
+
858
+ <!-- participants -->
859
+ <div class="space-y-1">
860
+ <div
861
+ v-for="(p, pIdx) in pipelines.draftConsensus[unit.index]!.participants"
862
+ :key="p.id"
863
+ class="flex items-center gap-1.5"
864
+ >
865
+ <input
866
+ v-model="p.role"
867
+ :placeholder="t('pipeline.builder.rolePlaceholder')"
868
+ class="w-28 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
869
+ />
870
+ <input
871
+ v-model="p.modelId"
872
+ :placeholder="t('pipeline.builder.modelIdPlaceholder')"
873
+ class="flex-1 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-300"
874
+ />
875
+ <UButton
876
+ icon="i-lucide-x"
877
+ color="error"
878
+ variant="ghost"
879
+ size="xs"
880
+ :disabled="pipelines.draftConsensus[unit.index]!.participants.length <= 2"
881
+ :title="t('pipeline.builder.removeParticipant')"
882
+ @click="removeParticipant(unit.index, pIdx)"
883
+ />
884
+ </div>
806
885
  <UButton
807
- icon="i-lucide-x"
808
- color="error"
886
+ icon="i-lucide-plus"
887
+ color="neutral"
809
888
  variant="ghost"
810
889
  size="xs"
811
- :disabled="pipelines.draftConsensus[unit.index]!.participants.length <= 2"
812
- :title="t('pipeline.builder.removeParticipant')"
813
- @click="removeParticipant(unit.index, pIdx)"
890
+ :label="t('pipeline.builder.addParticipant')"
891
+ @click="addParticipant(unit.index)"
814
892
  />
815
893
  </div>
816
- <UButton
817
- icon="i-lucide-plus"
818
- color="neutral"
819
- variant="ghost"
820
- size="xs"
821
- :label="t('pipeline.builder.addParticipant')"
822
- @click="addParticipant(unit.index)"
823
- />
824
- </div>
825
894
 
826
- <!-- gating -->
827
- <div class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2">
828
- <UButton
829
- :icon="
830
- pipelines.draftConsensus[unit.index]!.gating?.enabled
831
- ? 'i-lucide-toggle-right'
832
- : 'i-lucide-toggle-left'
833
- "
834
- :color="
835
- pipelines.draftConsensus[unit.index]!.gating?.enabled ? 'success' : 'neutral'
836
- "
837
- variant="ghost"
838
- size="xs"
839
- :label="t('pipeline.builder.gateOnEstimate')"
840
- :title="t('pipeline.builder.consensusGateTooltip')"
841
- @click="toggleGating(unit.index)"
842
- />
843
- <template v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled">
844
- <label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
845
- <input
846
- v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minRisk"
847
- type="number"
848
- min="0"
849
- max="1"
850
- step="0.1"
851
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
852
- />
853
- <label class="text-slate-400">{{
854
- t('pipeline.builder.impactThreshold')
855
- }}</label>
856
- <input
857
- v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minImpact"
858
- type="number"
859
- min="0"
860
- max="1"
861
- step="0.1"
862
- class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
895
+ <!-- gating -->
896
+ <div class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2">
897
+ <UButton
898
+ :icon="
899
+ pipelines.draftConsensus[unit.index]!.gating?.enabled
900
+ ? 'i-lucide-toggle-right'
901
+ : 'i-lucide-toggle-left'
902
+ "
903
+ :color="
904
+ pipelines.draftConsensus[unit.index]!.gating?.enabled
905
+ ? 'success'
906
+ : 'neutral'
907
+ "
908
+ variant="ghost"
909
+ size="xs"
910
+ :label="t('pipeline.builder.gateOnEstimate')"
911
+ :title="t('pipeline.builder.consensusGateTooltip')"
912
+ @click="toggleGating(unit.index)"
863
913
  />
864
- </template>
865
- </div>
914
+ <template v-if="pipelines.draftConsensus[unit.index]!.gating?.enabled">
915
+ <label class="text-slate-400">{{
916
+ t('pipeline.builder.riskThreshold')
917
+ }}</label>
918
+ <input
919
+ v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minRisk"
920
+ type="number"
921
+ min="0"
922
+ max="1"
923
+ step="0.1"
924
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
925
+ />
926
+ <label class="text-slate-400">{{
927
+ t('pipeline.builder.impactThreshold')
928
+ }}</label>
929
+ <input
930
+ v-model.number="pipelines.draftConsensus[unit.index]!.gating!.minImpact"
931
+ type="number"
932
+ min="0"
933
+ max="1"
934
+ step="0.1"
935
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
936
+ />
937
+ </template>
938
+ </div>
939
+ </template>
866
940
  </div>
867
941
 
868
942
  <!-- Test quality-control companion config (shown when QC is enabled on a Tester