@cat-factory/app 0.76.0 → 0.78.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.
@@ -21,6 +21,9 @@ const memberSegments = ref<MemberSeg[]>([])
21
21
  // Frontend frame → bound service frame links (from a frontend's backend bindings).
22
22
  type FrontendSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
23
23
  const frontendSegments = ref<FrontendSeg[]>([])
24
+ // Service frame → connected provider service frame links (from serviceConnections).
25
+ type ConnectionSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
26
+ const connectionSegments = ref<ConnectionSeg[]>([])
24
27
 
25
28
  // task → its dependencies, both ends being tasks
26
29
  const taskDeps = computed(() => {
@@ -66,6 +69,24 @@ const frontendLinks = computed(() => {
66
69
  return out
67
70
  })
68
71
 
72
+ // consumer service frame → each provider service it connects to (a serviceConnections
73
+ // entry, stored on the consumer end). Deduped; a target deleted out of band draws nothing.
74
+ const connectionLinks = computed(() => {
75
+ const out: { id: string; source: string; target: string }[] = []
76
+ for (const f of board.frames) {
77
+ if (f.type !== 'service') continue
78
+ const seen = new Set<string>()
79
+ for (const connection of f.serviceConnections ?? []) {
80
+ const providerId = connection.serviceBlockId
81
+ if (seen.has(providerId)) continue
82
+ seen.add(providerId)
83
+ if (board.getBlock(providerId))
84
+ out.push({ id: `${f.id}__conn__${providerId}`, source: f.id, target: providerId })
85
+ }
86
+ }
87
+ return out
88
+ })
89
+
69
90
  /** Resolve a task's anchor: walk up task → module → service to the first card
70
91
  * that's actually rendered (a container may be collapsed). */
71
92
  function anchorEl(taskId: string): HTMLElement | null {
@@ -152,6 +173,23 @@ function recompute() {
152
173
  fes.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
153
174
  }
154
175
  frontendSegments.value = fes
176
+
177
+ const conns: ConnectionSeg[] = []
178
+ for (const link of connectionLinks.value) {
179
+ const a = anchorEl(link.source)
180
+ const b = anchorEl(link.target)
181
+ if (!a || !b || a === b) continue
182
+ const ra = a.getBoundingClientRect()
183
+ const rb = b.getBoundingClientRect()
184
+ const ax = ra.left + ra.width / 2 - origin.left
185
+ const ay = ra.top + ra.height / 2 - origin.top
186
+ const bx = rb.left + rb.width / 2 - origin.left
187
+ const by = rb.top + rb.height / 2 - origin.top
188
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
189
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
190
+ conns.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
191
+ }
192
+ connectionSegments.value = conns
155
193
  }
156
194
 
157
195
  const { pause, resume } = useRafFn(recompute, { immediate: false })
@@ -195,8 +233,34 @@ onBeforeUnmount(pause)
195
233
  >
196
234
  <path d="M0,0 L10,5 L0,10 z" fill="#22d3ee" />
197
235
  </marker>
236
+ <marker
237
+ id="service-connection-arrow"
238
+ viewBox="0 0 10 10"
239
+ refX="8"
240
+ refY="5"
241
+ markerWidth="6"
242
+ markerHeight="6"
243
+ orient="auto-start-reverse"
244
+ >
245
+ <path d="M0,0 L10,5 L0,10 z" fill="#34d399" />
246
+ </marker>
198
247
  </defs>
199
248
 
249
+ <!-- consumer service → provider service connection links (emerald, arrow toward the provider) -->
250
+ <line
251
+ v-for="s in connectionSegments"
252
+ :key="s.id"
253
+ :x1="s.x1"
254
+ :y1="s.y1"
255
+ :x2="s.x2"
256
+ :y2="s.y2"
257
+ stroke="#34d399"
258
+ :stroke-width="1.5"
259
+ stroke-dasharray="3 4"
260
+ :stroke-opacity="0.55"
261
+ marker-end="url(#service-connection-arrow)"
262
+ />
263
+
200
264
  <!-- frontend frame → bound service frame links (cyan, arrow toward the service under test) -->
201
265
  <line
202
266
  v-for="s in frontendSegments"
@@ -20,6 +20,7 @@ const documents = useDocumentsStore()
20
20
  const tasks = useTasksStore()
21
21
  const tracker = useTrackerStore()
22
22
  const releaseHealth = useReleaseHealthStore()
23
+ const packageRegistries = usePackageRegistriesStore()
23
24
  const userSecrets = useUserSecretsStore()
24
25
  const apiKeys = useApiKeysStore()
25
26
  const workspace = useWorkspaceStore()
@@ -49,6 +50,7 @@ watch(
49
50
  if (isOpen) {
50
51
  query.value = ''
51
52
  void releaseHealth.ensureLoaded().catch(() => {})
53
+ void packageRegistries.ensureLoaded().catch(() => {})
52
54
  void userSecrets.load().catch(() => {})
53
55
  // Drives the OpenRouter row's "Key connected" badge.
54
56
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -256,6 +258,27 @@ const groups = computed<IntegrationGroup[]>(() => {
256
258
  })
257
259
  }
258
260
 
261
+ // --- Development (private package registries) -------------------------------
262
+ // Gated like observability: hidden until a probe confirms the module is wired
263
+ // (`available === true`), so an unconfigured backend doesn't show a dead row.
264
+ if (packageRegistries.available) {
265
+ const hasEntries = packageRegistries.entries.length > 0
266
+ out.push({
267
+ title: t('layout.integrationsHub.groups.development'),
268
+ items: [
269
+ {
270
+ key: 'package-registries',
271
+ icon: 'i-lucide-package',
272
+ label: t('layout.integrationsHub.items.packageRegistries.label'),
273
+ description: t('layout.integrationsHub.items.packageRegistries.description'),
274
+ status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
275
+ connected: hasEntries,
276
+ onClick: () => go(ui.openPackageRegistries),
277
+ },
278
+ ],
279
+ })
280
+ }
281
+
259
282
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
260
283
  // warm pool/checkout) is no longer listed here — it moved to its OWN top-level navbar menu
261
284
  // (SideBar → "Infrastructure" → the tabbed Infrastructure window). See `ui.openInfrastructure`.
@@ -7,6 +7,8 @@ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
7
7
  import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
8
8
  import StepTestReport from '~/components/panels/StepTestReport.vue'
9
9
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
10
+ import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
11
+ import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
10
12
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
11
13
  import IterationCapPrompt from '~/components/pipeline/IterationCapPrompt.vue'
12
14
  import { useStepTimer } from '~/composables/useStepTimer'
@@ -58,6 +60,24 @@ const testPhase = computed(() => step.value?.test ?? null)
58
60
  // coder consume it), so the panel shows its spinning-up/running/shutdown/errored state.
59
61
  const stepEnvironment = computed(() => step.value?.environment ?? null)
60
62
 
63
+ // For a frontend UI-test step (`tester-ui`): the enclosing `frontend` frame's backend-binding
64
+ // config, so the detail can project how each env var resolved (live URL | mocked) — rendered from
65
+ // the FROZEN bindings the engine stamped on the run (`instance.frontendBindings`), so a finished
66
+ // run shows what it actually drove against rather than re-resolving against current live state.
67
+ const frontendFrame = computed(() => (block.value ? board.serviceOf(block.value) : undefined))
68
+ const isFrontendFrame = computed(() => frontendFrame.value?.type === 'frontend')
69
+ const frontendConfig = computed(() =>
70
+ step.value?.agentKind === UI_TESTER_AGENT_KIND && isFrontendFrame.value
71
+ ? (frontendFrame.value!.frontendConfig ?? null)
72
+ : null,
73
+ )
74
+ // The frozen start-time resolution the tester ran against (absent for a non-frontend / pre-6b run).
75
+ const frontendBindings = computed(() => instance.value?.frontendBindings ?? [])
76
+ // The run-start advisories the engine stamped on the run (duplicate env vars / partially-mocked
77
+ // services) are a whole-RUN fact, so surface them on ANY step detail of a frontend-frame run, not
78
+ // only the `tester-ui` step — a duplicate-env-var note shouldn't be invisible from the coder step.
79
+ const runNotes = computed(() => (isFrontendFrame.value ? (instance.value?.notes ?? []) : []))
80
+
61
81
  // The run's infrastructure attempts (container/runner/env spin-up + tear-down), behind
62
82
  // a toggle. This is the surface that makes the per-run `container` log rows + the
63
83
  // executionId filter visible — most useful when the run failed to start a container.
@@ -345,6 +365,27 @@ async function copyOutput() {
345
365
  errored + the exact error), when this step runs against one -->
346
366
  <EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
347
367
 
368
+ <!-- frontend UI-test: how the frame's backend bindings resolved (env var →
369
+ live URL | mocked) + the run-start advisories (duplicate env vars /
370
+ partially-mocked services) the engine stamped on the run. Rendered from the
371
+ FROZEN start-time bindings so a finished run shows what it actually drove
372
+ against, not a live re-resolution. -->
373
+ <FrontendBindingsResolved
374
+ v-if="frontendConfig"
375
+ :config="frontendConfig"
376
+ :resolved="frontendBindings"
377
+ />
378
+ <ul v-if="runNotes.length" class="space-y-1" data-testid="run-notes">
379
+ <li
380
+ v-for="(note, i) in runNotes"
381
+ :key="i"
382
+ class="flex items-start gap-1.5 text-[11px] leading-snug text-amber-300/80"
383
+ >
384
+ <UIcon name="i-lucide-info" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
385
+ <span>{{ note }}</span>
386
+ </li>
387
+ </ul>
388
+
348
389
  <!-- this run's infrastructure attempts (container/runner/env spin-up +
349
390
  tear-down): the surface for the per-run container log rows + the exact
350
391
  provider error, behind a toggle (most useful on a failed-to-start run) -->
@@ -9,6 +9,7 @@ import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.v
9
9
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
10
10
  import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
11
11
  import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
12
+ import ServiceConnections from '~/components/panels/inspector/ServiceConnections.vue'
12
13
  import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
13
14
  import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
14
15
  import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
@@ -465,6 +466,9 @@ const showOriginalDescription = ref(false)
465
466
  <!-- frontend (frame): build/serve/mock config + backend bindings (board links) -->
466
467
  <FrontendConfig v-if="isFrame && block.type === 'frontend'" :block="block" />
467
468
 
469
+ <!-- service (frame): directed connections to the other services it uses (board links) -->
470
+ <ServiceConnections v-if="isFrame && block.type === 'service'" :block="block" />
471
+
468
472
  <!-- service (frame): test infra + provisioning configuration -->
469
473
  <ServiceTestConfig v-if="isFrame" :block="block" />
470
474
 
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted } from 'vue'
3
+ import {
4
+ duplicateBindingEnvVars,
5
+ resolveFrontendBindings,
6
+ type FrontendBackendBinding,
7
+ type FrontendConfig,
8
+ type ResolvedFrontendBinding,
9
+ } from '@cat-factory/contracts'
10
+
11
+ // The resolution of a frontend frame's backend bindings — each env var → a bound service's live
12
+ // ephemeral URL, or WireMock. Two modes, same view:
13
+ // - **Live** (frame inspector, `resolved` omitted): resolves against the workspace's CURRENT env
14
+ // handles (fetched once via the environments store), so the operator sees how a run would
15
+ // resolve RIGHT NOW. Feeds the SAME pure helpers the backend uses so it can't drift.
16
+ // - **Projected** (`tester-ui` run/step detail, `resolved` provided): renders the FROZEN
17
+ // start-time bindings the engine stamped on the run, so a finished run shows what it ACTUALLY
18
+ // drove against — truthful even after the underlying envs are torn down (no live re-read).
19
+ // Also surfaces the duplicate-env-var misconfiguration in live mode (projected mode leaves that to
20
+ // the run-start note, which owns the frozen advisory).
21
+ const props = defineProps<{ config: FrontendConfig; resolved?: ResolvedFrontendBinding[] }>()
22
+
23
+ const environments = useEnvironmentsStore()
24
+ const board = useBoardStore()
25
+ const { t } = useI18n()
26
+
27
+ const projected = computed(() => props.resolved !== undefined)
28
+
29
+ // Live mode refreshes the env handles when this view opens so a just-provisioned service shows as
30
+ // live; a projected snapshot needs no live read.
31
+ onMounted(() => {
32
+ if (!projected.value) void environments.load()
33
+ })
34
+
35
+ // The duplicate advisory is config-derived; in projected mode the run-start note owns it (frozen
36
+ // at start), so don't re-derive it here against a possibly-since-edited config.
37
+ const duplicates = computed(() => (projected.value ? [] : duplicateBindingEnvVars(props.config)))
38
+
39
+ // Each resolved binding + the display metadata a bare {envVar, serviceUrl} can't carry: whether
40
+ // a mocked upstream was a `mock` source or a `service` with no live env, and the bound service's
41
+ // title. Joined off the LAST config binding per envVar (matching `resolveFrontendBindings`'
42
+ // last-wins dedup), so the extra labels stay in step with the canonical resolution.
43
+ const rows = computed(() => {
44
+ const resolved =
45
+ props.resolved ??
46
+ resolveFrontendBindings(props.config, environments.liveServiceEnvUrls(props.config))
47
+ const lastByEnvVar = new Map<string, FrontendBackendBinding>()
48
+ for (const b of props.config.backendBindings) {
49
+ const key = b.envVar.trim()
50
+ if (key) lastByEnvVar.set(key, b)
51
+ }
52
+ return resolved.map((r) => {
53
+ const source = lastByEnvVar.get(r.envVar)?.source
54
+ const serviceFrameId = source?.kind === 'service' ? source.serviceBlockId : undefined
55
+ return {
56
+ envVar: r.envVar,
57
+ serviceUrl: r.serviceUrl,
58
+ kind: r.serviceUrl ? 'live' : source?.kind === 'service' ? 'service-offline' : 'mock',
59
+ serviceTitle: serviceFrameId
60
+ ? (board.getBlock(serviceFrameId)?.title ?? serviceFrameId)
61
+ : undefined,
62
+ } as const
63
+ })
64
+ })
65
+ </script>
66
+
67
+ <template>
68
+ <div v-if="rows.length || duplicates.length" class="space-y-1.5" data-testid="frontend-resolved">
69
+ <div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
70
+ {{ t('inspector.frontendConfig.resolved.title') }}
71
+ </div>
72
+
73
+ <p
74
+ v-if="duplicates.length"
75
+ class="text-[11px] leading-snug text-amber-300/80"
76
+ data-testid="frontend-resolved-duplicates"
77
+ >
78
+ {{ t('inspector.frontendConfig.resolved.duplicateWarning', { vars: duplicates.join(', ') }) }}
79
+ </p>
80
+
81
+ <ul v-if="rows.length" class="space-y-0.5">
82
+ <li
83
+ v-for="row in rows"
84
+ :key="row.envVar"
85
+ class="flex items-baseline gap-1.5 text-[11px] leading-snug"
86
+ data-testid="frontend-resolved-row"
87
+ >
88
+ <span
89
+ class="mt-1 h-1.5 w-1.5 shrink-0 rounded-full"
90
+ :class="{
91
+ 'bg-emerald-400': row.kind === 'live',
92
+ 'bg-amber-400': row.kind === 'service-offline',
93
+ 'bg-slate-500': row.kind === 'mock',
94
+ }"
95
+ />
96
+ <span class="font-mono text-slate-300">{{ row.envVar }}</span>
97
+ <span class="text-slate-600">→</span>
98
+ <template v-if="row.kind === 'live'">
99
+ <span class="truncate font-mono text-emerald-300/90">{{ row.serviceUrl }}</span>
100
+ <span v-if="row.serviceTitle" class="text-slate-500">({{ row.serviceTitle }})</span>
101
+ </template>
102
+ <span v-else-if="row.kind === 'service-offline'" class="text-amber-300/80">
103
+ {{ t('inspector.frontendConfig.resolved.serviceOffline', { service: row.serviceTitle }) }}
104
+ </span>
105
+ <span v-else class="text-slate-500">
106
+ {{ t('inspector.frontendConfig.resolved.mock') }}
107
+ </span>
108
+ </li>
109
+ </ul>
110
+ </div>
111
+ </template>
@@ -10,6 +10,7 @@ import type {
10
10
  FrontendServeMode,
11
11
  PreviewStatus,
12
12
  } from '~/types/domain'
