@cat-factory/app 0.152.0 → 0.153.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.
@@ -1,81 +1,11 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
3
- import type { AgentKind, Pipeline } from '~/types/domain'
4
- import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
- import type { PipelinePurpose, StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
6
- import { companionForProducer, uid } from '~/utils/catalog'
2
+ import { ref } from 'vue'
3
+ import type { Pipeline } from '~/types/domain'
4
+ import type { PipelinePurpose } from '@cat-factory/contracts'
7
5
  import { useUpsertList } from '~/composables/useUpsertList'
8
- import { useWorkspaceStore } from '~/stores/workspace'
9
-
10
- /** A sensible default config when a step is first flipped to consensus in the builder. */
11
- function defaultConsensusConfig(): ConsensusStepConfig {
12
- return {
13
- enabled: true,
14
- strategy: 'specialist-panel',
15
- participants: [
16
- { id: uid('cp'), role: 'Pragmatist', systemFraming: 'Favour the simplest viable approach.' },
17
- {
18
- id: uid('cp'),
19
- role: 'Skeptic',
20
- systemFraming: 'Probe risks, edge cases and failure modes.',
21
- },
22
- ],
23
- }
24
- }
25
-
26
- /**
27
- * The per-step, index-aligned draft arrays. Every one is kept the same length as `draft` and
28
- * spliced/reordered in lockstep (see `insertAt` / `removeFromDraft` / `moveUnit`), so grouping
29
- * their construction keeps that alignment contract — and its documentation — in one place.
30
- */
31
- function createDraftStepState() {
32
- /** The chain currently being assembled in the builder. */
33
- const draft = ref<AgentKind[]>([])
34
- /** Per-step approval gates, kept index-aligned with `draft`. */
35
- const draftGates = ref<boolean[]>([])
36
- /** Per-step enable flags, kept index-aligned with `draft` (false ⇒ skipped at run). */
37
- const draftEnabled = ref<boolean[]>([])
38
- /**
39
- * Per-step companion thresholds, kept index-aligned with `draft`. Not editable in the
40
- * builder UI today, but carried through edits so an existing pipeline's thresholds
41
- * survive a save.
42
- */
43
- const draftThresholds = ref<(number | null)[]>([])
44
- /** Per-step consensus configs, kept index-aligned with `draft` (null ⇒ standard agent). */
45
- const draftConsensus = ref<(ConsensusStepConfig | null)[]>([])
46
- /** Per-step estimate gating, kept index-aligned with `draft` (null ⇒ always run). */
47
- const draftGating = ref<(StepGating | null)[]>([])
48
- /**
49
- * Per-step Follow-up companion toggle, kept index-aligned with `draft`. Only meaningful on
50
- * a `coder` step; `false` disables the companion there (default/true ⇒ enabled).
51
- */
52
- const draftFollowUps = ref<(boolean | null)[]>([])
53
- /**
54
- * Per-step test quality-control companion config, kept index-aligned with `draft`. Only
55
- * meaningful on a Tester step (`tester-api`/`tester-ui`); `null`/absent means "enabled, no
56
- * gating" (the QC companion is on by default), `{ enabled: false }` disables it, and an
57
- * entry with `gating` makes it conditional on the task estimate.
58
- */
59
- const draftTesterQuality = ref<(TesterQualityConfig | null)[]>([])
60
- /**
61
- * Per-step options bag, kept index-aligned with `draft`: the extensible home for new per-step
62
- * parameters (see `StepOptions`). `null`/absent per step ⇒ that step's defaults. Today the only
63
- * field is `autoRecommend` (requirements-review); by convention we store ONLY deviations from a
64
- * default, so an entry exists only when a step opts out of something.
65
- */
66
- const draftStepOptions = ref<(StepOptions | null)[]>([])
67
- return {
68
- draft,
69
- draftGates,
70
- draftEnabled,
71
- draftThresholds,
72
- draftConsensus,
73
- draftGating,
74
- draftFollowUps,
75
- draftTesterQuality,
76
- draftStepOptions,
77
- }
78
- }
6
+ import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
7
+ import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
8
+ import { createPipelinePersistence } from '~/stores/pipelines/persistence'
79
9
 
80
10
  /**
81
11
  * Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
@@ -83,6 +13,11 @@ function createDraftStepState() {
83
13
  * the draft is transient client state. The draft doubles as the EDIT surface: a
84
14
  * custom pipeline can be loaded into it (`loadForEdit`) and saved back in place,
85
15
  * while a built-in is cloned first (`clonePipeline`) into an editable copy.
16
+ *
17
+ * The draft manipulation + persistence operations live in cohesive factories
18
+ * ({@link createPipelineDraftActions} / {@link createPipelinePersistence}, under
19
+ * `stores/pipelines/`) that close over the shared state assembled here — a size-only
20
+ * split mirroring `stores/board/`, not a new seam.
86
21
  */
87
22
  export const usePipelinesStore = defineStore('pipelines', () => {
88
23
  const api = useApi()
@@ -135,360 +70,31 @@ export const usePipelinesStore = defineStore('pipelines', () => {
135
70
  return pipelines.value.find((p) => p.id === id)
136
71
  }
137
72
 
138
- /** Insert a step (with its default per-step config) at `index`, keeping arrays aligned. */
139
- function insertAt(index: number, kind: AgentKind) {
140
- draft.value.splice(index, 0, kind)
141
- draftGates.value.splice(index, 0, false)
142
- draftEnabled.value.splice(index, 0, true)
143
- draftThresholds.value.splice(index, 0, null)
144
- draftConsensus.value.splice(index, 0, null)
145
- draftGating.value.splice(index, 0, null)
146
- draftFollowUps.value.splice(index, 0, null)
147
- draftTesterQuality.value.splice(index, 0, null)
148
- draftStepOptions.value.splice(index, 0, null)
149
- }
150
-
151
- function addToDraft(kind: AgentKind) {
152
- insertAt(draft.value.length, kind)
153
- }
154
-
155
- function removeFromDraft(index: number) {
156
- draft.value.splice(index, 1)
157
- draftGates.value.splice(index, 1)
158
- draftEnabled.value.splice(index, 1)
159
- draftThresholds.value.splice(index, 1)
160
- draftConsensus.value.splice(index, 1)
161
- draftGating.value.splice(index, 1)
162
- draftFollowUps.value.splice(index, 1)
163
- draftTesterQuality.value.splice(index, 1)
164
- draftStepOptions.value.splice(index, 1)
165
- }
166
-
167
- function moveInDraft(from: number, to: number) {
168
- if (to < 0 || to >= draft.value.length) return
169
- const [item] = draft.value.splice(from, 1)
170
- if (item) draft.value.splice(to, 0, item)
171
- const [gate] = draftGates.value.splice(from, 1)
172
- draftGates.value.splice(to, 0, gate ?? false)
173
- const [on] = draftEnabled.value.splice(from, 1)
174
- draftEnabled.value.splice(to, 0, on ?? true)
175
- const [th] = draftThresholds.value.splice(from, 1)
176
- draftThresholds.value.splice(to, 0, th ?? null)
177
- const [cons] = draftConsensus.value.splice(from, 1)
178
- draftConsensus.value.splice(to, 0, cons ?? null)
179
- const [gat] = draftGating.value.splice(from, 1)
180
- draftGating.value.splice(to, 0, gat ?? null)
181
- const [fu] = draftFollowUps.value.splice(from, 1)
182
- draftFollowUps.value.splice(to, 0, fu ?? null)
183
- const [tq] = draftTesterQuality.value.splice(from, 1)
184
- draftTesterQuality.value.splice(to, 0, tq ?? null)
185
- const [so] = draftStepOptions.value.splice(from, 1)
186
- draftStepOptions.value.splice(to, 0, so ?? null)
187
- }
188
-
189
- /** Whether the producer step at `index` currently has its companion attached after it. */
190
- function hasCompanion(index: number): boolean {
191
- const companion = companionForProducer(draft.value[index] ?? '')
192
- return companion !== undefined && draft.value[index + 1] === companion
193
- }
194
-
195
- /**
196
- * Toggle the dependent companion on the producer step at `index`: insert it immediately
197
- * after (turn on) or remove it (turn off). A no-op for a kind that has no companion.
198
- */
199
- function toggleCompanion(index: number) {
200
- const companion = companionForProducer(draft.value[index] ?? '')
201
- if (!companion) return
202
- if (draft.value[index + 1] === companion) removeFromDraft(index + 1)
203
- else insertAt(index + 1, companion)
204
- }
205
-
206
- /** Toggle estimate gating on/off for the (companion) step at `index`. */
207
- function toggleDraftGating(index: number) {
208
- draftGating.value[index] = draftGating.value[index]?.enabled
209
- ? null
210
- : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
211
- }
212
-
213
- /**
214
- * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
215
- * that sits immediately after its producer — that companion is folded into the producer's
216
- * unit (`companionIndex`) and surfaced as a toggle on it, not a standalone row. The backend
217
- * now REJECTS a companion that is not immediately after its producer (strict adjacency in
218
- * `validatePipelineShape`), so a saved pipeline never has one — but a stray companion that
219
- * still shows up in the draft (e.g. a pre-existing pipeline saved before adjacency was
220
- * enforced) is emitted as its own standalone unit so it stays visible and removable/
221
- * reorderable into a valid shape rather than being silently dropped — and, crucially, so
222
- * every `draft` index belongs to exactly one unit, which is what lets {@link moveUnit}
223
- * reorder by unit boundaries without ever dropping a step.
224
- * `index`/`companionIndex` are positions in the raw `draft` arrays.
225
- */
226
- const units = computed(() => {
227
- const out: { index: number; kind: AgentKind; companionIndex: number | null }[] = []
228
- let folded = -1 // draft index already consumed as the previous unit's adjacent companion
229
- for (let i = 0; i < draft.value.length; i++) {
230
- const kind = draft.value[i]
231
- if (kind === undefined || i === folded) continue
232
- const companion = companionForProducer(kind)
233
- const companionIndex = companion && draft.value[i + 1] === companion ? i + 1 : null
234
- if (companionIndex !== null) folded = companionIndex
235
- out.push({ index: i, kind, companionIndex })
236
- }
237
- return out
238
- })
239
-
240
- /**
241
- * Move the unit at visible position `from` to `to`, carrying its attached companion. Rebuilds
242
- * every parallel array by the SAME unit boundaries so they stay index-aligned.
243
- */
244
- function moveUnit(from: number, to: number) {
245
- const u = units.value
246
- if (to < 0 || to >= u.length || from === to) return
247
- const reorder = <T>(arr: T[]): T[] => {
248
- const chunks = u.map((unit) =>
249
- arr.slice(unit.index, unit.index + (unit.companionIndex !== null ? 2 : 1)),
250
- )
251
- const [moved] = chunks.splice(from, 1)
252
- if (moved) chunks.splice(to, 0, moved)
253
- return chunks.flat()
254
- }
255
- draft.value = reorder(draft.value)
256
- draftGates.value = reorder(draftGates.value)
257
- draftEnabled.value = reorder(draftEnabled.value)
258
- draftThresholds.value = reorder(draftThresholds.value)
259
- draftConsensus.value = reorder(draftConsensus.value)
260
- draftGating.value = reorder(draftGating.value)
261
- draftFollowUps.value = reorder(draftFollowUps.value)
262
- draftTesterQuality.value = reorder(draftTesterQuality.value)
263
- draftStepOptions.value = reorder(draftStepOptions.value)
264
- }
265
-
266
- /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
267
- function toggleDraftConsensus(index: number) {
268
- draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
269
- }
270
-
271
- /** Replace the consensus config of the draft step at `index` (builder editor edits). */
272
- function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
273
- draftConsensus.value[index] = config
274
- }
275
-
276
- /** Toggle the approval gate on the draft step at `index`. */
277
- function toggleDraftGate(index: number) {
278
- draftGates.value[index] = !draftGates.value[index]
279
- }
280
-
281
- /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
282
- function toggleDraftFollowUps(index: number) {
283
- // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
284
- draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
285
- }
286
-
287
- /**
288
- * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
289
- * companion is enabled by default (a `null` entry), so the first toggle disables it
290
- * (`{ enabled: false }`, dropping any gating) and the next restores the default.
291
- */
292
- function toggleDraftTesterQuality(index: number) {
293
- draftTesterQuality.value[index] =
294
- draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
295
- }
296
-
297
- /**
298
- * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
299
- * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
300
- * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
301
- * default `null` (enabled, ungated).
302
- */
303
- function toggleDraftTesterQualityGating(index: number) {
304
- const cur = draftTesterQuality.value[index]
305
- if (cur?.enabled === false) return
306
- draftTesterQuality.value[index] = cur?.gating?.enabled
307
- ? null
308
- : {
309
- enabled: true,
310
- gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
311
- }
312
- }
313
-
314
- /** Enable/disable the draft step at `index` without removing it. */
315
- function toggleDraftEnabled(index: number) {
316
- draftEnabled.value[index] = draftEnabled.value[index] === false
317
- }
318
-
319
- /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
320
- function draftAutoRecommendEnabled(index: number): boolean {
321
- return draftStepOptions.value[index]?.autoRecommend !== false
322
- }
323
-
324
- /**
325
- * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
326
- * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
327
- * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
328
- */
329
- function toggleDraftAutoRecommend(index: number) {
330
- const next: StepOptions = { ...draftStepOptions.value[index] }
331
- if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
332
- else delete next.autoRecommend
333
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
334
- }
335
-
336
- /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
337
- function draftSkillId(index: number): string | undefined {
338
- return draftStepOptions.value[index]?.skillId
339
- }
340
-
341
- /**
342
- * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
343
- * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
344
- * bag empties, the whole entry (so it normalizes away like the other options).
345
- */
346
- function setDraftSkillId(index: number, skillId: string | undefined) {
347
- const next: StepOptions = { ...draftStepOptions.value[index] }
348
- if (skillId) next.skillId = skillId
349
- else delete next.skillId
350
- draftStepOptions.value[index] = Object.keys(next).length ? next : null
351
- }
352
-
353
- function clearDraft() {
354
- draft.value = []
355
- draftGates.value = []
356
- draftEnabled.value = []
357
- draftThresholds.value = []
358
- draftConsensus.value = []
359
- draftGating.value = []
360
- draftFollowUps.value = []
361
- draftTesterQuality.value = []
362
- draftStepOptions.value = []
363
- draftLabels.value = []
364
- draftPurpose.value = null
365
- draftName.value = 'New pipeline'
366
- draftDescription.value = ''
367
- editingId.value = null
368
- }
369
-
370
- /** Load an existing (custom) pipeline into the draft so it can be edited in place. */
371
- function loadForEdit(pipeline: Pipeline) {
372
- draft.value = [...pipeline.agentKinds]
373
- draftGates.value = pipeline.agentKinds.map((_, i) => pipeline.gates?.[i] ?? false)
374
- draftEnabled.value = pipeline.agentKinds.map((_, i) => pipeline.enabled?.[i] ?? true)
375
- draftThresholds.value = pipeline.agentKinds.map((_, i) => pipeline.thresholds?.[i] ?? null)
376
- draftConsensus.value = pipeline.agentKinds.map((_, i) => pipeline.consensus?.[i] ?? null)
377
- draftGating.value = pipeline.agentKinds.map((_, i) => pipeline.gating?.[i] ?? null)
378
- draftFollowUps.value = pipeline.agentKinds.map((_, i) => pipeline.followUps?.[i] ?? null)
379
- draftTesterQuality.value = pipeline.agentKinds.map(
380
- (_, i) => pipeline.testerQuality?.[i] ?? null,
381
- )
382
- draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
383
- draftLabels.value = [...(pipeline.labels ?? [])]
384
- draftPurpose.value = pipeline.purpose ?? null
385
- draftName.value = pipeline.name
386
- draftDescription.value = pipeline.description ?? ''
387
- editingId.value = pipeline.id
388
- }
389
-
390
- /** The optional arrays to send, omitting the ones that are at their defaults. */
391
- function draftPayload() {
392
- return {
393
- name: draftName.value.trim() || 'Untitled pipeline',
394
- // ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
395
- // reads as "keep existing", so toggling the last of the text away must send the empty string.
396
- // The backend trims + drops a blank one, so this is a no-op on create / an empty description.
397
- description: draftDescription.value.trim(),
398
- agentKinds: [...draft.value],
399
- // Only send gates when at least one step is gated.
400
- ...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
401
- // Only send enabled when at least one step is disabled (default is all-on).
402
- ...(draftEnabled.value.some((e) => e === false) ? { enabled: [...draftEnabled.value] } : {}),
403
- // Only send thresholds when at least one step pins an explicit value.
404
- ...(draftThresholds.value.some((t) => t != null)
405
- ? { thresholds: [...draftThresholds.value] }
406
- : {}),
407
- // Only send consensus when at least one step is consensus-enabled.
408
- ...(draftConsensus.value.some((c) => c?.enabled)
409
- ? { consensus: [...draftConsensus.value] }
410
- : {}),
411
- // Only send gating when at least one step has gating enabled.
412
- ...(draftGating.value.some((g) => g?.enabled) ? { gating: [...draftGating.value] } : {}),
413
- // Only send followUps when at least one step disables it (default is on, so only the
414
- // explicit `false` opt-outs are worth persisting).
415
- ...(draftFollowUps.value.some((f) => f === false)
416
- ? { followUps: [...draftFollowUps.value] }
417
- : {}),
418
- // Only send testerQuality when at least one Tester step deviates from the default
419
- // (companion disabled, or an estimate gate configured) — the default (null/enabled,
420
- // ungated) is not worth persisting.
421
- ...(draftTesterQuality.value.some((q) => q?.enabled === false || q?.gating?.enabled)
422
- ? { testerQuality: [...draftTesterQuality.value] }
423
- : {}),
424
- // ALWAYS send stepOptions, unlike the legacy per-step arrays above. Those omit-when-default,
425
- // which means an update can never CLEAR them (an omitted field reads as "keep existing"), so
426
- // toggling the last opt-out back to its default on a saved pipeline would silently not
427
- // persist. Sending the aligned array always lets `update` overwrite; the backend normalizes
428
- // an all-default array away (stores nothing), so this is a no-op on create / all-default.
429
- stepOptions: draftStepOptions.value.map((o) => o ?? null),
430
- // Only send labels when there are any.
431
- ...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
432
- // Only send purpose when the pipeline is classified (null ⇒ leave unclassified). Like the
433
- // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
434
- // a classification back to unclassified is not a supported edit (every built-in ships one).
435
- ...(draftPurpose.value ? { purpose: draftPurpose.value } : {}),
436
- }
437
- }
438
-
439
- /** Persist the draft: update the pipeline being edited, else create a new one. */
440
- async function saveDraft(): Promise<Pipeline | null> {
441
- if (draft.value.length === 0) return null
442
- const wsId = useWorkspaceStore().requireId()
443
- const payload = draftPayload()
444
- if (editingId.value) {
445
- const updated = await api.updatePipeline(wsId, editingId.value, payload)
446
- upsertPipeline(updated)
447
- clearDraft()
448
- return updated
449
- }
450
- const pipeline = await api.createPipeline(wsId, payload)
451
- upsertPipeline(pipeline)
452
- clearDraft()
453
- return pipeline
454
- }
455
-
456
- /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
457
- async function clonePipeline(id: string): Promise<Pipeline> {
458
- const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
459
- upsertPipeline(clone)
460
- loadForEdit(clone)
461
- return clone
462
- }
463
-
464
- async function removePipeline(id: string) {
465
- await api.removePipeline(useWorkspaceStore().requireId(), id)
466
- dropPipeline(id)
467
- if (editingId.value === id) clearDraft()
468
- }
469
-
470
- /**
471
- * Reseed a built-in pipeline from the backend's current catalog definition: restores its
472
- * canonical structure + version (adopting an update, or repairing a drifted/invalid copy)
473
- * while preserving its labels/archive state. Replaces the pipeline in the cache.
474
- */
475
- async function reseed(id: string): Promise<Pipeline> {
476
- const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
477
- upsertPipeline(updated)
478
- if (editingId.value === id) clearDraft()
479
- return updated
480
- }
481
-
482
- /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
483
- async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
484
- const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
485
- upsertPipeline(updated)
486
- return updated
73
+ // The draft manipulation + persistence operations, split into cohesive factories sharing the
74
+ // state above (a size-only extraction behaviour is identical to the former in-closure
75
+ // functions). Persistence drives the draft-lifecycle helpers (`clearDraft`/`loadForEdit`).
76
+ const context: PipelinesContext = {
77
+ api,
78
+ pipelines,
79
+ upsertPipeline,
80
+ dropPipeline,
81
+ draft,
82
+ draftGates,
83
+ draftEnabled,
84
+ draftThresholds,
85
+ draftConsensus,
86
+ draftGating,
87
+ draftFollowUps,
88
+ draftTesterQuality,
89
+ draftStepOptions,
90
+ draftLabels,
91
+ draftPurpose,
92
+ draftName,
93
+ draftDescription,
94
+ editingId,
487
95
  }
488
-
489
- const archive = (id: string) => organize(id, { archived: true })
490
- const unarchive = (id: string) => organize(id, { archived: false })
491
- const setLabels = (id: string, labels: string[]) => organize(id, { labels })
96
+ const draftActions = createPipelineDraftActions(context)
97
+ const persistence = createPipelinePersistence(context, draftActions)
492
98
 
493
99
  return {
494
100
  pipelines,
@@ -507,36 +113,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
507
113
  draftName,
508
114
  draftDescription,
509
115
  editingId,
510
- units,
511
116
  hydrate,
512
117
  getPipeline,
513
- addToDraft,
514
- removeFromDraft,
515
- moveInDraft,
516
- moveUnit,
517
- hasCompanion,
518
- toggleCompanion,
519
- toggleDraftGating,
520
- toggleDraftGate,
521
- toggleDraftFollowUps,
522
- toggleDraftTesterQuality,
523
- toggleDraftTesterQualityGating,
524
- draftAutoRecommendEnabled,
525
- toggleDraftAutoRecommend,
526
- draftSkillId,
527
- setDraftSkillId,
528
- toggleDraftEnabled,
529
- toggleDraftConsensus,
530
- setDraftConsensus,
531
- clearDraft,
532
- loadForEdit,
533
- saveDraft,
534
- clonePipeline,
535
- removePipeline,
536
- reseed,
537
- organize,
538
- archive,
539
- unarchive,
540
- setLabels,
118
+ ...draftActions,
119
+ ...persistence,
541
120
  }
542
121
  })
@@ -839,6 +839,33 @@ function createAiOnboardingModals() {
839
839
  }
840
840
  }
