@cat-factory/app 0.215.2 → 0.217.1

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 (37) hide show
  1. package/README.md +133 -2
  2. package/app/components/board/AddTaskModal.vue +54 -78
  3. package/app/components/board/CreateInitiativeModal.vue +8 -6
  4. package/app/components/{board/InitiativePresetFields.vue → common/DescriptorFields.vue} +57 -63
  5. package/app/components/inputGate/InputGateNotice.vue +176 -0
  6. package/app/components/panels/AgentStepDetail.vue +27 -3
  7. package/app/components/panels/inspector/ServiceTestConfig.vue +1 -1
  8. package/app/components/panels/inspector/TaskExecution.vue +32 -3
  9. package/app/components/pipeline/PipelineProgress.vue +1 -1
  10. package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
  11. package/app/composables/api/inputGate.ts +25 -0
  12. package/app/composables/useApi.ts +2 -0
  13. package/app/composables/usePipelineErrorToast.ts +8 -0
  14. package/app/modular/nav-contributions.spec.ts +1 -1
  15. package/app/modular/nav-contributions.ts +1 -1
  16. package/app/stores/inputGate.ts +58 -0
  17. package/app/stores/ui/resultViews.ts +9 -1
  18. package/app/stores/workspaceSettings.ts +1 -0
  19. package/app/types/domain.ts +7 -0
  20. package/app/utils/descriptorFields.spec.ts +126 -0
  21. package/app/utils/descriptorFields.ts +104 -0
  22. package/app/utils/initiative.ts +0 -30
  23. package/app/utils/inputGate.spec.ts +52 -0
  24. package/app/utils/inputGate.ts +44 -0
  25. package/app/utils/pipelineRender.spec.ts +47 -9
  26. package/app/utils/pipelineRender.ts +23 -2
  27. package/i18n/locales/de.json +62 -2
  28. package/i18n/locales/en.json +65 -2
  29. package/i18n/locales/es.json +77 -5
  30. package/i18n/locales/fr.json +77 -5
  31. package/i18n/locales/he.json +63 -2
  32. package/i18n/locales/it.json +62 -2
  33. package/i18n/locales/ja.json +62 -2
  34. package/i18n/locales/pl.json +77 -5
  35. package/i18n/locales/tr.json +62 -2
  36. package/i18n/locales/uk.json +77 -5
  37. package/package.json +2 -2
package/README.md CHANGED
@@ -18,6 +18,9 @@ The SPA source lives under `app/` (the Nuxt srcDir).
18
18
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
19
19
  - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