13
+ import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
13
14
 
14
15
  // Frontend-frame (`type: 'frontend'`) configuration: how to build, serve, and mock this
15
16
  // frontend for a self-contained UI test (+ an optional browsable preview on local/node),
@@ -625,6 +626,13 @@ onUnmounted(() => preview.stopPolling(props.block.id))
625
626
  <div v-else class="text-[11px] text-slate-500">
626
627
  {{ t('inspector.frontendConfig.bindings.empty') }}
627
628
  </div>
629
+
630
+ <!-- How the bindings resolve RIGHT NOW: each env var → a bound service's live ephemeral
631
+ URL, or WireMock — plus the duplicate-env-var warning. The same view a UI-test run
632
+ would resolve against (shared helpers), so what you see is what a run will drive. -->
633
+ <div class="border-t border-slate-800/60 pt-2">
634
+ <FrontendBindingsResolved :config="config" />
635
+ </div>
628
636
  </div>
629
637
  </div>
630
638
 
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Block, ServiceConnection } from '~/types/domain'
4
+
5
+ // Service-frame (`type: 'service'`) connections: the other services this one USES
6
+ // (consumer→provider edges, stored on this frame — the consumer end). Each row picks a
7
+ // provider service frame and optionally describes the relationship (folded into agent
8
+ // prompts when the provider is involved in a task). The rows ARE the board's
9
+ // service→service links, and the source of a task's "involved services" choices.
10
+ // Persisted as serviceConnections on the block via the shared updateBlock PATCH.
11
+ // The read-only "Used by" list below is the reverse direction, computed from the
12
+ // OTHER frames' connections targeting this one.
13
+ const props = defineProps<{ block: Block }>()
14
+
15
+ const board = useBoardStore()
16
+ const { t } = useI18n()
17
+
18
+ const connections = computed<ServiceConnection[]>(() => props.block.serviceConnections ?? [])
19
+
20
+ function save(next: ServiceConnection[]) {
21
+ board.updateBlock(props.block.id, { serviceConnections: next })
22
+ }
23
+
24
+ // Provider candidates: every OTHER service frame on the board. A frame already used by
25
+ // another row is excluded per row (duplicates are rejected server-side too).
26
+ const serviceFrames = computed(() =>
27
+ board.frames.filter((b) => b.type === 'service' && b.id !== props.block.id),
28
+ )
29
+
30
+ function targetItems(index: number) {
31
+ const takenElsewhere = new Set(
32
+ connections.value.filter((_, i) => i !== index).map((c) => c.serviceBlockId),
33
+ )
34
+ return serviceFrames.value
35
+ .filter((f) => !takenElsewhere.has(f.id))
36
+ .map((f) => ({ label: f.title || f.id, value: f.id }))
37
+ }
38
+
39
+ function replaceConnection(index: number, next: ServiceConnection) {
40
+ save(connections.value.map((c, i) => (i === index ? next : c)))
41
+ }
42
+
43
+ function setTarget(index: number, serviceBlockId: string) {
44
+ const c = connections.value[index]
45
+ if (c) replaceConnection(index, { ...c, serviceBlockId })
46
+ }
47
+
48
+ function setDescription(index: number, value: string) {
49
+ const c = connections.value[index]
50
+ if (c) replaceConnection(index, { ...c, description: value.trim() || undefined })
51
+ }
52
+
53
+ // A new row starts on the first still-available provider; with none available the add
54
+ // button is disabled, so a placeholder row never round-trips an invalid PATCH.
55
+ const nextAvailable = computed(() => {
56
+ const taken = new Set(connections.value.map((c) => c.serviceBlockId))
57
+ return serviceFrames.value.find((f) => !taken.has(f.id))
58
+ })
59
+
60
+ function addConnection() {
61
+ const target = nextAvailable.value
62
+ if (target) save([...connections.value, { serviceBlockId: target.id }])
63
+ }
64
+
65
+ function removeConnection(index: number) {
66
+ save(connections.value.filter((_, i) => i !== index))
67
+ }
68
+
69
+ // Reverse direction, read-only: the service frames whose own connections name this one.
70
+ const usedBy = computed(() =>
71
+ board.frames.filter(
72
+ (b) =>
73
+ b.type === 'service' &&
74
+ b.id !== props.block.id &&
75
+ (b.serviceConnections ?? []).some((c) => c.serviceBlockId === props.block.id),
76
+ ),
77
+ )
78
+ </script>
79
+
80
+ <template>
81
+ <div class="space-y-2 border-t border-slate-800 pt-2" data-testid="service-connections">
82
+ <div class="flex items-center justify-between">
83
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
84
+ {{ t('inspector.serviceConnections.title') }}
85
+ </span>
86
+ <UButton
87
+ size="xs"
88
+ variant="ghost"
89
+ color="neutral"
90
+ icon="i-lucide-plus"
91
+ :disabled="!nextAvailable"
92
+ data-testid="service-connection-add"
93
+ @click="addConnection"
94
+ />
95
+ </div>
96
+ <p class="text-[11px] leading-snug text-slate-500">
97
+ {{ t('inspector.serviceConnections.hint') }}
98
+ </p>
99
+
100
+ <div v-if="connections.length" class="space-y-1.5">
101
+ <div
102
+ v-for="(c, i) in connections"
103
+ :key="c.serviceBlockId"
104
+ class="flex items-center gap-1"
105
+ data-testid="service-connection-row"
106
+ >
107
+ <USelect
108
+ :model-value="c.serviceBlockId"
109
+ :items="targetItems(i)"
110
+ size="xs"
111
+ class="flex-1"
112
+ data-testid="service-connection-target"
113
+ @update:model-value="(v: string) => setTarget(i, v)"
114
+ />
115
+ <UInput
116
+ :model-value="c.description ?? ''"
117
+ size="xs"
118
+ class="flex-1"
119
+ maxlength="300"
120
+ :placeholder="t('inspector.serviceConnections.descriptionPlaceholder')"
121
+ data-testid="service-connection-description"
122
+ @blur="(e: FocusEvent) => setDescription(i, (e.target as HTMLInputElement).value)"
123
+ @keydown.enter="
124
+ (e: KeyboardEvent) => setDescription(i, (e.target as HTMLInputElement).value)
125
+ "
126
+ />
127
+ <UButton
128
+ size="xs"
129
+ variant="ghost"
130
+ color="neutral"
131
+ icon="i-lucide-x"
132
+ :title="t('inspector.serviceConnections.remove')"
133
+ data-testid="service-connection-remove"
134
+ @click="removeConnection(i)"
135
+ />
136
+ </div>
137
+ </div>
138
+ <div v-else class="text-[11px] text-slate-500">
139
+ {{ t('inspector.serviceConnections.empty') }}
140
+ </div>
141
+
142
+ <div v-if="usedBy.length" class="space-y-1" data-testid="service-connections-used-by">
143
+ <span class="text-[11px] text-slate-400">{{ t('inspector.serviceConnections.usedBy') }}</span>
144
+ <div class="flex flex-wrap gap-1">
145
+ <UBadge v-for="f in usedBy" :key="f.id" size="sm" variant="soft" color="neutral">
146
+ {{ f.title || f.id }}
147
+ </UBadge>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </template>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted } from 'vue'
3
+ import { connectionNeighborIds } from '@cat-factory/contracts'
3
4
  import type { Block } from '~/types/domain'
