@cat-factory/app 0.47.6 → 0.47.8

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.
@@ -1,16 +1,18 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
 
5
5
  const auth = useAuthStore()
6
6
  const { t } = useI18n()
7
7
 
8
- // Local-mode source-control PAT login. GitHub/GitLab are brand names (kept verbatim across
9
- // locales), as are the token-settings URLs, so they're inline constants rather than catalog
10
- // keys same convention as the provider descriptors in ApiKeysSection. The actual link
11
- // prefers the server's scopes-preselected deep link (`patLogin.setupUrls`); these are the
12
- // fallback when it's absent.
8
+ // Local-mode source-control PAT login. The PAT lives server-side in env (GITHUB_PAT /
9
+ // GITLAB_PAT); the login screen only SELECTS a configured provider no token is ever typed
10
+ // into or shown in the browser. GitHub/GitLab are brand names (kept verbatim across locales),
11
+ // as are the token-settings URLs, so they're inline constants rather than catalog keys — same
12
+ // convention as the provider descriptors in ApiKeysSection. The "create a token" link prefers
13
+ // the server's scopes-preselected deep link (`patLogin.setupUrls`); these are the fallback.
13
14
  type PatProvider = 'github' | 'gitlab'
15
+ const ALL_PROVIDERS: PatProvider[] = ['github', 'gitlab']
14
16
  const PROVIDER_LABELS: Record<PatProvider, string> = { github: 'GitHub', gitlab: 'GitLab' }
