@cat-factory/app 0.46.11 → 0.47.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 (42) hide show
  1. package/app/components/auth/AuthGate.vue +3 -2
  2. package/app/components/auth/LoginScreen.vue +172 -32
  3. package/app/components/auth/ResetPasswordScreen.vue +16 -13
  4. package/app/components/auth/UserMenu.vue +3 -2
  5. package/app/components/layout/AccountDeploymentSettings.vue +307 -37
  6. package/app/components/layout/AccountFragmentSettings.vue +2 -3
  7. package/app/components/layout/AccountTeamSettings.vue +75 -45
  8. package/app/components/layout/AiProvidersBanner.vue +11 -11
  9. package/app/components/layout/BoardSwitcher.vue +63 -29
  10. package/app/components/layout/CommandBar.vue +68 -54
  11. package/app/components/layout/GitHubPatBanner.vue +13 -6
  12. package/app/components/layout/IntegrationBackTitle.vue +4 -1
  13. package/app/components/layout/IntegrationsHub.vue +67 -49
  14. package/app/components/layout/NotificationsInbox.vue +48 -19
  15. package/app/components/layout/PersonalSetupModal.vue +26 -16
  16. package/app/components/layout/ProviderConfigBanner.vue +14 -9
  17. package/app/components/layout/SideBar.vue +20 -18
  18. package/app/components/layout/SpendWarningBanner.vue +16 -16
  19. package/app/components/providers/ApiKeysSection.vue +27 -3
  20. package/app/components/providers/PersonalSubscriptionSection.vue +18 -4
  21. package/app/components/providers/SignInRequiredNotice.vue +19 -0
  22. package/app/components/settings/AccountSettingsPanel.vue +9 -6
  23. package/app/components/settings/IssueTrackerPanel.vue +114 -53
  24. package/app/components/settings/LocalModeSettingsPanel.vue +60 -28
  25. package/app/components/settings/LocalModelEndpointsPanel.vue +72 -27
  26. package/app/components/settings/MergeThresholdsPanel.vue +93 -45
  27. package/app/components/settings/ModelConfigurationPanel.vue +62 -36
  28. package/app/components/settings/ObservabilityConnectionPanel.vue +65 -35
  29. package/app/components/settings/OpenRouterCatalogPanel.vue +70 -40
  30. package/app/components/settings/ProviderConnectionPanel.vue +115 -61
  31. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +9 -12
  32. package/app/components/settings/UserSecretsSection.vue +37 -18
  33. package/app/components/settings/WorkspaceSettingsPanel.vue +114 -62
  34. package/app/composables/api/auth.ts +7 -0
  35. package/app/stores/auth.ts +25 -1
  36. package/app/types/accountSettings.ts +4 -0
  37. package/i18n/locales/en.json +908 -0
  38. package/i18n/locales/es.json +899 -0
  39. package/i18n/locales/fr.json +899 -0
  40. package/i18n/locales/pl.json +899 -0
  41. package/i18n/locales/uk.json +899 -0
  42. package/package.json +2 -2
@@ -16,6 +16,7 @@ interface Command {
16
16
  run: () => void | Promise<void>
17
17
  }
18
18
 
19
+ const { t } = useI18n()
19
20
  const ui = useUiStore()
20
21
  const github = useGitHubStore()
21
22
  const slack = useSlackStore()
