@cat-factory/app 0.198.1 → 0.200.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 (46) hide show
  1. package/README.md +6 -1
  2. package/app/components/foundational/FoundationalContractSummary.vue +36 -0
  3. package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
  4. package/app/components/foundational/FoundationalServiceManager.vue +111 -0
  5. package/app/components/foundational/FoundationalServicePanel.vue +37 -0
  6. package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
  7. package/app/components/foundational/FoundationalServiceSources.vue +398 -0
  8. package/app/components/foundational/FoundationalSuppressions.vue +75 -0
  9. package/app/components/layout/AccountFoundationalSettings.vue +25 -0
  10. package/app/components/layout/BoardToolbar.vue +1 -1
  11. package/app/components/layout/CommandBar.vue +8 -2
  12. package/app/components/layout/SideBar.vue +24 -3
  13. package/app/components/settings/AccountSettingsPanel.vue +17 -1
  14. package/app/components/settings/WorkspaceMetadataSettings.vue +151 -0
  15. package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
  16. package/app/composables/api/foundationalServices.ts +131 -0
  17. package/app/composables/useApi.ts +2 -0
  18. package/app/composables/useNavContributions.ts +92 -1
  19. package/app/composables/usePipelineErrorToast.ts +4 -0
  20. package/app/docs/consumer-extensions.md +76 -10
  21. package/app/modular/external-tools.spec.ts +281 -0
  22. package/app/modular/external-tools.ts +265 -0
  23. package/app/modular/nav-contributions.spec.ts +38 -14
  24. package/app/modular/nav-contributions.ts +58 -1
  25. package/app/modular/registry.ts +2 -0
  26. package/app/modular/slots.ts +15 -0
  27. package/app/modular/workspace-metadata.spec.ts +160 -0
  28. package/app/modular/workspace-metadata.ts +173 -0
  29. package/app/pages/index.vue +4 -0
  30. package/app/stores/foundationalServices.spec.ts +121 -0
  31. package/app/stores/foundationalServices.ts +276 -0
  32. package/app/stores/ui/modals.ts +12 -0
  33. package/app/stores/workspaceSettings.ts +3 -0
  34. package/app/types/domain.ts +2 -0
  35. package/app/types/foundationalServices.ts +32 -0
  36. package/i18n/locales/de.json +162 -6
  37. package/i18n/locales/en.json +162 -6
  38. package/i18n/locales/es.json +162 -6
  39. package/i18n/locales/fr.json +162 -6
  40. package/i18n/locales/he.json +162 -6
  41. package/i18n/locales/it.json +162 -6
  42. package/i18n/locales/ja.json +162 -6
  43. package/i18n/locales/pl.json +162 -6
  44. package/i18n/locales/tr.json +162 -6
  45. package/i18n/locales/uk.json +162 -6
  46. package/package.json +2 -2