841
841
 
842
+ /**
843
+ * The generic CONSUMER-overlay host (extension slice D — the frontend-extension-mechanism
844
+ * initiative). Unlike every sub-slice above, this holds NO per-modal boolean: it is a single
845
+ * pick-one pointer to whichever consumer-contributed overlay (the `appOverlays` slot) is
846
+ * currently open, so a consumer nav item's `run` closure finally has something to open. A
847
+ * deployment registers `{ id: '<ns>:<name>', component }` in the `appOverlays` slot and calls
848
+ * `ui.openOverlay(id, subject?)` (usually via the auto-imported `useAppOverlays()` composable);
849
+ * the single `<AppOverlayHost>` in `pages/index.vue` resolves the slot and mounts the matching
850
+ * component, handing it the optional `subject`. First-party modals stay hand-mounted in
851
+ * `index.vue` — this seam is deliberately scoped to consumer extensions (strangler discipline).
852
+ */
853
+ function createConsumerOverlayHost() {
854
+ // The active consumer overlay: its slot id + an optional opaque subject the overlay renders
855
+ // against (e.g. a block id). Null when no consumer overlay is open. Only ONE at a time —
856
+ // opening another replaces it (a pick-one host, like the result-view seam).
857
+ const activeOverlay = ref<{ id: string; subject?: unknown } | null>(null)
858
+
859
+ function openOverlay(id: string, subject?: unknown) {
860
+ activeOverlay.value = { id, subject }
861
+ }
862
+ function closeOverlay() {
863
+ activeOverlay.value = null
864
+ }
865
+
866
+ return { activeOverlay, openOverlay, closeOverlay }
867
+ }
868
+
842
869
  /**
843
870
  * The modal / panel slice of the UI store: every open-close flag for the dozens of modals,
844
871
  * panels and hubs (document + task import, bootstrap, integrations, workspace/account settings,
@@ -904,6 +931,7 @@ export function createUiModals() {
904
931
  const misc = createMiscModals()
905
932
  const documentsTasks = createDocumentTaskModals(resetHubReturn)
906
933
  const overlays = createOverlayModals()
934
+ const consumerOverlays = createConsumerOverlayHost()
907
935
  const panels = createIntegrationPanelModals(resetHubReturn)
908
936
  const settings = createSettingsModals(resetHubReturn)
909
937
  const infra = createInfraModals(resetHubReturn)
@@ -924,6 +952,7 @@ export function createUiModals() {
924
952
  ...misc,
925
953
  ...documentsTasks,
926
954
  ...overlays,
955
+ ...consumerOverlays,
927
956
  ...panels,
928
957
  ...settings,
929
958
  ...infra,
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createUiModals } from '~/stores/ui/modals'
3
+
4
+ /**
5
+ * The consumer-overlay host slice (extension slice D — the frontend-extension-mechanism
6
+ * initiative). It is composed into `createUiModals`, so these tests drive it there directly
7
+ * (plain refs/functions, no Pinia). The pointer is pick-one: opening a second overlay replaces
8
+ * the first, and closing clears it — this is what `<AppOverlayHost>` reads to mount exactly one
9
+ * consumer overlay.
10
+ */
11
+ describe('ui overlay host (slice D)', () => {
12
+ it('starts with no active overlay', () => {
13
+ const ui = createUiModals()
14
+ expect(ui.activeOverlay.value).toBeNull()
15
+ })
16
+
17
+ it('openOverlay sets the active id + subject; closeOverlay clears it', () => {
18
+ const ui = createUiModals()
19
+ ui.openOverlay('acme:security-dashboard-overlay', { blockId: 'blk_1' })
20
+ expect(ui.activeOverlay.value).toEqual({
21
+ id: 'acme:security-dashboard-overlay',
22
+ subject: { blockId: 'blk_1' },
23
+ })
24
+ ui.closeOverlay()
25
+ expect(ui.activeOverlay.value).toBeNull()
26
+ })
27
+
28
+ it('openOverlay without a subject leaves subject undefined', () => {
29
+ const ui = createUiModals()
30
+ ui.openOverlay('acme:security-dashboard-overlay')
31
+ expect(ui.activeOverlay.value).toEqual({
32
+ id: 'acme:security-dashboard-overlay',
33
+ subject: undefined,
34
+ })
35
+ })
36
+
37
+ it('is pick-one — opening a second overlay replaces the first', () => {
38
+ const ui = createUiModals()
39
+ ui.openOverlay('acme:one')
40
+ ui.openOverlay('acme:two', 42)
41
+ expect(ui.activeOverlay.value).toEqual({ id: 'acme:two', subject: 42 })
42
+ })
43
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.152.0",
3
+ "version": "0.153.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",