@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
package/README.md CHANGED
@@ -86,7 +86,7 @@ hoc where it can be avoided:
86
86
  it**, and marking an item does one of two distinguishable things:
87
87
  - **Reached another way** — a shortcut whose surface a basic destination also opens, so
88
88
  nothing is lost (the Merge / Service-best-practices palette entries into Workspace
89
- settings, the local-models knob the Integrations hub already offers).
89
+ settings, the local-models knob the Model providers hub already offers).
90
90
  - **Out of the tier** — the sole route, hidden on purpose, so the capability is _absent_
91
91
  from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
92
92
  and the deployment-wide operator + reports rollups).
@@ -153,9 +153,36 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
153
153
  by every VCS provider), `vcs` (the GitLab personal-access-token connect),
154
154
  `bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
155
155
  (acceptance), and `fragments` (the prompt-fragment library).
156
+ - **Model providers** — `ModelProvidersHub.vue`, the sibling hub for the ENGINES
157
+ (OpenRouter, vendor keys, personal subscriptions, own-machine runners). Kept out
158
+ of the Integrations hub on purpose: an integration is optional context in or
159
+ output out, while a provider is what executes the work, so a deployment with none
160
+ connected runs nothing at all. **A new provider-shaped connection belongs here,
161
+ never in `IntegrationsHub.vue`.** Both hubs, plus the user-scoped `PersonalSetupModal`,
162
+ share the `IntegrationBackTitle` Back control, which returns to whichever hub set
163
+ its came-from marker (`ui.cameFrom{Integrations,ModelProviders,Personal}`) — a panel
164
+ reachable from more than one hub must not hard-code its return.
156
165
  - **Auth** (`components/auth`) — `AuthGate` / `LoginScreen` / `UserMenu`; the app
157
166
  is gated when the backend requires sign-in.
158
167
 
168
+ ## Where a surface lives
169
+
170
+ Two placements are load-bearing enough to state, because putting a new one in the
171
+ wrong place is invisible until a user cannot find it:
172
+
173
+ - **The sidebar section is a claim about what the destination IS.** `models` is the
174
+ engines, `integrations` the optional systems, `infrastructure` where agent
175
+ containers and test environments run, `configuration` workspace/account settings.
176
+ `nav-contributions.spec.ts` pins the section order and each section's membership.
177
+ - **A flow that edits ONE entity's config is a section of the window that owns that
178
+ config, not a sibling nav entry.** The guided Docker Compose environment setup
179
+ (`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
180
+ Infrastructure → Test environments for exactly this reason: it writes a service's
181
+ Compose recipe plus the workspace's Compose handler, both configured in that tab.
182
+ It also carries a full "how it works / when you need this / when you can skip it"
183
+ explanation there rather than a one-line hint, since the decision to run it has to
184
+ be made before opening a five-minute wizard.
185
+
159
186
  ## Develop & test
160
187
 