4
5
  import type { WritebackOverride } from '~/types/tracker'
5
6
  import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
@@ -154,6 +155,33 @@ function setPipeline(id: string) {
154
155
  board.updateBlock(props.block.id, { pipelineId: id })
155
156
  }
156
157
 
158
+ // ---- involved services ------------------------------------------------------
159
+ // Which of the connected services are directly involved in this task (beyond its own
160
+ // service, which is always implicit): each involved service is spun up as an ephemeral
161
+ // environment alongside it, and the coding agent may change its repo too. Choices come
162
+ // from the frame's connection NEIGHBORS (either direction). An id whose connection was
163
+ // removed after selection is stale: badged, and dropped on the next toggle (the write
164
+ // gate would reject it).
165
+ const connectedServices = computed(() => {
166
+ const frame = taskFrame.value
167
+ if (!frame) return []
168
+ return [...connectionNeighborIds(board.blocks, frame.id)]
169
+ .map((id) => board.getBlock(id))
170
+ .filter((b): b is Block => !!b)
171
+ })
172
+ const involvedIds = computed(() => props.block.involvedServiceIds ?? [])
173
+ const staleInvolvedServices = computed(() => {
174
+ const connected = new Set(connectedServices.value.map((b) => b.id))
175
+ return involvedIds.value.filter((id) => !connected.has(id))
176
+ })
177
+ function toggleInvolved(serviceId: string, on: boolean) {
178
+ const connected = new Set(connectedServices.value.map((b) => b.id))
179
+ const kept = involvedIds.value.filter((id) => id !== serviceId && connected.has(id))
180
+ board.updateBlock(props.block.id, {
181
+ involvedServiceIds: on ? [...kept, serviceId] : kept,
182
+ })
183
+ }
184
+
157
185
  // ---- issue-tracker writeback overrides -------------------------------------
