@cat-factory/app 0.49.2 → 0.50.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.
@@ -20,7 +20,6 @@ const documents = useDocumentsStore()
20
20
  const tasks = useTasksStore()
21
21
  const tracker = useTrackerStore()
22
22
  const releaseHealth = useReleaseHealthStore()
23
- const providerConnections = useProviderConnectionsStore()
24
23
  const userSecrets = useUserSecretsStore()
25
24
  const apiKeys = useApiKeysStore()
26
25
  const workspace = useWorkspaceStore()
@@ -50,7 +49,6 @@ watch(
50
49
  if (isOpen) {
51
50
  query.value = ''
52
51
  void releaseHealth.ensureLoaded().catch(() => {})
53
- void providerConnections.ensureLoaded().catch(() => {})
54
52
  void userSecrets.load().catch(() => {})
55
53
  // Drives the OpenRouter row's "Key connected" badge.
56
54
  if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
@@ -258,70 +256,9 @@ const groups = computed<IntegrationGroup[]>(() => {
258
256
  })
259
257
  }
260
258
 
261
- // --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
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.
269
- const infra: IntegrationItem[] = []
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
299
- infra.push({
300
- key: 'infrastructure',
301
- icon: 'i-lucide-server-cog',
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)),
309
- })
310
- }
311
- // Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
312
- // only on the local-mode service (the controller 503s elsewhere, and `auth.localMode`
313
- // is set from /auth/config).
314
- if (auth.localMode?.enabled) {
315
- infra.push({
316
- key: 'local-mode',
317
- icon: 'i-lucide-container',
318
- label: t('layout.integrationsHub.items.localMode.label'),
319
- description: t('layout.integrationsHub.items.localMode.description'),
320
- onClick: () => go(ui.openLocalModeSettings),
321
- })
322
- }
323
- if (infra.length)
324
- out.push({ title: t('layout.integrationsHub.groups.infrastructure'), items: infra })
259
+ // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
260
+ // warm pool/checkout) is no longer listed here it moved to its OWN top-level navbar menu
261
+ // (SideBar "Infrastructure" the tabbed Infrastructure window). See `ui.openInfrastructure`.
325
262
 
326
263
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
327
264
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -20,8 +20,20 @@ const slack = useSlackStore()
20
20
  const library = useFragmentLibraryStore()
21
21
  const workspace = useWorkspaceStore()
22
22
  const accounts = useAccountsStore()
23
+ const auth = useAuthStore()
24
+ const providerConnections = useProviderConnectionsStore()
23
25
  const ui = useUiStore()
24
26
 
27
+ // The Infrastructure menu (agent-container execution + test environments) is relevant when
28
+ // either provider integration is available OR we're running the local-mode facade (which
29
+ // always has host-Docker execution + the warm-pool/checkout settings to configure).
30
+ const showInfrastructure = computed(
31
+ () =>
32
+ auth.localMode?.enabled === true ||
33
+ providerConnections.isAvailable('runner-pool') ||
34
+ providerConnections.isAvailable('environment'),
35
+ )
36
+
25
37
  // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
26
38
  // above it the aside is static and the drawer flag is inert.
27
39
  const { isCompact } = useViewport()
@@ -89,6 +101,7 @@ watch(
89
101
  void github.probe()
90
102
  void slack.probe()
91
103
  void library.probe()
104
+ void providerConnections.ensureLoaded().catch(() => {})
92
105
  },
93
106
  { immediate: true },
94
107
  )
