@cat-factory/app 0.256.3 → 0.258.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 (59) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
  3. package/app/components/board/AddTaskModal.vue +38 -12
  4. package/app/components/board/RecurringPipelineModal.vue +24 -12
  5. package/app/components/board/nodes/TaskCard.vue +34 -7
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  7. package/app/components/panels/AgentStepDetail.vue +42 -20
  8. package/app/components/panels/InspectorPanel.vue +39 -0
  9. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  11. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
  12. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
  13. package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
  14. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  15. package/app/components/pipeline/PipelinePreview.vue +20 -1
  16. package/app/components/pipeline/PipelineProgress.vue +50 -0
  17. package/app/composables/api/binaryCandidates.ts +36 -0
  18. package/app/composables/api/execution.ts +19 -0
  19. package/app/composables/useApi.ts +2 -0
  20. package/app/composables/usePipelineHealth.spec.ts +42 -5
  21. package/app/composables/usePipelineHealth.ts +109 -48
  22. package/app/modular/agent-kinds.ts +5 -0
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/environmentWizard/context.ts +0 -2
  26. package/app/stores/environmentWizard/flow.ts +11 -6
  27. package/app/stores/environmentWizard.ts +12 -11
  28. package/app/stores/execution/commands.ts +26 -1
  29. package/app/stores/pipelines/draftActions.ts +2 -0
  30. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  31. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  32. package/app/stores/ui/resultViews.ts +8 -6
  33. package/app/stores/ui/runStepOpeners.ts +23 -1
  34. package/app/types/domain.ts +6 -0
  35. package/app/types/execution.ts +5 -0
  36. package/app/utils/agentPalette.spec.ts +26 -0
  37. package/app/utils/agentPalette.ts +9 -4
  38. package/app/utils/binaryCandidates.spec.ts +110 -0
  39. package/app/utils/binaryCandidates.ts +126 -0
  40. package/app/utils/binaryOutput.spec.ts +122 -1
  41. package/app/utils/binaryOutput.ts +149 -2
  42. package/app/utils/catalog.spec.ts +24 -0
  43. package/app/utils/catalog.ts +21 -4
  44. package/app/utils/pipeline.spec.ts +35 -3
  45. package/app/utils/pipeline.ts +54 -3
  46. package/app/utils/pipelineRender.spec.ts +122 -1
  47. package/app/utils/pipelineRender.ts +113 -2
  48. package/i18n/locales/de.json +107 -3
  49. package/i18n/locales/en.json +107 -3
  50. package/i18n/locales/es.json +107 -3
  51. package/i18n/locales/fr.json +107 -3
  52. package/i18n/locales/he.json +107 -3
  53. package/i18n/locales/it.json +107 -3
  54. package/i18n/locales/ja.json +107 -3
  55. package/i18n/locales/pl.json +107 -3
  56. package/i18n/locales/tr.json +107 -3
  57. package/i18n/locales/uk.json +107 -3
  58. package/i18n/plural-forms.spec.ts +15 -0
  59. package/package.json +2 -2
@@ -23,12 +23,24 @@ import { computed, ref, watch } from 'vue'
23
23
  import {
24
24
  ASSET_STORAGE_CAPABILITY,
25
25
  GENERATION_CONTEXT_CAPABILITY,
26
+ isBinaryGeneratorCapability,
26
27
  isBinaryModality,
28
+ type BinaryGenerationOptions,
29
+ type BinaryGeneratorCapability,
27
30
  type BinaryModality,
28
31
  type BinaryOutputConfig,
32
+ type ConflictingOutputSizeOption,
29
33
  } from '@cat-factory/contracts'
30
34
  import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
31
- import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
35
+ import {
36
+ formatExtent,
37
+ formatReferenceImages,
38
+ generationControlOffer,
39
+ parseExtent,
40
+ parseMediaTypeRequirement,
41
+ parseReferenceImages,
42
+ sameFormats,
43
+ } from './BinaryOutputStepPicker.logic'
32
44
 
33
45
  const props = defineProps<{ index: number }>()
34
46
 