158
186
  // Per-task overrides for the two workspace writeback toggles (comment on PR open,
159
187
  // close linked issue on merge). null override ⇒ inherit the workspace default.
@@ -400,6 +428,44 @@ const technicalLabel = computed(() => {
400
428
  </div>
401
429
  </div>
402
430
 
431
+ <!-- involved services: connected services this task spans (envs + possible code changes) -->
432
+ <div data-testid="involved-services">
433
+ <div class="mb-1 flex items-center justify-between">
434
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
435
+ {{ t('inspector.runSettings.involvedServices') }}
436
+ </span>
437
+ </div>
438
+ <div v-if="connectedServices.length" class="space-y-1">
439
+ <UCheckbox
440
+ v-for="s in connectedServices"
441
+ :key="s.id"
442
+ :model-value="involvedIds.includes(s.id)"
443
+ :label="s.title || s.id"
444
+ size="xs"
445
+ data-testid="involved-service-toggle"
446
+ @update:model-value="(v: boolean | 'indeterminate') => toggleInvolved(s.id, v === true)"
447
+ />
448
+ </div>
449
+ <div v-else class="text-[11px] text-slate-500">
450
+ {{ t('inspector.runSettings.involvedServicesEmpty') }}
451
+ </div>
452
+ <div v-if="staleInvolvedServices.length" class="mt-1 flex flex-wrap gap-1">
453
+ <UBadge
454
+ v-for="id in staleInvolvedServices"
455
+ :key="id"
456
+ size="sm"
457
+ variant="soft"
458
+ color="warning"
459
+ :title="t('inspector.runSettings.involvedServiceStale')"
460
+ >
461
+ {{ board.getBlock(id)?.title ?? id }}
462
+ </UBadge>
463
+ </div>
464
+ <div class="mt-1 text-[11px] text-slate-500">
465
+ {{ t('inspector.runSettings.involvedServicesHint') }}
466
+ </div>
467
+ </div>
468
+
403
469
  <!-- issue-tracker writeback overrides -->
404
470
  <div>
405
471
  <div class="mb-1 flex items-center justify-between">