@cat-factory/app 0.174.0 → 0.178.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.
Files changed (34) hide show
  1. package/README.md +52 -13
  2. package/app/components/board/nodes/BlockNode.vue +30 -0
  3. package/app/components/environments/steps/EnvPickStep.vue +3 -0
  4. package/app/components/layout/IntegrationBackTitle.vue +11 -8
  5. package/app/components/layout/IntegrationsHub.vue +35 -56
  6. package/app/components/layout/ModelProvidersHub.vue +250 -0
  7. package/app/components/panels/InspectorPanel.vue +3 -28
  8. package/app/components/panels/inspector/ContainerSummary.vue +54 -62
  9. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +11 -0
  10. package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
  11. package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
  12. package/app/components/settings/InfrastructureWindow.vue +11 -1
  13. package/app/composables/api/validationChecks.ts +8 -0
  14. package/app/composables/useIntegrationBack.ts +5 -2
  15. package/app/composables/useNavContributions.ts +1 -1
  16. package/app/modular/nav-contributions.spec.ts +64 -21
  17. package/app/modular/nav-contributions.ts +59 -26
  18. package/app/pages/index.vue +4 -0
  19. package/app/stores/ui/modals.ts +32 -6
  20. package/app/stores/validationChecks.ts +17 -2
  21. package/app/types/validationChecks.ts +3 -0
  22. package/app/utils/validationDetection.spec.ts +51 -0
  23. package/app/utils/validationDetection.ts +67 -0
  24. package/i18n/locales/de.json +110 -33
  25. package/i18n/locales/en.json +110 -33
  26. package/i18n/locales/es.json +110 -33
  27. package/i18n/locales/fr.json +110 -33
  28. package/i18n/locales/he.json +110 -33
  29. package/i18n/locales/it.json +110 -33
  30. package/i18n/locales/ja.json +110 -33
  31. package/i18n/locales/pl.json +110 -33
  32. package/i18n/locales/tr.json +110 -33
  33. package/i18n/locales/uk.json +110 -33
  34. package/package.json +2 -2