@@ -241,6 +253,181 @@ const overlapSummary = computed(() =>
241
253
  .join('; '),
242
254
  )
243
255
 
256
+ /**
257
+ * The capability vocabulary, as STATIC literal `t()` keys: one per member, never assembled at
258
+ * runtime, so the typed-message-key check covers them (the standing rule for an enum-keyed set).
259
+ */
260
+ const CAPABILITY_LABELS: Record<BinaryGeneratorCapability, () => string> = {
261
+ 'reference-image': () => t('pipeline.builder.binaryCapability.reference-image'),
262
+ 'multi-reference': () => t('pipeline.builder.binaryCapability.multi-reference'),
263
+ 'instruction-edit': () => t('pipeline.builder.binaryCapability.instruction-edit'),
264
+ 'mask-edit': () => t('pipeline.builder.binaryCapability.mask-edit'),
265
+ 'negative-prompt': () => t('pipeline.builder.binaryCapability.negative-prompt'),
266
+ seed: () => t('pipeline.builder.binaryCapability.seed'),
267
+ 'aspect-ratio': () => t('pipeline.builder.binaryCapability.aspect-ratio'),
268
+ 'exact-size': () => t('pipeline.builder.binaryCapability.exact-size'),
269
+ 'candidate-batch': () => t('pipeline.builder.binaryCapability.candidate-batch'),
270
+ upscale: () => t('pipeline.builder.binaryCapability.upscale'),
271
+ 'transparent-background': () => t('pipeline.builder.binaryCapability.transparent-background'),
272
+ tileable: () => t('pipeline.builder.binaryCapability.tileable'),
273
+ }
274
+
275
+ /**
276
+ * The options that restate a delivered size, named by the FIELD LABEL each one carries in this
277
+ * form, so the refusal points at a control the reader can see. Static literal keys, the
278
+ * enum-keyed-set rule again; the union is closed here (not a persisted vocabulary a stale step
279
+ * could carry a retired member of), so the lookup needs no membership guard.
280
+ */
281
+ const SIZE_CONFLICT_LABELS: Record<ConflictingOutputSizeOption, () => string> = {
282
+ aspectRatio: () => t('pipeline.builder.binaryAspectRatio'),
283
+ upscale: () => t('pipeline.builder.binaryUpscale'),
284
+ }
285
+
286
+ /**
287
+ * A capability in the reader's language, INCLUDING one this build does not define.
288
+ *
289
+ * The `Record` is exhaustive over the union and the lookup is still not total: the integrations
290
+ * ride the workspace snapshot, and on a mothership-mode deployment that snapshot is served by a
291
+ * process which may be a build AHEAD of this one. A bare lookup on such a value is a `TypeError`
292
+ * on the surface where the selection is made. Same guard, same reason, as `modalityLabel`.
293
+ */
294
+ function capabilityLabel(capability: BinaryGeneratorCapability): string {
295
+ return isBinaryGeneratorCapability(capability)
296
+ ? CAPABILITY_LABELS[capability]()
297
+ : t('pipeline.builder.binaryCapabilityUnknown', { capability: String(capability) })
298
+ }
299
+
300
+ const generation = computed<BinaryGenerationOptions>(() => config.value?.generation ?? {})
301
+
302
+ /** The registered integrations this step has selected, in the order the step names them. */
303
+ const selectedGenerators = computed(() => {
304
+ const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
305
+ return (config.value?.generatorIds ?? []).flatMap((id) => byId.get(id) ?? [])
306
+ })
307
+
308
+ /**
309
+ * Whether to OFFER the control an option belongs to. The rule itself is pure and lives in the
310
+ * logic sibling, where its three cases (undeclared, declared, already-set) are testable without
311
+ * mounting this component.
312
+ */
313
+ const offers = computed(() => generationControlOffer(selectedGenerators.value, generation.value))
314
+
315
+ /** The edit modes, as static literal keys: the enum-keyed-set rule again. */
316
+ const editModeItems = computed(() => [
317
+ { label: t('pipeline.builder.binaryEditModeInstruction'), value: 'instruction' },
318
+ { label: t('pipeline.builder.binaryEditModeMask'), value: 'mask' },
319
+ ])
320
+
321
+ /**
322
+ * Patch ONE generation option, dropping it when it is cleared.
323
+ *
324
+ * A cleared option is REMOVED rather than stored as an empty value, because the two are different
325
+ * requirements: an absent `seed` means the step does not pin one, while `seed: 0` is a pinned
326
+ * seed of zero, and admission judges the option's PRESENCE.
327
+ */
328
+ function setGeneration(fields: Partial<BinaryGenerationOptions>) {
329
+ const next: Record<string, unknown> = { ...generation.value, ...fields }
330
+ for (const [key, value] of Object.entries(fields)) {
331
+ if (value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
332
+ delete next[key]
333
+ }
334
+ }
335
+ patch({
336
+ generation: Object.keys(next).length > 0 ? (next as BinaryGenerationOptions) : undefined,
337
+ })
338
+ }
339
+
340
+ /**
341
+ * The size fields' own text, held locally like {@link referenceText} and for the same reason: a
342
+ * control has to render what is being typed, and the step must not store what is being typed.
343
+ *
344
+ * The pair is ONE requirement, so it reaches the step only once BOTH halves read as a pixel
345
+ * extent. Writing the unset half as 0 while the first number is typed would store a value the
346
+ * schema refuses (the minimum is 1), which makes the step unsaveable behind an opaque validation
347
+ * error and renders "0" back into a field nobody touched. A half-entered pair is instead simply
348
+ * not stored, and {@link outputSizeIncomplete} says so where it is being typed.
349
+ */
350
+ const outputSizeText = ref({
351
+ width: formatExtent(config.value?.generation?.outputSize?.width),
352
+ height: formatExtent(config.value?.generation?.outputSize?.height),
353
+ })
354
+
355
+ watch(
356
+ () => config.value?.generation?.outputSize,
357
+ (size) => {
358
+ outputSizeText.value = { width: formatExtent(size?.width), height: formatExtent(size?.height) }
359
+ },
360
+ )
361
+
362
+ /** One half is filled and the other is not, so nothing is stored and the composer is told why. */
363
+ const outputSizeIncomplete = computed(() => {
364
+ const filled = [outputSizeText.value.width, outputSizeText.value.height].filter(
365
+ (half) => half.trim() !== '',
366
+ )
367
+ return filled.length === 1
368
+ })
369
+
370
+ /** Patch ONE half of the exact output size, committing the pair only once both halves read. */
371
+ function setOutputSize(axis: 'width' | 'height', raw: string) {
372
+ outputSizeText.value = { ...outputSizeText.value, [axis]: raw }
373
+ const width = parseExtent(outputSizeText.value.width)
374
+ const height = parseExtent(outputSizeText.value.height)
375
+ setGeneration({ outputSize: width !== null && height !== null ? { width, height } : undefined })
376
+ }
377
+
378
+ /** The reference-image field's text, and the lines it refused (see `parseReferenceImages`). */
379
+ const referenceText = ref(formatReferenceImages(config.value?.generation?.referenceImages))
380
+ const unusableReferences = ref<string[]>([])
381
+
382
+ watch(
383
+ () => config.value?.generation?.referenceImages,
384
+ (references) => {
385
+ referenceText.value = formatReferenceImages(references)
386
+ },
387
+ )
388
+
389
+ function setReferences(text: string) {
390
+ const { usable, unusable } = parseReferenceImages(text)
391
+ unusableReferences.value = unusable
392
+ referenceText.value = formatReferenceImages(usable)
393
+ setGeneration({ referenceImages: usable })
394
+ }
395
+
396
+ /**
397
+ * Turn the candidate comparison on or off. Off DROPS the whole bag rather than storing a disabled
398
+ * one: the presence of `comparison` is what the engine reads, so a saved "off" object would be a
399
+ * configuration describing a behaviour the run does not have.
400
+ */
401
+ function setComparison(on: boolean) {
402
+ patch({ comparison: on ? (config.value?.comparison ?? {}) : undefined })
403
+ }
404
+
405
+ function setPerGenerator(value: number) {
406
+ const current = config.value?.comparison
407
+ if (!current) return
408
+ patch({ comparison: { ...current, perGenerator: value } })
409
+ }
410
+
411
+ function setMultiSelect(on: boolean) {
412
+ const current = config.value?.comparison
413
+ if (!current) return
414
+ patch({
415
+ comparison: on ? { ...current, multiSelect: true } : { perGenerator: current.perGenerator },
416
+ })
417
+ }
418
+
419
+ /**
420
+ * Whether the step could actually produce a comparison: the SAME rule
421
+ * `assertComparableCandidates` refuses at save, stated here where it is fixable. Without it a
422
+ * step saves clean, starts, and keeps its only candidate without ever asking anyone.
423
+ */
424
+ const comparisonUnreachable = computed(() => {
425
+ const comparison = config.value?.comparison
426
+ if (!comparison) return false
427
+ if ((comparison.perGenerator ?? 1) > 1) return false
428
+ return (config.value?.generatorIds?.length ?? 0) < 2
429
+ })
430
+
244
431
  /** What the SELECTED integrations say they emit — the discoverable half of the free-text field. */