@@ -0,0 +1,398 @@
1
+ <script setup lang="ts">
2
+ // Repo sources of foundational-service definitions (backend/docs/adr/0031-foundational-services.md).
3
+ // Two link shapes, and the choice is the whole reason this form has a mode switch:
4
+ // - `directory` — every immediate subdirectory of the linked path is a service, identified by
5
+ // its `service.md`, with its contract files beside it. The "we keep our specs in a repo" case.
6
+ // - `files` — an explicit list of contract files, all describing the ONE service the link
7
+ // names. The "just point at my openapi.yaml" case, where there is no directory convention to
8
+ // adopt — which is why the link itself must supply the identity, and the form demands it.
9
+ // Mirrors the skill library's sources UI: with the GitHub App connected the user searches a repo
10
+ // and browses to a path; otherwise the manual owner/name fields are the fallback.
11
+ import { computed, reactive, ref, watch } from 'vue'
12
+ import type {
13
+ FoundationalServiceOwnerKind,
14
+ FoundationalServiceSourceMode,
15
+ GitHubAvailableRepo,
16
+ } from '~/types/domain'
17
+ import {
18
+ useFoundationalServices,
19
+ useFoundationalServicesStore,
20
+ } from '~/stores/foundationalServices'
21
+ import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
22
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
23
+
24
+ const props = defineProps<{ kind: FoundationalServiceOwnerKind; ownerId: string }>()
25
+
26
+ const catalog =
27
+ props.kind === 'workspace'
28
+ ? useFoundationalServicesStore()
29
+ : useFoundationalServices(props.kind, props.ownerId)
30
+ const github = useGitHubStore()
31
+ const toast = useToast()
32
+ const { t, d } = useI18n()
33
+ const { confirm } = useConfirm()
34
+
35
+ // The rich GitHub pickers reuse the active board's App installation; without it the form falls
36
+ // back to manual text entry.
37
+ const githubReady = computed(() => github.available === true && github.connected)
38
+
39
+ function notifyError(title: string, e: unknown) {
40
+ toast.add({
41
+ title,
42
+ description: e instanceof Error ? e.message : String(e),
43
+ icon: 'i-lucide-triangle-alert',
44
+ color: 'error',
45
+ })
46
+ }
47
+
48
+ // Per-row in-flight tracking so only the control that triggered an action spins.
49
+ const busyRows = reactive(new Set<string>())
50
+ const rowBusy = (key: string) => busyRows.has(key)
51
+ async function withRow(key: string, fn: () => Promise<void>) {
52
+ if (busyRows.has(key)) return
53
+ busyRows.add(key)
54
+ try {
55
+ await fn()
56
+ } finally {
57
+ busyRows.delete(key)
58
+ }
59
+ }
60
+
61
+ // ---- link a repo source ----------------------------------------------------
62
+ const mode = ref<FoundationalServiceSourceMode>('directory')
63
+ const repoId = ref<number | undefined>(undefined)
64
+ const repo = ref<GitHubAvailableRepo | undefined>(undefined)
65
+ const dirPath = ref<string | undefined>(undefined)
66
+ const filePaths = ref<string[]>([])
67
+ const gitRef = ref('')
68
+ const manual = reactive({ repoOwner: '', repoName: '', dirPath: '', filePaths: '' })
69
+ const named = reactive({ serviceId: '', serviceName: '', serviceSummary: '' })
70
+ const linking = ref(false)
71
+
72
+ // A new repo selection clears whatever was browsed against the previous one.
73
+ watch(repoId, () => {
74
+ dirPath.value = undefined
75
+ filePaths.value = []
76
+ })
77
+
78
+ const modeItems = computed(() => [
79
+ { value: 'directory' as const, label: t('foundational.sources.mode.directory') },
80
+ { value: 'files' as const, label: t('foundational.sources.mode.files') },
81
+ ])
82
+
83
+ const ownerName = computed<{ owner: string; name: string } | null>(() => {
84
+ if (githubReady.value) {
85
+ return repo.value ? { owner: repo.value.owner, name: repo.value.name } : null
86
+ }
87
+ const owner = manual.repoOwner.trim()
88
+ const name = manual.repoName.trim()
89
+ return owner && name ? { owner, name } : null
90
+ })
91
+
92
+ /** The linked files, from the browser cart or the manual newline/comma list. */
93
+ const linkedFiles = computed(() =>
94
+ githubReady.value
95
+ ? filePaths.value
96
+ : manual.filePaths
97
+ .split(/[\n,]/)
98
+ .map((p) => p.trim())
99
+ .filter(Boolean),
100
+ )
101
+
102
+ // A `files` source names the service its files describe — the backend refuses the link
103
+ // otherwise, so the button is disabled rather than letting the user discover it as a 422.
104
+ const valid = computed(() => {
105
+ if (!ownerName.value) return false
106
+ if (mode.value !== 'files') return true
107
+ return Boolean(named.serviceId.trim() && named.serviceName.trim() && linkedFiles.value.length)
108
+ })
109
+
110
+ function resetDraft() {
111
+ repoId.value = undefined
112
+ repo.value = undefined
113
+ dirPath.value = undefined
114
+ filePaths.value = []
115
+ gitRef.value = ''
116
+ Object.assign(manual, { repoOwner: '', repoName: '', dirPath: '', filePaths: '' })
117
+ Object.assign(named, { serviceId: '', serviceName: '', serviceSummary: '' })
118
+ }
119
+
120
+ function toggleFile(path: string) {
121
+ filePaths.value = filePaths.value.includes(path)
122
+ ? filePaths.value.filter((p) => p !== path)
123
+ : [...filePaths.value, path]
124
+ }
125
+
126
+ async function link() {
127
+ const target = ownerName.value
128
+ if (!target || !valid.value) return
129
+ linking.value = true
130
+ try {
131
+ const result = await catalog.linkSource({
132
+ repoOwner: target.owner,
133
+ repoName: target.name,
134
+ gitRef: gitRef.value.trim() || undefined,
135
+ mode: mode.value,
136
+ dirPath: (githubReady.value ? dirPath.value : manual.dirPath.trim()) || undefined,
137
+ ...(mode.value === 'files'
138
+ ? {
139
+ filePaths: linkedFiles.value,
140
+ serviceId: named.serviceId.trim(),
141
+ serviceName: named.serviceName.trim(),
142
+ serviceSummary: named.serviceSummary.trim() || undefined,
143
+ }
144
+ : {}),
145
+ })
146
+ resetDraft()
147
+ toast.add({ title: t('foundational.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
148
+ return result
149
+ } catch (e) {
150
+ notifyError(t('foundational.toast.linkSourceFailed'), e)
151
+ } finally {
152
+ linking.value = false
153
+ }
154
+ }
155
+
156
+ async function sync(id: string) {
157
+ await withRow(`sync:${id}`, async () => {
158
+ try {
159
+ const result = await catalog.syncSource(id)
160
+ toast.add({
161
+ title: t('foundational.toast.synced', {
162
+ updated: result.upserted,
163
+ removed: result.tombstoned,
164
+ }),
165
+ icon: 'i-lucide-refresh-cw',
166
+ color: 'info',
167
+ })
168
+ } catch (e) {
169
+ notifyError(t('foundational.toast.syncFailed'), e)
170
+ }
171
+ })
172
+ }
173
+
174
+ async function check(id: string) {
175
+ await withRow(`check:${id}`, async () => {
176
+ try {
177
+ const status = await catalog.checkSource(id)
178
+ toast.add({
179
+ title: status.changed
180
+ ? t('foundational.toast.changesAvailable')
181
+ : t('foundational.toast.upToDate'),
182
+ icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
183
+ })
184
+ } catch (e) {
185
+ notifyError(t('foundational.toast.checkSourceFailed'), e)
186
+ }
187
+ })
188
+ }
189
+
190
+ async function unlink(id: string) {
191
+ const source = catalog.sources.find((s) => s.id === id)
192
+ const ok = await confirm({
193
+ title: t('foundational.confirmUnlinkSource.title'),
194
+ description: t('foundational.confirmUnlinkSource.body', {
195
+ repo: source ? `${source.repoOwner}/${source.repoName}` : '',
196
+ }),
197
+ variant: 'destructive',
198
+ confirmLabel: t('foundational.confirmUnlinkSource.confirm'),
199
+ icon: 'i-lucide-unplug',
200
+ })
201
+ if (!ok) return
202
+ await withRow(`unlink:${id}`, async () => {
203
+ try {
204
+ await catalog.unlinkSource(id)
205
+ toast.add({ title: t('foundational.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
206
+ } catch (e) {
207
+ notifyError(t('foundational.toast.unlinkSourceFailed'), e)
208
+ }
209
+ })
210
+ }
211
+ </script>
212
+
213
+ <template>
214
+ <div class="flex flex-col gap-3" data-testid="foundational-sources">
215
+ <div
216
+ v-for="s in catalog.sources"
217
+ :key="s.id"
218
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
219
+ >
220
+ <UIcon name="i-lucide-git-branch" class="h-4 w-4 shrink-0 text-slate-400" />
221
+ <div class="min-w-0">
222
+ <span class="font-mono text-sm text-slate-100">
223
+ {{ s.repoOwner }}/{{ s.repoName }}<span class="text-slate-500">/{{ s.dirPath }}</span>
224
+ </span>
225
+ <p class="text-xs text-slate-500">
226
+ {{
227
+ s.mode === 'files'
228
+ ? t('foundational.sources.metaFiles', {
229
+ service: s.serviceName ?? s.serviceId ?? '',
230
+ count: s.filePaths.length,
231
+ })
232
+ : t('foundational.sources.metaDirectory')
233
+ }}
234
+ </p>
235
+ <p class="text-xs text-slate-500">
236
+ {{
237
+ s.lastSyncedAt
238
+ ? t('foundational.sources.metaSynced', {
239
+ ref: s.gitRef,
240
+ date: d(new Date(s.lastSyncedAt), 'short'),
241
+ })
242
+ : t('foundational.sources.metaNever', { ref: s.gitRef })
243
+ }}
244
+ </p>
245
+ </div>
246
+ <UBadge
247
+ v-if="catalog.sourceChanges[s.id]"
248
+ size="xs"
249
+ color="warning"
250
+ variant="subtle"
251
+ class="ms-auto"
252
+ >
253
+ {{ t('foundational.sources.changes') }}
254
+ </UBadge>
255
+ <div class="ms-auto flex gap-1">
256
+ <UButton
257
+ icon="i-lucide-search-check"
258
+ size="xs"
259
+ variant="ghost"
260
+ :loading="rowBusy(`check:${s.id}`)"
261
+ :title="t('foundational.sources.check')"
262
+ @click="check(s.id)"
263
+ />
264
+ <UButton
265
+ icon="i-lucide-refresh-cw"
266
+ size="xs"
267
+ variant="ghost"
268
+ :loading="rowBusy(`sync:${s.id}`)"
269
+ :title="t('foundational.sources.sync')"
270
+ @click="sync(s.id)"
271
+ />
272
+ <UButton
273
+ icon="i-lucide-unplug"
274
+ size="xs"
275
+ color="error"
276
+ variant="ghost"
277
+ :loading="rowBusy(`unlink:${s.id}`)"
278
+ :title="t('foundational.sources.unlink')"
279
+ @click="unlink(s.id)"
280
+ />
281
+ </div>
282
+ </div>
283
+ <p v-if="!catalog.sources.length" class="text-sm text-slate-500">
284
+ {{ t('foundational.sources.empty') }}
285
+ </p>
286
+
287
+ <!-- Linking needs the GitHub integration; say so rather than offering a form that 503s. -->
288
+ <div
289
+ v-if="!catalog.sourcesAvailable"
290
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-500"
291
+ >
292
+ {{ t('foundational.sources.githubRequired') }}
293
+ </div>
294
+ <div v-else class="rounded-md border border-slate-800 p-3">
295
+ <p class="mb-2 text-sm font-medium">{{ t('foundational.sources.linkTitle') }}</p>
296
+ <div class="flex flex-col gap-2">
297
+ <URadioGroup v-model="mode" :items="modeItems" orientation="horizontal" size="sm" />
298
+ <p class="text-xs text-slate-500">
299
+ {{
300
+ mode === 'files'
301
+ ? t('foundational.sources.mode.filesHint')
302
+ : t('foundational.sources.mode.directoryHint')
303
+ }}
304
+ </p>
305
+
306
+ <!-- Connected: search a repo, then browse to the folder / pick the contract files -->
307
+ <template v-if="githubReady">
308
+ <GitHubRepoSearchSelect v-model="repoId" @update:repo="repo = $event" />
309
+ <div
310
+ v-if="repoId !== undefined"
311
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
312
+ >
313
+ <RepoTreeBrowser
314
+ v-if="mode === 'files'"
315
+ :repo-github-id="repoId"
316
+ mode="file"
317
+ multiple
318
+ :selected-paths="filePaths"
319
+ @toggle="toggleFile"
320
+ />
321
+ <RepoTreeBrowser v-else v-model="dirPath" :repo-github-id="repoId" mode="dir" />
322
+ <p class="mt-2 truncate text-xs text-slate-400">
323
+ <template v-if="mode === 'files'">
324
+ {{ t('foundational.sources.selectedFiles', { count: filePaths.length }) }}
325
+ </template>
326
+ <template v-else-if="dirPath">
327
+ {{ t('foundational.sources.selectedDir') }}
328
+ <code class="text-slate-200">{{ dirPath }}</code>
329
+ </template>
330
+ <template v-else>{{ t('foundational.sources.wholeRepo') }}</template>
331
+ </p>
332
+ </div>
333
+ </template>
334
+
335
+ <!-- Not connected to the App: manual owner/name + path fallback -->
336
+ <template v-else>
337
+ <div class="flex gap-2">
338
+ <UInput
339
+ v-model="manual.repoOwner"
340
+ :placeholder="t('foundational.sources.ownerPlaceholder')"
341
+ class="flex-1"
342
+ />
343
+ <UInput
344
+ v-model="manual.repoName"
345
+ :placeholder="t('foundational.sources.repoPlaceholder')"
346
+ class="flex-1"
347
+ />
348
+ </div>
349
+ <UTextarea
350
+ v-if="mode === 'files'"
351
+ v-model="manual.filePaths"
352
+ :rows="3"
353
+ :placeholder="t('foundational.sources.filePathsPlaceholder')"
354
+ />
355
+ <UInput
356
+ v-else
357
+ v-model="manual.dirPath"
358
+ :placeholder="t('foundational.sources.dirPlaceholder')"
359
+ />
360
+ </template>
361
+
362
+ <!-- `files` mode carries no directory convention to read identity from, so the link
363
+ supplies it. -->
364
+ <template v-if="mode === 'files'">
365
+ <div class="flex gap-2">
366
+ <UInput
367
+ v-model="named.serviceId"
368
+ :placeholder="t('foundational.sources.serviceIdPlaceholder')"
369
+ class="flex-1"
370
+ />
371
+ <UInput
372
+ v-model="named.serviceName"
373
+ :placeholder="t('foundational.sources.serviceNamePlaceholder')"
374
+ class="flex-1"
375
+ />
376
+ </div>
377
+ <UInput
378
+ v-model="named.serviceSummary"
379
+ :placeholder="t('foundational.sources.serviceSummaryPlaceholder')"
380
+ />
381
+ </template>
382
+
383
+ <UInput v-model="gitRef" :placeholder="t('foundational.sources.refPlaceholder')" />
384
+ <UButton
385
+ icon="i-lucide-link"
386
+ size="sm"
387
+ :disabled="!valid"
388
+ :loading="linking"
389
+ class="self-start"
390
+ data-testid="foundational-link-source"
391
+ @click="link"
392
+ >
393
+ {{ t('foundational.sources.link') }}
394
+ </UButton>
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ // What this board is opted OUT of (backend/docs/adr/0031-foundational-services.md). A suppressed id is
3
+ // by construction absent from the merged catalog, so without this list the suppress action would
4
+ // be a one-way door — which is why it is a section of its own rather than a badge on the catalog.
5
+ //
6
+ // `inherited: false` is rendered distinctly rather than hidden: the tombstone shadows nothing
7
+ // today (the account withdrew the service, or this row is what remains of the board deleting its
8
+ // own registration), and an operator reading it as "a capability is being withheld" would go
9
+ // looking for something that is not there.
10
+ import { reactive } from 'vue'
11
+ import { useFoundationalServicesStore } from '~/stores/foundationalServices'
12
+
13
+ const catalog = useFoundationalServicesStore()
14
+ const toast = useToast()
15
+ const { t } = useI18n()
16
+
17
+ const busyRows = reactive(new Set<string>())
18
+ const rowBusy = (id: string) => busyRows.has(id)
19
+
20
+ async function restore(serviceId: string) {
21
+ if (busyRows.has(serviceId)) return
22
+ busyRows.add(serviceId)
23
+ try {
24
+ await catalog.restore(serviceId)
25
+ toast.add({ title: t('foundational.toast.restored'), icon: 'i-lucide-eye' })
26
+ } catch (e) {
27
+ toast.add({
28
+ title: t('foundational.toast.restoreFailed'),
29
+ description: e instanceof Error ? e.message : String(e),
30
+ icon: 'i-lucide-triangle-alert',
31
+ color: 'error',
32
+ })
33
+ } finally {
34
+ busyRows.delete(serviceId)
35
+ }
36
+ }
37
+ </script>
38
+
39
+ <template>
40
+ <div
41
+ v-if="catalog.suppressions.length"
42
+ class="flex flex-col gap-2"
43
+ data-testid="foundational-suppressions"
44
+ >
45
+ <p class="text-sm font-medium">{{ t('foundational.suppressions.title') }}</p>
46
+ <p class="text-xs text-slate-500">{{ t('foundational.suppressions.intro') }}</p>
47
+ <div
48
+ v-for="s in catalog.suppressions"
49
+ :key="s.id"
50
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 p-3"
51
+ >
52
+ <UIcon name="i-lucide-eye-off" class="h-4 w-4 shrink-0 text-slate-500" />
53
+ <div class="min-w-0 flex-1">
54
+ <p class="truncate text-sm text-slate-300">
55
+ {{ s.name || s.id }}
56
+ <code v-if="s.name" class="ms-1 text-[11px] text-slate-500">{{ s.id }}</code>
57
+ </p>
58
+ <p v-if="s.summary" class="text-xs text-slate-500">{{ s.summary }}</p>
59
+ <p v-if="!s.inherited" class="text-[11px] text-slate-500">
60
+ {{ t('foundational.suppressions.shadowsNothing') }}
61
+ </p>
62
+ </div>
63
+ <UButton
64
+ icon="i-lucide-eye"
65
+ size="xs"
66
+ variant="ghost"
67
+ :loading="rowBusy(s.id)"
68
+ :data-testid="`foundational-restore-${s.id}`"
69
+ @click="restore(s.id)"
70
+ >
71
+ {{ t('foundational.suppressions.restore') }}
72
+ </UButton>
73
+ </div>
74
+ </div>
75
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ // Account-tier foundational services (backend/docs/adr/0031-foundational-services.md): the shared
3
+ // capabilities the whole organisation runs — file storage, notifications, audit — which every
4
+ // board in the account inherits and can override or opt out of. A body-only section rendered in
5
+ // the "Foundational services" tab of AccountSettingsPanel; available for ALL account types.
6
+ import FoundationalServiceManager from '~/components/foundational/FoundationalServiceManager.vue'
7
+
8
+ const props = defineProps<{ accountId: string }>()
9
+ const { t } = useI18n()
10
+ </script>
11
+
12
+ <template>
13
+ <div class="space-y-6 text-sm">
14
+ <section>
15
+ <p class="mb-3 text-[11px] text-slate-400">
16
+ {{ t('layout.accountFoundational.intro') }}
17
+ </p>
18
+ <FoundationalServiceManager
19
+ kind="account"
20
+ :owner-id="props.accountId"
21
+ :show-catalog="false"
22
+ />
23
+ </section>
24
+ </div>
25
+ </template>
@@ -242,7 +242,7 @@ const decisionItems = computed(() =>
242
242
  <IconButton
243
243
  v-for="item in toolbarItems"
244
244
  :key="item.id"
245
- :label="t(item.labelKey)"
245
+ :label="item.label ?? t(item.labelKey)"
246
246
  :icon="item.icon"
247
247
  color="neutral"
248
248
  variant="ghost"
@@ -139,10 +139,14 @@ const commands = computed<Command[]>(() => {
139
139
  const asCommand = (g: (typeof commandGroups.value)[number]): Command[] =>
140
140
  g.items.map((ci) => ({
141
141
  id: ci.item.id,
142
- label: t(ci.labelKey),
142
+ // A contribution whose copy is deployment DATA (a registered external tool's title)
143
+ // carries a literal `label`; catalog destinations resolve their key. Running a tool's
144
+ // own name through `t()` would show the raw name plus a missing-key warning.
145
+ label: ci.item.label ?? t(ci.labelKey),
143
146
  group: t(g.labelKey),
144
147
  icon: ci.item.icon,
145
- keywords: ci.keywordsKey ? t(ci.keywordsKey) : undefined,
148
+ // The description doubles as fuzzy-match keywords for a tool, which has no keyword key.
149
+ keywords: ci.keywordsKey ? t(ci.keywordsKey) : ci.item.description,
146
150
  run: () => invoke(ci.item),
147
151
  }))
148
152
  const groupOrEmpty = (name: (typeof commandGroups.value)[number]['group']) => {
@@ -154,6 +158,8 @@ const commands = computed<Command[]>(() => {
154
158
  ...groupOrEmpty('repositories'),
155
159
  ...groupOrEmpty('integrations'),
156
160
  ...dynamicIntegrationCommands.value,
161
+ // The deployment's own applications (the `externalTools` slot, projected onto nav items).
162
+ ...groupOrEmpty('externalTools'),
157
163
  ...groupOrEmpty('workspace'),
158
164
  ...groupOrEmpty('account'),
159
165
  ]
@@ -17,6 +17,7 @@ import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
17
17
  import UiModeSwitcher from '~/components/layout/UiModeSwitcher.vue'
18
18
  import UserMenu from '~/components/auth/UserMenu.vue'
19
19
  import { useViewport } from '~/composables/useViewport'
20
+ import type { NavContribution } from '~/modular/nav-contributions'
20
21
 
21
22
  const { t } = useI18n()
22
23
 
@@ -37,6 +38,26 @@ const ui = useUiStore()
37
38
  // or connection flips, so this shell no longer hand-rolls per-item `show*` computeds.
38
39
  const { sidebarGroups, invoke } = useNavContributions()
39
40
 
41
+ /**
42
+ * A destination's visible label: catalog copy for a first-party item, the LITERAL `label` for
43
+ * one whose copy is deployment data (a registered external tool's title). A tool's name is not
44
+ * a catalog key — the deployment ships whatever locales it needs in its own catalog — so
45
+ * running it through `t()` would render the raw name back with a missing-key warning.
46
+ */
47
+ function navLabel(item: NavContribution): string {
48
+ return item.label ?? t(item.labelKey)
49
+ }
50
+
51
+ /**
52
+ * The hover tooltip. In the rail it names the destination (the label is hidden); expanded it
53
+ * carries the item's `description` when it has one, which is how an external tool explains
54
+ * what it is without a second line in the sidebar.
55
+ */
56
+ function navTitle(item: NavContribution, railed: boolean): string | undefined {
57
+ if (railed) return item.description ? `${navLabel(item)}: ${item.description}` : navLabel(item)
58
+ return item.description
59
+ }
60
+
40
61
  // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
41
62
  // above it the aside is static and the drawer flag is inert.
42
63
  const { isCompact } = useViewport()
@@ -235,12 +256,12 @@ watch(
235
256
  :square="railed"
236
257
  class="w-full"
237
258
  :class="railed ? 'justify-center' : 'justify-start'"
238
- :aria-label="railed ? t(item.labelKey) : undefined"
239
- :title="railed ? t(item.labelKey) : undefined"
259
+ :aria-label="railed ? navLabel(item) : undefined"
260
+ :title="navTitle(item, railed)"
240
261
  :data-testid="item.testId"
241
262
  @click="invoke(item)"
242
263
  >
243
- <span v-if="!railed">{{ t(item.labelKey) }}</span>
264
+ <span v-if="!railed">{{ navLabel(item) }}</span>
244
265
  </UButton>
245
266
  </div>
246
267
  </section>
@@ -2,12 +2,14 @@
2
2
  // Account settings — a single tabbed modal for the per-account configuration, distinct
3
3
  // from Workspace settings. Hosts the team panel (members + roles, invitations, email
4
4
  // sender, account-wide API keys; org-scoped, with a create-org CTA on a personal account)
5
- // and the account-tier prompt-fragment library (available for every account type).
5
+ // the account-tier prompt-fragment library, the repo-sourced skills, and the foundational-service
6
+ // catalog (all available for every account type).
6
7
  // Opened from the SideBar Configuration section, the account switcher and the command
7
8
  // bar; bound to the `ui` store so any surface can open it and deep-link to a tab.
8
9
  import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
9
10
  import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
10
11
  import AccountSkillSettings from '~/components/layout/AccountSkillSettings.vue'
12
+ import AccountFoundationalSettings from '~/components/layout/AccountFoundationalSettings.vue'
11
13
 
12
14
  const { t } = useI18n()
13
15
  const ui = useUiStore()
@@ -39,6 +41,12 @@ const tabs = computed(() => [
39
41
  icon: 'i-lucide-book-open-check',
40
42
  slot: 'skills',
41
43
  },
44
+ {
45
+ value: 'foundational',
46
+ label: t('settings.account.tabs.foundational'),
47
+ icon: 'i-lucide-boxes',
48
+ slot: 'foundational',
49
+ },
42
50
  ])
43
51
  </script>
44
52
 
@@ -61,6 +69,14 @@ const tabs = computed(() => [
61
69
  <template #fragments>
62
70
  <AccountFragmentSettings :account-id="accounts.activeAccountId" />
63
71
  </template>
72
+ <template #foundational>
73
+ <!-- Key on the account for the same reason the skills tab is: a mid-modal account
74
+ switch must remount against a fresh owner-keyed store, not the stale initial one. -->
75
+ <AccountFoundationalSettings
76
+ :key="accounts.activeAccountId ?? undefined"
77
+ :account-id="accounts.activeAccountId"
78
+ />
79
+ </template>
64
80
  <template #skills>
65
81
  <!-- Key on the account so a mid-modal account switch remounts against a fresh
66
82
  account-keyed skill-library store rather than the stale initial one. -->