20
20
  - [In-app tutorial tours](#in-app-tutorial-tours)
21
+ - [Real-time store coherence](#real-time-store-coherence-avoid-the-full-refresh-clobber)
22
+ - [Internationalization (i18n) authoring](#internationalization-i18n-authoring)
23
+ - [Extending the layer (consumer modules)](#extending-the-layer-consumer-modules)
21
24
  - [Key UI surfaces](#key-ui-surfaces)
22
25
  - [Develop & test](#develop--test)
23
26
 
@@ -76,6 +79,29 @@ earlier than before takes the entire SPA down at boot, and the unit suite cannot
76
79
  (nothing there installs the plugin). Every e2e spec does, because every one of them boots
77
80
  the app.
78
81
 
82
+ ### A backend-DECLARED form renders through `DescriptorFields.vue`
83
+
84
+ When the backend declares the fields and the SPA only collects them, render them with the shared
85
+ `components/common/DescriptorFields.vue` over the contracts vocabulary
86
+ (`contracts/src/form-fields.ts`), and never hand-roll a second renderer for the same shapes. Two
87
+ surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
88
+ custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
89
+
90
+ Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
91
+ button reflects exactly what the server will refuse, and **submit the shared
92
+ `sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
93
+ reaches the wire. **The labels are deployment-authored English rendered verbatim**: only the chrome
94
+ around them (a path-invalid message, section captions) is i18n, so no descriptor string enters a
95
+ locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
96
+ (`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
97
+ `toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
98
+ must be able to reach, and a rule inside a component is only reachable by mounting one.
99
+
100
+ Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can
101
+ re-register the descriptor while the dialog sits open, so a create can come back `422` with
102
+ `details.reason: 'task_type_fields_invalid'`. Map it to translated copy like any other reason
103
+ (`AddTaskModal`'s `createRefusalMessage`) rather than showing the server's field-key prose.
104
+
79
105
  ### Always import a layer component explicitly
80
106
 
81
107
  **Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
@@ -132,7 +158,7 @@ hoc where it can be avoided:
132
158
  and the deployment-wide operator + reports rollups).
133
159
 
134
160
  Sole-route items stay in basic when the delivery loop runs on them: the pipeline builder,
135
- add-from-repo, the fragment library, the infrastructure/PREnv windows, and the workspace /
161
+ add-from-repo, the fragment library, the infrastructure/ephemeral-env windows, and the workspace /
136
162
  model configuration a run actually reads. `nav-contributions.spec.ts` pins the advanced set
137
163
  against a table naming each item's kind and reason, so promoting one forces that claim to be
138
164
  written down rather than assumed.
@@ -329,7 +355,7 @@ is SUPPRESSED rather than unmounted, because it holds the running tour's resolve
329
355
  remount would re-resolve it against gates that may have flipped since the tour started.
330
356
 
331
357
  The decisions behind this surface, and why each alternative was rejected, are recorded in
332
- [ADR 0033](../../backend/docs/adr/0033-in-app-tutorials.md). This section is the authority on how
358
+ [ADR 0036](../../backend/docs/adr/0036-in-app-tutorials.md). This section is the authority on how
333
359
  the thing WORKS.
334
360
 
335
361
  A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
@@ -511,6 +537,111 @@ guard scans the layer for both ways an id is named: written onto an element, or
511
537
  `testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
512
538
  is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.
513
539
 
540
+ ## Real-time store coherence: avoid the full-refresh CLOBBER
541
+
542
+ The recurring product bug behind most e2e flakes: a stale full-snapshot refresh clobbering newer
543
+ live state. The SPA has two delivery shapes and mixing them wrong drops live-added state with NO
544
+ event left to restore it.
545
+
546
+ - **Know how your entity is delivered.** A `board` event is COARSE: no payload, only a debounced
547
+ full `workspace.refresh()`, and `hydrate` REPLACES whole lists. A spawned task/module block
548
+ reaches the browser ONLY this way. Targeted events (`execution`/`bootstrap`/`initiative`) carry
549
+ the entity and `upsert` it, so they don't clobber. Prefer a targeted upsert for anything that
550
+ must appear reliably.
551
+ - **Full refreshes MUST be monotonic.** Two `refresh()` calls can be in flight; a staler one
552
+ resolving later overwrites the newer. `workspace.refresh()` guards this with a sequence. Do not
553
+ reintroduce an unguarded `hydrate(await fetch())`, and apply the guard to any new coalesced
554
+ refresh path.
555
+ - **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
556
+ `connected` only after it settles (which is why e2e gates on `data-connected`).
557
+ - **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
558
+ into the snapshot or reconcile rather than replace.
559
+ - **An action's OPTIMISTIC ECHO is a clobber too, and it bypasses both guards above.** A store
560
+ that awaits a mutation and then assigns the returned sub-state onto the cached run
561
+ (`step.forkDecision`, `step.prReview`, `step.judge`, `step.followUps`) is writing straight past
562
+ `upsert`'s `rev` check. Where the mutation WAKES THE DRIVER, the driver's next emit routinely
563
+ beats the HTTP response, so the echo puts the run back; if the run then parks, nothing emits
564
+ again and the newer state is gone for good (the fork-chat reply that vanished, leaving a
565
+ "thinking…" bubble spinning). Every echo therefore goes through
566
+ `execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
567
+ request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
568
+ - **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
569
+ `stores/execution.spec.ts` for echoes): drive the two orderings and assert the fresher one
570
+ wins.
571
+
572
+ ## Internationalization (i18n) authoring
573
+
574
+ All user-facing SPA copy goes through `@nuxtjs/i18n`; never hard-code a display string. This
575
+ layer ships the base `en` locale, and a downstream deployment overrides by dropping its own files
576
+ (the per-layer deep-merge is the override seam, consumer wins key by key). Migration status:
577
+ [`docs/localization.md`](../../docs/localization.md).
578
+
579
+ - `i18n/locales/<locale>.json`: the catalogs (the v9+ `i18n/` convention, NOT `app/locales/`).
580
+ - `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the named
581
+ `numberFormats`/`datetimeFormats`). Messages are deliberately NOT here so the module can
582
+ deep-merge across the `extends` chain. Referenced as the BARE filename
583
+ `vueI18n: 'i18n.config.ts'`, never `layerDir`-anchored.
584
+ - `package.json` `files` MUST include `"i18n"`. Release-blocking.
585
+
586
+ **Adding a string**: add the key to `en.json` under the feature namespace, resolve with
587
+ `t('feature.area.key')`, and format numbers/dates through `$n`/`$d` (the named formats), never
588
+ raw `Intl`.
589
+
590
+ **Key conventions**: one namespace per feature; **leaf keys mirror the enum/code value verbatim**
591
+ so a dynamic lookup is total; **no cross-key concatenation** (a full sentence is ONE key with
592
+ `{named}` placeholders, plurals use the pipe form).
593
+
594
+ **Component mechanics that bite:**
595
+
596
+ - `useI18n` is auto-imported; destructure in `<script setup>` and use those fns in the template
597
+ so the typed-key check sees literal keys. Never `import` it.
598
+ - Plural + interpolation: `t(key, { vendor, count }, count)`, where the THIRD arg is the choice.
599
+ - **Code/format-example placeholders stay INLINE**, not in the catalog; required when they
600
+ contain `{`/`}` (vue-i18n metacharacters). Only prose placeholders get a key. Same for brand
601
+ names.
602
+ - **No HTML in message bodies**: drop mid-sentence `<strong>`, or use `<i18n-t>` with slots.
603
+ - For a vendor/enum-keyed set, build an array of STATIC literal `t()` keys, one per member.
604
+ Reserve the runtime-assembled key + exhaustive `Record` guard for lookups genuinely unknown
605
+ until runtime.
606
+ - Straight quotes, no em-dashes in new entries.
607
+
608
+ **Translator descriptions (`@<key>` siblings): default to NONE.** They live only in `en.json` and
609
+ are notes to a translator, never runtime data. Add one ONLY when a competent translator seeing
610
+ the English and the key path could plausibly get it wrong: homograph / part-of-speech ambiguity
611
+ (`@close`), proper nouns that must NOT be translated (`@kaizen`), umbrella strings hiding cases
612
+ the text doesn't show, placeholder/format constraints, or plural-form requirements beyond
613
+ English's two.
614
+
615
+ **Presenting a backend failure**: raw backend prose is DETAIL, never the description. Even with
616
+ no `reason` to key off, a failure is described from its STATUS CLASS through an exhaustive
617
+ `Record<ApiErrorCode, …>` of translated copy, and the untranslated `message` (plus a validation
618
+ 400's `issues` and the envelope's `requestId`) is reached through a "Show details" disclosure
619
+ that reveals it in place. So a non-English user is never handed English as the primary
620
+ explanation, and the elaborate operator remedies the backend does write stay one click away
621
+ rather than being dropped. A new failure-presenting surface copies that split (the
622
+ `usePipelineErrorToast.ts` pattern; the wire vocabulary comes from `@cat-factory/contracts`).
623
+
624
+ **Drift guards** (oxlint has no `no-raw-text` rule, so these replace it):
625
+
626
+ 1. **Typed message keys** make a statically written unknown `t('literal.key')` a typecheck
627
+ failure. This does NOT cover a runtime-assembled key.
628
+ 2. For enum→key lookups, guard with an **exhaustive `Record<TheEnum, string>`** keyed off the
629
+ contracts union, plus a runtime `te()` fallback. Never rely on tier 1 alone for a
630
+ reason/status-keyed lookup.
631
+ 3. `pnpm --filter @cat-factory/app run i18n:check` hard-fails on MISSING keys and reports unused
632
+ ones as non-blocking warnings (the catalog legitimately seeds keys ahead of use).
633
+ 4. **Locale parity**: `i18n-locale-parity.mjs --since origin/<base>` requires a PR that adds,
634
+ changes, or removes an `en.json` key to make the SAME change in every other locale. It is
635
+ change-coupling against the merge-base, NOT full key parity.
636
+
637
+ **Translate for real: NEVER ship an English string as a non-`en` value.** The parity gate checks
638
+ only that the key exists, so it will pass a verbatim English copy, and that copy is a bug. The
639
+ only values that may legitimately match `en` are proper nouns identical across languages
640
+ (`DeepSeek`, `AWS Bedrock`). If you genuinely cannot produce a translation, say so in the PR
641
+ rather than committing a placeholder that reads as done.
642
+
643
+ Migration is incremental: when you touch a component, lift its visible copy into the catalog.
644
+
514
645
  ## Extending the layer (consumer modules)
515
646
 
516
647
  A deployment can contribute its own components (result windows, nav entries, inspector
@@ -15,6 +15,7 @@
15
15
  // (see useContextLinking) — the same context the agents see for every step of the run.
16
16
  import type {
17
17
  CreateTaskType,
18
+ DescriptorFieldValues,
18
19
  DocKind,
19
20
  DocKindFieldKey,
20
21
  TaskSourceKind,
@@ -25,11 +26,14 @@ import { resolveComponentRegistry } from '@modular-vue/core'
25
26
  import { useReactiveSlots } from '@modular-vue/runtime'
26
27
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
27
28
  import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
29
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
28
30
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
29
31
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
30
32
  import { parseConflict } from '~/composables/usePipelineErrorToast'
31
33
  import { apiErrorEnvelope } from '~/composables/api/errors'
32
34
  import type { ReviewTargetReason } from '@cat-factory/contracts'
35
+ import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
36
+ import { defaultDescriptorValues } from '~/utils/descriptorFields'
33
37
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
34
38
 
35
39
  const ui = useUiStore()
@@ -155,8 +159,9 @@ const selectedCustomType = computed(() =>
155
159
  customTaskTypes.value.find((tt) => tt.taskType === taskType.value),
156
160
  )
157
161
  // Descriptor-field values for a selected custom type (or a bespoke form panel's own bag), folded
158
- // into `taskTypeFields.custom` on submit. Cleared when the type changes / the modal reopens.
159
- const customFieldValues = ref<Record<string, string | number>>({})
162
+ // into `taskTypeFields.custom` on submit. Re-seeded to the descriptor's own defaults when the type
163
+ // changes / the modal reopens (its fields differ per type, so a carried-over bag would be foreign).
164
+ const customFieldValues = ref<DescriptorFieldValues>({})
160
165
  // A bespoke create-form section paired to the custom type's `formPanel` id via the
161
166
  // `taskTypeFormPanels` slot; shown INSTEAD of the descriptor fields. Unpaired ⇒ descriptor fields
162
167
  // (degrade, never crash) — the same pairing shape as the result-view windows.
@@ -168,19 +173,15 @@ const customFormPanel = computed(() => {
168
173
  const id = selectedCustomType.value?.formPanel
169
174
  return id ? (formPanelRegistry.value.get(id) ?? null) : null
170
175
  })
171
- // Whether every REQUIRED descriptor field of the selected custom type has a value. Only the
172
- // descriptor path is enforced up front a bespoke `formPanel` owns its own validation, so we
173
- // don't block on it (nor can we read its required semantics). No custom type selected (or none
174
- // of its fields are required) trivially satisfied. Folded into `canAdd` so the required marker
175
- // the form renders is actually honoured before submit.
176
- const customRequiredFieldsFilled = computed(() => {
176
+ // Client-side mirror of the server's creation check (the SAME shared function `BoardService` runs),
177
+ // so the submit button reflects an invalid form: a missing required answer, a value outside its
178
+ // declared options, an over-long string. Only the descriptor path is checked up front, since a
179
+ // bespoke `formPanel` owns its own validation and the platform cannot read its required semantics.
180
+ // The per-field path error is rendered inline by `DescriptorFields`.
181
+ const customFieldProblems = computed(() => {
177
182
  const custom = selectedCustomType.value
178
- if (!custom || customFormPanel.value) return true
179
- return (custom.fields ?? []).every((field) => {
180
- if (!field.required) return true
181
- const raw = customFieldValues.value[field.key]
182
- return raw !== undefined && String(raw).trim() !== ''
183
- })
183
+ if (!custom || customFormPanel.value) return []
184
+ return validateDescriptorFields(custom.fields ?? [], customFieldValues.value)
184
185
  })
185
186
  // The type picker: the built-in choices (i18n labels) + the custom types (their wire presentation).
186
187
  const typeChoices = computed<{ value: TaskTypeChoice; label: string; icon: string }[]>(() => [
@@ -258,28 +259,21 @@ const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
258
259
  const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
259
260
 
260
261
  // A CUSTOM (deployment-registered) task type: fold the collected values into the sparse
261
- // `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim); the
262
- // descriptor path reads only the declared fields, coercing a `number` descriptor's string input.
262
+ // `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim, minus blank
263
+ // entries); the descriptor path sends the SANITIZED subset (declared, currently-visible fields), so
264
+ // a stale answer on a since-hidden `showWhen` field never reaches the wire. The renderer already
265
+ // keeps each value in its contract shape, so nothing needs coercing here.
263
266
  function buildCustomTypeFields(): TaskTypeFields | undefined {
264
267
  const custom = selectedCustomType.value
265
268
  if (!custom) return undefined
266
- const bag: Record<string, string | number> = {}
269
+ let bag: DescriptorFieldValues
267
270
  if (customFormPanel.value) {
271
+ bag = {}
268
272
  for (const [key, value] of Object.entries(customFieldValues.value)) {
269
273
  if (value !== undefined && value !== '') bag[key] = value
270
274
  }
271
275
  } else {
272
- for (const field of custom.fields ?? []) {
273
- const raw = customFieldValues.value[field.key]
274
- if (raw === undefined || raw === '') continue
275
- if (field.type === 'number') {
276
- // Skip a non-numeric value rather than sending NaN (which serialises to null on the wire).
277
- const n = Number(raw)
278
- if (Number.isFinite(n)) bag[field.key] = n
279
- } else {
280
- bag[field.key] = raw
281
- }
282
- }
276
+ bag = sanitizeDescriptorFields(custom.fields ?? [], customFieldValues.value)
283
277
  }
284
278
  return Object.keys(bag).length ? { custom: bag } : undefined
285
279
  }
@@ -416,12 +410,13 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
416
410
  review: 'pl_review',
417
411
  }
418
412
  watch(taskType, (next) => {
419
- // A custom type owns a fresh field bag on every switch (its descriptors differ per type).
420
- customFieldValues.value = {}
413
+ const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
414
+ // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
415
+ // to whatever defaults the new type declares.
416
+ customFieldValues.value = defaultDescriptorValues(custom?.fields ?? [])
421
417
  // Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
422
418
  // the built-in map. (For a custom type with no default, `BoardService` applies the registry
423
419
  // default at creation, so leaving the picker unset is fine.)
424
- const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
425
420
  const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
426
421
  if (!preset) return
427
422
  const match = pipelines.pipelines.find((p) => p.id === preset)
@@ -526,6 +521,9 @@ watch(open, (isOpen) => {
526
521
  docOutlineHints.value = ''
527
522
  reviewPrRef.value = ''
528
523
  reviewFocus.value = ''
524
+ // Empty rather than default-seeded: `taskType` was just reset to a BUILT-IN above, which
525
+ // declares no descriptor fields. Picking a custom type from here runs the `taskType` watcher,
526
+ // and that is the one place the new type's declared defaults are seeded.
529
527
  customFieldValues.value = {}
530
528
  // Pre-seed the best-practice fragments from the enclosing service's standards, so a new task
531
529
  // ships with its service's fragments already selected (and freely add/removable here). The task
@@ -610,8 +608,8 @@ const canAdd = computed(() => {
610
608
  configValue(RALPH_VALIDATION_COMMAND_ID, '').trim().length === 0
611
609
  )
612
610
  return false
613
- // A custom type's required descriptor fields must be filled (its form renders them as required).
614
- if (!customRequiredFieldsFilled.value) return false
611
+ // A custom type's collected form must satisfy its descriptor (the same rule the server enforces).
612
+ if (customFieldProblems.value.length > 0) return false
615
613
  return true
616
614
  })
617
615
 
@@ -684,7 +682,7 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
684
682
  }
685
683
  toast.add({
686
684
  title: t('board.addTask.addFailedTitle'),
687
- description: reviewTargetMessage(e) ?? (e instanceof Error ? e.message : String(e)),
685
+ description: createRefusalMessage(e) ?? (e instanceof Error ? e.message : String(e)),
688
686
  icon: 'i-lucide-triangle-alert',
689
687
  color: 'error',
690
688
  })
@@ -714,10 +712,22 @@ const REVIEW_TARGET_MESSAGES: Record<
714
712
  : null,
715
713
  }
716
714
 
717
- function reviewTargetMessage(error: unknown): string | null {
715
+ /**
716
+ * Translated copy for a machine-readable creation refusal, or null to fall back to the server's
717
+ * own English prose. Two reasons are recognised: a review task's unresolvable target PR, and a
718
+ * custom type's collected values contradicting its descriptor.
719
+ *
720
+ * The second one is reachable here even though `canAdd` mirrors the same check client-side, and
721
+ * that is the whole point of the server-side check: the descriptor can be re-registered while this
722
+ * dialog sits open, so the form the user filled is not the form the server now validates against.
723
+ * The individual problems stay out of the copy: they are backend English naming field keys, so
724
+ * what the user is told is the ONE thing they can act on (reopen the dialog).
725
+ */
726
+ function createRefusalMessage(error: unknown): string | null {
718
727
  const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
719
728
  const reason = details.reason
720
729
  if (typeof reason !== 'string') return null
730
+ if (reason === 'task_type_fields_invalid') return t('board.addTask.customFieldsInvalid')
721
731
  return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
722
732
  }
723
733
 
@@ -1045,8 +1055,9 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1045
1055
 
1046
1056
  <!-- A CUSTOM (deployment-registered) task type: a bespoke create-form section when its
1047
1057
  `formPanel` is paired to the `taskTypeFormPanels` slot, else the descriptor-driven
1048
- `fields` the type declares. None of the built-in `v-if` branches above match a
1049
- namespaced custom type, so this renders on its own. -->
1058
+ `fields` the type declares, rendered by the SHARED renderer the initiative-preset form
1059
+ uses. None of the built-in `v-if` branches above match a namespaced custom type, so
1060
+ this renders on its own. -->
1050
1061
  <div v-if="selectedCustomType" class="space-y-3" data-testid="custom-task-fields">
1051
1062
  <component
1052
1063
  :is="customFormPanel"
@@ -1055,47 +1066,12 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1055
1066
  :model-value="customFieldValues"
1056
1067
  @update:model-value="customFieldValues = $event"
1057
1068
  />
1058
- <template v-else>
1059
- <UFormField
1060
- v-for="field in selectedCustomType.fields ?? []"
1061
- :key="field.key"
1062
- :label="field.label"
1063
- :help="field.help"
1064
- :required="field.required"
1065
- >
1066
- <div v-if="field.type === 'select'" class="flex flex-wrap gap-1">
1067
- <UButton
1068
- v-for="opt in field.options ?? []"
1069
- :key="opt.value"
1070
- :color="customFieldValues[field.key] === opt.value ? 'primary' : 'neutral'"
1071
- :variant="customFieldValues[field.key] === opt.value ? 'soft' : 'ghost'"
1072
- size="xs"
1073
- :data-testid="`custom-field-${field.key}-${opt.value}`"
1074
- @click="() => (customFieldValues[field.key] = opt.value)"
1075
- >
1076
- {{ opt.label }}
1077
- </UButton>
1078
- </div>
1079
- <UTextarea
1080
- v-else-if="field.type === 'textarea'"
1081
- v-model="customFieldValues[field.key]"
1082
- :rows="2"
1083
- :maxlength="field.maxLength"
1084
- :placeholder="field.placeholder"
1085
- :data-testid="`custom-field-${field.key}`"
1086
- class="w-full"
1087
- />
1088
- <UInput
1089
- v-else
1090
- v-model="customFieldValues[field.key]"
1091
- :type="field.type === 'number' ? 'number' : 'text'"
1092
- :maxlength="field.maxLength"
1093
- :placeholder="field.placeholder"
1094
- :data-testid="`custom-field-${field.key}`"
1095
- class="w-full"
1096
- />
1097
- </UFormField>
1098
- </template>
1069
+ <DescriptorFields
1070
+ v-else
1071
+ v-model="customFieldValues"
1072
+ :fields="selectedCustomType.fields ?? []"
1073
+ testid-prefix="custom-field"
1074
+ />
1099
1075
  </div>
1100
1076
 
1101
1077
  <!-- One column in basic mode, where the pipeline picker is the only survivor and a
@@ -6,7 +6,8 @@
6
6
  // sanitized) preset inputs on the entity. Nothing is planned here: the user then runs the preset's
7
7
  // planning pipeline on the block from the inspector.
8
8
  //
9
- // The preset form is rendered GENERICALLY from `descriptor.fields` (InitiativePresetFields) zero
9
+ // The preset form is rendered GENERICALLY from `descriptor.fields` by the shared
10
+ // `DescriptorFields` renderer (the same one a custom task type's per-case form uses): zero
10
11
  // per-preset frontend code. A preset with a repo-detection probe prefills its form from the frame's
11
12
  // repo on selection (best-effort; failures fall back to descriptor defaults and never block create).
12
13
  //
@@ -20,9 +21,9 @@ import {
20
21
  validateInitiativePresetInputs,
21
22
  } from '@cat-factory/contracts'
22
23
  import type { InitiativePresetInputs, InitiativePresetInputValue } from '~/types/domain'
23
- import { defaultPresetInputs } from '~/utils/initiative'
24
+ import { defaultDescriptorValues } from '~/utils/descriptorFields'
24
25
  import { GENERIC_PRESET_ID } from '~/stores/initiative'
25
- import InitiativePresetFields from '~/components/board/InitiativePresetFields.vue'
26
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
26
27
  import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
27
28
  import type { PendingContext } from '~/composables/useContextLinking'
28
29
 
@@ -62,7 +63,7 @@ let probeSeq = 0
62
63
  /** Seed the form to the selected preset's descriptor defaults, then fire its detection probe. */
63
64
  function applyPreset(): void {
64
65
  const descriptor = selectedPreset.value
65
- inputs.value = descriptor ? defaultPresetInputs(descriptor) : {}
66
+ inputs.value = descriptor ? defaultDescriptorValues(descriptor.fields) : {}
66
67
  void runProbe()
67
68
  }
68
69
 
@@ -227,10 +228,11 @@ async function create() {
227
228
  </UFormField>
228
229
 
229
230
  <!-- The preset's descriptor-driven form (renders nothing for the fieldless generic preset). -->
230
- <InitiativePresetFields
231
+ <DescriptorFields
231
232
  v-if="selectedPreset"
232
233
  v-model="inputs"
233
- :descriptor="selectedPreset"
234
+ :fields="selectedPreset.fields"
235
+ testid-prefix="initiative-preset-field"
234
236
  />
235
237
 
236
238
  <!-- Attached requirements / issues, staged here and linked once the block exists. The
@@ -1,68 +1,68 @@
1
1
  <script setup lang="ts">
2
- // Generic, descriptor-driven renderer for an initiative preset's create-time FORM. Extends the
3
- // `ProviderConnectionTab.vue` flat-field pattern with the three shapes a preset form adds:
4
- // `checkbox-group` (multi-select `string[]`), `path` (a repo-relative dir with inline
5
- // safety validation), and single-condition `showWhen` visibility. Every preset renders through
6
- // THIS component with zero per-preset frontend code — the backend descriptor supplies the fields
7
- // (labels/help/options are backend-supplied English, per the `describeConfig` convention). The
8
- // model is the typed `InitiativePresetInputs` map (scalars stay strings, `number` a number,
9
- // `checkbox` a boolean, `checkbox-group` a `string[]`) so it round-trips the wire contract and the
10
- // shared `validateInitiativePresetInputs` unchanged.
2
+ // Generic, descriptor-driven form renderer: ONE component behind every surface where the BACKEND
3
+ // declares the fields and the SPA only collects them. Two use it today, and they used to carry
4
+ // near-duplicate renderers of the same vocabulary:
5
+ //
6
+ // - an initiative PRESET's create-time form (`CreateInitiativeModal`)
7
+ // - a reusable OPERATION's per-case brief on a custom task type (`AddTaskModal`)
8
+ //
9
+ // It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
10
+ // `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
11
+ // safety validation), and single-condition `showWhen` visibility. Labels/help/option captions are
12
+ // backend-supplied English (the `describeConfig` convention); only the chrome is i18n.
13
+ //
14
+ // The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
15
+ // `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
16
+ // shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
11
17
  import { computed } from 'vue'
12
- import { isPresetFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
13
- import type {
14
- InitiativePresetDescriptor,
15
- InitiativePresetField,
16
- InitiativePresetInputs,
17
- InitiativePresetInputValue,
18
- } from '~/types/domain'
18
+ import { isDescriptorFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
19
+ import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
20
+ import {
21
+ descriptorGroupValue,
22
+ setDescriptorCheckbox,
23
+ setDescriptorValue,
24
+ toggleDescriptorGroupValue,
25
+ } from '~/utils/descriptorFields'
19
26
 
20
- const props = defineProps<{ descriptor: InitiativePresetDescriptor }>()
21
- const model = defineModel<InitiativePresetInputs>({ required: true })
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ /** The fields to render, in declaration order (a preset's `fields`, a task type's `fields`). */
30
+ fields: readonly DescriptorField[]
31
+ /**
32
+ * Prefix for each field's `data-testid`, so a spec targets a field by the SURFACE it is on
33
+ * (`custom-field-entity`) rather than by which component happens to render it.
34
+ */
35
+ testidPrefix?: string
36
+ }>(),
37
+ { testidPrefix: 'descriptor-field' },
38
+ )
39
+ const model = defineModel<DescriptorFieldValues>({ required: true })
22
40
  const { t } = useI18n()
23
41
 
24
42
  // Only fields whose `showWhen` holds against the current values are shown; a hidden field's stale
25
43
  // value is kept in the model (so re-showing restores it) but the server + client both drop it at
26
44
  // sanitize/validate time, so it can never freeze an unvalidated value.
27
45
  const visibleFields = computed(() =>
28
- props.descriptor.fields.filter((f) => isPresetFieldVisible(f, model.value)),
46
+ props.fields.filter((f) => isDescriptorFieldVisible(f, model.value)),
29
47
  )
30
48
 
31
- /**
32
- * An "empty" value that must stay ABSENT from the model rather than freeze on the entity: an
33
- * unchecked (`false`) checkbox, a blank string, or an empty multi-select. A numeric `0` is a real
34
- * value and is kept (strict `=== false`/`=== ''` never match it).
35
- */
36
- function isEmptyValue(value: InitiativePresetInputValue): boolean {
37
- return value === false || value === '' || (Array.isArray(value) && value.length === 0)
49
+ // The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
50
+ // an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
51
+ // so they are unit-tested without mounting this component. Here they are only bound to the model.
52
+ function set(key: string, value: DescriptorFieldValue | undefined): void {
53
+ model.value = setDescriptorValue(model.value, key, value)
54
+ }
55
+
56
+ function setCheckbox(field: DescriptorField, checked: boolean): void {
57
+ model.value = setDescriptorCheckbox(model.value, field, checked)
38
58
  }
39
59
 
40
- /**
41
- * Immutably set one field's value on the model, DROPPING empty values so a cleared field never
42
- * freezes an empty `''`/`[]`/`false` (mirrors `ProviderConnectionTab`'s delete-when-blank and what
43
- * the shared `validate`/`sanitize` treat as unset — an unchecked box / blank field stays absent).
44
- */
45
- function set(key: string, value: InitiativePresetInputValue | undefined): void {
46
- const next = { ...model.value }
47
- if (value === undefined || isEmptyValue(value)) delete next[key]
48
- else next[key] = value
49
- model.value = next
60
+ function toggleGroup(key: string, option: string, checked: boolean): void {
61
+ model.value = toggleDescriptorGroupValue(model.value, key, option, checked)
50
62
  }
51
63
 
52
- /**
53
- * Set a checkbox value. A checkbox whose descriptor default is ON (`default: 'true'`) must be able
54
- * to persist an explicit `false`: {@link set} otherwise drops a `false` (an off box "stays unset"),
55
- * which for a default-ON field is indistinguishable from "untouched, still on" — so a consumer that
56
- * reads the opt-out as `humanReview !== false` (e.g. `seedMigrationPlan`) could never observe the
57
- * unchecked state and the toggle would be dead. A default-OFF checkbox keeps the drop-when-false
58
- * behaviour (absent === unchecked), so it never freezes a redundant `false`.
59
- */
60
- function setCheckbox(field: InitiativePresetField, checked: boolean): void {
61
- if (!checked && field.default === 'true') {
62
- model.value = { ...model.value, [field.key]: false }
63
- return
64
- }
65
- set(field.key, checked)
64
+ function groupValue(key: string): string[] {
65
+ return descriptorGroupValue(model.value, key)
66
66
  }
67
67
 
68
68
  function stringValue(key: string): string {
@@ -76,24 +76,15 @@ function numberStr(key: string): string {
76
76
  const v = model.value[key]
77
77
  return typeof v === 'number' ? String(v) : ''
78
78
  }
79
- function groupValue(key: string): string[] {
80
- const v = model.value[key]
81
- return Array.isArray(v) ? v : []
82
- }
83
-
84
- function toggleGroup(key: string, option: string, checked: boolean): void {
85
- const current = groupValue(key)
86
- set(key, checked ? [...new Set([...current, option])] : current.filter((o) => o !== option))
87
- }
88
79
 
89
80
  /** A `path` field is flagged only when non-empty AND unsafe (empty is handled by `required`). */
90
- function pathInvalid(field: InitiativePresetField): boolean {
81
+ function pathInvalid(field: DescriptorField): boolean {
91
82
  if (field.type !== 'path') return false
92
83
  const value = stringValue(field.key)
93
84
  return value.trim().length > 0 && !isSafeRepoDirPath(value)
94
85
  }
95
86
 
96
- function selectItems(field: InitiativePresetField) {
87
+ function selectItems(field: DescriptorField) {
97
88
  return (field.options ?? []).map((o) => ({ label: o.label, value: o.value }))
98
89
  }
99
90
  </script>
@@ -106,8 +97,8 @@ function selectItems(field: InitiativePresetField) {
106
97
  :label="field.label"
107
98
  :help="field.help"
108
99
  :required="field.required"
109
- :error="pathInvalid(field) ? t('initiative.create.pathInvalid') : undefined"
110
- :data-testid="`initiative-preset-field-${field.key}`"
100
+ :error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
101
+ :data-testid="`${testidPrefix}-${field.key}`"
111
102
  >
112
103
  <!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
113
104
  <div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
@@ -116,6 +107,7 @@ function selectItems(field: InitiativePresetField) {
116
107
  :key="opt.value"
117
108
  :model-value="groupValue(field.key).includes(opt.value)"
118
109
  :label="opt.label"
110
+ :data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
119
111
  @update:model-value="
120
112
  (v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
121
113
  "
@@ -143,6 +135,7 @@ function selectItems(field: InitiativePresetField) {
143
135
  :rows="3"
144
136
  autoresize
145
137
  class="w-full"
138
+ :maxlength="field.maxLength"
146
139
  :placeholder="field.placeholder"
147
140
  @update:model-value="(v: string) => set(field.key, v)"
148
141
  />
@@ -163,6 +156,7 @@ function selectItems(field: InitiativePresetField) {
163
156
  :type="field.type === 'password' ? 'password' : 'text'"
164
157
  class="w-full"
165
158
  :class="{ 'font-mono': field.type === 'path' }"
159
+ :maxlength="field.maxLength"
166
160
  :placeholder="field.placeholder"
167
161
  @update:model-value="(v: string) => set(field.key, v)"
168
162
  />