245
432
  const declaredFormats = computed(() => {
246
433
  const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
@@ -347,6 +534,241 @@ const declaredFormats = computed(() => {
347
534
  }}
348
535
  </p>
349
536
 
537
+ <!-- GENERATION OPTIONS. Each control is gated on a capability the selected integrations
538
+ declare, so a step cannot ask for a reference image from an endpoint that takes no image
539
+ input. While nothing has DECLARED its capabilities every control stays offered and the
540
+ advisory line below says the support is unconfirmed: hiding one would be a claim about a
541
+ vendor's API that nobody established. Both tiers, like the rest of this picker: an
542
+ option nobody stated is an option the run does not apply. -->
543
+ <template v-if="config?.storageServiceId">
544
+ <div v-if="offers('reference-image')" class="flex items-start gap-2">
545
+ <span class="mt-1 text-[10px] text-slate-500">{{
546
+ t('pipeline.builder.binaryReferenceImages')
547
+ }}</span>
548
+ <UTextarea
549
+ class="w-56"
550
+ :rows="2"
551
+ size="xs"
552
+ :model-value="referenceText"
553
+ :placeholder="t('pipeline.builder.binaryReferenceImagesPlaceholder')"
554
+ data-testid="binary-output-reference-input"
555
+ @update:model-value="referenceText = String($event)"
556
+ @change="setReferences(referenceText)"
557
+ />
558
+ </div>
559
+ <p
560
+ v-if="unusableReferences.length"
561
+ class="ms-1 text-[10px] text-amber-400"
562
+ data-testid="binary-output-reference-unusable"
563
+ >
564
+ {{
565
+ t('pipeline.builder.binaryReferenceImagesUnusable', {
566
+ entries: unusableReferences.join('; '),
567
+ })
568
+ }}
569
+ </p>
570
+
571
+ <div v-if="offers('instruction-edit') || offers('mask-edit')" class="flex items-center gap-2">
572
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryEditMode') }}</span>
573
+ <USelect
574
+ class="w-56"
575
+ size="xs"
576
+ :model-value="generation.edit?.mode ?? ''"
577
+ :items="editModeItems"
578
+ value-key="value"
579
+ :placeholder="t('pipeline.builder.binaryEditModeNone')"
580
+ data-testid="binary-output-edit-mode"
581
+ @update:model-value="
582
+ setGeneration({
583
+ edit: $event
584
+ ? { ...generation.edit, mode: $event as 'instruction' | 'mask' }
585
+ : undefined,
586
+ })
587
+ "
588
+ />
589
+ </div>
590
+ <div v-if="generation.edit" class="flex items-center gap-2">
591
+ <span class="text-[10px] text-slate-500">{{
592
+ t('pipeline.builder.binaryEditInstruction')
593
+ }}</span>
594
+ <UInput
595
+ class="w-56"
596
+ size="xs"
597
+ :model-value="generation.edit.instruction ?? ''"
598
+ :placeholder="t('pipeline.builder.binaryEditInstructionPlaceholder')"
599
+ data-testid="binary-output-edit-instruction"
600
+ @change="
601
+ setGeneration({
602
+ edit: { ...generation.edit!, instruction: ($event.target as HTMLInputElement).value },
603
+ })
604
+ "
605
+ />
606
+ </div>
607
+
608
+ <div v-if="offers('negative-prompt')" class="flex items-center gap-2">
609
+ <span class="text-[10px] text-slate-500">{{
610
+ t('pipeline.builder.binaryNegativePrompt')
611
+ }}</span>
612
+ <UInput
613
+ class="w-56"
614
+ size="xs"
615
+ :model-value="generation.negativePrompt ?? ''"
616
+ :placeholder="t('pipeline.builder.binaryNegativePromptPlaceholder')"
617
+ data-testid="binary-output-negative-prompt"
618
+ @change="
619
+ setGeneration({ negativePrompt: ($event.target as HTMLInputElement).value.trim() })
620
+ "
621
+ />
622
+ </div>
623
+
624
+ <div v-if="offers('aspect-ratio')" class="flex items-center gap-2">
625
+ <span class="text-[10px] text-slate-500">{{
626
+ t('pipeline.builder.binaryAspectRatio')
627
+ }}</span>
628
+ <UInput
629
+ class="w-56"
630
+ size="xs"
631
+ :model-value="generation.aspectRatio ?? ''"
632
+ :placeholder="t('pipeline.builder.binaryAspectRatioPlaceholder')"
633
+ data-testid="binary-output-aspect-ratio"
634
+ @change="setGeneration({ aspectRatio: ($event.target as HTMLInputElement).value.trim() })"
635
+ />
636
+ </div>
637
+
638
+ <!-- Two numbers rather than one free-text "WxH": the requirement is a pair of integers and
639
+ a parser over a string is a second place for it to be wrong. Offered only where an
640
+ integration declares `exact-size`, because a bucketed endpoint cannot be asked for
641
+ dimensions at all and a control implying otherwise is the misconception this axis
642
+ exists to remove. -->
643
+ <div v-if="offers('exact-size')" class="flex flex-col gap-1">
644
+ <div class="flex items-center gap-2">
645
+ <span class="text-[10px] text-slate-500">{{
646
+ t('pipeline.builder.binaryOutputSize')
647
+ }}</span>
648
+ <UInput
649
+ class="w-24"
650
+ type="number"
651
+ size="xs"
652
+ :model-value="outputSizeText.width"
653
+ :placeholder="t('pipeline.builder.binaryOutputSizeWidth')"
654
+ data-testid="binary-output-size-width"
655
+ @change="setOutputSize('width', ($event.target as HTMLInputElement).value)"
656
+ />
657
+ <span class="text-[10px] text-slate-500">×</span>
658
+ <UInput
659
+ class="w-24"
660
+ type="number"
661
+ size="xs"
662
+ :model-value="outputSizeText.height"
663
+ :placeholder="t('pipeline.builder.binaryOutputSizeHeight')"
664
+ data-testid="binary-output-size-height"
665
+ @change="setOutputSize('height', ($event.target as HTMLInputElement).value)"
666
+ />
667
+ </div>
668
+ <!-- Nothing is stored until both halves read, so the half-entered state is stated where
669
+ it is being typed. Silence there would be a field that looks filled in and a step
670
+ that does not carry the requirement. -->
671
+ <p
672
+ v-if="outputSizeIncomplete"
673
+ class="text-[10px] text-amber-400"
674
+ data-testid="binary-output-size-incomplete"
675
+ >
676
+ {{ t('pipeline.builder.binaryOutputSizeIncomplete') }}
677
+ </p>
678
+ </div>
679
+
680
+ <div v-if="offers('seed')" class="flex items-center gap-2">
681
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binarySeed') }}</span>
682
+ <UInput
683
+ class="w-56"
684
+ type="number"
685
+ size="xs"
686
+ :model-value="generation.seed === undefined ? '' : String(generation.seed)"
687
+ :placeholder="t('pipeline.builder.binarySeedPlaceholder')"
688
+ data-testid="binary-output-seed"
689
+ @change="
690
+ setGeneration({
691
+ seed: ($event.target as HTMLInputElement).value
692
+ ? Number(($event.target as HTMLInputElement).value)
693
+ : undefined,
694
+ })
695
+ "
696
+ />
697
+ </div>
698
+
699
+ <div class="flex flex-wrap items-center gap-3">
700
+ <label v-if="offers('transparent-background')" class="flex items-center gap-1.5">
701
+ <UCheckbox
702
+ :model-value="generation.transparentBackground === true"
703
+ data-testid="binary-output-transparent"
704
+ @update:model-value="
705
+ setGeneration({ transparentBackground: $event ? true : undefined })
706
+ "
707
+ />
708
+ <span class="text-[10px] text-slate-500">{{
709
+ t('pipeline.builder.binaryTransparent')
710
+ }}</span>
711
+ </label>
712
+ <label v-if="offers('tileable')" class="flex items-center gap-1.5">
713
+ <UCheckbox
714
+ :model-value="generation.tileable === true"
715
+ data-testid="binary-output-tileable"
716
+ @update:model-value="setGeneration({ tileable: $event ? true : undefined })"
717
+ />
718
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryTileable') }}</span>
719
+ </label>
720
+ <label v-if="offers('upscale')" class="flex items-center gap-1.5">
721
+ <UCheckbox
722
+ :model-value="generation.upscale !== undefined"
723
+ data-testid="binary-output-upscale"
724
+ @update:model-value="setGeneration({ upscale: $event ? 2 : undefined })"
725
+ />
726
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryUpscale') }}</span>
727
+ </label>
728
+ </div>
729
+
730
+ <!-- CANDIDATE COMPARISON: generate several and let a person choose, rather than letting the
731
+ agent commit to one producer unobserved. -->
732
+ <label class="flex items-center gap-1.5">
733
+ <UCheckbox
734
+ :model-value="config.comparison !== undefined"
735
+ data-testid="binary-output-comparison"
736
+ @update:model-value="setComparison($event === true)"
737
+ />
738
+ <span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binaryComparison') }}</span>
739
+ </label>
740
+ <div v-if="config.comparison" class="ms-6 flex flex-wrap items-center gap-3">
741
+ <span class="text-[10px] text-slate-500">{{
742
+ t('pipeline.builder.binaryPerGenerator')
743
+ }}</span>
744
+ <USelect
745
+ class="w-20"
746
+ size="xs"
747
+ :model-value="config.comparison.perGenerator ?? 1"
748
+ :items="[1, 2, 3, 4]"
749
+ data-testid="binary-output-per-generator"
750
+ @update:model-value="setPerGenerator(Number($event))"
751
+ />
752
+ <label class="flex items-center gap-1.5">
753
+ <UCheckbox
754
+ :model-value="config.comparison.multiSelect === true"
755
+ data-testid="binary-output-multi-select"
756
+ @update:model-value="setMultiSelect($event === true)"
757
+ />
758
+ <span class="text-[10px] text-slate-500">{{
759
+ t('pipeline.builder.binaryMultiSelect')
760
+ }}</span>
761
+ </label>
762
+ </div>
763
+ <p
764
+ v-if="comparisonUnreachable"
765
+ class="text-[10px] text-amber-400"
766
+ data-testid="binary-output-comparison-unreachable"
767
+ >
768
+ {{ t('pipeline.builder.binaryComparisonUnreachable') }}
769
+ </p>
770
+ </template>
771
+
350
772
  <!-- Every refusal this step would hit, named where it is fixable. Each is its own line
351
773
  with its own remedy: an unreachable catalog is not an empty one, a lost service is not
352
774
  an untagged one, and a lost CONTEXT service is not a lost storage target. -->
@@ -448,6 +870,46 @@ const declaredFormats = computed(() => {
448
870
  >
449
871
  {{ t('pipeline.builder.binaryOutputGeneratorOverlap', { overlaps: overlapSummary }) }}
450
872
  </p>
873
+ <p
874
+ v-if="has('capability_unsupported')"
875
+ class="text-[10px] text-amber-400"
876
+ data-testid="binary-output-capability-unsupported"
877
+ >
878
+ {{
879
+ t('pipeline.builder.binaryCapabilityUnsupported', {
880
+ capabilities: pick.unsupportedCapabilities.map(capabilityLabel).join(', '),
881
+ })
882
+ }}
883
+ </p>
884
+ <!-- A refusal the SAVE makes on the step's own fields, so it is stated here rather than
885
+ waited for: all three controls are offered together, and the remedy is deleting one of
886
+ two values on this form. -->
887
+ <p
888
+ v-if="has('output_size_ambiguous')"
889
+ class="text-[10px] text-amber-400"
890
+ data-testid="binary-output-size-ambiguous"
891
+ >
892
+ {{
893
+ t('pipeline.builder.binaryOutputSizeAmbiguous', {
894
+ options: pick.conflictingSizeOptions.map((o) => SIZE_CONFLICT_LABELS[o]()).join(', '),
895
+ })
896
+ }}
897
+ </p>
898
+ <!-- ADVISORY, grouped with the other two: an integration that declared no capabilities has
899
+ said only that they are unknown, and every integration registered before this axis
900
+ existed is in exactly that state. Styling it as a refusal would flag most working
901
+ selections in the product. -->
902
+ <p
903
+ v-if="has('capability_unverifiable')"
904
+ class="text-[10px] text-slate-500"
905
+ data-testid="binary-output-capability-unverifiable"
906
+ >
907
+ {{
908
+ t('pipeline.builder.binaryCapabilityUnverifiable', {
909
+ capabilities: pick.unverifiableCapabilities.map(capabilityLabel).join(', '),
910
+ })
911
+ }}
912
+ </p>
451
913
  <p
452
914
  v-if="unusableMediaTypes.length"
453
915
  class="text-[10px] text-amber-400"
@@ -12,11 +12,20 @@ import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker
12
12
  import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
13
13
  import { showOverrideField } from '~/utils/uiMode'
14
14
  import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
15
+ import { CONDITION_MARKERS, stepConditionsAt } from '~/utils/pipeline'
16
+
17
+ /** The cycle button's icon per state — the two condition markers, plus the unconditional one. */
18
+ const CONDITION_ICONS = {
19
+ always: 'i-lucide-infinity',
20
+ frontend: CONDITION_MARKERS.frontend.icon,
21
+ backend: CONDITION_MARKERS.backend.icon,
22
+ } as const
15
23
  import {
16
24
  agentKindMeta,
17
25
  companionForProducer,
18
26
  isConsensusEligibleKind,
19
27
  isTesterKind,
28
+ mayCarrySkipAxis,
20
29
  } from '~/utils/catalog'
21
30
  import type { ConsensusStrategy } from '~/types/consensus'
22
31
 
@@ -611,6 +620,31 @@ async function clone(p: Pipeline) {
611
620
  "
612
621
  @click="toggleEnabled(unit)"
613
622
  />
623
+ <!-- Run condition: restrict this step to tasks that change a frontend
624
+ service, or to tasks that change anything else. Cycles through the three
625
+ states (see `cycleDraftStepCondition`). Shown in BOTH interface tiers, and
626
+ not behind `showOverrideField`: cloning a built-in carries the tester pair's
627
+ conditions in, so a control that hid them by default would leave a basic-mode
628
+ editor saving a step whose "when does this run" they were never shown.
629
+
630
+ Offered only where the step MAY be skipped at all. A condition is a skip axis,
631
+ so the engine holds it to the same gatability rule as an estimate gate
632
+ (`assertValidRunConditions`) — without this the builder invited a condition on
633
+ `merger` and answered the save with a 422. -->
634
+ <UButton
635
+ v-if="mayCarrySkipAxis(unit.kind)"
636
+ :icon="CONDITION_ICONS[pipelines.draftStepCondition(unit.index) ?? 'always']"
637
+ :color="pipelines.draftStepCondition(unit.index) ? 'info' : 'neutral'"
638
+ variant="ghost"
639
+ size="xs"
640
+ :title="
641
+ t(
642
+ `pipeline.builder.condition.${pipelines.draftStepCondition(unit.index) ?? 'always'}`,
643
+ )
644
+ "
645
+ data-testid="pipeline-step-condition"
646
+ @click="pipelines.cycleDraftStepCondition(unit.index)"
647
+ />
614
648
  <!-- Approval gate: pause after this step so a human reviews (and
615
649
  can edit) its proposal before the next step runs. -->
616
650
  <UButton
@@ -1308,6 +1342,16 @@ async function clone(p: Pipeline) {
1308
1342
  i + 1
1309
1343
  }}</span>
1310
1344
  <AgentKindIcon :kind="k" show-label />
1345
+ <!-- A step that does not run on every task says so HERE, in the library, because
1346
+ this list is what a reader compares two pipelines by: a preset whose testers
1347
+ are conditional and one whose testers always run look identical otherwise. -->
1348
+ <UIcon
1349
+ v-for="c in stepConditionsAt(p, i)"
1350
+ :key="c"
1351
+ :name="CONDITION_MARKERS[c].icon"
1352
+ class="h-3 w-3 shrink-0 text-sky-400"
1353
+ :title="t(CONDITION_MARKERS[c].key)"
1354
+ />
1311
1355
  </li>
1312
1356
  </ol>
1313
1357
  </li>
@@ -11,7 +11,12 @@
11
11
  import { computed } from 'vue'
12
12
  import type { Pipeline } from '~/types/domain'
13
13
  import { agentKindMeta } from '~/utils/catalog'
14
- import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
14
+ import {
15
+ CONDITION_MARKERS,
16
+ pipelineConditionalCount,
17
+ pipelineDisplaySteps,
18
+ pipelineGateCount,
19
+ } from '~/utils/pipeline'
15
20
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
16
21
 
17
22
  const props = defineProps<{ pipeline: Pipeline }>()
@@ -19,6 +24,7 @@ const { t } = useI18n()
19
24
 
20
25
  const steps = computed(() => pipelineDisplaySteps(props.pipeline))
21
26
  const gateCount = computed(() => pipelineGateCount(props.pipeline))
27
+ const conditionalCount = computed(() => pipelineConditionalCount(props.pipeline))
22
28
 
23
29
  /** What the agent at this step does — the same catalog prose the palette and step tooltips use. */
24
30
  function stepDescription(kind: string): string {
@@ -50,6 +56,12 @@ function stepDescription(kind: string): string {
50
56
  <UIcon name="i-lucide-shield-check" class="h-3 w-3" />
51
57
  {{ t('pipeline.preview.gateCount', { count: gateCount }, gateCount) }}
52
58
  </span>
59
+ <!-- Conditional steps change what a run of this pipeline actually does from task to task,
60
+ which is exactly what a preview read BEFORE picking has to say out loud. -->
61
+ <span v-if="conditionalCount" class="inline-flex items-center gap-1 text-sky-500">
62
+ <UIcon name="i-lucide-git-branch" class="h-3 w-3" />
63
+ {{ t('pipeline.preview.conditionalCount', { count: conditionalCount }, conditionalCount) }}
64
+ </span>
53
65
  </div>
54
66
 
55
67
  <!-- The ordered steps. The number column doubles as the flow connector (a rule drawn between
@@ -79,6 +91,13 @@ function stepDescription(kind: string): string {
79
91
  class="h-3 w-3 shrink-0 text-amber-400"
80
92
  :title="t('pipeline.preview.gated')"
81
93
  />
94
+ <UIcon
95
+ v-for="c in s.conditions"
96
+ :key="c"
97
+ :name="CONDITION_MARKERS[c].icon"
98
+ class="h-3 w-3 shrink-0 text-sky-400"
99
+ :title="t(CONDITION_MARKERS[c].key)"
100
+ />
82
101
  </div>
83
102
  <!-- Clamped: the catalog prose runs long for some kinds, and <AgentKindIcon> already
84
103
  carries the full text in its hover tooltip. -->