@cat-factory/app 0.176.0 → 0.178.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +28 -1
  2. package/app/components/documents/DocumentImportModal.vue +3 -17
  3. package/app/components/documents/DocumentTemplatesModal.vue +1 -1
  4. package/app/components/documents/SpawnPreviewModal.vue +9 -19
  5. package/app/components/environments/steps/EnvPickStep.vue +3 -0
  6. package/app/components/layout/CommandBar.vue +1 -1
  7. package/app/components/layout/IntegrationBackTitle.vue +11 -8
  8. package/app/components/layout/IntegrationsHub.vue +22 -56
  9. package/app/components/layout/ModelProvidersHub.vue +250 -0
  10. package/app/components/panels/InspectorPanel.vue +2 -19
  11. package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
  12. package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
  13. package/app/components/settings/InfrastructureWindow.vue +11 -1
  14. package/app/composables/api/validationChecks.ts +8 -0
  15. package/app/composables/useIntegrationBack.ts +5 -2
  16. package/app/composables/useNavContributions.ts +1 -1
  17. package/app/modular/nav-contributions.spec.ts +11 -3
  18. package/app/modular/nav-contributions.ts +35 -16
  19. package/app/pages/index.vue +4 -0
  20. package/app/stores/documents.ts +9 -6
  21. package/app/stores/ui/modals.ts +43 -27
  22. package/app/stores/validationChecks.ts +17 -2
  23. package/app/types/validationChecks.ts +3 -0
  24. package/app/utils/validationDetection.spec.ts +51 -0
  25. package/app/utils/validationDetection.ts +67 -0
  26. package/i18n/locales/de.json +103 -27
  27. package/i18n/locales/en.json +103 -27
  28. package/i18n/locales/es.json +103 -27
  29. package/i18n/locales/fr.json +103 -27
  30. package/i18n/locales/he.json +103 -27
  31. package/i18n/locales/it.json +103 -27
  32. package/i18n/locales/ja.json +103 -27
  33. package/i18n/locales/pl.json +103 -27
  34. package/i18n/locales/tr.json +103 -27
  35. package/i18n/locales/uk.json +103 -27
  36. package/package.json +2 -2
@@ -7,7 +7,9 @@ import {
7
7
  VALIDATION_MAX_ATTEMPTS_CEILING,
8
8
  VALIDATION_MAX_CHECKS,
9
9
  type ValidationCheck,
10
+ type ValidationEcosystem,
10
11
  } from '~/types/validationChecks'
12
+ import { mergeDetectedChecks } from '~/utils/validationDetection'
11
13
 
12
14
  // Per-service (frame) PRE-PR VALIDATION CHECKS: the shell commands the executor-harness runs
13
15
  // against the checkout after the coder settles and BEFORE the PR opens. A failing command's
@@ -18,10 +20,11 @@ const props = defineProps<{ block: Block }>()
18
20
 
19
21
  const store = useValidationChecksStore()
20
22
  const toast = useToast()
21
- const { t } = useI18n()
23
+ const { t, te } = useI18n()
22
24
  const { confirmAction, toastDone } = useConfirmAction()
23
25
 
24
26
  const busy = ref(false)
27
+ const detecting = ref(false)
25
28
  const rows = ref<ValidationCheck[]>([])
26
29
  const maxAttempts = ref(VALIDATION_DEFAULT_MAX_ATTEMPTS)
27
30
 
@@ -64,6 +67,88 @@ function notifyError(title: string, e: unknown) {
64
67
  })
65
68
  }
66
69
 
