@cat-factory/app 0.47.2 → 0.47.4

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.
@@ -39,18 +39,6 @@ async function loadRepos() {
39
39
  }
40
40
  }
41
41
 
42
- // On open: ensure we know the connection + which repos the App can access, and
43
- // the workspace's already-tracked repos (to flag ones already on the board).
44
- watch(
45
- open,
46
- (isOpen) => {
47
- if (!isOpen) return
48
- resetSelection()
49
- void loadRepos()
50
- },
51
- { immediate: true },
52
- )
53
-
54
42
  // If the user connects from inside the modal (the not-connected prompt), pull the
55
43
  // repo list as soon as the connection is bound.
56
44
  watch(
@@ -152,6 +140,20 @@ const configuredBlock = computed(() =>
152
140
  configuredBlockId.value ? board.getBlock(configuredBlockId.value) : undefined,
153
141
  )
154
142
 
143
+ // On open: ensure we know the connection + which repos the App can access, and
144
+ // the workspace's already-tracked repos (to flag ones already on the board).
145
+ // Declared after every ref resetSelection() touches so the `immediate` run
146
+ // doesn't access them inside their temporal dead zone.
147
+ watch(
148
+ open,
149
+ (isOpen) => {
150
+ if (!isOpen) return
151
+ resetSelection()
152
+ void loadRepos()
153
+ },
154
+ { immediate: true },
155
+ )
156
+
155
157
  // A monorepo service needs a chosen directory; a whole-repo service can be added once.
156
158
  const canAdd = computed(
157
159
  () =>
@@ -259,32 +259,53 @@ const groups = computed<IntegrationGroup[]>(() => {
259
259
  }
260
260
 
261
261
  // --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
262
- // Each gates on its own availability probe, so a backend with the integration off
263
- // shows no dead row. The connected badge reflects a saved connection; the
264
- // ProviderConfigBanner handles the louder "missing mandatory fields" warning.
262
+ // The two providers container agents (runner pool) and Tester environments are the
263
+ // same "bring your own infra" idea and the same custom pool typically backs both, so they
264
+ // collapse into ONE row opening the tabbed Infrastructure window. The row shows a combined
265
+ // per-concern summary ("Agents: connected · Envs: not connected"). Each concern still gates
266
+ // on its own availability probe (a tab whose backend is off simply doesn't render), so the
267
+ // row appears whenever EITHER is available. The ProviderConfigBanner handles the louder
268
+ // "missing mandatory fields" warning.
265
269
  const infra: IntegrationItem[] = []
266
- if (providerConnections.isAvailable('environment')) {
267
- const conn = providerConnections.connectionFor('environment')
268
- infra.push({
269
- key: 'environment',
270
- icon: 'i-lucide-cloud',
271
- label: t('layout.integrationsHub.items.environment.label'),
272
- description: t('layout.integrationsHub.items.environment.description'),
273
- status: conn ? t('layout.integrationsHub.status.connected') : undefined,
274
- connected: !!conn,
275
- onClick: () => go(() => ui.openProviderConnection('environment')),
276
- })
277
- }
278
- if (providerConnections.isAvailable('runner-pool')) {
279
- const conn = providerConnections.connectionFor('runner-pool')
270
+ const agentsAvailable = providerConnections.isAvailable('runner-pool')
271
+ const envsAvailable = providerConnections.isAvailable('environment')
272
+ if (agentsAvailable || envsAvailable) {
273
+ const agentsConn = providerConnections.connectionFor('runner-pool')
274
+ const envsConn = providerConnections.connectionFor('environment')
275
+ // Combined summary across the available concerns only.
276
+ const stateWord = (conn: unknown) =>
277
+ conn
278
+ ? t('layout.integrationsHub.status.connected')
279
+ : t('layout.integrationsHub.status.notConnected')
280
+ const parts: string[] = []
281
+ if (agentsAvailable)
282
+ parts.push(
283
+ t('layout.integrationsHub.items.infrastructure.agents', { state: stateWord(agentsConn) }),
284
+ )
285
+ if (envsAvailable)
286
+ parts.push(
287
+ t('layout.integrationsHub.items.infrastructure.envs', { state: stateWord(envsConn) }),
288
+ )
289
+ // Default the window to the agents tab when available (the common case), else envs.
290
+ const defaultKind = agentsAvailable ? 'runner-pool' : 'environment'
291
+ // A concern counts as satisfied when it's unavailable (nothing to configure) or connected.
292
+ // The row is "connected" (green) only when EVERY available concern is connected — a
293
+ // half-connected state shows the amber "attention" badge instead, so the green badge can't
294
+ // claim "done" while one concern is still unconfigured.
295
+ const agentsSatisfied = !agentsAvailable || !!agentsConn
296
+ const envsSatisfied = !envsAvailable || !!envsConn
297
+ const allConnected = agentsSatisfied && envsSatisfied
298
+ const someConnected = !!agentsConn || !!envsConn
280
299
  infra.push({
281
- key: 'runner-pool',
300
+ key: 'infrastructure',
282
301
  icon: 'i-lucide-server-cog',
283
- label: t('layout.integrationsHub.items.runnerPool.label'),
284
- description: t('layout.integrationsHub.items.runnerPool.description'),
285
- status: conn ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: !!conn,
287
- onClick: () => go(() => ui.openProviderConnection('runner-pool')),
302
+ label: t('layout.integrationsHub.items.infrastructure.label'),
303
+ description: t('layout.integrationsHub.items.infrastructure.description'),
304
+ status: parts.join(' · '),
305
+ connected: allConnected,
306
+ attention: someConnected && !allConnected,
307
+ attentionLabel: parts.join(' · '),
308
+ onClick: () => go(() => ui.openProviderConnection(defaultKind)),
288
309
  })
289
310
  }
290
311
  // Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
@@ -0,0 +1,221 @@
1
+ <script setup lang="ts">
2
+ // The single tabbed "Infrastructure" window. It merges what used to be two separate
3
+ // Integrations-Hub entries — the self-hosted runner pool (container agents) and the
4
+ // ephemeral-environment provider (Tester environments) — into one surface, because the
5
+ // same custom pool typically backs both jobs, so configuring them together reflects
6
+ // reality. Each provider gets its own tab (ProviderConnectionTab); a tab whose backend
7
+ // integration is disabled (503) simply doesn't render.
8
+ //
9
+ // The local-mode delegation toggles are cross-cutting (one per concern), so they live at
10
+ // the TOP of the window rather than buried in one tab — removing the old awkward
11
+ // cross-link hint that pointed from the runner-pool screen back to the env screen.
12
+ import { computed, ref, watch } from 'vue'
13
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
14
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
+ import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
16
+
17
+ const { t } = useI18n()
18
+ const ui = useUiStore()
19
+ const store = useProviderConnectionsStore()
20
+ const auth = useAuthStore()
21
+ const settings = useWorkspaceSettingsStore()
22
+ const toast = useToast()
23
+
24
+ const open = computed({
25
+ get: () => ui.infrastructureOpen,
26
+ set: (v: boolean) => {
27
+ if (!v) ui.closeProviderConnection()
28
+ },
29
+ })
30
+ const back = useIntegrationBack(open)
31
+
32
+ // Each concern gates on its own availability probe; an unavailable tab isn't offered.
33
+ const agentsAvailable = computed(() => store.isAvailable('runner-pool'))
34
+ const envsAvailable = computed(() => store.isAvailable('environment'))
35
+
36
+ const tabs = computed(() => {
37
+ const out: { value: ProviderConnectionKind; label: string; icon: string; slot: string }[] = []
38
+ if (agentsAvailable.value)
39
+ out.push({
40
+ value: 'runner-pool',
41
+ label: t('settings.providerConnection.tabs.containerAgents'),
42
+ icon: 'i-lucide-server-cog',
43
+ slot: 'runner-pool',
44
+ })
45
+ if (envsAvailable.value)
46
+ out.push({
47
+ value: 'environment',
48
+ label: t('settings.providerConnection.tabs.testEnvironments'),
49
+ icon: 'i-lucide-cloud',
50
+ slot: 'environment',
51
+ })
52
+ return out
53
+ })
54
+
55
+ const activeTab = ref<ProviderConnectionKind>(ui.infrastructureTab)
56
+
57
+ // Honour the deep-linked tab each time the window opens (e.g. the banner's per-kind
58
+ // "Configure…" button), falling back to the first available tab if the requested one is off.
59
+ watch(
60
+ open,
61
+ (isOpen) => {
62
+ if (!isOpen) return
63
+ void store.ensureLoaded().catch(() => {})
64
+ const requested = ui.infrastructureTab
65
+ const available = tabs.value.map((x) => x.value)
66
+ activeTab.value = available.includes(requested) ? requested : (available[0] ?? requested)
67
+ },
68
+ { immediate: true },
69
+ )
70
+ // When availability resolves after open, re-pin onto a valid tab — but keep honouring the
71
+ // deep-linked request. The two availability probes resolve independently, so `tabs` can pass
72
+ // through a transient single-tab list (e.g. the runner-pool probe lands a tick before the
73
+ // environment one). We must NOT let that transient list steal focus from a still-loading
74
+ // requested tab, so only fall back to the first tab once loading has fully settled.
75
+ watch([tabs, () => store.loaded], () => {
76
+ const list = tabs.value
77
+ if (list.some((x) => x.value === activeTab.value)) return
78
+ const requested = ui.infrastructureTab
79
+ if (list.some((x) => x.value === requested)) {
80
+ activeTab.value = requested
81
+ } else if (store.loaded && list.length) {
82
+ activeTab.value = list[0]!.value
83
+ }
84
+ })
85
+
86
+ // --- Local-mode infrastructure delegation (cross-cutting; shown only in local mode) ---
87
+ // In local mode this is where a developer chooses, per workspace, whether to run on this
88
+ // machine (host Docker for agents, in-container docker-compose for the Tester) or delegate
89
+ // to an external service. Each toggle is enabled only once its provider is registered.
90
+ const isLocal = computed(() => auth.localMode?.enabled === true)
91
+ const runnerPoolRegistered = computed(() => !!store.connectionFor('runner-pool'))
92
+ const envRegistered = computed(() => !!store.connectionFor('environment'))
93
+ const savingDelegation = ref(false)
94
+
95
+ async function setDelegation(patch: {
96
+ delegateAgentsToRunnerPool?: boolean
97
+ delegateTestEnvToProvider?: boolean
98
+ }) {
99
+ savingDelegation.value = true
100
+ try {
101
+ await settings.update(patch)
102
+ } catch (e) {
103
+ toast.add({
104
+ title: t('settings.providerConnection.delegation.updateFailed'),
105
+ description: e instanceof Error ? e.message : String(e),
106
+ icon: 'i-lucide-triangle-alert',
107
+ color: 'error',
108
+ })
109
+ } finally {
110
+ savingDelegation.value = false
111
+ }
112
+ }
113
+
114
+ function selectTab(kind: ProviderConnectionKind) {
115
+ activeTab.value = kind
116
+ }
117
+ </script>
118
+
119
+ <template>
120
+ <UModal
121
+ v-model:open="open"
122
+ :title="t('settings.providerConnection.windowTitle')"
123
+ :ui="{ content: 'max-w-xl' }"
124
+ >
125
+ <template #title>
126
+ <IntegrationBackTitle :title="t('settings.providerConnection.windowTitle')" @back="back" />
127
+ </template>
128
+ <template #body>
129
+ <div class="space-y-4">
130
+ <!-- Local-mode delegation: the local-vs-external choice for BOTH container agents
131
+ AND the Tester's ephemeral environments, made once here at the top. -->
132
+ <section
133
+ v-if="isLocal"
134
+ class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
135
+ >
136
+ <div>
137
+ <h3 class="text-sm font-semibold text-slate-200">
138
+ {{ t('settings.providerConnection.delegation.title') }}
139
+ </h3>
140
+ <p class="mt-1 text-[11px] text-slate-400">
141
+ {{ t('settings.providerConnection.delegation.intro') }}
142
+ </p>
143
+ </div>
144
+
145
+ <!-- Container agents → self-hosted runner pool -->
146
+ <div class="space-y-1">
147
+ <label class="flex items-center gap-2">
148
+ <USwitch
149
+ size="sm"
150
+ :model-value="settings.settings.delegateAgentsToRunnerPool"
151
+ :disabled="savingDelegation || !runnerPoolRegistered"
152
+ @update:model-value="(v) => setDelegation({ delegateAgentsToRunnerPool: v })"
153
+ />
154
+ <span class="text-sm text-slate-200">
155
+ {{ t('settings.providerConnection.delegation.agentsToggle') }}
156
+ </span>
157
+ </label>
158
+ <p class="pl-9 text-[11px] text-slate-400">
159
+ {{ t('settings.providerConnection.delegation.agentsHint') }}
160
+ <template v-if="!runnerPoolRegistered">
161
+ <i18n-t
162
+ keypath="settings.providerConnection.delegation.registerPoolPrompt"
163
+ tag="span"
164
+ scope="global"
165
+ >
166
+ <template #link>
167
+ <button
168
+ type="button"
169
+ class="text-sky-400 underline underline-offset-2 hover:text-sky-300"
170
+ @click="selectTab('runner-pool')"
171
+ >
172
+ {{ t('settings.providerConnection.delegation.registerPoolLink') }}
173
+ </button>
174
+ </template>
175
+ </i18n-t>
176
+ </template>
177
+ </p>
178
+ </div>
179
+
180
+ <!-- Tester environments → environment provider -->
181
+ <div class="space-y-1">
182
+ <label class="flex items-center gap-2">
183
+ <USwitch
184
+ size="sm"
185
+ :model-value="settings.settings.delegateTestEnvToProvider"
186
+ :disabled="savingDelegation || !envRegistered"
187
+ @update:model-value="(v) => setDelegation({ delegateTestEnvToProvider: v })"
188
+ />
189
+ <span class="text-sm text-slate-200">
190
+ {{ t('settings.providerConnection.delegation.envToggle') }}
191
+ </span>
192
+ </label>
193
+ <p class="pl-9 text-[11px] text-slate-400">
194
+ {{ t('settings.providerConnection.delegation.envHint') }}
195
+ </p>
196
+ </div>
197
+ </section>
198
+
199
+ <UTabs
200
+ v-if="tabs.length"
201
+ v-model="activeTab"
202
+ :items="tabs"
203
+ variant="link"
204
+ :ui="{ root: 'gap-4' }"
205
+ data-testid="infrastructure-tabs"
206
+ >
207
+ <template #runner-pool>
208
+ <ProviderConnectionTab kind="runner-pool" />
209
+ </template>
210
+ <template #environment>
211
+ <ProviderConnectionTab kind="environment" />
212
+ </template>
213
+ </UTabs>
214
+
215
+ <p v-else class="px-1 py-6 text-center text-sm text-slate-500">
216
+ {{ t('settings.providerConnection.noneAvailable') }}
217
+ </p>
218
+ </div>
219
+ </template>
220
+ </UModal>
221
+ </template>