@cat-factory/app 0.17.1 → 0.18.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.
@@ -9,6 +9,7 @@ import { STATUS_META } from '~/utils/catalog'
9
9
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
10
10
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
11
11
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
12
+ import { useFrameExpansion } from '~/composables/useFrameExpansion'
12
13
 
13
14
  const board = useBoardStore()
14
15
  const pipelines = usePipelinesStore()
@@ -21,8 +22,11 @@ const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(
21
22
 
22
23
  // Gate which task cards expand their pipeline list on deep zoom: only on-screen
23
24
  // cards, and only the centre-most of any that would overlap (see useTaskExpansion).
25
+ // The frame-level gate is the same idea one level up: which service frames may
26
+ // auto-expand to their task canvas once zoomed in (see useFrameExpansion).
24
27
  const boardEl = ref<HTMLElement | null>(null)
25
28
  useTaskExpansion(boardEl)
29
+ useFrameExpansion(boardEl)
26
30
 
27
31
  // Only frames are board nodes. Dependencies live on tasks (rendered inside the
28
32
  // frames), so there are no frame-to-frame edges on the canvas.
@@ -24,6 +24,30 @@ const services = useServicesStore()
24
24
  const composePath = computed(() => props.block.testComposePath ?? '')
25
25
  const noInfra = computed(() => props.block.noInfraDependencies === true)
26
26
 
27
+ // The default test environment a task under this service is spawned with. `local`
28
+ // stands the dependencies up via docker-compose (or "no infra"); `ephemeral` runs
29
+ // against a provisioned environment. A task can override it per-task in its agent
30
+ // settings. Absent ⇒ the built-in `ephemeral`.
31
+ type TestEnvironment = 'local' | 'ephemeral'
32
+ const TEST_ENVIRONMENTS: { value: TestEnvironment; label: string; hint: string }[] = [
33
+ {
34
+ value: 'ephemeral',
35
+ label: 'Ephemeral environment',
36
+ hint: 'tests run against a provisioned env',
37
+ },
38
+ { value: 'local', label: 'Local (docker-compose)', hint: 'the Tester stands deps up locally' },
39
+ ]
40
+ const effectiveTestEnv = computed<TestEnvironment>(
41
+ () => props.block.defaultTestEnvironment ?? 'ephemeral',
42
+ )
43
+ function setDefaultTestEnv(value: TestEnvironment) {
44
+ board.updateBlock(props.block.id, { defaultTestEnvironment: value })
45
+ }
46
+
47
+ // The provisioning hints (cloud provider + instance size) are advisory inputs to the
48
+ // ephemeral-environment provisioner, not commonly tuned — keep them collapsed by default.
49
+ const showProvisioning = ref(false)
50
+
27
51
  // The repo + service subdirectory backing this frame, for the compose-file browser.
28
52
  // A monorepo service isn't on the `github_repos` blockId link (that stays null), so
29
53
  // fall back to the service catalog mapping, which carries the repo + directory.
@@ -93,6 +117,27 @@ const missingInfra = computed(() => !noInfra.value && composePath.value.trim() =
93
117
  Test infrastructure
94
118
  </div>
95
119
 
120
+ <div class="space-y-1">
121
+ <span class="text-[11px] text-slate-400">Default test environment</span>
122
+ <div class="flex flex-wrap gap-1">
123
+ <UButton
124
+ v-for="e in TEST_ENVIRONMENTS"
125
+ :key="e.value"
126
+ :color="effectiveTestEnv === e.value ? 'primary' : 'neutral'"
127
+ :variant="effectiveTestEnv === e.value ? 'soft' : 'ghost'"
128
+ size="xs"
129
+ :title="e.hint"
130
+ @click="setDefaultTestEnv(e.value)"
131
+ >
132
+ {{ e.label }}
133
+ </UButton>
134
+ </div>
135
+ <p class="text-[11px] leading-snug text-slate-500">
136
+ The default tasks under this service are spawned with — each task can override it in its
137
+ agent settings.
138
+ </p>
139
+ </div>
140
+
96
141
  <div class="space-y-1">
97
142
  <label class="text-[11px] text-slate-400">docker-compose path</label>
98
143
  <div class="flex items-center gap-1">
@@ -163,35 +208,59 @@ const missingInfra = computed(() => !noInfra.value && composePath.value.trim() =
163
208
  Tester won't start.
164
209
  </p>
165
210
 
166
- <div class="space-y-1">
167
- <span class="text-[11px] text-slate-400">Cloud provider</span>
168
- <div class="flex flex-wrap gap-1">
169
- <UButton
170
- v-for="p in PROVIDERS"
171
- :key="p.value"
172
- :color="effectiveProvider === p.value ? 'primary' : 'neutral'"
173
- :variant="effectiveProvider === p.value ? 'soft' : 'ghost'"
174
- size="xs"
175
- @click="setProvider(p.value)"
176
- >
177
- {{ p.label }}
178
- </UButton>
179
- </div>
180
- </div>
211
+ <!-- Provisioning hints: advisory inputs to the ephemeral-environment provisioner.
212
+ Collapsed by default — most services never tune them. -->
213
+ <div class="border-t border-slate-800 pt-2">
214
+ <button
215
+ type="button"
216
+ class="flex w-full items-center gap-1.5 text-left text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
217
+ @click="showProvisioning = !showProvisioning"
218
+ >
219
+ <UIcon
220
+ :name="showProvisioning ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
221
+ class="h-3.5 w-3.5"
222
+ />
223
+ Ephemeral environment provisioning
224
+ </button>
181
225
 
182
- <div class="space-y-1">
183
- <span class="text-[11px] text-slate-400">Instance size</span>
184
- <div class="flex flex-wrap gap-1">
185
- <UButton
186
- v-for="s in SIZES"
187
- :key="s.value"
188
- :color="(block.instanceSize ?? 'medium') === s.value ? 'primary' : 'neutral'"
189
- :variant="(block.instanceSize ?? 'medium') === s.value ? 'soft' : 'ghost'"
190
- size="xs"
191
- @click="setSize(s.value)"
192
- >
193
- {{ s.label }}
194
- </UButton>
226
+ <div v-if="showProvisioning" class="mt-2 space-y-3">
227
+ <p class="text-[11px] leading-snug text-slate-500">
228
+ A hint for provisioning this service's ephemeral test environment — which cloud provider
229
+ to deploy to and how large an instance to request. Ignored for local (docker-compose)
230
+ testing.
231
+ </p>
232
+
233
+ <div class="space-y-1">
234
+ <span class="text-[11px] text-slate-400">Cloud provider</span>
235
+ <div class="flex flex-wrap gap-1">
236
+ <UButton
237
+ v-for="p in PROVIDERS"
238
+ :key="p.value"
239
+ :color="effectiveProvider === p.value ? 'primary' : 'neutral'"
240
+ :variant="effectiveProvider === p.value ? 'soft' : 'ghost'"
241
+ size="xs"
242
+ @click="setProvider(p.value)"
243
+ >
244
+ {{ p.label }}
245
+ </UButton>
246
+ </div>
247
+ </div>
248
+
249
+ <div class="space-y-1">
250
+ <span class="text-[11px] text-slate-400">Instance size</span>
251
+ <div class="flex flex-wrap gap-1">
252
+ <UButton
253
+ v-for="s in SIZES"
254
+ :key="s.value"
255
+ :color="(block.instanceSize ?? 'medium') === s.value ? 'primary' : 'neutral'"
256
+ :variant="(block.instanceSize ?? 'medium') === s.value ? 'soft' : 'ghost'"
257
+ size="xs"
258
+ @click="setSize(s.value)"
259
+ >
260
+ {{ s.label }}
261
+ </UButton>
262
+ </div>
263
+ </div>
195
264
  </div>
196
265
  </div>
197
266
  </div>
@@ -29,6 +29,33 @@ const descriptors = computed(() => {
29
29
 
30
30
  const run = computed(() => execution.getByBlock(props.block.id))
31
31
 
32
+ // The Tester's environment descriptor inherits its default from the service frame this
33
+ // task lives under (set in the service inspector); a task only overrides it by clicking.
34
+ // Walk up the parent chain (frame → module → task) to find that default.
35
+ const serviceDefaultTestEnv = computed<'local' | 'ephemeral' | undefined>(() => {
36
+ let cur: Block | undefined = props.block
37
+ for (let i = 0; i < 8 && cur; i++) {
38
+ if (cur.level === 'frame') return cur.defaultTestEnvironment
39
+ if (!cur.parentId) break
40
+ cur = board.getBlock(cur.parentId)
41
+ }
42
+ return undefined
43
+ })
44
+
45
+ /** The effective default for a descriptor — the inherited service value for the Tester's
46
+ * environment, otherwise the descriptor's own static default. */
47
+ function effectiveDefault(d: { id: string; default: string }): string {
48
+ if (d.id === 'tester.environment' && serviceDefaultTestEnv.value) {
49
+ return serviceDefaultTestEnv.value
50
+ }
51
+ return d.default
52
+ }
53
+
54
+ /** Whether a descriptor's shown value is inherited (not explicitly pinned on this task). */
55
+ function isInherited(d: { id: string }): boolean {
56
+ return d.id === 'tester.environment' && props.block.agentConfig?.[d.id] === undefined
57
+ }
58
+
32
59
  /** A descriptor freezes once its contributing agent's step has left `pending`. */
33
60
  function isFrozen(agentKind: string): boolean {
34
61
  const steps = run.value?.steps
@@ -55,19 +82,24 @@ function setValue(id: string, value: string) {
55
82
  <div v-for="d in descriptors" :key="d.id" class="space-y-1">
56
83
  <div class="flex items-center justify-between">
57
84
  <span class="text-[11px] text-slate-400">{{ d.label }}</span>
58
- <UIcon
59
- v-if="isFrozen(d.agentKind)"
60
- name="i-lucide-lock"
61
- class="h-3 w-3 text-slate-500"
62
- title="Frozen — the agent has started"
63
- />
85
+ <div class="flex items-center gap-1.5">
86
+ <span v-if="isInherited(d)" class="text-[10px] text-slate-500"
87
+ >inherited from service</span
88
+ >
89
+ <UIcon
90
+ v-if="isFrozen(d.agentKind)"
91
+ name="i-lucide-lock"
92
+ class="h-3 w-3 text-slate-500"
93
+ title="Frozen — the agent has started"
94
+ />
95
+ </div>
64
96
  </div>
65
97
  <div class="flex flex-wrap gap-1">
66
98
  <UButton
67
99
  v-for="opt in d.options"
68
100
  :key="opt.value"
69
- :color="valueOf(d.id, d.default) === opt.value ? 'primary' : 'neutral'"
70
- :variant="valueOf(d.id, d.default) === opt.value ? 'soft' : 'ghost'"
101
+ :color="valueOf(d.id, effectiveDefault(d)) === opt.value ? 'primary' : 'neutral'"
102
+ :variant="valueOf(d.id, effectiveDefault(d)) === opt.value ? 'soft' : 'ghost'"
71
103
  size="xs"
72
104
  :disabled="isFrozen(d.agentKind)"
73
105
  @click="setValue(d.id, opt.value)"
@@ -203,12 +203,17 @@ async function clone(p: Pipeline) {
203
203
  </script>
204
204
 
205
205
  <template>
206
- <USlideover v-model:open="open" title="Pipeline builder" side="left">
206
+ <USlideover
207
+ v-model:open="open"
208
+ title="Pipeline builder"
209
+ side="left"
210
+ :ui="{ content: 'max-w-[90vw] sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl' }"
211
+ >
207
212
  <template #body>
208
- <div class="grid h-full grid-cols-2 gap-4">
213
+ <div class="grid h-full grid-cols-1 gap-4 lg:grid-cols-3">
209
214
  <!-- agent palette -->
210
- <div class="overflow-y-auto pr-1">
211
- <div class="mb-2 flex items-center justify-between gap-2">
215
+ <div class="flex min-h-0 flex-col overflow-hidden">
216
+ <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
212
217
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
213
218
  Agent palette
214
219
  </h3>
@@ -222,11 +227,13 @@ async function clone(p: Pipeline) {
222
227
  Add agent
223
228
  </UButton>
224
229
  </div>
225
- <AgentPalette @add="add" />
230
+ <div class="min-h-0 flex-1 overflow-y-auto pr-1">
231
+ <AgentPalette @add="add" />
232
+ </div>
226
233
  </div>
227
234
 
228
235
  <!-- draft chain -->
229
- <div class="flex flex-col">
236
+ <div class="flex min-h-0 flex-col overflow-hidden">
230
237
  <div class="mb-2 flex items-center justify-between gap-2">
231
238
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">Pipeline</h3>
232
239
  <UButton
@@ -286,7 +293,7 @@ async function clone(p: Pipeline) {
286
293
  Click agents on the left to assemble a linear pipeline.
287
294
  </div>
288
295
 
289
- <ol v-else class="flex-1 space-y-2 overflow-y-auto">
296
+ <ol v-else class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
290
297
  <li
291
298
  v-for="(unit, vi) in pipelines.units"
292
299
  :key="unit.index"
@@ -576,160 +583,160 @@ async function clone(p: Pipeline) {
576
583
  </div>
577
584
  </li>
578
585
  </ol>
586
+ </div>
579
587
 
580
- <!-- Saved pipelines: review the library + delete (the run affordance
581
- moved to the task card / inspector when the palettes were removed). -->
582
- <div v-if="pipelines.pipelines.length" class="mt-4 border-t border-slate-800 pt-3">
583
- <div class="mb-2 flex items-center justify-between gap-2">
584
- <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
585
- Saved pipelines
586
- </h3>
587
- <UButton
588
- v-if="archivedCount"
589
- :icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
590
- :color="showArchived ? 'primary' : 'neutral'"
591
- variant="ghost"
592
- size="xs"
593
- @click="showArchived = !showArchived"
594
- >
595
- {{ showArchived ? 'Hide archived' : `Archived (${archivedCount})` }}
596
- </UButton>
597
- </div>
598
-
599
- <!-- Label filter chips. -->
600
- <div v-if="allLabels.length" class="mb-2 flex flex-wrap items-center gap-1">
601
- <UBadge
602
- :color="labelFilter === null ? 'primary' : 'neutral'"
603
- variant="soft"
604
- size="xs"
605
- class="cursor-pointer"
606
- @click="labelFilter = null"
607
- >
608
- All
609
- </UBadge>
610
- <UBadge
611
- v-for="l in allLabels"
612
- :key="l"
613
- :color="labelFilter === l ? 'primary' : 'neutral'"
614
- variant="soft"
615
- size="xs"
616
- class="cursor-pointer"
617
- @click="labelFilter = labelFilter === l ? null : l"
618
- >
619
- {{ l }}
620
- </UBadge>
621
- </div>
622
-
623
- <ul class="space-y-1.5">
624
- <li
625
- v-for="p in visiblePipelines"
626
- :key="p.id"
627
- class="group rounded-lg border border-slate-700 bg-slate-800/40"
628
- :class="{ 'opacity-60': p.archived }"
629
- >
630
- <div class="flex items-center gap-2 px-2 py-1.5">
631
- <button
632
- type="button"
633
- class="flex min-w-0 flex-1 items-center gap-2 text-left"
634
- @click="toggleSaved(p.id)"
588
+ <!-- Saved pipelines: review the library + delete (the run affordance
589
+ moved to the task card / inspector when the palettes were removed). -->
590
+ <div v-if="pipelines.pipelines.length" class="flex min-h-0 flex-col overflow-hidden">
591
+ <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
592
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
593
+ Saved pipelines
594
+ </h3>
595
+ <UButton
596
+ v-if="archivedCount"
597
+ :icon="showArchived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
598
+ :color="showArchived ? 'primary' : 'neutral'"
599
+ variant="ghost"
600
+ size="xs"
601
+ @click="showArchived = !showArchived"
602
+ >
603
+ {{ showArchived ? 'Hide archived' : `Archived (${archivedCount})` }}
604
+ </UButton>
605
+ </div>
606
+
607
+ <!-- Label filter chips. -->
608
+ <div v-if="allLabels.length" class="mb-2 flex shrink-0 flex-wrap items-center gap-1">
609
+ <UBadge
610
+ :color="labelFilter === null ? 'primary' : 'neutral'"
611
+ variant="soft"
612
+ size="xs"
613
+ class="cursor-pointer"
614
+ @click="labelFilter = null"
615
+ >
616
+ All
617
+ </UBadge>
618
+ <UBadge
619
+ v-for="l in allLabels"
620
+ :key="l"
621
+ :color="labelFilter === l ? 'primary' : 'neutral'"
622
+ variant="soft"
623
+ size="xs"
624
+ class="cursor-pointer"
625
+ @click="labelFilter = labelFilter === l ? null : l"
626
+ >
627
+ {{ l }}
628
+ </UBadge>
629
+ </div>
630
+
631
+ <ul class="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
632
+ <li
633
+ v-for="p in visiblePipelines"
634
+ :key="p.id"
635
+ class="group rounded-lg border border-slate-700 bg-slate-800/40"
636
+ :class="{ 'opacity-60': p.archived }"
637
+ >
638
+ <div class="flex items-center gap-2 px-2 py-1.5">
639
+ <button
640
+ type="button"
641
+ class="flex min-w-0 flex-1 items-center gap-2 text-left"
642
+ @click="toggleSaved(p.id)"
643
+ >
644
+ <UIcon
645
+ :name="
646
+ expandedSaved.has(p.id) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'
647
+ "
648
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
649
+ />
650
+ <span class="min-w-0 flex-1 truncate text-xs text-slate-200">{{ p.name }}</span>
651
+ <UBadge
652
+ v-for="l in p.labels ?? []"
653
+ :key="l"
654
+ color="info"
655
+ variant="soft"
656
+ size="xs"
657
+ class="shrink-0"
635
658
  >
636
- <UIcon
637
- :name="
638
- expandedSaved.has(p.id) ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'
639
- "
640
- class="h-3.5 w-3.5 shrink-0 text-slate-500"
641
- />
642
- <span class="min-w-0 flex-1 truncate text-xs text-slate-200">{{ p.name }}</span>
643
- <UBadge
644
- v-for="l in p.labels ?? []"
645
- :key="l"
646
- color="info"
647
- variant="soft"
648
- size="xs"
649
- class="shrink-0"
650
- >
651
- {{ l }}
652
- </UBadge>
653
- <UBadge
654
- v-if="p.builtin"
655
- color="neutral"
656
- variant="soft"
657
- size="xs"
658
- class="shrink-0"
659
- >
660
- default
661
- </UBadge>
662
- <span class="shrink-0 text-[10px] text-slate-500">
663
- {{ p.agentKinds.length }} {{ p.agentKinds.length === 1 ? 'step' : 'steps' }}
664
- </span>
665
- </button>
666
- <div
667
- class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
659
+ {{ l }}
660
+ </UBadge>
661
+ <UBadge
662
+ v-if="p.builtin"
663
+ color="neutral"
664
+ variant="soft"
665
+ size="xs"
666
+ class="shrink-0"
668
667
  >
669
- <!-- Archive/unarchive: organize the library without deleting. Works on
668
+ default
669
+ </UBadge>
670
+ <span class="shrink-0 text-[10px] text-slate-500">
671
+ {{ p.agentKinds.length }} {{ p.agentKinds.length === 1 ? 'step' : 'steps' }}
672
+ </span>
673
+ </button>
674
+ <div
675
+ class="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100"
676
+ >
677
+ <!-- Archive/unarchive: organize the library without deleting. Works on
670
678
  built-ins too (view metadata, not structure). -->
671
- <UButton
672
- :icon="p.archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
673
- color="neutral"
674
- variant="ghost"
675
- size="xs"
676
- :title="p.archived ? 'Unarchive' : 'Archive (hide from the default view)'"
677
- @click="toggleArchive(p)"
678
- />
679
- <!-- Clone is available on every pipeline — it's how a read-only
679
+ <UButton
680
+ :icon="p.archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
681
+ color="neutral"
682
+ variant="ghost"
683
+ size="xs"
684
+ :title="p.archived ? 'Unarchive' : 'Archive (hide from the default view)'"
685
+ @click="toggleArchive(p)"
686
+ />
687
+ <!-- Clone is available on every pipeline — it's how a read-only
680
688
  built-in template becomes an editable copy. -->
681
- <UButton
682
- icon="i-lucide-copy"
683
- color="neutral"
684
- variant="ghost"
685
- size="xs"
686
- :title="p.builtin ? 'Clone this default into an editable copy' : 'Clone'"
687
- @click="clone(p)"
688
- />
689
- <!-- Built-in templates are read-only; only custom pipelines edit in place. -->
690
- <UButton
691
- v-if="!p.builtin"
692
- icon="i-lucide-pencil"
693
- color="neutral"
694
- variant="ghost"
695
- size="xs"
696
- title="Edit this pipeline"
697
- @click="edit(p)"
698
- />
699
- <!-- Built-in templates are read-only — they can be cloned but not
689
+ <UButton
690
+ icon="i-lucide-copy"
691
+ color="neutral"
692
+ variant="ghost"
693
+ size="xs"
694
+ :title="p.builtin ? 'Clone this default into an editable copy' : 'Clone'"
695
+ @click="clone(p)"
696
+ />
697
+ <!-- Built-in templates are read-only; only custom pipelines edit in place. -->
698
+ <UButton
699
+ v-if="!p.builtin"
700
+ icon="i-lucide-pencil"
701
+ color="neutral"
702
+ variant="ghost"
703
+ size="xs"
704
+ title="Edit this pipeline"
705
+ @click="edit(p)"
706
+ />
707
+ <!-- Built-in templates are read-only — they can be cloned but not
700
708
  deleted (the backend rejects it too); only custom ones delete. -->
701
- <UButton
702
- v-if="!p.builtin"
703
- icon="i-lucide-trash-2"
704
- color="neutral"
705
- variant="ghost"
706
- size="xs"
707
- @click="pipelines.removePipeline(p.id)"
708
- />
709
- </div>
709
+ <UButton
710
+ v-if="!p.builtin"
711
+ icon="i-lucide-trash-2"
712
+ color="neutral"
713
+ variant="ghost"
714
+ size="xs"
715
+ @click="pipelines.removePipeline(p.id)"
716
+ />
710
717
  </div>
718
+ </div>
711
719
 
712
- <!-- Full ordered step list, revealed on click. -->
713
- <ol
714
- v-if="expandedSaved.has(p.id)"
715
- class="space-y-1 border-t border-slate-800 px-2 py-2 pl-7"
720
+ <!-- Full ordered step list, revealed on click. -->
721
+ <ol
722
+ v-if="expandedSaved.has(p.id)"
723
+ class="space-y-1 border-t border-slate-800 px-2 py-2 pl-7"
724
+ >
725
+ <li
726
+ v-for="(k, i) in p.agentKinds"
727
+ :key="i"
728
+ class="flex items-center gap-2"
729
+ :class="{ 'opacity-50 line-through': p.enabled?.[i] === false }"
730
+ :title="p.enabled?.[i] === false ? 'Disabled — skipped at run' : undefined"
716
731
  >
717
- <li
718
- v-for="(k, i) in p.agentKinds"
719
- :key="i"
720
- class="flex items-center gap-2"
721
- :class="{ 'opacity-50 line-through': p.enabled?.[i] === false }"
722
- :title="p.enabled?.[i] === false ? 'Disabled — skipped at run' : undefined"
723
- >
724
- <span class="w-4 shrink-0 text-center text-[10px] text-slate-500">{{
725
- i + 1
726
- }}</span>
727
- <AgentKindIcon :kind="k" show-label />
728
- </li>
729
- </ol>
730
- </li>
731
- </ul>
732
- </div>
732
+ <span class="w-4 shrink-0 text-center text-[10px] text-slate-500">{{
733
+ i + 1
734
+ }}</span>
735
+ <AgentKindIcon :kind="k" show-label />
736
+ </li>
737
+ </ol>
738
+ </li>
739
+ </ul>
733
740
  </div>
734
741
  </div>
735
742
  </template>
@@ -0,0 +1,118 @@
1
+ import type { Ref } from 'vue'
2
+ import { onMounted, onBeforeUnmount } from 'vue'
3
+ import { useRafFn } from '@vueuse/core'
4
+ import { lodAtLeast } from '~/composables/useSemanticZoom'
5
+
6
+ type Rect = { left: number; right: number; top: number; bottom: number }
7
+
8
+ function intersects(a: Rect, b: Rect) {
9
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
10
+ }
11
+
12
+ function sameSet(a: Set<string>, b: Set<string>) {
13
+ if (a.size !== b.size) return false
14
+ for (const id of a) if (!b.has(id)) return false
15
+ return true
16
+ }
17
+
18
+ /**
19
+ * Board-level driver deciding which service frames may auto-expand to their task
20
+ * canvas once zoomed past the `close` band. The frame analogue of
21
+ * `useTaskExpansion`: two gates, recomputed every frame against live DOM rects so
22
+ * they follow pan / zoom / drag / resize:
23
+ *
24
+ * - visibility: a frame expands only while its card overlaps the board viewport,
25
+ * so a service that isn't on screen at all never expands when you zoom in.
26
+ * - overlap: walking the visible frames nearest-to-screen-centre first, a frame
27
+ * expands only if its (expanded) footprint doesn't collide with one already
28
+ * granted — so the small service the user centred on wins, and a larger
29
+ * neighbour can't "snap out" over it.
30
+ *
31
+ * Writes the permitted id set into the `frameExpansion` store; `ui.isFrameExpanded`
32
+ * reads it. Manually-expanded frames bypass this gate entirely (see the store).
33
+ */
34
+ export function useFrameExpansion(container: Ref<HTMLElement | null>) {
35
+ const board = useBoardStore()
36
+ const ui = useUiStore()
37
+ const store = useFrameExpansionStore()
38
+
39
+ // Last-known expanded size per frame. A frame's card balloons from a chip to its
40
+ // full task canvas only while it's granted, so its live rect collapses the moment
41
+ // it's denied. Testing overlap with the collapsed chip is what would cause
42
+ // flashing: a denied frame no longer overlaps its neighbour, gets re-granted,
43
+ // expands, overlaps again, gets denied — every frame. We cache the expanded
44
+ // extent while a frame is granted and project the footprint with it, so a denied
45
+ // frame is still tested at its expanded size and stays denied. Stable.
46
+ const expandedSize = new Map<string, { w: number; h: number }>()
47
+
48
+ function rectOf(id: string): DOMRect | null {
49
+ const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
50
+ return el ? el.getBoundingClientRect() : null
51
+ }
52
+
53
+ function recompute() {
54
+ // Frames only auto-expand at the `close` band and deeper; clear otherwise.
55
+ if (!lodAtLeast(ui.lod, 'close')) {
56
+ if (store.allowed.size) store.setAllowed(new Set())
57
+ return
58
+ }
59
+ const view = container.value?.getBoundingClientRect()
60
+ if (!view) return
61
+ const cx = view.left + view.width / 2
62
+ const cy = view.top + view.height / 2
63
+
64
+ const candidates: { id: string; rect: Rect; dist: number }[] = []
65
+ const liveIds = new Set<string>()
66
+ for (const f of board.frames) {
67
+ const rect = rectOf(f.id)
68
+ if (!rect) continue
69
+ liveIds.add(f.id)
70
+ // While granted the frame is rendered expanded, so its live rect is its
71
+ // expanded footprint — cache it. A denied frame keeps its last cached value.
72
+ if (store.allowed.has(f.id)) expandedSize.set(f.id, { w: rect.width, h: rect.height })
73
+ // Visibility: the card must intersect the board viewport (live rect).
74
+ if (!intersects(rect, view)) continue
75
+ // Project to the cached expanded extent so the overlap test is independent of
76
+ // the card's current (possibly collapsed-chip) state. A frame grows rightward
77
+ // and downward from its top-left, which stays put as it expands.
78
+ const cached = expandedSize.get(f.id)
79
+ const width = Math.max(rect.width, cached?.w ?? 0)
80
+ const height = Math.max(rect.height, cached?.h ?? 0)
81
+ const footprint: Rect = {
82
+ left: rect.left,
83
+ right: rect.left + width,
84
+ top: rect.top,
85
+ bottom: rect.top + height,
86
+ }
87
+ // Stable anchor: the card's top-left. It doesn't move as the frame grows, so
88
+ // the ordering can't oscillate as frames expand / collapse.
89
+ const dist = (rect.left - cx) ** 2 + (rect.top - cy) ** 2
90
+ candidates.push({ id: f.id, rect: footprint, dist })
91
+ }
92
+ // Drop cached sizes for frames that are gone, so the map can't grow unbounded.
93
+ for (const id of expandedSize.keys()) if (!liveIds.has(id)) expandedSize.delete(id)
94
+ candidates.sort((a, b) => a.dist - b.dist)
95
+
96
+ // Greedy by distance to centre: a frame is granted only if its projected
97
+ // footprint clears every footprint already granted, so the centre-most frame
98
+ // wins any overlap.
99
+ const claimed: Rect[] = []
100
+ const next = new Set<string>()
101
+ for (const c of candidates) {
102
+ if (claimed.some((r) => intersects(c.rect, r))) continue
103
+ next.add(c.id)
104
+ claimed.push(c.rect)
105
+ }
106
+ if (!sameSet(next, store.allowed)) store.setAllowed(next)
107
+ }
108
+
109
+ const { pause, resume } = useRafFn(recompute, { immediate: false })
110
+ onMounted(() => {
111
+ store.setDriverActive(true)
112
+ resume()
113
+ })
114
+ onBeforeUnmount(() => {
115
+ pause()
116
+ store.setDriverActive(false)
117
+ })
118
+ }
@@ -0,0 +1,38 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+
4
+ /**
5
+ * Which service frames may auto-expand to reveal their tasks once zoomed in.
6
+ *
7
+ * Past the `close` band a frame opens from a chip to its full task canvas, which
8
+ * can balloon across the viewport. Expanding *every* frame at once (the old
9
+ * behaviour) made a large off-centre service "snap out" over the small one the
10
+ * user was actually centred on, and expanded services that weren't even on screen.
11
+ * The board driver (`useFrameExpansion`) recomputes a permitted set every frame —
12
+ * only on-screen frames, and only the one closest to the screen centre when two
13
+ * expanded footprints would overlap — and writes it here. `ui.isFrameExpanded`
14
+ * reads `canExpand` to decide whether the zoom band may open a frame.
15
+ *
16
+ * `driverActive` lets the gate degrade gracefully: with no board driver mounted
17
+ * (e.g. the focus view, or a frame rendered in isolation / tests) `canExpand`
18
+ * falls back to "allowed", so the plain zoom behaviour is unchanged.
19
+ */
20
+ export const useFrameExpansionStore = defineStore('frameExpansion', () => {
21
+ const allowed = ref<Set<string>>(new Set())
22
+ const driverActive = ref(false)
23
+
24
+ function setAllowed(ids: Set<string>) {
25
+ allowed.value = ids
26
+ }
27
+
28
+ function setDriverActive(active: boolean) {
29
+ driverActive.value = active
30
+ if (!active) allowed.value = new Set()
31
+ }
32
+
33
+ function canExpand(id: string) {
34
+ return driverActive.value ? allowed.value.has(id) : true
35
+ }
36
+
37
+ return { allowed, driverActive, setAllowed, setDriverActive, canExpand }
38
+ })
package/app/stores/ui.ts CHANGED
@@ -4,6 +4,7 @@ import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domai
4
4
  import type { PendingContext } from '~/composables/useContextLinking'
5
5
  import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
6
6
  import { useExecutionStore } from '~/stores/execution'
7
+ import { useFrameExpansionStore } from '~/stores/frameExpansion'
7
8
  import { agentKindMeta } from '~/utils/catalog'
8
9
 
9
10
  /** Values used to seed the add-task form when it is opened from another surface. */
@@ -159,9 +160,14 @@ export const useUiStore = defineStore('ui', () => {
159
160
  }
160
161
 
161
162
  /** A frame shows its tasks when manually expanded OR once zoomed in to `close`
162
- * or any deeper band (`steps`/`subtasks` drill further into those tasks). */
163
+ * or any deeper band (`steps`/`subtasks` drill further into those tasks). The
164
+ * zoom-driven branch is gated by the board's frame-expansion driver so only
165
+ * on-screen, centre-most frames open — a large off-centre or off-screen service
166
+ * no longer snaps out over the one the user is focused on. The gate degrades to
167
+ * "allowed" when no board driver is mounted (focus view / tests). */
163
168
  function isFrameExpanded(id: string) {
164
- return expandedFrames.value.has(id) || lodAtLeast(lod.value, 'close')
169
+ if (expandedFrames.value.has(id)) return true
170
+ return lodAtLeast(lod.value, 'close') && useFrameExpansionStore().canExpand(id)
165
171
  }
166
172
 
167
173
  function select(id: string | null) {
@@ -143,6 +143,13 @@ export interface Block {
143
143
  testComposePath?: string
144
144
  /** service-only (frame): the service has no infra dependencies to stand up. */
145
145
  noInfraDependencies?: boolean
146
+ /**
147
+ * service-only (frame): the default test environment a task under this service is
148
+ * spawned with — `local` (docker-compose infra) or `ephemeral` (provisioned env).
149
+ * Tasks inherit it unless they override via their `tester.environment` config.
150
+ * absent = the built-in `ephemeral`.
151
+ */
152
+ defaultTestEnvironment?: 'local' | 'ephemeral'
146
153
  /** service-only (frame): cloud provider the service's jobs run on; absent = account default. */
147
154
  cloudProvider?: CloudProvider
148
155
  /** service-only (frame): abstract instance size for the service's jobs; absent = default. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.17.1",
3
+ "version": "0.18.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",