@@ -241,6 +254,32 @@ watch(
241
254
  </div>
242
255
  </section>
243
256
 
257
+ <template v-if="showInfrastructure">
258
+ <USeparator />
259
+ <section>
260
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
261
+ {{ t('nav.infrastructure') }}
262
+ </h2>
263
+ <div class="space-y-1.5">
264
+ <!-- Where agent containers run + Tester environments + (local mode) the warm
265
+ pool/checkout reuse. Its own top-level destination, no longer inside the
266
+ Integrations hub. -->
267
+ <UButton
268
+ block
269
+ color="primary"
270
+ variant="soft"
271
+ size="sm"
272
+ icon="i-lucide-server-cog"
273
+ class="justify-start"
274
+ data-testid="nav-infrastructure"
275
+ @click="ui.openInfrastructure()"
276
+ >
277
+ {{ t('nav.infrastructure') }}
278
+ </UButton>
279
+ </div>
280
+ </section>
281
+ </template>
282
+
244
283
  <template v-if="library.available">
245
284
  <USeparator />
246
285
  <section>
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ // A clear picker for WHERE work runs, replacing the old bare "delegate to runner pool"
3
+ // yes/no switch. It reads the deployment's available backends from the capability descriptor
4
+ // (`auth.infrastructure`) and presents the actual options for THIS deployment:
5
+ // - execution axis → where agent containers run (local Docker host / Cloudflare Containers /
6
+ // self-hosted runner pool);
7
+ // - testEnv axis → where the Tester's ephemeral environments run (in-container
8
+ // docker-compose / an environment provider).
9
+ //
10
+ // The control is WRITABLE only in local mode, where the choice is a real per-workspace toggle
11
+ // (the `delegateAgentsToRunnerPool` / `delegateTestEnvToProvider` settings — this selector is a
12
+ // nicer face over those existing booleans, no new persistence). On the Worker/Node facades the
13
+ // active backend is determined by the deployment + whether a pool is registered (the Worker
14
+ // routes to a registered pool automatically, else Cloudflare Containers), so there it renders a
15
+ // read-only "Active: …" line instead of a control that wouldn't actually switch anything.
16
+ import { computed, ref } from 'vue'
17
+ import type { ExecutionBackendKind, TestEnvBackendKind } from '@cat-factory/contracts'
18
+
19
+ const props = defineProps<{ axis: 'execution' | 'testEnv' }>()
20
+
21
+ const { t } = useI18n()
22
+ const auth = useAuthStore()
23
+ const settings = useWorkspaceSettingsStore()
24
+ const providerConnections = useProviderConnectionsStore()
25
+ const toast = useToast()
26
+
27
+ type BackendKind = ExecutionBackendKind | TestEnvBackendKind
28
+
29
+ // Per-axis labels — static literal keys whose leaves mirror the contract enum values verbatim
30
+ // (so the typed-message-keys check stays live and a dynamic lookup is total).
31
+ const EXECUTION_LABELS: Record<ExecutionBackendKind, string> = {
32
+ 'local-docker': 'settings.infrastructure.executionBackend.local-docker',
33
+ 'cloudflare-containers': 'settings.infrastructure.executionBackend.cloudflare-containers',
34
+ 'runner-pool': 'settings.infrastructure.executionBackend.runner-pool',
35
+ }
36
+ const TEST_ENV_LABELS: Record<TestEnvBackendKind, string> = {
37
+ 'local-compose': 'settings.infrastructure.testEnvBackend.local-compose',
38
+ 'environment-provider': 'settings.infrastructure.testEnvBackend.environment-provider',
39
+ }
40
+
41
+ function labelFor(kind: BackendKind): string {
42
+ const key =
43
+ props.axis === 'execution'
44
+ ? EXECUTION_LABELS[kind as ExecutionBackendKind]
45
+ : TEST_ENV_LABELS[kind as TestEnvBackendKind]
46
+ return t(key)
47
+ }
48
+
49
+ // Normalise to the union element type so array ops (`includes`/`find`) don't collapse to
50
+ // `never` across the execution/testEnv discriminated union.
51
+ const cap = computed<{ available: BackendKind[]; active: BackendKind } | null>(() => {
52
+ const c = auth.infrastructure?.[props.axis]
53
+ return c ? { available: c.available as BackendKind[], active: c.active as BackendKind } : null
54
+ })
55
+ const isLocal = computed(() => auth.localMode?.enabled === true)
56
+
57
+ // The backend reached by "delegating" away from the on-machine default, plus the provider
58
+ // connection that must exist for it to work.
59
+ const delegatedKind = computed<BackendKind>(() =>
60
+ props.axis === 'execution' ? 'runner-pool' : 'environment-provider',
61
+ )
62
+ const connectionKind = computed<'runner-pool' | 'environment'>(() =>
63
+ props.axis === 'execution' ? 'runner-pool' : 'environment',
64
+ )
65
+ const delegatedRegistered = computed(
66
+ () => !!providerConnections.connectionFor(connectionKind.value),
67
+ )
68
+
69
+ // The on-machine / built-in default (the available option that isn't the delegated one).
70
+ const localKind = computed<BackendKind>(
71
+ () =>
72
+ cap.value?.available.find((k) => k !== delegatedKind.value) ??
73
+ cap.value?.active ??
74
+ 'local-docker',
75
+ )
76
+
77
+ // The delegation flag is a genuine per-workspace toggle ONLY in local mode; elsewhere the
78
+ // active backend is registration/deployment-driven, so the control is read-only there.
79
+ const writable = computed(() => isLocal.value && (cap.value?.available.length ?? 0) > 1)
80
+
81
+ const delegated = computed(() =>
82
+ props.axis === 'execution'
83
+ ? settings.settings.delegateAgentsToRunnerPool
84
+ : settings.settings.delegateTestEnvToProvider,
85
+ )
86
+
87
+ // The effective active backend. In local mode it follows the delegation flag; otherwise the
88
+ // delegated backend is active when its provider is registered (the Worker auto-routes to a
89
+ // registered pool), else the deployment default from the descriptor.
90
+ const activeKind = computed<BackendKind>(() => {
91
+ if (!cap.value) return localKind.value
92
+ if (writable.value) return delegated.value ? delegatedKind.value : localKind.value
93
+ if (cap.value.available.includes(delegatedKind.value) && delegatedRegistered.value) {
94
+ return delegatedKind.value
95
+ }
96
+ return cap.value.active
97
+ })
98
+
99
+ const saving = ref(false)
100
+
101
+ async function select(kind: BackendKind) {
102
+ if (kind === activeKind.value) return
103
+ const toRunnerPool = kind === delegatedKind.value
104
+ saving.value = true
105
+ try {
106
+ await settings.update(
107
+ props.axis === 'execution'
108
+ ? { delegateAgentsToRunnerPool: toRunnerPool }
109
+ : { delegateTestEnvToProvider: toRunnerPool },
110
+ )
111
+ } catch (e) {
112
+ toast.add({
113
+ title: t('settings.infrastructure.updateFailed'),
114
+ description: e instanceof Error ? e.message : String(e),
115
+ icon: 'i-lucide-triangle-alert',
116
+ color: 'error',
117
+ })
118
+ } finally {
119
+ saving.value = false
120
+ }
121
+ }
122
+
123
+ const labelKey = computed(() =>
124
+ props.axis === 'execution'
125
+ ? 'settings.infrastructure.executionBackend.label'
126
+ : 'settings.infrastructure.testEnvBackend.label',
127
+ )
128
+ </script>
129
+
130
+ <template>
131
+ <section v-if="cap" class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
132
+ <h3 class="text-sm font-semibold text-slate-200">{{ t(labelKey) }}</h3>
133
+
134
+ <!-- Local mode: a real choice the user can flip (writes the delegation setting). -->
135
+ <div v-if="writable" class="space-y-1.5" :data-testid="`${axis}-backend-options`">
136
+ <label
137
+ v-for="kind in cap.available"
138
+ :key="kind"
139
+ class="flex items-start gap-2"
140
+ :class="
141
+ kind === delegatedKind && !delegatedRegistered
142
+ ? 'cursor-not-allowed opacity-50'
143
+ : 'cursor-pointer'
144
+ "
145
+ >
146
+ <input
147
+ type="radio"
148
+ class="mt-1"
149
+ :value="kind"
150
+ :checked="kind === activeKind"
151
+ :disabled="saving || (kind === delegatedKind && !delegatedRegistered)"
152
+ @change="select(kind)"
153
+ />
154
+ <span class="min-w-0">
155
+ <span class="text-sm text-slate-200">{{ labelFor(kind) }}</span>
156
+ <span
157
+ v-if="kind === delegatedKind && !delegatedRegistered"
158
+ class="block text-[11px] text-amber-300/80"
159
+ >
160
+ {{ t('settings.infrastructure.registerHint') }}
161
+ </span>
162
+ </span>
163
+ </label>
164
+ </div>
165
+
166
+ <!-- Worker/Node: the active backend is deployment/registration-driven, not a toggle. -->
167
+ <p v-else class="text-sm text-slate-300" :data-testid="`${axis}-backend-active`">
168
+ {{ t('settings.infrastructure.active', { backend: labelFor(activeKind) }) }}
169
+ </p>
170
+ </section>
171
+ </template>
@@ -1,25 +1,24 @@
1
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.
2
+ // The single tabbed "Infrastructure" window now a TOP-LEVEL navbar destination (no longer
3
+ // reached through the Integrations hub). Two topical tabs:
4
+ // - "Agent containers"where repo-operating agent containers run. Shows the execution
5
+ // backend selector, the runner-pool connection (ProviderConnectionTab), and in local
6
+ // mode the warm-container-pool + checkout-reuse settings (the local agent-container
7
+ // runtime, folded in from the former LocalModeSettingsPanel).
8
+ // - "Test environments" — where the Tester's ephemeral environments run. Shows the test-env
9
+ // backend selector and the environment-provider connection.
10
+ // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
11
+ // backend integration is disabled (503) simply doesn't render.
12
12
  import { computed, ref, watch } from 'vue'
13
13
  import type { ProviderConnectionKind } from '~/types/providerConnections'
14
- import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
14
  import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
15
+ import ExecutionBackendSelector from '~/components/settings/ExecutionBackendSelector.vue'
16
+ import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
16
17
 
17
18
  const { t } = useI18n()
18
19
  const ui = useUiStore()
19
20
  const store = useProviderConnectionsStore()
20
21
  const auth = useAuthStore()
21
- const settings = useWorkspaceSettingsStore()
22
- const toast = useToast()
23
22
 
24
23
  const open = computed({
25
24
  get: () => ui.infrastructureOpen,
@@ -27,7 +26,8 @@ const open = computed({
27
26
  if (!v) ui.closeProviderConnection()
28
27
  },
29
28
  })
30
- const back = useIntegrationBack(open)
29
+
30
+ const isLocal = computed(() => auth.localMode?.enabled === true)
31
31
 
32
32
  // Each concern gates on its own availability probe; an unavailable tab isn't offered.
33
33
  const agentsAvailable = computed(() => store.isAvailable('runner-pool'))
@@ -38,7 +38,7 @@ const tabs = computed(() => {
38
38
  if (agentsAvailable.value)
39
39
  out.push({
40
40
  value: 'runner-pool',
41
- label: t('settings.providerConnection.tabs.containerAgents'),
41
+ label: t('settings.providerConnection.tabs.agentContainers'),
42
42
  icon: 'i-lucide-server-cog',
43
43
  slot: 'runner-pool',
44
44
  })
@@ -54,8 +54,8 @@ const tabs = computed(() => {
54
54
 
55
55
  const activeTab = ref<ProviderConnectionKind>(ui.infrastructureTab)
56
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.
57
+ // Honour the deep-linked tab each time the window opens, falling back to the first available
58
+ // tab if the requested one is off.
59
59
  watch(
60
60
  open,
61
61
  (isOpen) => {
@@ -69,9 +69,7 @@ watch(
69
69
  )
70
70
  // When availability resolves after open, re-pin onto a valid tab — but keep honouring the
71
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.
72
+ // through a transient single-tab list; only fall back to the first tab once loading settled.
75
73
  watch([tabs, () => store.loaded], () => {
76
74
  const list = tabs.value
77
75
  if (list.some((x) => x.value === activeTab.value)) return
@@ -82,38 +80,6 @@ watch([tabs, () => store.loaded], () => {
82
80
  activeTab.value = list[0]!.value
83
81
  }
84
82
  })
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
83
  </script>
118
84
 
119
85
  <template>
@@ -122,80 +88,8 @@ function selectTab(kind: ProviderConnectionKind) {
122
88
  :title="t('settings.providerConnection.windowTitle')"
123
89
  :ui="{ content: 'max-w-xl' }"
124
90
  >
125
- <template #title>
126
- <IntegrationBackTitle :title="t('settings.providerConnection.windowTitle')" @back="back" />
127
- </template>
128
91
  <template #body>
129
92
  <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
93
  <UTabs
200
94
  v-if="tabs.length"
201
95
  v-model="activeTab"
@@ -205,10 +99,26 @@ function selectTab(kind: ProviderConnectionKind) {
205
99
  data-testid="infrastructure-tabs"
206
100
  >
207
101
  <template #runner-pool>
208
- <ProviderConnectionTab kind="runner-pool" />
102
+ <div class="space-y-4">
103
+ <!-- Where agent containers run (writable in local mode; read-only elsewhere). -->
104
+ <ExecutionBackendSelector axis="execution" />
105
+ <ProviderConnectionTab kind="runner-pool" />
106
+ <!-- Local mode: the warm-pool + checkout reuse ARE the host agent-container
107
+ runtime, so they live here rather than in a separate menu. -->
108
+ <section v-if="isLocal" class="border-t border-slate-800 pt-4">
109
+ <h3 class="mb-3 text-sm font-semibold text-slate-200">
110
+ {{ t('settings.localMode.title') }}
111
+ </h3>
112
+ <LocalContainerPoolSettings />
113
+ </section>
114
+ </div>
209
115
  </template>
210
116
  <template #environment>
211
- <ProviderConnectionTab kind="environment" />
117
+ <div class="space-y-4">
118
+ <!-- Where Tester environments run (writable in local mode; read-only elsewhere). -->
119
+ <ExecutionBackendSelector axis="testEnv" />
120
+ <ProviderConnectionTab kind="environment" />
121
+ </div>
212
122
  </template>
213
123
  </UTabs>
214
124