@@ -34,13 +35,19 @@ const activeIndex = ref(0)
34
35
  const commands = computed<Command[]>(() => {
35
36
  const list: Command[] = []
36
37
 
38
+ const groupCreate = t('layout.commandBar.groups.create')
39
+ const groupRepositories = t('layout.commandBar.groups.repositories')
40
+ const groupIntegrations = t('layout.commandBar.groups.integrations')
41
+ const groupWorkspace = t('layout.commandBar.groups.workspace')
42
+ const groupAccount = t('layout.commandBar.groups.account')
43
+
37
44
  // ---- Create -------------------------------------------------------------
38
45
  list.push({
39
46
  id: 'new-pipeline',
40
- label: 'Build a pipeline',
41
- group: 'Create',
47
+ label: t('layout.commandBar.cmd.newPipeline'),
48
+ group: groupCreate,
42
49
  icon: 'i-lucide-workflow',
43
- keywords: 'pipeline agents chain',
50
+ keywords: t('layout.commandBar.keywords.newPipeline'),
44
51
  run: () => ui.openBuilder(),
45
52
  })
46
53
 
@@ -48,19 +55,19 @@ const commands = computed<Command[]>(() => {
48
55
  if (github.available) {
49
56
  list.push({
50
57
  id: 'add-from-repo',
51
- label: 'Add service from existing repo',
52
- group: 'Repositories',
58
+ label: t('layout.commandBar.cmd.addFromRepo'),
59
+ group: groupRepositories,
53
60
  icon: 'i-lucide-folder-git-2',
54
- keywords: 'github import existing',
61
+ keywords: t('layout.commandBar.keywords.addFromRepo'),
55
62
  run: () => ui.openAddService(),
56
63
  })
57
64
  }
58
65
  list.push({
59
66
  id: 'bootstrap-repo',
60
- label: 'Bootstrap a new repo',
61
- group: 'Repositories',
67
+ label: t('layout.commandBar.cmd.bootstrapRepo'),
68
+ group: groupRepositories,
62
69
  icon: 'i-lucide-git-branch-plus',
63
- keywords: 'scaffold create reference architecture',
70
+ keywords: t('layout.commandBar.keywords.bootstrapRepo'),
64
71
  run: () => ui.openBootstrap(),
65
72
  })
66
73
 
@@ -68,20 +75,24 @@ const commands = computed<Command[]>(() => {
68
75
  if (github.available) {
69
76
  list.push({
70
77
  id: 'github',
71
- label: github.connected ? 'Manage GitHub connection' : 'Connect GitHub',
72
- group: 'Integrations',
78
+ label: github.connected
79
+ ? t('layout.commandBar.cmd.githubManage')
80
+ : t('layout.commandBar.cmd.githubConnect'),
81
+ group: groupIntegrations,
73
82
  icon: 'i-lucide-github',
74
- keywords: 'git repos pull requests issues',
83
+ keywords: t('layout.commandBar.keywords.github'),
75
84
  run: () => ui.openGitHub(),
76
85
  })
77
86
  }
78
87
  if (slack.available) {
79
88
  list.push({
80
89
  id: 'slack',
81
- label: slack.connected ? 'Manage Slack notifications' : 'Connect Slack',
82
- group: 'Integrations',
90
+ label: slack.connected
91
+ ? t('layout.commandBar.cmd.slackManage')
92
+ : t('layout.commandBar.cmd.slackConnect'),
93
+ group: groupIntegrations,
83
94
  icon: 'i-lucide-slack',
84
- keywords: 'slack notifications channel mentions',
95
+ keywords: t('layout.commandBar.keywords.slack'),
85
96
  run: () => ui.openSlack(),
86
97
  })
87
98
  }
@@ -89,20 +100,22 @@ const commands = computed<Command[]>(() => {
89
100
  for (const src of documents.sources) {
90
101
  list.push({
91
102
  id: `doc-connect-${src.source}`,
92
- label: documents.isConnected(src.source) ? `Manage ${src.label}` : `Connect ${src.label}`,
93
- group: 'Integrations',
103
+ label: documents.isConnected(src.source)
104
+ ? t('layout.commandBar.cmd.sourceManage', { source: src.label })
105
+ : t('layout.commandBar.cmd.sourceConnect', { source: src.label }),
106
+ group: groupIntegrations,
94
107
  icon: src.icon,
95
- keywords: 'document source prd rfc',
108
+ keywords: t('layout.commandBar.keywords.documentSource'),
96
109
  run: () => ui.openDocumentConnect(src.source),
97
110
  })
98
111
  }
99
112
  if (documents.anyConnected) {
100
113
  list.push({
101
114
  id: 'doc-import',
102
- label: 'Import & spawn from documents',
103
- group: 'Integrations',
115
+ label: t('layout.commandBar.cmd.documentImport'),
116
+ group: groupIntegrations,
104
117
  icon: 'i-lucide-file-down',
105
- keywords: 'document import spawn',
118
+ keywords: t('layout.commandBar.keywords.documentImport'),
106
119
  run: () => ui.openDocumentImport(null),
107
120
  })
108
121
  }
@@ -111,20 +124,22 @@ const commands = computed<Command[]>(() => {
111
124
  for (const src of tasks.sources) {
112
125
  list.push({
113
126
  id: `task-connect-${src.source}`,
114
- label: src.available ? `Manage ${src.label}` : `Connect ${src.label}`,
115
- group: 'Integrations',
127
+ label: src.available
128
+ ? t('layout.commandBar.cmd.sourceManage', { source: src.label })
129
+ : t('layout.commandBar.cmd.sourceConnect', { source: src.label }),
130
+ group: groupIntegrations,
116
131
  icon: src.icon,
117
- keywords: 'task source tracker issues',
132
+ keywords: t('layout.commandBar.keywords.taskSource'),
118
133
  run: () => ui.openTaskConnect(src.source),
119
134
  })
120
135
  }
121
136
  if (tasks.anyOffered) {
122
137
  list.push({
123
138
  id: 'task-import',
124
- label: 'Import issues',
125
- group: 'Integrations',
139
+ label: t('layout.commandBar.cmd.taskImport'),
140
+ group: groupIntegrations,
126
141
  icon: 'i-lucide-file-down',
127
- keywords: 'task import issues',
142
+ keywords: t('layout.commandBar.keywords.taskImport'),
128
143
  run: () => ui.openTaskImport(null),
129
144
  })
130
145
  }
@@ -134,68 +149,67 @@ const commands = computed<Command[]>(() => {
134
149
  if (library.available) {
135
150
  list.push({
136
151
  id: 'fragments',
137
- label: 'Context fragment library',
138
- group: 'Workspace',
152
+ label: t('layout.commandBar.cmd.fragments'),
153
+ group: groupWorkspace,
139
154
  icon: 'i-lucide-book-marked',
140
- keywords: 'prompt fragments best practice guidelines context',
155
+ keywords: t('layout.commandBar.keywords.fragments'),
141
156
  run: () => ui.openFragmentLibrary(),
142
157
  })
143
158
  }
144
159
  list.push({
145
160
  id: 'merge-thresholds',
146
- label: 'Merge thresholds',
147
- group: 'Workspace',
161
+ label: t('layout.commandBar.cmd.mergeThresholds'),
162
+ group: groupWorkspace,
148
163
  icon: 'i-lucide-git-merge',
149
- keywords: 'merge policy preset auto-merge ci',
164
+ keywords: t('layout.commandBar.keywords.mergeThresholds'),
150
165
  run: () => ui.openWorkspaceSettings('merge'),
151
166
  })
152
167
  list.push({
153
168
  id: 'workspace-settings',
154
- label: 'Workspace settings',
155
- group: 'Workspace',
169
+ label: t('layout.commandBar.cmd.workspaceSettings'),
170
+ group: groupWorkspace,
156
171
  icon: 'i-lucide-sliders-horizontal',
157
- keywords: 'limit running tasks per service waiting escalation overdue notification timeout',
172
+ keywords: t('layout.commandBar.keywords.workspaceSettings'),
158
173
  run: () => ui.openWorkspaceSettings(),
159
174
  })
160
175
  list.push({
161
176
  id: 'model-configuration',
162
- label: 'Model Configuration',
163
- group: 'Workspace',
177
+ label: t('layout.commandBar.cmd.modelConfiguration'),
178
+ group: groupWorkspace,
164
179
  icon: 'i-lucide-cpu',
165
- keywords: 'model llm routing agent kind default preset configuration',
180
+ keywords: t('layout.commandBar.keywords.modelConfiguration'),
166
181
  run: () => ui.openModelConfig(),
167
182
  })
168
183
  list.push({
169
184
  id: 'service-fragment-defaults',
170
- label: 'Default service best practices',
171
- group: 'Workspace',
185
+ label: t('layout.commandBar.cmd.serviceFragmentDefaults'),
186
+ group: groupWorkspace,
172
187
  icon: 'i-lucide-book-open-check',
173
- keywords: 'fragment best practice guideline service default code-aware',
188
+ keywords: t('layout.commandBar.keywords.serviceFragmentDefaults'),
174
189
  run: () => ui.openWorkspaceSettings('fragments'),
175
190
  })
176
191
  list.push({
177
192
  id: 'account-settings',
178
- label: 'Account settings',
179
- group: 'Account',
193
+ label: t('layout.commandBar.cmd.accountSettings'),
194
+ group: groupAccount,
180
195
  icon: 'i-lucide-settings',
181
- keywords:
182
- 'account team members roles invitations email api keys fragment best practice library context organization personal',
196
+ keywords: t('layout.commandBar.keywords.accountSettings'),
183
197
  run: () => ui.openAccountSettings(),
184
198
  })
185
199
  list.push({
186
200
  id: 'local-models',
187
- label: 'My local runners',
188
- group: 'Workspace',
201
+ label: t('layout.commandBar.cmd.localModels'),
202
+ group: groupWorkspace,
189
203
  icon: 'i-lucide-server',
190
- keywords: 'local model runner ollama lm studio llamacpp vllm endpoint',
204
+ keywords: t('layout.commandBar.keywords.localModels'),
191
205
  run: () => ui.openLocalModels(),
192
206
  })
193
207
  list.push({
194
208
  id: 'sandbox',
195
- label: 'Open Sandbox',
196
- group: 'Workspace',
209
+ label: t('layout.commandBar.cmd.sandbox'),
210
+ group: groupWorkspace,
197
211
  icon: 'i-lucide-flask-conical',
198
- keywords: 'sandbox prompt model test experiment judge fixture benchmark evaluate',
212
+ keywords: t('layout.commandBar.keywords.sandbox'),
199
213
  run: () => ui.openSandbox(),
200
214
  })
201
215
 
@@ -285,7 +299,7 @@ function indexOf(cmd: Command) {
285
299
  ref="inputRef"
286
300
  v-model="query"
287
301
  variant="none"
288
- placeholder="Search or run a command…"
302
+ :placeholder="t('layout.commandBar.searchPlaceholder')"
289
303
  class="w-full"
290
304
  :ui="{ base: 'py-3 text-sm' }"
291
305
  />
@@ -294,7 +308,7 @@ function indexOf(cmd: Command) {
294
308
 
295
309
  <div class="max-h-80 overflow-y-auto p-1.5">
296
310
  <p v-if="filtered.length === 0" class="px-3 py-6 text-center text-sm text-slate-500">
297
- No matching commands.
311
+ {{ t('layout.commandBar.noMatches') }}
298
312
  </p>
299
313
 
300
314
  <div v-for="group in groups" :key="group.name" class="mb-1">
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
3
 
4
+ const { t } = useI18n()
5
+
4
6
  // Local-mode setup prompt: when the local facade boots without a GitHub PAT, every
5
7
  // repo-operating agent step (clone, push, open PR, CI gate, merge) will fail. The server
6
8
  // also logs this, but a dev terminal is easy to miss — so surface it in the UI with the
@@ -23,19 +25,20 @@ const show = computed(() => !!setupUrl.value && !dismissed.value)
23
25
  <UIcon name="i-lucide-key-round" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
24
26
  <div class="min-w-0 flex-1">
25
27
  <div class="flex items-start justify-between gap-3">
26
- <h2 class="text-lg font-semibold text-amber-100">GitHub PAT not configured</h2>
28
+ <h2 class="text-lg font-semibold text-amber-100">
29
+ {{ t('layout.githubPatBanner.title') }}
30
+ </h2>
27
31
  <UButton
28
32
  color="neutral"
29
33
  variant="ghost"
30
34
  size="xs"
31
35
  icon="i-lucide-x"
32
- aria-label="Dismiss"
36
+ :aria-label="t('common.close')"
33
37
  @click="dismissed = true"
34
38
  />
35
39
  </div>
36
40
  <p class="mt-1 text-sm text-amber-200/90">
37
- Local mode reaches GitHub with a personal access token. Without one, agent steps that
38
- clone, push, open PRs, gate on CI or merge will fail.
41
+ {{ t('layout.githubPatBanner.body') }}
39
42
  </p>
40
43
 
41
44
  <div class="mt-4">
@@ -48,10 +51,14 @@ const show = computed(() => !!setupUrl.value && !dismissed.value)
48
51
  icon="i-lucide-external-link"
49
52
  trailing
50
53
  >
51
- Create a GitHub token (scopes pre-selected)
54
+ {{ t('layout.githubPatBanner.createToken') }}
52
55
  </UButton>
53
56
  <p class="mt-2 text-xs text-amber-300/70">
54
- Then set <code class="font-mono">GITHUB_PAT</code> and restart.
57
+ <i18n-t keypath="layout.githubPatBanner.thenSet" tag="span" scope="global">
58
+ <template #envVar>
59
+ <code class="font-mono">GITHUB_PAT</code>
60
+ </template>
61
+ </i18n-t>
55
62
  </p>
56
63
  </div>
57
64
  </div>
@@ -8,10 +8,13 @@
8
8
  // emits `back` and the host panel closes itself + reopens the right hub.
9
9
  defineProps<{ title?: string }>()
10
10
  const emit = defineEmits<{ back: [] }>()
11
+ const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const cameFromHub = computed(() => ui.cameFromIntegrations || ui.cameFromPersonal)
13
14
  const backLabel = computed(() =>
14
- ui.cameFromPersonal ? 'Back to My setup' : 'Back to Integrations',
15
+ ui.cameFromPersonal
16
+ ? t('layout.integrationBack.backToMySetup')
17
+ : t('layout.integrationBack.backToIntegrations'),
15
18
  )
16
19
  </script>
17
20
 
@@ -11,6 +11,7 @@
11
11
  // subscriptions) now live in the "My setup" hub (UserMenu → My setup), NOT here — keeping
12
12
  // this hub purely workspace-scoped. When auth is disabled there is no UserMenu to host them,
13
13
  // so a "Personal (only you)" group falls back into this hub so they stay reachable.
14
+ const { t } = useI18n()
14
15
  const ui = useUiStore()
15
16
  const auth = useAuthStore()
16
17
  const github = useGitHubStore()
@@ -112,14 +113,16 @@ const groups = computed<IntegrationGroup[]>(() => {
112
113
  // Top of the hub: an OpenRouter key is the fastest path to 300+ models, so it leads.
113
114
  const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
114
115
  out.push({
115
- title: 'Models & providers',
116
+ title: t('layout.integrationsHub.groups.models'),
116
117
  items: [
117
118
  {
118
119
  key: 'openrouter',
119
120
  icon: 'i-lucide-waypoints',
120
121
  label: 'OpenRouter',
121
- description: 'One gateway to 300+ models — add your key and enable models in one place.',
122
- status: openRouterKeyConnected ? 'Key connected' : undefined,
122
+ description: t('layout.integrationsHub.items.openrouter.description'),
123
+ status: openRouterKeyConnected
124
+ ? t('layout.integrationsHub.status.keyConnected')
125
+ : undefined,
123
126
  connected: openRouterKeyConnected,
124
127
  recommended: true,
125
128
  onClick: () => go(ui.openOpenRouter),
@@ -127,8 +130,8 @@ const groups = computed<IntegrationGroup[]>(() => {
127
130
  {
128
131
  key: 'vendors',
129
132
  icon: 'i-lucide-key-round',
130
- label: 'Vendors & keys',
131
- description: 'Workspace LLM subscriptions and provider API keys.',
133
+ label: t('layout.integrationsHub.items.vendors.label'),
134
+ description: t('layout.integrationsHub.items.vendors.description'),
132
135
  onClick: () => go(ui.openVendorCredentials),
133
136
  },
134
137
  ],
@@ -141,14 +144,15 @@ const groups = computed<IntegrationGroup[]>(() => {
141
144
  key: 'github',
142
145
  icon: 'i-lucide-github',
143
146
  label: 'GitHub',
144
- description: 'Connect the workspace’s GitHub App, browse repos, PRs and issues.',
147
+ description: t('layout.integrationsHub.items.github.description'),
145
148
  status: github.connected ? github.connection?.accountLogin : undefined,
146
149
  connected: github.connected,
147
150
  recommended: true,
148
151
  onClick: () => go(ui.openGitHub),
149
152
  })
150
153
  }
151
- if (code.length) out.push({ title: 'Source control', items: code })
154
+ if (code.length)
155
+ out.push({ title: t('layout.integrationsHub.groups.sourceControl'), items: code })
152
156
 
153
157
  // --- Communication ---------------------------------------------------------
154
158
  const comms: IntegrationItem[] = []
@@ -157,13 +161,14 @@ const groups = computed<IntegrationGroup[]>(() => {
157
161
  key: 'slack',
158
162
  icon: 'i-lucide-slack',
159
163
  label: 'Slack',
160
- description: 'Route notifications to your team’s Slack workspace.',
164
+ description: t('layout.integrationsHub.items.slack.description'),
161
165
  status: slack.connected ? slack.connection?.teamName : undefined,
162
166
  connected: slack.connected,
163
167
  onClick: () => go(ui.openSlack),
164
168
  })
165
169
  }
166
- if (comms.length) out.push({ title: 'Communication', items: comms })
170
+ if (comms.length)
171
+ out.push({ title: t('layout.integrationsHub.groups.communication'), items: comms })
167
172
 
168
173
  // --- Documents (dynamic sources: Confluence / Notion / GitHub) -------------
169
174
  if (documents.available && documents.sources.length) {
@@ -171,8 +176,12 @@ const groups = computed<IntegrationGroup[]>(() => {
171
176
  key: `doc:${src.source}`,
172
177
  icon: src.icon,
173
178
  label: src.label,
174
- description: `Link ${src.label} as a document source for requirements context.`,
175
- status: documents.isConnected(src.source) ? 'Connected' : undefined,
179
+ description: t('layout.integrationsHub.items.documentSource.description', {
180
+ source: src.label,
181
+ }),
182
+ status: documents.isConnected(src.source)
183
+ ? t('layout.integrationsHub.status.connected')
184
+ : undefined,
176
185
  connected: documents.isConnected(src.source),
177
186
  onClick: () => go(() => ui.openDocumentConnect(src.source)),
178
187
  }))
@@ -180,12 +189,12 @@ const groups = computed<IntegrationGroup[]>(() => {
180
189
  docs.push({
181
190
  key: 'doc:import',
182
191
  icon: 'i-lucide-file-down',
183
- label: 'Import & spawn',
184
- description: 'Pull documents from a connected source and spawn structure.',
192
+ label: t('layout.integrationsHub.items.documentImport.label'),
193
+ description: t('layout.integrationsHub.items.documentImport.description'),
185
194
  onClick: () => go(() => ui.openDocumentImport(null)),
186
195
  })
187
196
  }
188
- out.push({ title: 'Documents', items: docs })
197
+ out.push({ title: t('layout.integrationsHub.groups.documents'), items: docs })
189
198
  }
190
199
 
191
200
  // --- Task trackers (dynamic sources: Jira / GitHub) ------------------------
@@ -194,32 +203,32 @@ const groups = computed<IntegrationGroup[]>(() => {
194
203
  key: `task:${src.source}`,
195
204
  icon: src.icon,
196
205
  label: src.label,
197
- description: `Link ${src.label} to import and reference tracker issues.`,
206
+ description: t('layout.integrationsHub.items.taskSource.description', { source: src.label }),
198
207
  // Available + enabled ⇒ offered (green); available + off ⇒ "Disabled" (amber);
199
208
  // not available ⇒ no badge (Jira needs connecting; GitHub needs its App).
200
209
  connected: src.available && src.enabled,
201
210
  attention: src.available && !src.enabled,
202
- attentionLabel: 'Disabled',
211
+ attentionLabel: t('layout.integrationsHub.status.disabled'),
203
212
  onClick: () => go(() => ui.openTaskConnect(src.source)),
204
213
  }))
205
214
  if (tasks.anyOffered) {
206
215
  trackers.push({
207
216
  key: 'task:import',
208
217
  icon: 'i-lucide-file-down',
209
- label: 'Import issues',
210
- description: 'Pull issues from a connected tracker onto the board.',
218
+ label: t('layout.integrationsHub.items.taskImport.label'),
219
+ description: t('layout.integrationsHub.items.taskImport.description'),
211
220
  onClick: () => go(() => ui.openTaskImport(null)),
212
221
  })
213
222
  }
214
223
  // Choosing the filing tracker / writeback is workspace CONFIG, not an integration, so it
215
224
  // sits as a quiet footer link under the sources rather than a competing row.
216
225
  out.push({
217
- title: 'Task trackers',
226
+ title: t('layout.integrationsHub.groups.taskTrackers'),
218
227
  items: trackers,
219
228
  footerLink: {
220
229
  key: 'task:tracker',
221
230
  icon: 'i-lucide-list-checks',
222
- label: 'Issue tracker settings',
231
+ label: t('layout.integrationsHub.items.trackerSettings.label'),
223
232
  status: trackerLabel.value,
224
233
  onClick: () => go(() => ui.openWorkspaceSettings('tracker')),
225
234
  },
@@ -232,14 +241,16 @@ const groups = computed<IntegrationGroup[]>(() => {
232
241
  // doesn't show a dead "Connect" row that only 503s.
233
242
  if (releaseHealth.available) {
234
243
  out.push({
235
- title: 'Observability',
244
+ title: t('layout.integrationsHub.groups.observability'),
236
245
  items: [
237
246
  {
238
247
  key: 'observability',
239
248
  icon: 'i-lucide-activity',
240
- label: 'Post-release health',
241
- description: 'Watch monitors and SLOs after a release ships (Datadog).',
242
- status: releaseHealth.connection.connected ? 'Connected' : undefined,
249
+ label: t('layout.integrationsHub.items.observability.label'),
250
+ description: t('layout.integrationsHub.items.observability.description'),
251
+ status: releaseHealth.connection.connected
252
+ ? t('layout.integrationsHub.status.connected')
253
+ : undefined,
243
254
  connected: releaseHealth.connection.connected,
244
255
  onClick: () => go(ui.openObservabilityConnection),
245
256
  },
@@ -257,9 +268,9 @@ const groups = computed<IntegrationGroup[]>(() => {
257
268
  infra.push({
258
269
  key: 'environment',
259
270
  icon: 'i-lucide-cloud',
260
- label: 'Ephemeral environments',
261
- description: 'Where the Tester agent runs against a live preview environment.',
262
- status: conn ? 'Connected' : undefined,
271
+ label: t('layout.integrationsHub.items.environment.label'),
272
+ description: t('layout.integrationsHub.items.environment.description'),
273
+ status: conn ? t('layout.integrationsHub.status.connected') : undefined,
263
274
  connected: !!conn,
264
275
  onClick: () => go(() => ui.openProviderConnection('environment')),
265
276
  })
@@ -269,9 +280,9 @@ const groups = computed<IntegrationGroup[]>(() => {
269
280
  infra.push({
270
281
  key: 'runner-pool',
271
282
  icon: 'i-lucide-server-cog',
272
- label: 'Self-hosted runner pool',
273
- description: 'Where the coding agents run when not using Cloudflare Containers.',
274
- status: conn ? 'Connected' : undefined,
283
+ label: t('layout.integrationsHub.items.runnerPool.label'),
284
+ description: t('layout.integrationsHub.items.runnerPool.description'),
285
+ status: conn ? t('layout.integrationsHub.status.connected') : undefined,
275
286
  connected: !!conn,
276
287
  onClick: () => go(() => ui.openProviderConnection('runner-pool')),
277
288
  })
@@ -283,12 +294,13 @@ const groups = computed<IntegrationGroup[]>(() => {
283
294
  infra.push({
284
295
  key: 'local-mode',
285
296
  icon: 'i-lucide-container',
286
- label: 'Local mode',
287
- description: 'Warm container pool + per-repo checkout reuse for the local runner.',
297
+ label: t('layout.integrationsHub.items.localMode.label'),
298
+ description: t('layout.integrationsHub.items.localMode.description'),
288
299
  onClick: () => go(ui.openLocalModeSettings),
289
300
  })
290
301
  }
291
- if (infra.length) out.push({ title: 'Infrastructure', items: infra })
302
+ if (infra.length)
303
+ out.push({ title: t('layout.integrationsHub.groups.infrastructure'), items: infra })
292
304
 
293
305
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
294
306
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -296,22 +308,22 @@ const groups = computed<IntegrationGroup[]>(() => {
296
308
  if (!personalHubReachable.value) {
297
309
  const pat = !!userSecrets.statusFor('github_pat')
298
310
  out.push({
299
- title: 'Personal (only you)',
311
+ title: t('layout.integrationsHub.groups.personal'),
300
312
  items: [
301
313
  {
302
314
  key: 'github-pat',
303
315
  icon: 'i-lucide-key-round',
304
- label: 'My GitHub token',
305
- description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
306
- status: pat ? 'Connected' : undefined,
316
+ label: t('layout.integrationsHub.items.githubPat.label'),
317
+ description: t('layout.integrationsHub.items.githubPat.description'),
318
+ status: pat ? t('layout.integrationsHub.status.connected') : undefined,
307
319
  connected: pat,
308
320
  onClick: () => go(ui.openUserSecrets),
309
321
  },
310
322
  {
311
323
  key: 'local-runners',
312
324
  icon: 'i-lucide-server',
313
- label: 'My local runners',
314
- description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
325
+ label: t('layout.integrationsHub.items.localRunners.label'),
326
+ description: t('layout.integrationsHub.items.localRunners.description'),
315
327
  onClick: () => go(ui.openLocalModels),
316
328
  },
317
329
  ],
@@ -360,11 +372,15 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
360
372
  </script>
361
373
 
362
374
  <template>
363
- <UModal v-model:open="open" title="Integrations" :ui="{ content: 'max-w-xl' }">
375
+ <UModal
376
+ v-model:open="open"
377
+ :title="t('layout.integrationsHub.title')"
378
+ :ui="{ content: 'max-w-xl' }"
379
+ >
364
380
  <template #body>
365
381
  <div class="space-y-5">
366
382
  <p class="text-xs text-slate-400">
367
- Connect and manage the external systems this workspace can link in.
383
+ {{ t('layout.integrationsHub.intro') }}
368
384
  </p>
369
385
 
370
386
  <!-- Get-started cue: an empty workspace gets the two essentials up front so the first
@@ -375,10 +391,10 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
375
391
  >
376
392
  <div class="mb-2 flex items-center gap-2 text-sm font-medium text-primary-200">
377
393
  <UIcon name="i-lucide-rocket" class="h-4 w-4 shrink-0" />
378
- <span>Get started</span>
394
+ <span>{{ t('layout.integrationsHub.getStarted.title') }}</span>
379
395
  </div>
380
396
  <p class="mb-3 text-xs text-slate-300">
381
- Connect a code source and a model provider to run your first pipeline.
397
+ {{ t('layout.integrationsHub.getStarted.body') }}
382
398
  </p>
383
399
  <div class="flex flex-wrap gap-2">
384
400
  <UButton
@@ -399,12 +415,12 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
399
415
  v-model="query"
400
416
  icon="i-lucide-search"
401
417
  size="sm"
402
- placeholder="Search integrations…"
418
+ :placeholder="t('layout.integrationsHub.searchPlaceholder')"
403
419
  class="w-full"
404
420
  />
405
421
 
406
422
  <p v-if="!filteredGroups.length" class="px-1 py-6 text-center text-sm text-slate-500">
407
- No integrations match “{{ query }}”.
423
+ {{ t('layout.integrationsHub.noMatches', { query }) }}
408
424
  </p>
409
425
 
410
426
  <section v-for="group in filteredGroups" :key="group.title">
@@ -424,19 +440,21 @@ const filteredGroups = computed<IntegrationGroup[]>(() => {
424
440
  <div class="flex items-center gap-2">
425
441
  <span class="truncate text-sm font-medium text-slate-100">{{ item.label }}</span>
426
442
  <UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
427
- {{ item.status || 'Connected' }}
443
+ {{ item.status || t('layout.integrationsHub.status.connected') }}
428
444
  </UBadge>
429
445
  <UBadge v-else-if="item.attention" color="warning" variant="subtle" size="sm">
430
- {{ item.attentionLabel || 'Needs attention' }}
446
+ {{ item.attentionLabel || t('layout.integrationsHub.status.needsAttention') }}
431
447
  </UBadge>
432
- <span v-else class="text-[11px] text-slate-500">Not connected</span>
448
+ <span v-else class="text-[11px] text-slate-500">{{
449
+ t('layout.integrationsHub.status.notConnected')
450
+ }}</span>
433
451
  <UBadge
434
452
  v-if="!anyConnected && item.recommended && !item.connected"
435
453
  color="primary"
436
454
  variant="subtle"
437
455
  size="sm"
438
456
  >
439
- Recommended
457
+ {{ t('layout.integrationsHub.status.recommended') }}
440
458
  </UBadge>
441
459
  </div>
442
460
  <p class="truncate text-xs text-slate-400">{{ item.description }}</p>