161
188
  ```bash
@@ -4,12 +4,11 @@ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
4
4
 
5
5
  // Import pages from a connected document source and pick one to expand into
6
6
  // board structure. A source selector lets the user choose which connected source
7
- // to import from (Confluence, Notion, …). Carries an optional target frame from
8
- // the inspector so "Preview & spawn" lands the structure inside that frame.
7
+ // to import from (Confluence, Notion, …). "Preview & spawn" always creates new
8
+ // top-level frames see `SpawnPreviewModal` for why there is no frame target.
9
9
  const { t } = useI18n()
10
10
  const ui = useUiStore()
11
11
  const documents = useDocumentsStore()
12
- const board = useBoardStore()
13
12
  const toast = useToast()
14
13
 
15
14
  const open = computed({
@@ -20,11 +19,6 @@ const open = computed({
20
19
  })
21
20
  const back = useIntegrationBack(open)
22
21
 
23
- const targetFrameId = computed(() => ui.documentImport?.targetFrameId ?? null)
24
- const targetFrameTitle = computed(() =>
25
- targetFrameId.value ? board.getBlock(targetFrameId.value)?.title : null,
26
- )
27
-
28
22
  /** Which connected source we're importing from (defaults to the first). */
29
23
  const source = ref<DocumentSourceKind | undefined>(undefined)
30
24
  const ref_ = ref('')
@@ -78,7 +72,7 @@ async function doImport() {
78
72
  }
79
73
 
80
74
  function preview(externalId: string) {
81
- if (source.value) ui.openSpawnPreview(source.value, externalId, targetFrameId.value)
75
+ if (source.value) ui.openSpawnPreview(source.value, externalId)
82
76
  }
83
77
  </script>
84
78
 
@@ -106,14 +100,6 @@ function preview(externalId: string) {
106
100
  </div>
107
101
 
108
102
  <div v-else class="space-y-4">
109
- <p v-if="targetFrameTitle" class="text-xs text-slate-400">
110
- <i18n-t keypath="documents.import.spawningInto" scope="global">
111
- <template #frame>
112
- <span class="font-medium text-slate-200">{{ targetFrameTitle }}</span>
113
- </template>
114
- </i18n-t>
115
- </p>
116
-
117
103
  <UFormField v-if="sourceItems.length > 1" :label="t('documents.import.sourceLabel')">
118
104
  <USelect v-model="source" :items="sourceItems" class="w-full" />
119
105
  </UFormField>
@@ -117,7 +117,7 @@ async function unlink(doc: SourceDocument) {
117
117
  color="primary"
118
118
  variant="soft"
119
119
  icon="i-lucide-file-down"
120
- @click="ui.openDocumentImport(null)"
120
+ @click="ui.openDocumentImport()"
121
121
  >
122
122
  {{ t('documents.templates.importButton') }}
123
123
  </UButton>
@@ -4,10 +4,16 @@ import type { DocumentBoardPlan } from '~/types/domain'
4
4
  // Preview the structure an imported document expands into, then spawn it. The
5
5
  // plan is fetched fresh on open; a badge makes clear whether an LLM or the
6
6
  // deterministic heading parser produced it.
7
+ //
8
+ // A spawn always creates new top-level frames. The planner's job is to decompose a
9
+ // document into services, so spawning into an existing frame could only flatten the
10
+ // planned frames into it — silently discarding the frame titles and types rendered
11
+ // below, i.e. producing something other than what this preview showed. Scoping a
12
+ // spawn to one service needs a target-aware PLAN (a prompt that yields modules and
13
+ // tasks for an existing service), not a target-aware write.
7
14
  const { t } = useI18n()
8
15
  const ui = useUiStore()
9
16
  const documents = useDocumentsStore()
10
- const board = useBoardStore()
11
17
  const toast = useToast()
12
18
 
13
19
  const open = computed({
@@ -17,11 +23,6 @@ const open = computed({
17
23
  },
18
24
  })
19
25
 
20
- const targetFrameId = computed(() => ui.spawnPreview?.targetFrameId ?? null)
21
- const targetFrameTitle = computed(() =>
22
- targetFrameId.value ? board.getBlock(targetFrameId.value)?.title : null,
23
- )
24
-
25
26
  const plan = ref<DocumentBoardPlan | null>(null)
26
27
  const loadingPlan = ref(false)
27
28
  const spawning = ref(false)
@@ -54,11 +55,7 @@ async function spawn() {
54
55
  if (!preview) return
55
56
  spawning.value = true
56
57
  try {
57
- const result = await documents.spawn(
58
- preview.source,
59
- preview.externalId,
60
- targetFrameId.value ?? undefined,
61
- )
58
+ const result = await documents.spawn(preview.source, preview.externalId)
62
59
  toast.add({
63
60
  title: t('documents.spawn.spawned'),
64
61
  description: t('documents.spawn.summary', {
@@ -100,14 +97,7 @@ async function spawn() {
100
97
  : t('documents.spawn.plannerHeadings')
101
98
  }}
102
99
  </UBadge>
103
- <span v-if="targetFrameTitle" class="text-xs text-slate-400">
104
- <i18n-t keypath="documents.spawn.intoFrame" scope="global">
105
- <template #frame>
106
- <span class="font-medium text-slate-200">{{ targetFrameTitle }}</span>
107
- </template>
108
- </i18n-t>
109
- </span>
110
- <span v-else class="text-xs text-slate-400">{{ t('documents.spawn.asTopLevel') }}</span>
100
+ <span class="text-xs text-slate-400">{{ t('documents.spawn.asTopLevel') }}</span>
111
101
  </div>
112
102
 
113
103
  <div v-if="loadingPlan" class="flex items-center gap-2 text-sm text-slate-400">
@@ -18,6 +18,9 @@ const { t } = useI18n()
18
18
  <template>
19
19
  <section class="space-y-3" data-testid="env-setup-step-pick">
20
20
  <p class="text-sm text-slate-400">{{ t('environmentWizard.pick.intro') }}</p>
21
+ <!-- The one-service scope caveat, stated on the step that asks for the service: nothing
22
+ here changes any other service, and running it twice just re-edits this one's recipe. -->
23
+ <p class="text-xs leading-relaxed text-slate-500">{{ t('environmentWizard.pick.scope') }}</p>
21
24
  <p v-if="!store.serviceFrames.length" class="text-sm text-slate-500">
22
25
  {{ t('environmentWizard.pick.empty') }}
23
26
  </p>
@@ -92,7 +92,7 @@ const dynamicIntegrationCommands = computed<Command[]>(() => {
92
92
  group: groupIntegrations,
93
93
  icon: 'i-lucide-file-down',
94
94
  keywords: t('layout.commandBar.keywords.documentImport'),
95
- run: () => ui.openDocumentImport(null),
95
+ run: () => ui.openDocumentImport(),
96
96
  })
97
97
  }
98
98
  }
@@ -1,21 +1,24 @@
1
1
  <script setup lang="ts">
2
2
  // Title content for an integration sub-panel's modal header. Renders the panel
3
3
  // title with a leading "back" control that returns to the hub the panel was reached
4
- // from — the workspace Integrations hub (`ui.cameFromIntegrations`) or the user-scoped
5
- // "My setup" hub (`ui.cameFromPersonal`) shown only when there is one. Panels opened
6
- // from the command bar, sidebar, a banner or an inspector link don't grow a dead Back.
4
+ // from — the workspace Integrations hub (`ui.cameFromIntegrations`), the Model providers
5
+ // hub (`ui.cameFromModelProviders`) or the user-scoped "My setup" hub
6
+ // (`ui.cameFromPersonal`) shown only when there is one. Panels opened from the command
7
+ // bar, sidebar, a banner or an inspector link don't grow a dead Back.
7
8
  // Dropped into a UModal's #title slot, so it inherits the modal's title styling; it
8
9
  // emits `back` and the host panel closes itself + reopens the right hub.
9
10
  defineProps<{ title?: string }>()
10
11
  const emit = defineEmits<{ back: [] }>()
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
13
- const cameFromHub = computed(() => ui.cameFromIntegrations || ui.cameFromPersonal)
14
- const backLabel = computed(() =>
15
- ui.cameFromPersonal
16
- ? t('layout.integrationBack.backToMySetup')
17
- : t('layout.integrationBack.backToIntegrations'),
14
+ const cameFromHub = computed(
15
+ () => ui.cameFromIntegrations || ui.cameFromModelProviders || ui.cameFromPersonal,
18
16
  )
17
+ const backLabel = computed(() => {
18
+ if (ui.cameFromPersonal) return t('layout.integrationBack.backToMySetup')
19
+ if (ui.cameFromModelProviders) return t('layout.integrationBack.backToModelProviders')
20
+ return t('layout.integrationBack.backToIntegrations')
21
+ })
19
22
  </script>
20
23
 
21
24
  <template>
@@ -1,18 +1,26 @@
1
1
  <script setup lang="ts">
2
2
  import { showOverrideField } from '~/utils/uiMode'
3
3
 
4
- // The Integrations hub: a single modal that lists every external system the WORKSPACE can
5
- // enable or link in. Each row reuses the existing per-integration panel handlers on the `ui`
6
- // store (so the integrations themselves are unchanged); opening one closes the hub and
7
- // reveals that integration's own panel/modal.
4
+ // The Integrations hub: a single modal that lists the OPTIONAL external systems the WORKSPACE
5
+ // can enable or link in the ones that feed a run its context (source control, documents,
6
+ // trackers) or receive its output (chat, observability). Each row reuses the existing
7
+ // per-integration panel handlers on the `ui` store (so the integrations themselves are
8
+ // unchanged); opening one closes the hub and reveals that integration's own panel/modal.
8
9
  //
9
10
  // Sections gate on the same `available` probes the navbar used, so a system that the backend
10
11
  // has turned off simply doesn't appear here.
11
12
  //
12
- // Scope split: per-USER connections (a personal GitHub token, own-machine runners, personal
13
- // subscriptions) now live in the "My setup" hub (UserMenu → My setup), NOT here keeping
14
- // this hub purely workspace-scoped. When auth is disabled there is no UserMenu to host them,
15
- // so a "Personal (only you)" group falls back into this hub so they stay reachable.
13
+ // Scope split 1 MODEL PROVIDERS are NOT integrations here. They are the engines the
14
+ // harnesses run on (with none connected nothing runs at all), so they have their own
15
+ // top-level hub, `ModelProvidersHub.vue`. Keeping them out is the point: an integration is
16
+ // something a deployment can live without, and burying the one mandatory connection among a
17
+ // dozen optional ones is what made this hub confusing.
18
+ //
19
+ // Scope split 2 — per-USER connections (a personal GitHub token, own-machine runners, personal
20
+ // subscriptions) live in the "My setup" hub (UserMenu → My setup), NOT here, keeping this hub
21
+ // workspace-scoped. When auth is disabled there is no UserMenu to host them, so a "Personal
22
+ // (only you)" group falls back into this hub so the workspace-adjacent ones stay reachable
23
+ // (the model-shaped ones fall back into the Model providers hub instead).
16
24
  const { t } = useI18n()
17
25
  const ui = useUiStore()
18
26
  const auth = useAuthStore()
@@ -25,8 +33,6 @@ const releaseHealth = useReleaseHealthStore()
25
33
  const packageRegistries = usePackageRegistriesStore()
26
34
  const publicApiKeys = usePublicApiKeysStore()
27
35
  const userSecrets = useUserSecretsStore()
28
- const apiKeys = useApiKeysStore()
29
- const workspace = useWorkspaceStore()
30
36
  const uiMode = useUiModeStore()
31
37
 
32
38
  // True when the per-user "My setup" hub is reachable (UserMenu renders only when signed in).
@@ -57,8 +63,6 @@ watch(
57
63
  void packageRegistries.ensureLoaded().catch(() => {})
58
64
  void publicApiKeys.ensureLoaded().catch(() => {})
59
65
  void userSecrets.load().catch(() => {})
60
- // Drives the OpenRouter row's "Key connected" badge.
61
- if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
62
66
  }
63
67
  },
64
68
  // Lazy v-if mount: the hub mounts with `integrationsOpen` already true → load immediately.
@@ -114,42 +118,9 @@ function go(fn: () => void) {
114
118
  const groups = computed<IntegrationGroup[]>(() => {
115
119
  const out: IntegrationGroup[] = []
116
120
 
117
- // --- Models & providers ----------------------------------------------------
118
- // Top of the hub: an OpenRouter key is the fastest path to 300+ models, so it leads.
119
- const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
120
- out.push({
121
- title: t('layout.integrationsHub.groups.models'),
122
- items: [
123
- {
124
- key: 'openrouter',
125
- icon: 'i-lucide-waypoints',
126
- label: 'OpenRouter',
127
- description: t('layout.integrationsHub.items.openrouter.description'),
128
- status: openRouterKeyConnected
129
- ? t('layout.integrationsHub.status.keyConnected')
130
- : undefined,
131
- connected: openRouterKeyConnected,
132
- recommended: true,
133
- onClick: () => go(ui.openOpenRouter),
134
- },
135
- {
136
- key: 'vendors',
137
- icon: 'i-lucide-key-round',
138
- label: t('layout.integrationsHub.items.vendors.label'),
139
- description: t('layout.integrationsHub.items.vendors.description'),
140
- onClick: () => go(ui.openVendorCredentials),
141
- },
142
- ],
143
- // Personal (individual-usage) subscriptions are per-USER, so they live in My setup —
144
- // but this hub is where people look first when connecting "their Claude plan". A quiet
145
- // pointer link keeps them findable without a workspace-scoped row competing here.
146
- footerLink: {
147
- key: 'personal-subs',
148
- icon: 'i-lucide-user',
149
- label: t('layout.integrationsHub.items.personalSubs.label'),
150
- onClick: () => go(() => ui.openVendorCredentials('personal')),
151
- },
152
- })
121
+ // NOTE: model providers (OpenRouter, vendor keys, personal subscriptions, local runners)
122
+ // are NOT listed here they have their own top-level hub (`ModelProvidersHub.vue`, SideBar
123
+ // "Model providers"). See the scope split at the top of this file.
153
124
 
154
125
  // --- Source control --------------------------------------------------------
155
126
  const code: IntegrationItem[] = []
@@ -205,7 +176,7 @@ const groups = computed<IntegrationGroup[]>(() => {
205
176
  icon: 'i-lucide-file-down',
206
177
  label: t('layout.integrationsHub.items.documentImport.label'),
207
178
  description: t('layout.integrationsHub.items.documentImport.description'),
208
- onClick: () => go(() => ui.openDocumentImport(null)),
179
+ onClick: () => go(() => ui.openDocumentImport()),
209
180
  })
210
181
  }
211
182
  // Per-DocKind template + exemplar links are workspace CONFIG over the imported corpus, not an
@@ -338,6 +309,8 @@ const groups = computed<IntegrationGroup[]>(() => {
338
309
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
339
310
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
340
311
  // here so they stay reachable. (The badge reflects the signed-in user's stored secret.)
312
+ // Only the source-control one: the per-user MODEL connections are listed unconditionally
313
+ // in the Model providers hub, so they need no fallback.
341
314
  if (!personalHubReachable.value) {
342
315
  const pat = !!userSecrets.statusFor('github_pat')
343
316
  out.push({
@@ -352,13 +325,6 @@ const groups = computed<IntegrationGroup[]>(() => {
352
325
  connected: pat,
353
326
  onClick: () => go(ui.openUserSecrets),
354
327
  },
355
- {
356
- key: 'local-runners',
357
- icon: 'i-lucide-server',
358
- label: t('layout.integrationsHub.items.localRunners.label'),
359
- description: t('layout.integrationsHub.items.localRunners.description'),
360
- onClick: () => go(ui.openLocalModels),
361
- },
362
328
  ],
363
329
  })
364
330
  }
@@ -0,0 +1,250 @@
1
+ <script setup lang="ts">
2
+ // The Model providers hub: where the ENGINES the harnesses run on are connected.
3
+ //
4
+ // Split out of the Integrations hub deliberately. Both are technically "external systems we
5
+ // hold a credential for", but they answer different questions: an integration is an OPTIONAL
6
+ // system that feeds a run context or receives its output (source control, trackers, documents,
7
+ // chat, observability), while a model provider is what actually executes the work — with none
8
+ // connected, nothing runs at all. Listing the providers among a dozen optional connectors
9
+ // buried the one connection every deployment must make.
10
+ //
11
+ // Rows reuse the existing per-provider panel handlers on the `ui` store via
12
+ // `ui.openFromModelProviders(...)`, so opening one closes this hub and gives that panel a
13
+ // "Back to Model providers" control (IntegrationBackTitle). Layout mirrors the Integrations
14
+ // hub row-for-row so the two read as siblings.
15
+ const { t } = useI18n()
16
+ const ui = useUiStore()
17
+ const apiKeys = useApiKeysStore()
18
+ const localModels = useLocalModelsStore()
19
+ const personalSubs = usePersonalSubscriptionsStore()
20
+ const workspace = useWorkspaceStore()
21
+
22
+ const open = computed({
23
+ get: () => ui.modelProvidersOpen,
24
+ set: (v: boolean) => (v ? ui.openModelProviders() : ui.closeModelProviders()),
25
+ })
26
+
27
+ // Free-text filter over the rows (label + description), matching the Integrations hub.
28
+ // Declared before the `immediate` watch below, which resets it on open.
29
+ const query = ref('')
30
+
31
+ // Load the cheap status reads each time the hub opens so every badge is accurate.
32
+ watch(
33
+ () => ui.modelProvidersOpen,
34
+ (isOpen) => {
35
+ if (!isOpen) return
36
+ query.value = ''
37
+ if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
38
+ void localModels.load().catch(() => {})
39
+ void personalSubs.load().catch(() => {})
40
+ },
41
+ // Lazy v-if mount: the hub mounts with `modelProvidersOpen` already true → load immediately.
42
+ { immediate: true },
43
+ )
44
+
45
+ /** One provider row. `connected` drives the green badge; `status` is its line. */
46
+ interface ProviderItem {
47
+ key: string
48
+ icon: string
49
+ label: string
50
+ description: string
51
+ status?: string
52
+ connected?: boolean
53
+ /** Tags the fastest path to a working deployment while nothing is connected yet. */
54
+ recommended?: boolean
55
+ onClick: () => void
56
+ }
57
+
58
+ interface ProviderGroup {
59
+ title: string
60
+ /** Rendered under the group's rows — the scope caveat for the personal group. */
61
+ note?: string
62
+ items: ProviderItem[]
63
+ }
64
+
65
+ function go(fn: () => void) {
66
+ ui.openFromModelProviders(fn)
67
+ }
68
+
69
+ const groups = computed<ProviderGroup[]>(() => {
70
+ const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
71
+ const runnerCount = localModels.endpoints.length
72
+ const subCount = personalSubs.subscriptions.length
73
+
74
+ return [
75
+ // --- Workspace-wide providers ------------------------------------------
76
+ // An OpenRouter key is the fastest path to 300+ models, so it leads.
77
+ {
78
+ title: t('layout.modelProvidersHub.groups.workspace'),
79
+ items: [
80
+ {
81
+ key: 'openrouter',
82
+ icon: 'i-lucide-waypoints',
83
+ label: 'OpenRouter',
84
+ description: t('layout.modelProvidersHub.items.openrouter.description'),
85
+ status: openRouterKeyConnected
86
+ ? t('layout.modelProvidersHub.status.keyConnected')
87
+ : undefined,
88
+ connected: openRouterKeyConnected,
89
+ recommended: true,
90
+ onClick: () => go(ui.openOpenRouter),
91
+ },
92
+ {
93
+ key: 'vendors',
94
+ icon: 'i-lucide-key-round',
95
+ label: t('layout.modelProvidersHub.items.vendors.label'),
96
+ description: t('layout.modelProvidersHub.items.vendors.description'),
97
+ onClick: () => go(ui.openVendorCredentials),
98
+ },
99
+ ],
100
+ },
101
+ // --- Personal (per-user) providers -------------------------------------
102
+ // These live on the user, not the workspace, and are also reachable from "My setup".
103
+ // They are listed here as full rows rather than a pointer link because this hub is
104
+ // scoped by TOPIC (what runs the work) rather than by ownership — "where do I put my
105
+ // Claude plan" is the single most common reason to open it.
106
+ {
107
+ title: t('layout.modelProvidersHub.groups.personal'),
108
+ note: t('layout.modelProvidersHub.groups.personalNote'),
109
+ items: [
110
+ {
111
+ key: 'personal-subs',
112
+ icon: 'i-lucide-user',
113
+ label: t('layout.modelProvidersHub.items.personalSubs.label'),
114
+ description: t('layout.modelProvidersHub.items.personalSubs.description'),
115
+ status: subCount
116
+ ? t('layout.modelProvidersHub.status.connectedCount', { count: subCount }, subCount)
117
+ : undefined,
118
+ connected: subCount > 0,
119
+ onClick: () => go(() => ui.openVendorCredentials('personal')),
120
+ },
121
+ {
122
+ key: 'local-runners',
123
+ icon: 'i-lucide-server',
124
+ label: t('layout.modelProvidersHub.items.localRunners.label'),
125
+ description: t('layout.modelProvidersHub.items.localRunners.description'),
126
+ status: runnerCount
127
+ ? t(
128
+ 'layout.modelProvidersHub.status.connectedCount',
129
+ { count: runnerCount },
130
+ runnerCount,
131
+ )
132
+ : undefined,
133
+ connected: runnerCount > 0,
134
+ onClick: () => go(ui.openLocalModels),
135
+ },
136
+ ],
137
+ },
138
+ ]
139
+ })
140
+
141
+ // Connected rows first, then idle — a stable rank so each group reads "what's live" top-down.
142
+ function stateRank(item: ProviderItem): number {
143
+ return item.connected ? 0 : 1
144
+ }
145
+
146
+ const allItems = computed(() => groups.value.flatMap((g) => g.items))
147
+ const anyConnected = computed(() => allItems.value.some((i) => i.connected))
148
+
149
+ function matches(text: string, q: string): boolean {
150
+ return text.toLowerCase().includes(q)
151
+ }
152
+
153
+ const filteredGroups = computed<ProviderGroup[]>(() => {
154
+ const q = query.value.trim().toLowerCase()
155
+ return groups.value
156
+ .map((g) => ({
157
+ ...g,
158
+ items: (q ? g.items.filter((i) => matches(i.label, q) || matches(i.description, q)) : g.items)
159
+ .slice()
160
+ .sort((a, b) => stateRank(a) - stateRank(b)),
161
+ }))
162
+ .filter((g) => g.items.length)
163
+ })
164
+ </script>
165
+
166
+ <template>
167
+ <UModal
168
+ v-model:open="open"
169
+ :title="t('layout.modelProvidersHub.title')"
170
+ :ui="{ content: 'max-w-xl' }"
171
+ >
172
+ <template #body>
173
+ <div class="space-y-5" data-testid="model-providers-hub">
174
+ <p class="text-xs text-slate-400">
175
+ {{ t('layout.modelProvidersHub.intro') }}
176
+ </p>
177
+
178
+ <!-- Nothing connected at all is a hard stop, not a nudge: no provider means no agent
179
+ step can run. Say so plainly rather than showing an empty list of options. -->
180
+ <div
181
+ v-if="!anyConnected"
182
+ class="rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
183
+ >
184
+ <div class="mb-1.5 flex items-center gap-2 text-sm font-medium text-primary-200">
185
+ <UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0" />
186
+ <span>{{ t('layout.modelProvidersHub.getStarted.title') }}</span>
187
+ </div>
188
+ <p class="text-xs text-slate-300">
189
+ {{ t('layout.modelProvidersHub.getStarted.body') }}
190
+ </p>
191
+ </div>
192
+
193
+ <UInput
194
+ v-model="query"
195
+ icon="i-lucide-search"
196
+ size="sm"
197
+ :placeholder="t('layout.modelProvidersHub.searchPlaceholder')"
198
+ class="w-full"
199
+ />
200
+
201
+ <p v-if="!filteredGroups.length" class="px-1 py-6 text-center text-sm text-slate-500">
202
+ {{ t('layout.modelProvidersHub.noMatches', { query }) }}
203
+ </p>
204
+
205
+ <section v-for="group in filteredGroups" :key="group.title">
206
+ <h3 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
207
+ {{ group.title }}
208
+ </h3>
209
+ <div class="space-y-1.5">
210
+ <button
211
+ v-for="item in group.items"
212
+ :key="item.key"
213
+ type="button"
214
+ class="flex w-full items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-2.5 text-start transition hover:border-slate-700 hover:bg-slate-900"
215
+ :data-testid="`model-provider-${item.key}`"
216
+ @click="item.onClick()"
217
+ >
218
+ <UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
219
+ <div class="min-w-0 flex-1">
220
+ <div class="flex items-center gap-2">
221
+ <span class="truncate text-sm font-medium text-slate-100">{{ item.label }}</span>
222
+ <UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
223
+ {{ item.status || t('layout.modelProvidersHub.status.connected') }}
224
+ </UBadge>
225
+ <span v-else class="text-[11px] text-slate-500">{{
226
+ t('layout.modelProvidersHub.status.notConnected')
227
+ }}</span>
228
+ <UBadge
229
+ v-if="!anyConnected && item.recommended"
230
+ color="primary"
231
+ variant="subtle"
232
+ size="sm"
233
+ >
234
+ {{ t('layout.modelProvidersHub.status.recommended') }}
235
+ </UBadge>
236
+ </div>
237
+ <p class="truncate text-xs text-slate-400">{{ item.description }}</p>
238
+ </div>
239
+ <UIcon
240
+ name="i-lucide-chevron-right"
241
+ class="h-4 w-4 shrink-0 text-slate-500 rtl:-scale-x-100"
242
+ />
243
+ </button>
244
+ </div>
245
+ <p v-if="group.note" class="mt-1.5 px-1 text-[11px] text-slate-500">{{ group.note }}</p>
246
+ </section>
247
+ </div>
248
+ </template>
249
+ </UModal>
250
+ </template>
@@ -12,7 +12,6 @@ const board = useBoardStore()
12
12
  const pipelines = usePipelinesStore()
13
13
  const execution = useExecutionStore()
14
14
  const ui = useUiStore()
15
- const documents = useDocumentsStore()
16
15
  const fragments = useFragmentsStore()
17
16
  const agentRuns = useAgentRunsStore()
18
17
  const github = useGitHubStore()
@@ -30,13 +29,6 @@ onMounted(() => {
30
29
  github.ensureLoaded()
31
30
  })
32
31
 
33
- /** Open the document import/spawn flow, targeting this container's frame. */
34
- function spawnFromDocument() {
35
- if (!block.value) return
36
- const frameId = isFrame.value ? block.value.id : (board.serviceOf(block.value)?.id ?? null)
37
- ui.openDocumentImport(frameId)
38
- }
39
-
40
32
  const block = computed<Block | undefined>(() =>
41
33
  ui.selectedBlockId ? board.getBlock(ui.selectedBlockId) : undefined,
42
34
  )
@@ -451,17 +443,8 @@ const showOriginalDescription = ref(false)
451
443
  </UButton>
452
444
  <!-- Tracker entry points (import an issue, hunt bugs) live on the service frame's
453
445
  header, where they are already scoped to a frame — the inspector doesn't
454
- duplicate them. -->
455
- <UButton
456
- v-if="isContainer && documents.available && documents.anyConnected"
457
- color="neutral"
458
- variant="soft"
459
- size="xs"
460
- icon="i-lucide-wand-sparkles"
461
- @click="spawnFromDocument"
462
- >
463
- {{ t('panels.inspector.spawnFromDocument') }}
464
- </UButton>
446
+ duplicate them. Document spawn is board-level only (command bar, Integrations
447
+ hub, templates modal), since planning is target-blind. -->
465
448
  </div>
466
449
 
467
450
  <!-- service (frame): navigate the prescriptive spec tree (+ Gherkin scenarios when