@@ -22,72 +22,64 @@ function addTask() {
22
22
  </script>
23
23
 
24
24
  <template>
25
+ <!-- Modules and tasks are two peer collapsible lists, not one "Contents" group: the
26
+ grouping added a level of nesting without adding information, and a service's task
27
+ list is long enough to want collapsing independently of its modules. -->
25
28
  <InspectorSection
26
- :title="t('inspector.container.title')"
27
- :hint="t('inspector.container.hint')"
28
- :count="tasks.length"
29
+ v-if="modules.length"
30
+ :title="t('inspector.container.modulesTitle')"
31
+ :hint="t('inspector.container.modulesHint')"
32
+ :count="modules.length"
29
33
  default-open
30
34
  >
31
- <!-- modules (services only) -->
32
- <div v-if="modules.length">
33
- <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
34
- {{ t('inspector.container.modules', { count: modules.length }) }}
35
- </div>
36
- <ul class="space-y-1">
37
- <li
38
- v-for="m in modules"
39
- :key="m.id"
40
- class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
41
- @click="ui.select(m.id)"
42
- >
43
- <UIcon name="i-lucide-package" class="h-3.5 w-3.5 text-violet-400" />
44
- <span class="truncate text-xs text-slate-200">{{ m.title }}</span>
45
- <span class="ms-auto text-[10px] text-slate-500">{{
46
- t(
47
- 'inspector.container.taskCount',
48
- { count: board.tasksOf(m.id).length },
49
- board.tasksOf(m.id).length,
50
- )
51
- }}</span>
52
- </li>
53
- </ul>
54
- </div>
35
+ <ul class="space-y-1">
36
+ <li
37
+ v-for="m in modules"
38
+ :key="m.id"
39
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
40
+ @click="ui.select(m.id)"
41
+ >
42
+ <UIcon name="i-lucide-package" class="h-3.5 w-3.5 text-violet-400" />
43
+ <span class="truncate text-xs text-slate-200">{{ m.title }}</span>
44
+ <span class="ms-auto text-[10px] text-slate-500">{{
45
+ t(
46
+ 'inspector.container.taskCount',
47
+ { count: board.tasksOf(m.id).length },
48
+ board.tasksOf(m.id).length,
49
+ )
50
+ }}</span>
51
+ </li>
52
+ </ul>
53
+ </InspectorSection>
55
54
 
56
- <div>
57
- <div class="mb-1 flex items-center justify-between">
58
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
59
- {{
60
- isFrame
61
- ? t('inspector.container.allTasks', { count: tasks.length })
62
- : t('inspector.container.tasks', { count: tasks.length })
63
- }}
64
- </span>
65
- <div class="flex items-center gap-1.5">
66
- <ReviewDebtBadge />
67
- <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
68
- {{ t('inspector.container.addTask') }}
69
- </UButton>
70
- </div>
71
- </div>
72
- <ul v-if="tasks.length" class="space-y-1">
73
- <li
74
- v-for="task in tasks"
75
- :key="task.id"
76
- class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
77
- @click="ui.select(task.id)"
78
- >
79
- <span
80
- class="h-2 w-2 shrink-0 rounded-full"
81
- :style="{ backgroundColor: STATUS_META[task.status].color }"
82
- />
83
- <span class="truncate text-xs text-slate-200">{{ task.title }}</span>
84
- <span class="ms-auto text-[10px] text-slate-500">{{
85
- STATUS_META[task.status].label
86
- }}</span>
87
- </li>
88
- </ul>
89
- <div v-else class="text-[11px] text-slate-500">{{ t('inspector.container.noTasks') }}</div>
90
- </div>
55
+ <InspectorSection
56
+ :title="isFrame ? t('inspector.container.allTasksTitle') : t('inspector.container.tasksTitle')"
57
+ :hint="t('inspector.container.tasksHint')"
58
+ :count="tasks.length"
59
+ default-open
60
+ >
61
+ <template #actions>
62
+ <ReviewDebtBadge />
63
+ <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
64
+ {{ t('inspector.container.addTask') }}
65
+ </UButton>
66
+ </template>
67
+ <ul v-if="tasks.length" class="space-y-1">
68
+ <li
69
+ v-for="task in tasks"
70
+ :key="task.id"
71
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
72
+ @click="ui.select(task.id)"
73
+ >
74
+ <span
75
+ class="h-2 w-2 shrink-0 rounded-full"
76
+ :style="{ backgroundColor: STATUS_META[task.status].color }"
77
+ />
78
+ <span class="truncate text-xs text-slate-200">{{ task.title }}</span>
79
+ <span class="ms-auto text-[10px] text-slate-500">{{ STATUS_META[task.status].label }}</span>
80
+ </li>
81
+ </ul>
82
+ <div v-else class="text-[11px] text-slate-500">{{ t('inspector.container.noTasks') }}</div>
91
83
  <p v-if="isFrame" class="text-[11px] text-slate-500">
92
84
  {{ t('inspector.container.servicesHint') }}
93
85
  </p>
@@ -2,6 +2,7 @@
2
2
  import { computed, reactive, ref, watch } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import { showOverrideField } from '~/utils/uiMode'
5
6
 
6
7
  // Per-service (frame) post-release-health mapping: which observability monitors/SLOs the
7
8
  // `post-release-health` gate watches after this service's PRs ship. Keyed by THIS block's
@@ -11,6 +12,7 @@ const props = defineProps<{ block: Block }>()
11
12
 
12
13
  const store = useReleaseHealthStore()
13
14
  const ui = useUiStore()
15
+ const uiMode = useUiModeStore()
14
16
  const toast = useToast()
15
17
  const { t } = useI18n()
16
18
  const { confirmAction, toastDone } = useConfirmAction()
@@ -21,6 +23,14 @@ const draft = reactive({ monitorIds: '', sloIds: '', envTag: '' })
21
23
  const connected = computed(() => store.connection.connected)
22
24
  const saved = computed(() => store.configForBlock(props.block.id))
23
25
 
26
+ // Post-release health is an ADVANCED-tier concern: with no mapping the gate is a pass-through,
27
+ // so basic mode hides the whole section rather than showing a service-level knob whose absence
28
+ // changes nothing. `showOverrideField` (not a bare `isAdvanced`) because the mapping is per
29
+ // SERVICE and durable — once a teammate on the advanced tier maps monitors onto this frame,
30
+ // hiding the panel would leave a basic-mode user with releases being watched (and an on-call
31
+ // agent that can spawn) by a configuration they can neither see nor clear.
32
+ const show = computed(() => showOverrideField(uiMode.isAdvanced, saved.value))
33
+
24
34
  function parseIds(csv: string): string[] {
25
35
  return csv
26
36
  .split(',')
@@ -91,6 +101,7 @@ async function clear() {
91
101
 
92
102
  <template>
93
103
  <InspectorSection
104
+ v-if="show"
94
105
  :title="t('inspector.releaseHealth.title')"
95
106
  :hint="t('inspector.releaseHealth.sectionHint')"
96
107
  >
@@ -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,36 +99,74 @@ describe('navSlotFilter', () => {
99
99
  expect(kept).toContain('integrations-hub')
100
100
  expect(kept).toContain('workspace-settings')
101
101
  expect(kept).toContain('model-config')
102
- // ...and so does every destination that is the SOLE route to its capability, however deep
103
- // it feels: authoring a flow, the standards library, the PREnv/runner plumbing, and the
104
- // aggregate run-health / spend views.
102
+ // Model providers are NOT an integration: the split is what makes the engines findable.
103
+ expect(kept).toContain('model-providers')
104
+ // ...and so does everything the everyday delivery loop runs on, however deep it feels:
105
+ // authoring a flow, the standards library, and the PREnv/runner plumbing.
105
106
  expect(kept).toContain('build-pipeline')
106
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.
107
110
  expect(kept).toContain('infrastructure')
108
- expect(kept).toContain('environment-setup')
109
- expect(kept).toContain('operator-dashboard')
110
- expect(kept).toContain('reports')
111
- // What drops is beside the delivery path (experimentation) or a shortcut into a surface
112
- // basic mode already reaches another way.
111
+ // What drops is either a shortcut basic mode reaches another way, or a capability
112
+ // deliberately kept out of the tier (experimentation, one-off repo setup, the
113
+ // deployment-wide operator rollups).
113
114
  expect(kept).not.toContain('sandbox')
114
115
  expect(kept).not.toContain('kaizen')
115
116
  expect(kept).not.toContain('merge-thresholds')
117
+ expect(kept).not.toContain('bootstrap-repo')
118
+ expect(kept).not.toContain('operator-dashboard')
119
+ expect(kept).not.toContain('reports')
116
120
  })
117
121
 
118
- it('never makes an advanced item the only route to its capability', () => {
119
- // The tier's governing rule (see the catalog comment): every advanced destination either
120
- // sits beside the delivery path, or is a shortcut into a surface a basic destination also
121
- // opens. Spelled out here as a table so promoting an item to `advanced` forces an explicit
122
- // claim about how basic mode still reaches it, rather than a silent capability loss.
123
- const ALTERNATIVE_ROUTE: Record<string, string> = {
124
- sandbox: 'none needed - experimentation surface, not on the delivery path',
125
- kaizen: 'none needed - self-grading history, not on the delivery path',
126
- 'merge-thresholds': 'workspace-settings -> Merge tab',
127
- 'service-fragment-defaults': 'workspace-settings -> Service best practices tab',
128
- 'local-models': 'integrations-hub -> Local runners',
122
+ it('states, per advanced item, whether basic mode still reaches its capability', () => {
123
+ // Marking an item `advanced` does one of two things, and which one has to be said out
124
+ // loud. `reached-another-way` promises a basic destination opens the same surface, so
125
+ // nothing is lost. `out-of-tier` concedes the opposite: the capability is ABSENT from
126
+ // basic mode and the tier switch is the only way to it. Both are legitimate; a silent
127
+ // one is not. The table is the claim, and it must match the catalog exactly, so adding
128
+ // `advanced: true` fails here until the reason is written down.
129
+ const REASON: Record<string, { kind: 'reached-another-way' | 'out-of-tier'; why: string }> = {
130
+ 'merge-thresholds': {
131
+ kind: 'reached-another-way',
132
+ why: 'workspace-settings -> Merge tab',
133
+ },
134
+ 'service-fragment-defaults': {
135
+ kind: 'reached-another-way',
136
+ why: 'workspace-settings -> Service best practices tab',
137
+ },
138
+ 'local-models': {
139
+ kind: 'reached-another-way',
140
+ why: 'model-providers -> My local runners',
141
+ },
142
+ sandbox: {
143
+ kind: 'out-of-tier',
144
+ why: 'experimentation surface, beside the delivery path',
145
+ },
146
+ kaizen: {
147
+ kind: 'out-of-tier',
148
+ why: 'self-grading history, beside the delivery path',
149
+ },
150
+ 'bootstrap-repo': {
151
+ kind: 'out-of-tier',
152
+ why: 'one-off new-repo setup, not part of running work on an existing board',
153
+ },
154
+ 'operator-dashboard': {
155
+ kind: 'out-of-tier',
156
+ why: 'deployment-wide health rollup - an operator job, not a delivery one',
157
+ },
158
+ reports: {
159
+ kind: 'out-of-tier',
160
+ why: 'deployment-wide spend/activity rollup - an operator job, not a delivery one',
161
+ },
129
162
  }
130
163
  const advanced = NAV_CONTRIBUTIONS.filter((i) => i.advanced).map((i) => i.id)
131
- expect(advanced.sort()).toEqual(Object.keys(ALTERNATIVE_ROUTE).sort())
164
+ expect(advanced.sort()).toEqual(Object.keys(REASON).sort())
165
+ // An `out-of-tier` item is a real capability loss, so the switcher back must survive basic
166
+ // mode — pinned by the next case. Assert here only that every claim carries a reason.
167
+ for (const [id, reason] of Object.entries(REASON)) {
168
+ expect(reason.why.length, `${id} has no stated reason`).toBeGreaterThan(0)
169
+ }
132
170
  })
133
171
 
134
172
  it('keeps the tier switch itself reachable in basic mode', () => {
@@ -215,6 +253,7 @@ describe('NAV_CONTRIBUTIONS catalog integrity', () => {
215
253
  for (const group of [
216
254
  'create',
217
255
  'repositories',
256
+ 'models',
218
257
  'integrations',
219
258
  'infrastructure',
220
259
  'workspaceContext',
@@ -240,15 +279,19 @@ describe('nav grouping helpers', () => {
240
279
  expect(groups.map((g) => g.group)).toEqual([
241
280
  'create',
242
281
  'repositories',
282
+ 'models',
243
283
  'integrations',
244
284
  'infrastructure',
245
285
  'workspaceContext',
246
286
  'configuration',
247
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'])
248
292
  const configuration = groups.find((g) => g.group === 'configuration')
249
293
  expect(configuration?.items.map((i) => i.id)).toEqual([
250
294
  'workspace-settings',
251
- 'model-config',
252
295
  'account-settings',
253
296
  'operator-dashboard',
254
297
  'reports',