15
17
  const PROVIDER_ICONS: Record<PatProvider, string> = {
16
18
  github: 'i-lucide-github',
@@ -23,44 +25,29 @@ const PROVIDER_TOKEN_URLS: Record<PatProvider, string> = {
23
25
  }
24
26
 
25
27
  const patLoginCfg = computed(() => auth.localMode?.patLogin)
28
+ // Only providers whose PAT is configured in env can sign in (the token is the operational
29
+ // credential too). A provider without one gets no button — see the no-PAT notice instead.
26
30
  const configuredProviders = computed<PatProvider[]>(
27
31
  () => (patLoginCfg.value?.configured ?? []) as PatProvider[],
28
32
  )
29
- const availableProviders = computed<PatProvider[]>(
30
- () => (patLoginCfg.value?.available ?? []) as PatProvider[],
31
- )
32
- const showLocalLogin = computed(() => availableProviders.value.length > 0)
33
+ const isLocalMode = computed(() => auth.localMode?.enabled === true)
34
+ const hasConfiguredPat = computed(() => configuredProviders.value.length > 0)
33
35
 
34
- const patProvider = ref<PatProvider>('github')
35
- const patToken = ref('')
36
36
  const patBusy = ref(false)
37
37
  const patError = ref<string | null>(null)
38
38
 
39
- // Keep the picker on an actually-available provider.
40
- watch(
41
- availableProviders,
42
- (list) => {
43
- if (list.length && !list.includes(patProvider.value)) patProvider.value = list[0]!
44
- },
45
- { immediate: true },
46
- )
47
-
48
- const patProviderItems = computed(() =>
49
- availableProviders.value.map((p) => ({ label: PROVIDER_LABELS[p], value: p })),
50
- )
51
-
52
- // Prefer the server's scopes-preselected deep link (it owns the per-provider scopes);
53
- // fall back to the plain token page if it wasn't advertised.
54
- const tokenCreateUrl = computed(
55
- () => patLoginCfg.value?.setupUrls?.[patProvider.value] ?? PROVIDER_TOKEN_URLS[patProvider.value],
56
- )
39
+ // Per-provider "create a token" link: prefer the server's scopes-preselected deep link (it
40
+ // owns the per-provider scopes), fall back to the plain token page.
41
+ function tokenCreateUrl(provider: PatProvider): string {
42
+ return patLoginCfg.value?.setupUrls?.[provider] ?? PROVIDER_TOKEN_URLS[provider]
43
+ }
57
44
 
58
- /** One-click (configured PAT) or pasted-token sign-in; reloads so the app boots signed in. */
59
- async function submitPat(provider: PatProvider, token?: string) {
45
+ /** Sign in as the account the configured env PAT belongs to; reloads so the app boots in. */
46
+ async function submitPat(provider: PatProvider) {
60
47
  patError.value = null
61
48
  patBusy.value = true
62
49
  try {
63
- await auth.patLogin(token ? { provider, token } : { provider })
50
+ await auth.patLogin({ provider })
64
51
  if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
65
52
  } catch (e) {
66
53
  patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
@@ -154,9 +141,10 @@ const showOAuthDivider = computed(
154
141
  </p>
155
142
  </div>
156
143
 
157
- <!-- Local mode: sign in with a source-control PAT (no OAuth round-trip needed) -->
158
- <div v-if="showLocalLogin && mode !== 'forgot'" class="space-y-3">
159
- <!-- One-click: a PAT is already configured server-side -->
144
+ <!-- Local mode: sign in with the env-configured source-control PAT. The token lives
145
+ server-side (GITHUB_PAT / GITLAB_PAT); we only pick a provider here. -->
146
+ <div v-if="isLocalMode && mode !== 'forgot'" class="space-y-3">
147
+ <!-- One button per provider whose PAT is configured in env -->
160
148
  <UButton
161
149
  v-for="p in configuredProviders"
162
150
  :key="p"
@@ -167,53 +155,37 @@ const showOAuthDivider = computed(
167
155
  :loading="patBusy"
168
156
  @click="submitPat(p)"
169
157
  >
170
- {{ t('auth.localMode.continueWith', { provider: PROVIDER_LABELS[p] }) }}
158
+ {{ t('auth.localMode.continueWithConfigured', { provider: PROVIDER_LABELS[p] }) }}
171
159
  </UButton>
172
160
 
173
- <!-- Enter a PAT inline -->
174
- <form class="space-y-2" @submit.prevent="submitPat(patProvider, patToken.trim())">
175
- <p class="text-xs font-medium text-slate-400">{{ t('auth.localMode.enterPatTitle') }}</p>
176
- <USelect
177
- v-if="patProviderItems.length > 1"
178
- v-model="patProvider"
179
- :items="patProviderItems"
180
- size="lg"
181
- class="w-full"
182
- />
183
- <UTextarea
184
- v-model="patToken"
185
- :rows="2"
186
- :placeholder="
187
- t('auth.localMode.tokenPlaceholder', { provider: PROVIDER_LABELS[patProvider] })
188
- "
189
- class="w-full font-mono"
161
+ <!-- Neither GITHUB_PAT nor GITLAB_PAT is set: tell the developer how to configure one -->
162
+ <template v-if="!hasConfiguredPat">
163
+ <UAlert
164
+ color="warning"
165
+ variant="subtle"
166
+ icon="i-lucide-key-round"
167
+ :title="t('auth.localMode.noPatTitle')"
168
+ :description="t('auth.localMode.noPatBody')"
190
169
  />
191
- <div class="flex items-center justify-between gap-2">
170
+ <div class="flex flex-wrap gap-3 px-1">
192
171
  <a
193
- :href="tokenCreateUrl"
172
+ v-for="p in ALL_PROVIDERS"
173
+ :key="p"
174
+ :href="tokenCreateUrl(p)"
194
175
  target="_blank"
195
176
  rel="noopener noreferrer"
196
177
  class="text-xs text-indigo-400 hover:underline"
197
178
  >
198
- {{ t('auth.localMode.createToken', { provider: PROVIDER_LABELS[patProvider] }) }}
179
+ {{ t('auth.localMode.createToken', { provider: PROVIDER_LABELS[p] }) }}
199
180
  </a>
200
- <UButton
201
- size="lg"
202
- color="neutral"
203
- variant="subtle"
204
- type="submit"
205
- :loading="patBusy"
206
- :disabled="!patToken.trim()"
207
- >
208
- {{ t('auth.localMode.submit') }}
209
- </UButton>
210
181
  </div>
211
- </form>
182
+ </template>
183
+
212
184
  <p v-if="patError" class="text-sm text-rose-400">{{ patError }}</p>
213
185
  </div>
214
186
 
215
187
  <div
216
- v-if="showLocalLogin && auth.providers.password && mode !== 'forgot'"
188
+ v-if="isLocalMode && auth.providers.password && mode !== 'forgot'"
217
189
  class="my-4 flex items-center gap-3 text-xs text-slate-500"
218
190
  >
219
191
  <span class="h-px flex-1 bg-slate-800" /> {{ t('auth.localMode.orDivider') }}
@@ -7,12 +7,13 @@
7
7
  // gates; it branches on the step's `agentKind` for the copy and the failure detail.
8
8
  import { computed, ref } from 'vue'
9
9
  import { agentKindMeta } from '~/utils/catalog'
10
- import type { GateStepState } from '~/types/execution'
10
+ import type { GateAttempt, GateStepState } from '~/types/execution'
11
11
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
12
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
13
13
 
14
14
  const board = useBoardStore()
15
15
  const execution = useExecutionStore()
16
+ const { t, d } = useI18n()
16
17
 
17
18
  // Synchronous window: it reads its state straight off the execution step, so there's
18
19
  // nothing to fetch on open (no `onOpen` loader).
@@ -39,10 +40,10 @@ const helperMeta = computed(() => agentKindMeta(helperKind.value))
39
40
 
40
41
  const subtitle = computed(() =>
41
42
  isHumanReview.value
42
- ? 'Waits for a human code review on the PR, looping the fixer on comments'
43
+ ? t('gates.subtitle.humanReview')
43
44
  : isCi.value
44
- ? 'Gates the PR on green CI, looping the CI fixer on failure'
45
- : 'Gates the PR on a clean merge, looping the resolver on conflicts',
45
+ ? t('gates.subtitle.ci')
46
+ : t('gates.subtitle.conflicts'),
46
47
  )
47
48
 
48
49
  // Human-review: approval progress + the freeform "request a fix" control.
@@ -67,8 +68,15 @@ const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(
67
68
  // The helper-agent attempts this gate dispatched, newest first for the timeline.
68
69
  const attempts = computed(() => [...(gate.value?.attemptLog ?? [])].reverse())
69
70
 
71
+ // Exhaustive map of the attempt outcome enum → label (literal keys keep the typed-key
72
+ // drift guard live, vs a runtime-built `gates.outcome.${outcome}`).
73
+ const OUTCOME_LABELS = computed<Record<GateAttempt['outcome'], string>>(() => ({
74
+ completed: t('gates.outcome.completed'),
75
+ failed: t('gates.outcome.failed'),
76
+ }))
77
+
70
78
  function formatClock(ms?: number | null): string | null {
71
- return ms ? new Date(ms).toLocaleString() : null
79
+ return ms ? d(new Date(ms), 'long') : null
72
80
  }
73
81
 
74
82
  /**
@@ -94,45 +102,62 @@ const status = computed<GateDisplayStatus>(() => {
94
102
  return 'checking'
95
103
  })
96
104
 
97
- const STATUS_META: Record<
98
- GateDisplayStatus,
99
- { label: string; badge: 'success' | 'warning' | 'error' | 'neutral'; icon: string; text: string }
100
- > = {
105
+ const STATUS_META = computed<
106
+ Record<
107
+ GateDisplayStatus,
108
+ {
109
+ label: string
110
+ badge: 'success' | 'warning' | 'error' | 'neutral'
111
+ icon: string
112
+ text: string
113
+ }
114
+ >
115
+ >(() => ({
101
116
  passed: {
102
- label: 'Passed',
117
+ label: t('gates.status.passed'),
103
118
  badge: 'success',
104
119
  icon: 'i-lucide-circle-check',
105
120
  text: 'text-emerald-300',
106
121
  },
107
- 'gave-up': { label: 'Gave up', badge: 'error', icon: 'i-lucide-circle-x', text: 'text-rose-300' },
108
- fixing: { label: 'Fixing', badge: 'warning', icon: 'i-lucide-loader', text: 'text-amber-300' },
122
+ 'gave-up': {
123
+ label: t('gates.status.gaveUp'),
124
+ badge: 'error',
125
+ icon: 'i-lucide-circle-x',
126
+ text: 'text-rose-300',
127
+ },
128
+ fixing: {
129
+ label: t('gates.status.fixing'),
130
+ badge: 'warning',
131
+ icon: 'i-lucide-loader',
132
+ text: 'text-amber-300',
133
+ },
109
134
  failing: {
110
- label: 'Failing',
135
+ label: t('gates.status.failing'),
111
136
  badge: 'error',
112
137
  icon: 'i-lucide-circle-x',
113
138
  text: 'text-rose-300',
114
139
  },
115
140
  pending: {
116
- label: 'Pending',
141
+ label: t('gates.status.pending'),
117
142
  badge: 'neutral',
118
143
  icon: 'i-lucide-clock',
119
144
  text: 'text-slate-300',
120
145
  },
121
146
  checking: {
122
- label: 'Checking',
147
+ label: t('gates.status.checking'),
123
148
  badge: 'neutral',
124
149
  icon: 'i-lucide-loader',
125
150
  text: 'text-slate-300',
126
151
  },
127
- }
152
+ }))
128
153
 
129
154
  // The conflicts gate has no structured detail (GitHub reports mergeability as a single
130
155
  // verdict, no file list), so the window shows the verdict + a note rather than a list.
131
156
  const conflictVerdict = computed(() => {
132
- if (status.value === 'passed') return 'Mergeable'
133
- if (gate.value?.lastVerdict === 'pending') return 'Computing mergeability…'
134
- if (gate.value?.lastVerdict === 'fail') return 'Conflicts with base'
135
- return 'Unknown'
157
+ if (status.value === 'passed') return t('gates.conflict.mergeable')
158
+ if (gate.value?.lastVerdict === 'pending') return t('gates.conflict.computing')
159
+ if (gate.value?.lastVerdict === 'fail') return t('gates.conflict.conflicts')
160
+ return t('gates.conflict.unknown')
136
161
  })
137
162
  </script>
138
163
 
@@ -183,10 +208,9 @@ const conflictVerdict = computed(() => {
183
208
  class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
184
209
  >
185
210
  <UIcon :name="meta.icon" class="h-8 w-8 opacity-40" />
186
- <p class="text-sm">No gate activity yet.</p>
211
+ <p class="text-sm">{{ t('gates.noActivity') }}</p>
187
212
  <p class="max-w-sm text-[11px] text-slate-500">
188
- The precheck runs once the PR is open. While it polls, the step shows live state on
189
- the board.
213
+ {{ t('gates.noActivityHint') }}
190
214
  </p>
191
215
  </div>
192
216
 
@@ -201,9 +225,7 @@ const conflictVerdict = computed(() => {
201
225
  class="mt-0.5 h-4 w-4 shrink-0 text-emerald-400"
202
226
  />
203
227
  <p class="text-[13px] leading-relaxed text-emerald-200">
204
- {{
205
- step?.output || (isCi ? 'CI is green.' : 'The PR merges cleanly with its base.')
206
- }}
228
+ {{ step?.output || (isCi ? t('gates.passedCi') : t('gates.passedConflicts')) }}
207
229
  </p>
208
230
  </div>
209
231
 
@@ -214,14 +236,20 @@ const conflictVerdict = computed(() => {
214
236
  >
215
237
  <UIcon name="i-lucide-users" class="h-4 w-4 shrink-0 text-violet-300" />
216
238
  <span class="text-[13px] text-slate-200">
217
- {{ gate.lastApprovals ?? 0 }} / {{ requiredApprovals }} approval{{
218
- requiredApprovals === 1 ? '' : 's'
239
+ {{
240
+ t(
241
+ 'gates.humanReview.approvals',
242
+ { approved: gate.lastApprovals ?? 0, required: requiredApprovals },
243
+ requiredApprovals,
244
+ )
219
245
  }}
220
- <template v-if="status === 'fixing'"> · fixer addressing comments…</template>
246
+ <template v-if="status === 'fixing'">
247
+ {{ t('gates.humanReview.suffixFixing') }}</template
248
+ >
221
249
  <template v-else-if="status === 'failing'">
222
- · review comments to address</template
250
+ {{ t('gates.humanReview.suffixFailing') }}</template
223
251
  >
224
- <template v-else> · awaiting review</template>
252
+ <template v-else> {{ t('gates.humanReview.suffixAwaiting') }}</template>
225
253
  </span>
226
254
  </div>
227
255
  <p
@@ -237,7 +265,7 @@ const conflictVerdict = computed(() => {
237
265
  rel="noopener"
238
266
  class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
239
267
  >
240
- Review pull request on GitHub
268
+ {{ t('gates.humanReview.reviewPr') }}
241
269
  <UIcon name="i-lucide-external-link" class="h-3 w-3" />
242
270
  </a>
243
271
 
@@ -246,17 +274,16 @@ const conflictVerdict = computed(() => {
246
274
  <h3
247
275
  class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
248
276
  >
249
- Request a fix
277
+ {{ t('gates.humanReview.requestFixHeading') }}
250
278
  </h3>
251
279
  <p class="mb-2 text-[11px] leading-relaxed text-slate-500">
252
- Describe a change for the fixer to make on the PR branch now (in addition to any
253
- review comments, which it addresses automatically).
280
+ {{ t('gates.humanReview.requestFixDescription') }}
254
281
  </p>
255
282
  <textarea
256
283
  v-model="fixInstructions"
257
284
  rows="3"
258
285
  :disabled="fixBusy"
259
- placeholder="e.g. rename the helper and add a unit test for the empty-input case"
286
+ :placeholder="t('gates.humanReview.requestFixPlaceholder')"
260
287
  class="w-full resize-y rounded-md border border-slate-800 bg-slate-950/60 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-violet-500/60 focus:outline-none"
261
288
  />
262
289
  <div class="mt-2 flex justify-end">
@@ -268,7 +295,7 @@ const conflictVerdict = computed(() => {
268
295
  :disabled="fixBusy || fixInstructions.trim().length === 0"
269
296
  @click="submitFix"
270
297
  >
271
- Request fix
298
+ {{ t('gates.humanReview.requestFix') }}
272
299
  </UButton>
273
300
  </div>
274
301
  </section>
@@ -277,7 +304,7 @@ const conflictVerdict = computed(() => {
277
304
  <!-- CI: failing checks -->
278
305
  <template v-else-if="isCi">
279
306
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
280
- Failing checks
307
+ {{ t('gates.ci.failingChecks') }}
281
308
  </h3>
282
309
  <ul v-if="failingChecks.length" class="space-y-1">
283
310
  <li
@@ -292,7 +319,7 @@ const conflictVerdict = computed(() => {
292
319
  target="_blank"
293
320
  rel="noopener"
294
321
  class="group min-w-0 flex-1 truncate text-[13px] text-sky-300 hover:text-sky-200 hover:underline"
295
- :title="`Open ${c.name} on GitHub`"
322
+ :title="t('gates.ci.openOnGithub', { name: c.name })"
296
323
  >
297
324
  {{ c.name }}
298
325
  <UIcon
@@ -304,19 +331,19 @@ const conflictVerdict = computed(() => {
304
331
  c.name
305
332
  }}</span>
306
333
  <span class="shrink-0 text-[11px] uppercase text-rose-300">
307
- {{ c.conclusion ?? 'failure' }}
334
+ {{ c.conclusion ?? t('gates.ci.conclusionFallback') }}
308
335
  </span>
309
336
  </li>
310
337
  </ul>
311
338
  <p v-else class="text-[13px] leading-relaxed text-slate-300">
312
- {{ gate.lastFailureSummary || 'CI has not reported a failure on this commit.' }}
339
+ {{ gate.lastFailureSummary || t('gates.ci.failureFallback') }}
313
340
  </p>
314
341
  </template>
315
342
 
316
343
  <!-- Conflicts: verdict + the resolver's account of what it left -->
317
344
  <template v-else>
318
345
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
319
- Mergeability
346
+ {{ t('gates.conflicts.mergeability') }}
320
347
  </h3>
321
348
  <div
322
349
  class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
@@ -344,7 +371,7 @@ const conflictVerdict = computed(() => {
344
371
  rel="noopener"
345
372
  class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200 hover:underline"
346
373
  >
347
- View pull request on GitHub
374
+ {{ t('gates.conflicts.viewPr') }}
348
375
  <UIcon name="i-lucide-external-link" class="h-3 w-3" />
349
376
  </a>
350
377
  </template>
@@ -352,7 +379,7 @@ const conflictVerdict = computed(() => {
352
379
  <!-- Attempt history (both gates): what each helper run did and how it ended. -->
353
380
  <section v-if="attempts.length" class="mt-5">
354
381
  <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
355
- {{ helperMeta.label }} attempts
382
+ {{ t('gates.attemptsHeading', { helper: helperMeta.label }) }}
356
383
  </h3>
357
384
  <ol class="space-y-2">
358
385
  <li
@@ -361,14 +388,14 @@ const conflictVerdict = computed(() => {
361
388
  class="rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
362
389
  >
363
390
  <div class="flex items-center gap-2">
364
- <span class="text-[12px] font-semibold text-slate-200"
365
- >Attempt {{ a.attempt }}</span
366
- >
391
+ <span class="text-[12px] font-semibold text-slate-200">{{
392
+ t('gates.attempt', { number: a.attempt })
393
+ }}</span>
367
394
  <UBadge
368
395
  :color="a.outcome === 'failed' ? 'error' : 'neutral'"
369
396
  variant="subtle"
370
397
  size="sm"
371
- >{{ a.outcome }}</UBadge
398
+ >{{ OUTCOME_LABELS[a.outcome] }}</UBadge
372
399
  >
373
400
  <span v-if="formatClock(a.at)" class="ml-auto text-[11px] text-slate-500">{{
374
401
  formatClock(a.at)
@@ -392,7 +419,7 @@ const conflictVerdict = computed(() => {
392
419
  >
393
420
  <div v-if="gate">
394
421
  <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
395
- State
422
+ {{ t('gates.sidebar.state') }}
396
423
  </h4>
397
424
  <div class="flex items-center gap-2 text-[13px]">
398
425
  <UIcon
@@ -412,21 +439,29 @@ const conflictVerdict = computed(() => {
412
439
  <!-- The human-review gate's budget is effectively unbounded (it waits for a human
413
440
  indefinitely), so render a plain round count rather than "0/9007199254740991". -->
414
441
  <template v-if="isHumanReview">
415
- {{ gate.attempts }} fix round{{ gate.attempts === 1 ? '' : 's' }}
442
+ {{ t('gates.sidebar.fixRounds', { count: gate.attempts }, gate.attempts) }}
416
443
  </template>
417
444
  <template v-else>
418
- {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
419
- gate.maxAttempts === 1 ? '' : 's'
445
+ {{
446
+ t(
447
+ 'gates.sidebar.attempts',
448
+ { attempts: gate.attempts, max: gate.maxAttempts },
449
+ gate.maxAttempts,
450
+ )
420
451
  }}
421
452
  </template>
422
- <template v-if="gate.phase === 'working'"> · running…</template>
423
- <template v-else-if="gate.attempts === 0"> · not needed yet</template>
453
+ <template v-if="gate.phase === 'working'">
454
+ {{ t('gates.sidebar.suffixRunning') }}</template
455
+ >
456
+ <template v-else-if="gate.attempts === 0">
457
+ {{ t('gates.sidebar.suffixNotNeeded') }}</template
458
+ >
424
459
  </p>
425
460
  </div>
426
461
 
427
462
  <div v-if="shortSha">
428
463
  <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
429
- Gated commit
464
+ {{ t('gates.sidebar.gatedCommit') }}
430
465
  </h4>
431
466
  <p class="font-mono text-[12px] text-slate-300">{{ shortSha }}</p>
432
467
  </div>
@@ -444,8 +479,7 @@ const conflictVerdict = computed(() => {
444
479
  />
445
480
 
446
481
  <p class="mt-auto text-[10px] leading-relaxed text-slate-600">
447
- A gate runs a programmatic precheck and only spins up the
448
- {{ helperMeta.label }} when it fails — a green check advances with nothing spun up.
482
+ {{ t('gates.sidebar.footer', { helper: helperMeta.label }) }}
449
483
  </p>
450
484
  </aside>
451
485
  </div>
@@ -4,6 +4,7 @@ import { useLocalStorage } from '@vueuse/core'
4
4
  import type { AgentKind } from '~/types/domain'
5
5
  import { AGENT_CATEGORIES, OBSERVABILITY_GATE_ARCHETYPE } from '~/utils/catalog'
6
6
 
7
+ const { t } = useI18n()
7
8
  const agents = useAgentsStore()
8
9
  const releaseHealth = useReleaseHealthStore()
9
10
  defineEmits<{ (e: 'add', kind: AgentKind): void }>()
@@ -25,7 +26,8 @@ const groups = computed(() => {
25
26
  agents: palette.value.filter((a) => a.category === cat.id),
26
27
  }))
27
28
  const custom = palette.value.filter((a) => !a.category)
28
- if (custom.length) ordered.push({ id: 'custom', label: 'Custom agents', agents: custom })
29
+ if (custom.length)
30
+ ordered.push({ id: 'custom', label: t('palette.customAgents'), agents: custom })
29
31
  return ordered.filter((g) => g.agents.length)
30
32
  })
31
33
 
@@ -43,7 +45,7 @@ function toggle(id: string) {
43
45
 
44
46
  <template>
45
47
  <div class="space-y-2">
46
- <p class="px-1 text-[11px] text-slate-500">Click an agent to append it to the pipeline.</p>
48
+ <p class="px-1 text-[11px] text-slate-500">{{ t('palette.hint') }}</p>
47
49
  <div class="space-y-2">
48
50
  <section v-for="g in groups" :key="g.id">
49
51
  <button
@@ -7,7 +7,7 @@ import type { IterationCapChoice } from '~/types/execution'
7
7
  // or stop and reset the task. Used by both the requirements-review window and the
8
8
  // companion step detail (Spec Reviewer / Reviewer / Architect Companion), so the two
9
9
  // gates present an identical choice rather than each rolling its own.
10
- withDefaults(
10
+ const props = withDefaults(
11
11
  defineProps<{
12
12
  heading: string
13
13
  detail: string
@@ -16,14 +16,17 @@ withDefaults(
16
16
  proceedLabel?: string
17
17
  stopLabel?: string
18
18
  }>(),
19
- {
20
- loading: false,
21
- extraRoundLabel: 'One more round',
22
- proceedLabel: 'Proceed anyway',
23
- stopLabel: 'Stop & reset task',
24
- },
19
+ { loading: false },
25
20
  )
26
21
 
22
+ const { t } = useI18n()
23
+
24
+ // The three button labels default to the shared i18n copy, but a caller may override
25
+ // any of them with surface-specific wording.
26
+ const extraRound = computed(() => props.extraRoundLabel ?? t('pipeline.iterationCap.extraRound'))
27
+ const proceed = computed(() => props.proceedLabel ?? t('pipeline.iterationCap.proceed'))
28
+ const stop = computed(() => props.stopLabel ?? t('pipeline.iterationCap.stopReset'))
29
+
27
30
  const emit = defineEmits<{ resolve: [choice: IterationCapChoice] }>()
28
31
  </script>
29
32
 
@@ -43,7 +46,7 @@ const emit = defineEmits<{ resolve: [choice: IterationCapChoice] }>()
43
46
  :loading="loading"
44
47
  @click="emit('resolve', 'extra-round')"
45
48
  >
46
- {{ extraRoundLabel }}
49
+ {{ extraRound }}
47
50
  </UButton>
48
51
  <UButton
49
52
  color="warning"
@@ -53,7 +56,7 @@ const emit = defineEmits<{ resolve: [choice: IterationCapChoice] }>()
53
56
  :loading="loading"
54
57
  @click="emit('resolve', 'proceed')"
55
58
  >
56
- {{ proceedLabel }}
59
+ {{ proceed }}
57
60
  </UButton>
58
61
  <UButton
59
62
  color="error"
@@ -63,7 +66,7 @@ const emit = defineEmits<{ resolve: [choice: IterationCapChoice] }>()
63
66
  :loading="loading"
64
67
  @click="emit('resolve', 'stop-reset')"
65
68
  >
66
- {{ stopLabel }}
69
+ {{ stop }}
67
70
  </UButton>
68
71
  </div>
69
72
  </div>