70
+ // Ecosystem label KEYS, exhaustive over the contracts `ValidationEcosystem` union: a new
71
+ // backend detector fails THIS typecheck until it is mapped (the key is assembled at runtime,
72
+ // so the typed-message-keys check cannot see the `t()` lookup — the map's exhaustiveness is
73
+ // the drift guard, same pattern as `ENV_TEST_STAGE_KEYS`).
74
+ const ECOSYSTEM_KEYS: Record<ValidationEcosystem, string> = {
75
+ node: 'inspector.validationChecks.ecosystem.node',
76
+ python: 'inspector.validationChecks.ecosystem.python',
77
+ go: 'inspector.validationChecks.ecosystem.go',
78
+ rust: 'inspector.validationChecks.ecosystem.rust',
79
+ maven: 'inspector.validationChecks.ecosystem.maven',
80
+ gradle: 'inspector.validationChecks.ecosystem.gradle',
81
+ dotnet: 'inspector.validationChecks.ecosystem.dotnet',
82
+ ruby: 'inspector.validationChecks.ecosystem.ruby',
83
+ php: 'inspector.validationChecks.ecosystem.php',
84
+ elixir: 'inspector.validationChecks.ecosystem.elixir',
85
+ make: 'inspector.validationChecks.ecosystem.make',
86
+ just: 'inspector.validationChecks.ecosystem.just',
87
+ task: 'inspector.validationChecks.ecosystem.task',
88
+ }
89
+
90
+ function ecosystemLabel(id: ValidationEcosystem): string {
91
+ const key = ECOSYSTEM_KEYS[id]
92
+ // `te`-guarded so a locale missing the key shows the raw id, never a raw message key.
93
+ return te(key) ? t(key) : id
94
+ }
95
+
96
+ /**
97
+ * Fill the rows from what the service's repo declares. The suggestion is NOT saved — it
98
+ * lands in the same unsaved rows the operator edits by hand, so Detect is always reversible
99
+ * by walking away from the panel.
100
+ */
101
+ async function detect() {
102
+ detecting.value = true
103
+ try {
104
+ const result = await store.detect(props.block.id)
105
+ if (result.status !== 'ok') {
106
+ // The backend distinguishes "no repo linked" from "the repo could not be read"; say
107
+ // which, because they send the operator to different places.
108
+ toast.add({
109
+ title: t(`inspector.validationChecks.detect.${result.status}`),
110
+ icon: 'i-lucide-triangle-alert',
111
+ color: 'warning',
112
+ })
113
+ return
114
+ }
115
+ const merged = mergeDetectedChecks(rows.value, result.checks, VALIDATION_MAX_CHECKS)
116
+ rows.value = merged.rows
117
+ if (merged.added === 0) {
118
+ toast.add({
119
+ title: t('inspector.validationChecks.detect.nothingNew'),
120
+ description:
121
+ result.checks.length > 0
122
+ ? t('inspector.validationChecks.detect.alreadyPresent')
123
+ : t('inspector.validationChecks.detect.unrecognised'),
124
+ icon: 'i-lucide-info',
125
+ color: 'neutral',
126
+ })
127
+ return
128
+ }
129
+ const names = result.ecosystems.map(ecosystemLabel).join(', ')
130
+ toast.add({
131
+ title: t('inspector.validationChecks.detect.added', { count: merged.added }, merged.added),
132
+ // Name what was recognised AND what was left out: a cap that silently swallowed a
133
+ // suggestion reads as "that is everything your repo has".
134
+ description: [
135
+ names ? t('inspector.validationChecks.detect.found', { ecosystems: names }) : '',
136
+ merged.dropped > 0 || result.truncated
137
+ ? t('inspector.validationChecks.detect.capped', { max: VALIDATION_MAX_CHECKS })
138
+ : '',
139
+ ]
140
+ .filter(Boolean)
141
+ .join(' '),
142
+ icon: 'i-lucide-wand-sparkles',
143
+ color: 'success',
144
+ })
145
+ } catch (e) {
146
+ notifyError(t('inspector.validationChecks.detect.failed'), e)
147
+ } finally {
148
+ detecting.value = false
149
+ }
150
+ }
151
+
67
152
  async function save() {
68
153
  busy.value = true
69
154
  try {
@@ -179,6 +264,19 @@ async function clear() {
179
264
  />
180
265
  </UFormField>
181
266
  <div class="flex gap-2">
267
+ <UButton
268
+ color="neutral"
269
+ variant="soft"
270
+ size="xs"
271
+ icon="i-lucide-wand-sparkles"
272
+ :loading="detecting"
273
+ :disabled="!canAdd"
274
+ :title="t('inspector.validationChecks.detect.hint')"
275
+ data-testid="validation-detect"
276
+ @click="detect"
277
+ >
278
+ {{ t('inspector.validationChecks.detect.action') }}
279
+ </UButton>
182
280
  <UButton
183
281
  color="neutral"
184
282
  variant="soft"
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ // The guided per-service Docker Compose environment setup, as a section of the Infrastructure
3
+ // window's "Test environments" tab.
4
+ //
5
+ // It used to be its own top-level sidebar destination called "Environment setup", which was
6
+ // wrong twice over: the name said nothing about WHAT it sets up (people read it as the place
7
+ // to configure environments in general, which is the rest of this tab), and a wizard that
8
+ // fills in one field of one service's config sat at the same level as the workspace-wide
9
+ // infrastructure it depends on. It lives here now, next to the two settings it writes: the
10
+ // service's provision type and the workspace's Docker Compose handler.
11
+ //
12
+ // The section deliberately carries a full explanation rather than a one-line hint. The wizard
13
+ // is a five-minute flow that touches a repo and can trial-provision a stack, so "when you need
14
+ // this / when you don't" has to be answerable BEFORE opening it — most services on most boards
15
+ // never need it at all.
16
+ const { t } = useI18n()
17
+ const ui = useUiStore()
18
+
19
+ /** Bullet lists rendered below; static literal keys so the typed-key check sees them. */
20
+ const steps = computed(() => [
21
+ t('settings.composeEnvSetup.how.scan'),
22
+ t('settings.composeEnvSetup.how.analyse'),
23
+ t('settings.composeEnvSetup.how.preflight'),
24
+ t('settings.composeEnvSetup.how.save'),
25
+ ])
26
+ const needed = computed(() => [
27
+ t('settings.composeEnvSetup.needed.running'),
28
+ t('settings.composeEnvSetup.needed.compose'),
29
+ t('settings.composeEnvSetup.needed.setup'),
30
+ ])
31
+ const notNeeded = computed(() => [
32
+ t('settings.composeEnvSetup.notNeeded.inContainer'),
33
+ t('settings.composeEnvSetup.notNeeded.otherBackend'),
34
+ t('settings.composeEnvSetup.notNeeded.noRepo'),
35
+ ])
36
+
37
+ // The wizard is its own modal, so hand over rather than stacking: close this window first.
38
+ function start() {
39
+ ui.closeProviderConnection()
40
+ ui.openEnvironmentSetup()
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <section class="space-y-3" data-testid="compose-env-setup-section">
46
+ <div>
47
+ <h3 class="text-sm font-semibold text-slate-200">
48
+ {{ t('settings.composeEnvSetup.title') }}
49
+ </h3>
50
+ <p class="mt-1 text-xs leading-relaxed text-slate-400">
51
+ {{ t('settings.composeEnvSetup.lead') }}
52
+ </p>
53
+ </div>
54
+
55
+ <div class="rounded border border-slate-800 bg-slate-900/40 p-3">
56
+ <p class="text-[11px] font-medium uppercase tracking-wide text-slate-400">
57
+ {{ t('settings.composeEnvSetup.how.title') }}
58
+ </p>
59
+ <ol class="mt-2 list-decimal space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
60
+ <li v-for="(step, i) in steps" :key="`how-${i}`">{{ step }}</li>
61
+ </ol>
62
+ <p class="mt-2 text-[11px] leading-relaxed text-slate-500">
63
+ {{ t('settings.composeEnvSetup.how.outcome') }}
64
+ </p>
65
+ </div>
66
+
67
+ <div class="grid gap-2 sm:grid-cols-2">
68
+ <div class="rounded border border-emerald-900/50 bg-emerald-950/20 p-3">
69
+ <p class="flex items-center gap-1.5 text-[11px] font-medium text-emerald-200/90">
70
+ <UIcon name="i-lucide-check" class="h-3.5 w-3.5 shrink-0" />
71
+ {{ t('settings.composeEnvSetup.needed.title') }}
72
+ </p>
73
+ <ul class="mt-1.5 list-disc space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
74
+ <li v-for="(item, i) in needed" :key="`need-${i}`">{{ item }}</li>
75
+ </ul>
76
+ </div>
77
+ <div class="rounded border border-slate-800 bg-slate-900/40 p-3">
78
+ <p class="flex items-center gap-1.5 text-[11px] font-medium text-slate-300">
79
+ <UIcon name="i-lucide-minus" class="h-3.5 w-3.5 shrink-0" />
80
+ {{ t('settings.composeEnvSetup.notNeeded.title') }}
81
+ </p>
82
+ <ul class="mt-1.5 list-disc space-y-1 ps-4 text-[11px] leading-relaxed text-slate-400">
83
+ <li v-for="(item, i) in notNeeded" :key="`skip-${i}`">{{ item }}</li>
84
+ </ul>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="flex items-center gap-2">
89
+ <UButton
90
+ size="xs"
91
+ color="primary"
92
+ variant="soft"
93
+ icon="i-lucide-wand-sparkles"
94
+ data-testid="compose-env-setup-start"
95
+ @click="start()"
96
+ >
97
+ {{ t('settings.composeEnvSetup.start') }}
98
+ </UButton>
99
+ <span class="text-[11px] text-slate-500">{{ t('settings.composeEnvSetup.rerunHint') }}</span>
100
+ </div>
101
+ </section>
102
+ </template>
@@ -6,7 +6,10 @@
6
6
  // mode — the warm-container-pool + checkout-reuse settings (the local agent-container
7
7
  // runtime, folded in from the former LocalModeSettingsPanel).
8
8
  // - "Test environments" — where the Tester's ephemeral environments run. Shows the test-env
9
- // backend selector and the environment-provider connection.
9
+ // backend selector, the environment-provider connection, and the guided per-service Docker
10
+ // Compose setup (formerly a standalone "Environment setup" sidebar entry — it writes a
11
+ // service's Compose recipe plus the workspace's Compose handler, so it belongs beside the
12
+ // settings it edits rather than at the same level as them).
10
13
  // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
11
14
  // backend integration is disabled (503) simply doesn't render.
12
15
  import { computed, ref, watch } from 'vue'
@@ -16,6 +19,7 @@ import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfig
16
19
  import DefaultProvisionTypeSection from '~/components/settings/DefaultProvisionTypeSection.vue'
17
20
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
18
21
  import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
22
+ import ComposeEnvironmentSetupSection from '~/components/settings/ComposeEnvironmentSetupSection.vue'
19
23
 
20
24
  // The shared-stacks tab uses its own slot key beyond the provider-connection kinds.
21
25
  type InfraTabValue = ProviderConnectionKind | 'shared-stacks'
@@ -142,6 +146,12 @@ watch([tabs, () => store.loaded], () => {
142
146
  <div class="border-t border-slate-800 pt-4">
143
147
  <InfraHandlersConfigurator />
144
148
  </div>
149
+ <!-- The guided per-SERVICE Compose flow (formerly the standalone "Environment
150
+ setup" sidebar entry). Last, because it fills in one service's recipe on
151
+ top of the workspace-wide choices above. -->
152
+ <div class="border-t border-slate-800 pt-4">
153
+ <ComposeEnvironmentSetupSection />
154
+ </div>
145
155
  </div>
146
156
  </template>
147
157
  <template #shared-stacks>
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  deleteServiceValidationConfigContract,
3
+ detectServiceValidationChecksContract,
3
4
  getServiceValidationConfigContract,
4
5
  listServiceValidationConfigsContract,
5
6
  setServiceValidationConfigContract,
@@ -30,6 +31,13 @@ export function validationChecksApi({ send, ws }: ApiContext) {
30
31
  body,
31
32
  }),
32
33
 
34
+ /** Suggest checks from the service repo's manifests — a read; the operator still saves. */
35
+ detectServiceValidationChecks: (workspaceId: string, blockId: string) =>
36
+ send(detectServiceValidationChecksContract, {
37
+ pathPrefix: ws(workspaceId),
38
+ pathParams: { blockId },
39
+ }),
40
+
33
41
  deleteServiceValidationConfig: (workspaceId: string, blockId: string) =>
34
42
  send(deleteServiceValidationConfigContract, {
35
43
  pathPrefix: ws(workspaceId),
@@ -10,8 +10,9 @@ import type { Ref, WritableComputedRef } from 'vue'
10
10
  * resort to an obscure comma-operator expression. A named handler reads clearly instead.
11
11
  *
12
12
  * Returns to whichever hub the panel was reached from: the user-scoped "My setup" hub when
13
- * `cameFromPersonal` is set, else the workspace Integrations hub. A shared panel (e.g. the
14
- * vendor-credentials modal, reachable from both) thus lands the user back where they were.
13
+ * `cameFromPersonal` is set, the Model providers hub when `cameFromModelProviders` is set,
14
+ * else the workspace Integrations hub. A shared panel (e.g. the vendor-credentials modal,
15
+ * reachable from all three) thus lands the user back where they were.
15
16
  *
16
17
  * Pass the panel's `open` model (the writable ref/computed bound to its `UModal`).
17
18
  */
@@ -19,8 +20,10 @@ export function useIntegrationBack(open: Ref<boolean> | WritableComputedRef<bool
19
20
  const ui = useUiStore()
20
21
  return () => {
21
22
  const toPersonal = ui.cameFromPersonal
23
+ const toModelProviders = ui.cameFromModelProviders
22
24
  open.value = false
23
25
  if (toPersonal) ui.openPersonalSetup()
26
+ else if (toModelProviders) ui.openModelProviders()
24
27
  else ui.openIntegrations()
25
28
  }
26
29
  }
@@ -33,10 +33,10 @@ export function useNavContributions() {
33
33
  addFromRepo: () => ui.openAddService(),
34
34
  bootstrapRepo: () => ui.openBootstrap(),
35
35
  integrationsHub: () => ui.openIntegrations(),
36
+ modelProviders: () => ui.openModelProviders(),
36
37
  sandbox: () => ui.openSandbox(),
37
38
  kaizen: () => ui.openKaizen(),
38
39
  infrastructure: () => ui.openInfrastructure(),
39
- environmentSetup: () => ui.openEnvironmentSetup(),
40
40
  fragmentLibrary: () => ui.openFragmentLibrary(),
41
41
  mergeThresholds: () => ui.openWorkspaceSettings('merge'),
42
42
  workspaceSettings: () => ui.openWorkspaceSettings(),
@@ -99,12 +99,15 @@ describe('navSlotFilter', () => {
99
99
  expect(kept).toContain('integrations-hub')
100
100
  expect(kept).toContain('workspace-settings')
101
101
  expect(kept).toContain('model-config')
102
+ // Model providers are NOT an integration: the split is what makes the engines findable.
103
+ expect(kept).toContain('model-providers')
102
104
  // ...and so does everything the everyday delivery loop runs on, however deep it feels:
103
105
  // authoring a flow, the standards library, and the PREnv/runner plumbing.
104
106
  expect(kept).toContain('build-pipeline')
105
107
  expect(kept).toContain('fragments')
108
+ // Also the only route to the guided per-service Compose environment setup, which folded
109
+ // into this window rather than staying a sibling nav entry.
106
110
  expect(kept).toContain('infrastructure')
107
- expect(kept).toContain('environment-setup')
108
111
  // What drops is either a shortcut basic mode reaches another way, or a capability
109
112
  // deliberately kept out of the tier (experimentation, one-off repo setup, the
110
113
  // deployment-wide operator rollups).
@@ -134,7 +137,7 @@ describe('navSlotFilter', () => {
134
137
  },
135
138
  'local-models': {
136
139
  kind: 'reached-another-way',
137
- why: 'integrations-hub -> Local runners',
140
+ why: 'model-providers -> My local runners',
138
141
  },
139
142
  sandbox: {
140
143
  kind: 'out-of-tier',
@@ -250,6 +253,7 @@ describe('NAV_CONTRIBUTIONS catalog integrity', () => {
250
253
  for (const group of [
251
254
  'create',
252
255
  'repositories',
256
+ 'models',
253
257
  'integrations',
254
258
  'infrastructure',
255
259
  'workspaceContext',
@@ -275,15 +279,19 @@ describe('nav grouping helpers', () => {
275
279
  expect(groups.map((g) => g.group)).toEqual([
276
280
  'create',
277
281
  'repositories',
282
+ 'models',
278
283
  'integrations',
279
284
  'infrastructure',
280
285
  'workspaceContext',
281
286
  'configuration',
282
287
  ])
288
+ // The engines are their own section, ahead of the optional integrations; `model-config`
289
+ // sits beside the providers it picks models from rather than under `configuration`.
290
+ const models = groups.find((g) => g.group === 'models')
291
+ expect(models?.items.map((i) => i.id)).toEqual(['model-providers', 'model-config'])
283
292
  const configuration = groups.find((g) => g.group === 'configuration')
284
293
  expect(configuration?.items.map((i) => i.id)).toEqual([
285
294
  'workspace-settings',
286
- 'model-config',
287
295
  'account-settings',
288
296
  'operator-dashboard',
289
297
  'reports',
@@ -25,10 +25,21 @@ export type { AppSlots } from './slots'
25
25
  /** Which shell(s) render a contribution. */
26
26
  export type NavSurface = 'sidebar' | 'command' | 'toolbar'
27
27
 
28
- /** Sidebar section a contribution lands in (its i18n header is `nav.<group>`). */
28
+ /**
29
+ * Sidebar section a contribution lands in (its i18n header is `nav.<group>`).
30
+ *
31
+ * `models` and `integrations` are deliberately SEPARATE sections even though a model
32
+ * provider is technically also an external system we connect to. They answer different
33
+ * questions: `models` is the ENGINE the harnesses run on (no provider ⇒ nothing runs at
34
+ * all), `integrations` is the optional systems that feed a run context or receive its
35
+ * output (source control, trackers, documents, chat, observability) — each of which a
36
+ * deployment can live without. Folding the providers in among them buried the one
37
+ * connection every deployment must make in a list of ones most never touch.
38
+ */
29
39
  export type NavSidebarGroup =
30
40
  | 'create'
31
41
  | 'repositories'
42
+ | 'models'
32
43
  | 'integrations'
33
44
  | 'infrastructure'
34
45
  | 'workspaceContext'
@@ -90,10 +101,10 @@ export const NAV_ACTIONS = [
90
101
  'addFromRepo',
91
102
  'bootstrapRepo',
92
103
  'integrationsHub',
104
+ 'modelProviders',
93
105
  'sandbox',
94
106
  'kaizen',
95
107
  'infrastructure',
96
- 'environmentSetup',
97
108
  'fragmentLibrary',
98
109
  'mergeThresholds',
99
110
  'workspaceSettings',
@@ -164,7 +175,7 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
164
175
  * - `merge-thresholds` / `service-fragment-defaults` — palette shortcuts into Workspace
165
176
  * settings tabs (Merge, Service best practices), which basic mode reaches via
166
177
  * `workspace-settings`.
167
- * - `local-models` — a per-user endpoint knob the Integrations hub already offers.
178
+ * - `local-models` — a per-user endpoint knob the Model providers hub already offers.
168
179
  *
169
180
  * OUT OF THE TIER — the sole route, hidden deliberately, so the capability itself is absent
170
181
  * from basic mode. This is a product decision, not an oversight: each of these answers a
@@ -180,8 +191,9 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
180
191
  *
181
192
  * Everything else stays in basic because the delivery loop needs it: authoring a flow
182
193
  * (`build-pipeline`), adding a repo (`add-from-repo`), the standards/skills library
183
- * (`fragments`), the PREnv + runner plumbing (`infrastructure`, `environment-setup`), and the
184
- * workspace/model configuration a run actually reads (`workspace-settings`, `model-config`).
194
+ * (`fragments`), the PREnv + runner plumbing (`infrastructure`, which is also the only route
195
+ * to the guided per-service Compose environment setup), and the workspace/model configuration
196
+ * a run actually reads (`workspace-settings`, `model-config`).
185
197
  */
186
198
  export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
187
199
  {
@@ -233,6 +245,19 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
233
245
  keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
234
246
  },
235
247
  },
248
+ {
249
+ // The engines. Kept out of `integrations-hub` on purpose (see NavSidebarGroup): a
250
+ // deployment with no provider connected cannot run anything, so this is the one
251
+ // connection that must not sit in a list of optional ones.
252
+ id: 'model-providers',
253
+ labelKey: 'nav.modelProviders',
254
+ icon: 'i-lucide-plug-zap',
255
+ surfaces: S('sidebar'),
256
+ gate: (g) => g.canManageIntegrations,
257
+ action: 'modelProviders',
258
+ testId: 'nav-model-providers',
259
+ sidebar: { group: 'models', order: 10 },
260
+ },
236
261
  {
237
262
  id: 'integrations-hub',
238
263
  labelKey: 'nav.integrations',
@@ -280,16 +305,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
280
305
  testId: 'nav-infrastructure',
281
306
  sidebar: { group: 'infrastructure', order: 10 },
282
307
  },
283
- {
284
- id: 'environment-setup',
285
- labelKey: 'nav.environmentSetup',
286
- icon: 'i-lucide-flask-conical',
287
- surfaces: S('sidebar'),
288
- gate: (g) => g.infrastructureAvailable,
289
- action: 'environmentSetup',
290
- testId: 'nav-environment-setup',
291
- sidebar: { group: 'infrastructure', order: 20 },
292
- },
293
308
  {
294
309
  id: 'fragments',
295
310
  labelKey: 'nav.contextFragments',
@@ -344,7 +359,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
344
359
  gate: (g) => g.canManageSettings,
345
360
  action: 'modelConfiguration',
346
361
  testId: 'nav-model-config',
347
- sidebar: { group: 'configuration', order: 20 },
362
+ // Beside `model-providers`, not in `configuration`: "which key do we hold" and "which
363
+ // model does each agent kind use" are two halves of one question, and splitting them
364
+ // across sections is what sent people to Integrations looking for a model.
365
+ sidebar: { group: 'models', order: 20 },
348
366
  command: {
349
367
  group: 'workspace',
350
368
  order: 40,
@@ -491,6 +509,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
491
509
  export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
492
510
  'create',
493
511
  'repositories',
512
+ 'models',
494
513
  'integrations',
495
514
  'infrastructure',
496
515
  'workspaceContext',
@@ -89,6 +89,9 @@ const ModelPresetHealthModal = defineAsyncComponent(
89
89
  const IntegrationsHub = defineAsyncComponent(
90
90
  () => import('~/components/layout/IntegrationsHub.vue'),
91
91
  )
92
+ const ModelProvidersHub = defineAsyncComponent(
93
+ () => import('~/components/layout/ModelProvidersHub.vue'),
94
+ )
92
95
  const PersonalSetupModal = defineAsyncComponent(
93
96
  () => import('~/components/layout/PersonalSetupModal.vue'),
94
97
  )
@@ -429,6 +432,7 @@ watch(
429
432
  <RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
430
433
  <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
431
434
  <IntegrationsHub v-if="ui.integrationsOpen" />
435
+ <ModelProvidersHub v-if="ui.modelProvidersOpen" />
432
436
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
433
437
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
434
438
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
@@ -106,12 +106,15 @@ export const useDocumentsStore = defineStore('documents', () => {
106
106
  return api.planDocument(workspace.requireId(), source, externalId)
107
107
  }
108
108
 
109
- /** Apply a page's structure to the board, then refresh the board snapshot. */
110
- async function spawn(source: DocumentSourceKind, externalId: string, frameId?: string) {
111
- const { result } = await api.spawnDocument(workspace.requireId(), source, {
112
- externalId,
113
- frameId,
114
- })
109
+ /**
110
+ * Apply a page's structure to the board as new top-level frames, then refresh the
111
+ * board snapshot. The endpoint also accepts a `frameId` that flattens the planned
112
+ * frames into an existing service; the SPA deliberately never sends one, because the
113
+ * planner is target-blind and that path discards the frame titles/types the preview
114
+ * shows. Scoping a spawn to a service needs a target-aware plan first.
115
+ */
116
+ async function spawn(source: DocumentSourceKind, externalId: string) {
117
+ const { result } = await api.spawnDocument(workspace.requireId(), source, { externalId })
115
118
  await workspace.refresh()
116
119
  return result
117
120
  }