@cat-factory/app 0.215.1 → 0.216.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 (38) hide show
  1. package/README.md +109 -1
  2. package/app/components/bootstrap/BootstrapModal.vue +65 -44
  3. package/app/components/github/AddServiceFromRepoModal.vue +49 -27
  4. package/app/components/github/GitHubOnboarding.vue +4 -23
  5. package/app/components/github/GitHubPanel.vue +10 -34
  6. package/app/components/inputGate/InputGateNotice.vue +176 -0
  7. package/app/components/panels/AgentStepDetail.vue +27 -3
  8. package/app/components/panels/inspector/TaskExecution.vue +32 -3
  9. package/app/components/pipeline/PipelineProgress.vue +1 -1
  10. package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
  11. package/app/components/vcs/VcsConnectSurfaces.vue +58 -0
  12. package/app/composables/api/inputGate.ts +25 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +8 -0
  15. package/app/stores/github/vcsConnect.ts +62 -0
  16. package/app/stores/github.spec.ts +31 -0
  17. package/app/stores/github.ts +5 -26
  18. package/app/stores/inputGate.ts +58 -0
  19. package/app/stores/ui/resultViews.ts +9 -1
  20. package/app/stores/workspaceSettings.ts +1 -0
  21. package/app/types/domain.ts +1 -0
  22. package/app/utils/inputGate.spec.ts +52 -0
  23. package/app/utils/inputGate.ts +44 -0
  24. package/app/utils/pipelineRender.spec.ts +47 -9
  25. package/app/utils/pipelineRender.ts +23 -2
  26. package/app/utils/vcs.spec.ts +101 -0
  27. package/app/utils/vcs.ts +63 -1
  28. package/i18n/locales/de.json +76 -17
  29. package/i18n/locales/en.json +76 -17
  30. package/i18n/locales/es.json +76 -17
  31. package/i18n/locales/fr.json +76 -17
  32. package/i18n/locales/he.json +76 -17
  33. package/i18n/locales/it.json +76 -17
  34. package/i18n/locales/ja.json +76 -17
  35. package/i18n/locales/pl.json +76 -17
  36. package/i18n/locales/tr.json +76 -17
  37. package/i18n/locales/uk.json +76 -17
  38. package/package.json +2 -2
package/README.md CHANGED
@@ -18,6 +18,9 @@ The SPA source lives under `app/` (the Nuxt srcDir).
18
18
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
19
19
  - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
20
20
  - [In-app tutorial tours](#in-app-tutorial-tours)
21
+ - [Real-time store coherence](#real-time-store-coherence-avoid-the-full-refresh-clobber)
22
+ - [Internationalization (i18n) authoring](#internationalization-i18n-authoring)
23
+ - [Extending the layer (consumer modules)](#extending-the-layer-consumer-modules)
21
24
  - [Key UI surfaces](#key-ui-surfaces)
22
25
  - [Develop & test](#develop--test)
23
26
 
@@ -329,7 +332,7 @@ is SUPPRESSED rather than unmounted, because it holds the running tour's resolve
329
332
  remount would re-resolve it against gates that may have flipped since the tour started.
330
333
 
331
334
  The decisions behind this surface, and why each alternative was rejected, are recorded in
332
- [ADR 0033](../../backend/docs/adr/0033-in-app-tutorials.md). This section is the authority on how
335
+ [ADR 0036](../../backend/docs/adr/0036-in-app-tutorials.md). This section is the authority on how
333
336
  the thing WORKS.
334
337
 
335
338
  A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
@@ -511,6 +514,111 @@ guard scans the layer for both ways an id is named: written onto an element, or
511
514
  `testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
512
515
  is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.
513
516
 
517
+ ## Real-time store coherence: avoid the full-refresh CLOBBER
518
+
519
+ The recurring product bug behind most e2e flakes: a stale full-snapshot refresh clobbering newer
520
+ live state. The SPA has two delivery shapes and mixing them wrong drops live-added state with NO
521
+ event left to restore it.
522
+
523
+ - **Know how your entity is delivered.** A `board` event is COARSE: no payload, only a debounced
524
+ full `workspace.refresh()`, and `hydrate` REPLACES whole lists. A spawned task/module block
525
+ reaches the browser ONLY this way. Targeted events (`execution`/`bootstrap`/`initiative`) carry
526
+ the entity and `upsert` it, so they don't clobber. Prefer a targeted upsert for anything that
527
+ must appear reliably.
528
+ - **Full refreshes MUST be monotonic.** Two `refresh()` calls can be in flight; a staler one
529
+ resolving later overwrites the newer. `workspace.refresh()` guards this with a sequence. Do not
530
+ reintroduce an unguarded `hydrate(await fetch())`, and apply the guard to any new coalesced
531
+ refresh path.
532
+ - **Never gate readiness on a snapshot a later resync can undo.** The on-connect resync flips
533
+ `connected` only after it settles (which is why e2e gates on `data-connected`).
534
+ - **A REPLACE-style `hydrate` must never silently drop live-only state.** Either fold that state
535
+ into the snapshot or reconcile rather than replace.
536
+ - **An action's OPTIMISTIC ECHO is a clobber too, and it bypasses both guards above.** A store
537
+ that awaits a mutation and then assigns the returned sub-state onto the cached run
538
+ (`step.forkDecision`, `step.prReview`, `step.judge`, `step.followUps`) is writing straight past
539
+ `upsert`'s `rev` check. Where the mutation WAKES THE DRIVER, the driver's next emit routinely
540
+ beats the HTTP response, so the echo puts the run back; if the run then parks, nothing emits
541
+ again and the newer state is gone for good (the fork-chat reply that vanished, leaving a
542
+ "thinking…" bubble spinning). Every echo therefore goes through
543
+ `execution.echoAfter(executionId, send, apply)`, which captures the run's `rev` before the
544
+ request and drops the echo if anything advanced it. Never hand-roll the await-then-assign.
545
+ - **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
546
+ `stores/execution.spec.ts` for echoes): drive the two orderings and assert the fresher one
547
+ wins.
548
+
549
+ ## Internationalization (i18n) authoring
550
+
551
+ All user-facing SPA copy goes through `@nuxtjs/i18n`; never hard-code a display string. This
552
+ layer ships the base `en` locale, and a downstream deployment overrides by dropping its own files
553
+ (the per-layer deep-merge is the override seam, consumer wins key by key). Migration status:
554
+ [`docs/localization.md`](../../docs/localization.md).
555
+
556
+ - `i18n/locales/<locale>.json`: the catalogs (the v9+ `i18n/` convention, NOT `app/locales/`).
557
+ - `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the named
558
+ `numberFormats`/`datetimeFormats`). Messages are deliberately NOT here so the module can
559
+ deep-merge across the `extends` chain. Referenced as the BARE filename
560
+ `vueI18n: 'i18n.config.ts'`, never `layerDir`-anchored.
561
+ - `package.json` `files` MUST include `"i18n"`. Release-blocking.
562
+
563
+ **Adding a string**: add the key to `en.json` under the feature namespace, resolve with
564
+ `t('feature.area.key')`, and format numbers/dates through `$n`/`$d` (the named formats), never
565
+ raw `Intl`.
566
+
567
+ **Key conventions**: one namespace per feature; **leaf keys mirror the enum/code value verbatim**
568
+ so a dynamic lookup is total; **no cross-key concatenation** (a full sentence is ONE key with
569
+ `{named}` placeholders, plurals use the pipe form).
570
+
571
+ **Component mechanics that bite:**
572
+
573
+ - `useI18n` is auto-imported; destructure in `<script setup>` and use those fns in the template
574
+ so the typed-key check sees literal keys. Never `import` it.
575
+ - Plural + interpolation: `t(key, { vendor, count }, count)`, where the THIRD arg is the choice.
576
+ - **Code/format-example placeholders stay INLINE**, not in the catalog; required when they
577
+ contain `{`/`}` (vue-i18n metacharacters). Only prose placeholders get a key. Same for brand
578
+ names.
579
+ - **No HTML in message bodies**: drop mid-sentence `<strong>`, or use `<i18n-t>` with slots.
580
+ - For a vendor/enum-keyed set, build an array of STATIC literal `t()` keys, one per member.
581
+ Reserve the runtime-assembled key + exhaustive `Record` guard for lookups genuinely unknown
582
+ until runtime.
583
+ - Straight quotes, no em-dashes in new entries.
584
+
585
+ **Translator descriptions (`@<key>` siblings): default to NONE.** They live only in `en.json` and
586
+ are notes to a translator, never runtime data. Add one ONLY when a competent translator seeing
587
+ the English and the key path could plausibly get it wrong: homograph / part-of-speech ambiguity
588
+ (`@close`), proper nouns that must NOT be translated (`@kaizen`), umbrella strings hiding cases
589
+ the text doesn't show, placeholder/format constraints, or plural-form requirements beyond
590
+ English's two.
591
+
592
+ **Presenting a backend failure**: raw backend prose is DETAIL, never the description. Even with
593
+ no `reason` to key off, a failure is described from its STATUS CLASS through an exhaustive
594
+ `Record<ApiErrorCode, …>` of translated copy, and the untranslated `message` (plus a validation
595
+ 400's `issues` and the envelope's `requestId`) is reached through a "Show details" disclosure
596
+ that reveals it in place. So a non-English user is never handed English as the primary
597
+ explanation, and the elaborate operator remedies the backend does write stay one click away
598
+ rather than being dropped. A new failure-presenting surface copies that split (the
599
+ `usePipelineErrorToast.ts` pattern; the wire vocabulary comes from `@cat-factory/contracts`).
600
+
601
+ **Drift guards** (oxlint has no `no-raw-text` rule, so these replace it):
602
+
603
+ 1. **Typed message keys** make a statically written unknown `t('literal.key')` a typecheck
604
+ failure. This does NOT cover a runtime-assembled key.
605
+ 2. For enum→key lookups, guard with an **exhaustive `Record<TheEnum, string>`** keyed off the
606
+ contracts union, plus a runtime `te()` fallback. Never rely on tier 1 alone for a
607
+ reason/status-keyed lookup.
608
+ 3. `pnpm --filter @cat-factory/app run i18n:check` hard-fails on MISSING keys and reports unused
609
+ ones as non-blocking warnings (the catalog legitimately seeds keys ahead of use).
610
+ 4. **Locale parity**: `i18n-locale-parity.mjs --since origin/<base>` requires a PR that adds,
611
+ changes, or removes an `en.json` key to make the SAME change in every other locale. It is
612
+ change-coupling against the merge-base, NOT full key parity.
613
+
614
+ **Translate for real: NEVER ship an English string as a non-`en` value.** The parity gate checks
615
+ only that the key exists, so it will pass a verbatim English copy, and that copy is a bug. The
616
+ only values that may legitimately match `en` are proper nouns identical across languages
617
+ (`DeepSeek`, `AWS Bedrock`). If you genuinely cannot produce a translation, say so in the PR
618
+ rather than committing a placeholder that reads as done.
619
+
620
+ Migration is incremental: when you touch a component, lift its visible copy into the catalog.
621
+
514
622
  ## Extending the layer (consumer modules)
515
623
 
516
624
  A deployment can contribute its own components (result windows, nav entries, inspector
@@ -5,9 +5,8 @@
5
5
  // architecture, or from scratch following a freeform prompt. The modal pairs the
6
6
  // launch form with the managed base list.
7
7
  import type { BootstrapStatus, FrameRepoType, ReferenceArchitecture } from '~/types/domain'
8
- // Explicit import (see GitHubPanel): the auto-import name for github/GitHubConnect
9
- // doesn't match the `<GitHubConnect>` tag, so bind it directly.
10
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
8
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
9
+ import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
11
10
 
12
11
  const ui = useUiStore()
13
12
  const bootstrap = useBootstrapStore()
@@ -137,35 +136,42 @@ watch(
137
136
  { immediate: true },
138
137
  )
139
138
 
140
- // A bootstrap run pushes into a GitHub repo, so the workspace must be connected
141
- // first (the backend pre-flights the same and 409s otherwise). When the
142
- // integration is on but unconnected, surface the discover-and-link prompt inline
143
- // and block launch until it's bound.
144
- const needsGitHub = computed(() => github.available === true && !github.connected)
145
-
146
- // The account the repo must live under the connected installation's account. The
147
- // run pushes into an existing repo here (cat-factory doesn't create it: a GitHub App
148
- // can't create repos under a personal account, and we'd rather not hold the broad
149
- // Administration permission). The repo must be empty or hold only a prepopulated
150
- // README/.gitignore/license the push force-overwrites that boilerplate. The
151
- // convenience link opens GitHub's new-repo page prefilled so the user can create it
152
- // in one click.
139
+ // A bootstrap run pushes into a repo on the connected host, so the workspace must be
140
+ // connected first (the backend pre-flights the same and 409s otherwise). When the
141
+ // integration is on but unconnected, surface the connect prompt inline and block launch
142
+ // until it's bound.
143
+ const needsConnection = computed(() => github.available === true && !github.connected)
144
+
145
+ // The host this modal is about: the connected one, or the only one the deployment could
146
+ // connect while nothing is bound. Null where it offers several and none is connected, so
147
+ // `provider`'s own "what is connected" default can never send a GitLab deployment to github.com.
148
+ const hostProvider = computed(() => github.surfaceProvider)
149
+ const providerLabel = computed(() =>
150
+ hostProvider.value ? VCS_PROVIDER_LABELS[hostProvider.value] : '',
151
+ )
152
+
153
+ // The account the repo must live under — the connected account. The run pushes into an
154
+ // existing repo here (cat-factory doesn't create it: a GitHub App can't create repos under a
155
+ // personal account, and we'd rather not hold the broad Administration permission). The repo
156
+ // must be empty or hold only a prepopulated README/.gitignore/license — the push
157
+ // force-overwrites that boilerplate. The convenience link opens the host's own new-repo page,
158
+ // prefilled, and is ABSENT for any host `~/utils/vcs` can't name (an unresolved provider, or a
159
+ // GitLab whose instance nothing on the wire states); the copy and the button both key off it,
160
+ // so what the intro promises and what renders cannot disagree.
153
161
  const repoOwner = computed(() => github.connection?.accountLogin ?? '')
154
- const createRepoUrl = computed(() => {
155
- const params = new URLSearchParams()
156
- if (repoOwner.value) params.set('owner', repoOwner.value)
157
- const name = repoName.value.trim()
158
- if (name) params.set('name', name)
159
- const desc = description.value.trim()
160
- if (desc) params.set('description', desc)
161
- params.set('visibility', isPrivate.value ? 'private' : 'public')
162
- return `https://github.com/new?${params.toString()}`
163
- })
162
+ const createRepoUrl = computed(() =>
163
+ newRepoUrl(hostProvider.value, {
164
+ owner: repoOwner.value,
165
+ name: repoName.value.trim(),
166
+ description: description.value.trim(),
167
+ private: isPrivate.value,
168
+ }),
169
+ )
164
170
 
165
171
  const creatingRepo = ref(false)
166
172
 
167
173
  // The "create repository" button behaves differently per tier. Restricted orgs
168
- // (the default) open GitHub's new-repo page prefilled — cat-factory needs no
174
+ // (the default) open the host's new-repo page — cat-factory needs no
169
175
  // repo-creation permission. Privileged orgs (the connection reports
170
176
  // `canCreateRepos`) create it programmatically via the backend, with no page.
171
177
  async function openCreateRepo() {
@@ -173,7 +179,9 @@ async function openCreateRepo() {
173
179
  if (!name || repoNameError.value) return
174
180
 
175
181
  if (!github.canCreateRepos) {
176
- window.open(createRepoUrl.value, '_blank', 'noopener')
182
+ // The button is hidden without a resolved host, so there is always a URL here; the guard
183
+ // keeps that a local fact rather than an assumption about the template.
184
+ if (createRepoUrl.value) window.open(createRepoUrl.value, '_blank', 'noopener')
177
185
  return
178
186
  }
179
187
 
@@ -207,20 +215,15 @@ async function openCreateRepo() {
207
215
  // "not accessible to the GitHub App". Link straight to the connected
208
216
  // installation's settings page, where the user adds the repo to its access list
209
217
  // in one click — no install/connect round-trip (the workspace is already bound).
210
- const manageInstallUrl = computed(() => {
211
- const conn = github.connection
212
- if (!conn) return undefined
213
- return conn.targetType === 'Organization'
214
- ? `https://github.com/organizations/${conn.accountLogin}/settings/installations/${conn.installationId}`
215
- : `https://github.com/settings/installations/${conn.installationId}`
216
- })
218
+ // Absent on a PAT connection, which grants no per-installation access (see `~/utils/vcs`).
219
+ const manageInstallUrl = computed(() => appInstallationManageUrl(github.connection))
217
220
 
218
221
  function openManageInstall() {
219
222
  if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
220
223
  }
221
224
 
222
225
  const canLaunch = computed(() => {
223
- if (needsGitHub.value) return false
226
+ if (needsConnection.value) return false
224
227
  if (!repoName.value.trim() || repoNameError.value) return false
225
228
  return usingReference.value ? !!selectedArchId.value : instructions.value.trim().length > 0
226
229
  })
@@ -420,22 +423,34 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
420
423
  <UModal v-model:open="open" :title="t('bootstrap.title')" :ui="{ content: 'max-w-2xl' }">
421
424
  <template #body>
422
425
  <div class="space-y-6">
426
+ <!-- Three states, because each promises the user something different about the repo.
427
+ cat-factory creates it (privileged App tier, so a provider is always resolved);
428
+ the user creates it in one click on a host we can name; or the user creates it
429
+ themselves somewhere we cannot name, where promising a click below would be a lie
430
+ (the button is absent for exactly the same reason). -->
423
431
  <p class="text-sm text-slate-400">
424
- {{ github.canCreateRepos ? t('bootstrap.intro.canCreate') : t('bootstrap.intro.manual') }}
432
+ {{
433
+ github.canCreateRepos
434
+ ? t('vcs.bootstrap.introCanCreate', { provider: providerLabel })
435
+ : createRepoUrl
436
+ ? t('vcs.bootstrap.introManual', { provider: providerLabel })
437
+ : t('vcs.bootstrap.introManualAny')
438
+ }}
425
439
  </p>
426
440
 
427
- <!-- not connected: a run needs GitHub, so discover & link before launching -->
441
+ <!-- not connected: a run pushes to the host, so connect before launching. Offer
442
+ whichever methods the deployment serves, never just the GitHub App. -->
428
443
  <div
429
- v-if="needsGitHub"
444
+ v-if="needsConnection"
430
445
  class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
431
446
  >
432
447
  <div class="flex items-start gap-2">
433
448
  <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
434
449
  <p class="text-sm text-amber-200/90">
435
- {{ t('bootstrap.github.prompt') }}
450
+ {{ t('vcs.bootstrap.connectPrompt') }}
436
451
  </p>
437
452
  </div>
438
- <GitHubConnect />
453
+ <VcsConnectSurfaces />
439
454
  </div>
440
455
 
441
456
  <!-- launch -->
@@ -484,7 +499,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
484
499
  :placeholder="t('bootstrap.targetRepo.namePlaceholder')"
485
500
  class="w-full"
486
501
  />
502
+ <!-- Creating the repo for the user needs no host name; sending them to the
503
+ host's own form needs one, so that variant waits until a host is
504
+ resolved rather than guessing which page to open. -->
487
505
  <UButton
506
+ v-if="github.canCreateRepos || createRepoUrl"
488
507
  color="neutral"
489
508
  variant="subtle"
490
509
  :icon="github.canCreateRepos ? 'i-lucide-plus' : 'i-lucide-external-link'"
@@ -493,14 +512,14 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
493
512
  :title="
494
513
  github.canCreateRepos
495
514
  ? t('bootstrap.createRepo.titleNow')
496
- : t('bootstrap.createRepo.titleGitHub')
515
+ : t('vcs.bootstrap.createRepoTitle', { provider: providerLabel })
497
516
  "
498
517
  @click="openCreateRepo"
499
518
  >
500
519
  {{
501
520
  github.canCreateRepos
502
521
  ? t('bootstrap.createRepo.now')
503
- : t('bootstrap.createRepo.onGitHub')
522
+ : t('vcs.bootstrap.createRepoOn', { provider: providerLabel })
504
523
  }}
505
524
  </UButton>
506
525
  </div>
@@ -671,9 +690,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
671
690
  v-if="showArchForm"
672
691
  class="space-y-3 rounded-md border border-slate-700 bg-slate-900/80 p-3"
673
692
  >
693
+ <!-- The options come from the connected projection, so a repo to pick means a
694
+ connection exists and `providerLabel` names it rather than guessing. -->
674
695
  <UFormField
675
696
  v-if="hasRepoOptions"
676
- :label="t('bootstrap.arch.pickRepo.label')"
697
+ :label="t('vcs.bootstrap.archPickRepo', { provider: providerLabel })"
677
698
  :description="t('bootstrap.arch.pickRepo.description')"
678
699
  >
679
700
  <USelect
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
- // Add a board service backed by an EXISTING GitHub repository — no bootstrap
3
- // run. Unlike the bootstrap modal (which creates a repo and has an agent adapt
4
- // it in a container), this just links a repo the App can access to a fresh,
5
- // `ready` service frame. The workspace need not track the repo yet: the backend
6
- // links + syncs it on import. If the App can't see the wanted repo, the user
7
- // grants it access from here, then searches for it again.
2
+ // Add a board service backed by an EXISTING repository — no bootstrap run. Unlike the
3
+ // bootstrap modal (which creates a repo and has an agent adapt it in a container), this
4
+ // just links a repo the workspace's connection can reach to a fresh, `ready` service
5
+ // frame. The workspace need not track the repo yet: the backend links + syncs it on
6
+ // import. On a GitHub App connection, a repo the App can't see yet is granted from here
7
+ // and searched for again; a PAT connection has no such page (see `~/utils/vcs`), so what
8
+ // is listed follows the token's own access.
8
9
  //
9
10
  // MONOREPO support: a repo flagged a monorepo can back SEVERAL services, each
10
11
  // pinned to a subdirectory. When the selected repo is a monorepo, the user
@@ -12,11 +13,12 @@
12
13
  // parent folder, in one pass — then adds them all at once. Directories that
13
14
  // already back a service on this board are shown but not selectable.
14
15
  import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
15
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
16
16
  import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
17
17
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
18
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
18
19
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
19
20
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
21
+ import { appInstallationManageUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
20
22
 
21
23
  const { t } = useI18n()
22
24
 
@@ -64,7 +66,22 @@ watch(
64
66
  )
65
67
 
66
68
  // The integration is on but this workspace isn't bound yet — connect first.
67
- const needsGitHub = computed(() => github.available === true && !github.connected)
69
+ const needsConnection = computed(() => github.available === true && !github.connected)
70
+
71
+ // Brand name of whatever the workspace connected, for the hint that names it. Only read where
72
+ // a connection exists (the hints below the picker), so `provider` is the right question there.
73
+ const providerLabel = computed(() => VCS_PROVIDER_LABELS[github.provider])
74
+
75
+ // Which remedy the picker's hint offers: an App installation sends the user to its repo-access
76
+ // list, a pasted token to the token's own scope. Asked of the CONNECTION rather than of the
77
+ // manage URL below, so a host whose settings page we could not build (an Enterprise install,
78
+ // say) never tells an App-connected user to go check their token's scope.
79
+ const isAppConnection = computed(() => github.connection?.method === 'app')
80
+
81
+ // The intro renders BEFORE a connection may exist, so it asks `surfaceProvider` instead and
82
+ // stays neutral where the deployment offers several and none is bound: naming one would be a
83
+ // guess, and `provider`'s own default would name GitHub on a GitLab-only deployment.
84
+ const introProvider = computed(() => github.surfaceProvider)
68
85
 
69
86
  // Repos whose service is ALREADY mounted on THIS board can't be added again — adding here would
70
87
  // be a no-op. A repo whose service lives on ANOTHER board in the org stays addable: adding it
@@ -201,15 +218,10 @@ function clearSelection() {
201
218
  resetSelection()
202
219
  }
203
220
 
204
- // The App's installation settings page — where the user grants it access to a
205
- // repo it can't see yet (mirrors the bootstrap modal's "grant access" link).
206
- const manageInstallUrl = computed(() => {
207
- const conn = github.connection
208
- if (!conn) return undefined
209
- return conn.targetType === 'Organization'
210
- ? `https://github.com/organizations/${conn.accountLogin}/settings/installations/${conn.installationId}`
211
- : `https://github.com/settings/installations/${conn.installationId}`
212
- })
221
+ // The App's installation settings page — where the user grants it access to a repo it can't
222
+ // see yet (mirrors the bootstrap modal's "grant access" link). Absent on a PAT connection,
223
+ // which has no installation to manage, so the affordance and its hint both drop out.
224
+ const manageInstallUrl = computed(() => appInstallationManageUrl(github.connection))
213
225
 
214
226
  function openManageInstall() {
215
227
  if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
@@ -242,14 +254,14 @@ watch(
242
254
  // multi-selects directories and adds them together via `addServices`.
243
255
  const canAdd = computed(
244
256
  () =>
245
- !needsGitHub.value &&
257
+ !needsConnection.value &&
246
258
  selectedRepoId.value !== undefined &&
247
259
  !isMonorepo.value &&
248
260
  !configuredBlockId.value,
249
261
  )
250
262
  const canAddServices = computed(
251
263
  () =>
252
- !needsGitHub.value &&
264
+ !needsConnection.value &&
253
265
  selectedRepoId.value !== undefined &&
254
266
  isMonorepo.value &&
255
267
  selectedDirectories.value.length > 0,
@@ -357,27 +369,36 @@ function done() {
357
369
  <template #body>
358
370
  <div class="space-y-6">
359
371
  <p class="text-sm text-slate-400">
360
- {{ t('github.addService.intro') }}
372
+ {{
373
+ introProvider
374
+ ? t('vcs.addService.intro', { provider: VCS_PROVIDER_LABELS[introProvider] })
375
+ : t('vcs.addService.introAny')
376
+ }}
361
377
  </p>
362
378
 
363
- <!-- not connected: linking a repo needs the App bound to this workspace -->
379
+ <!-- not connected: linking a repo needs a connection bound to this workspace, so
380
+ offer whichever connect methods the deployment serves (never just the App) -->
364
381
  <div
365
- v-if="needsGitHub"
382
+ v-if="needsConnection"
366
383
  class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
367
384
  >
368
385
  <div class="flex items-start gap-2">
369
386
  <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
370
387
  <p class="text-sm text-amber-200/90">
371
- {{ t('github.addService.connectFirst') }}
388
+ {{ t('vcs.addService.connectFirst') }}
372
389
  </p>
373
390
  </div>
374
- <GitHubConnect />
391
+ <VcsConnectSurfaces />
375
392
  </div>
376
393
 
377
394
  <template v-else>
378
395
  <UFormField
379
396
  :label="t('github.addService.repository')"
380
- :description="t('github.addService.repositoryHint')"
397
+ :description="
398
+ isAppConnection
399
+ ? t('vcs.addService.repositoryHintApp')
400
+ : t('vcs.addService.repositoryHintToken', { provider: providerLabel })
401
+ "
381
402
  required
382
403
  >
383
404
  <!-- The wrapper, not the UInputMenu itself, carries the anchor: a tutorial tour
@@ -521,9 +542,10 @@ function done() {
521
542
  <ServiceFragments :block="configuredBlock" default-open />
522
543
  </div>
523
544
 
524
- <div class="flex flex-wrap items-center gap-2">
545
+ <!-- App connections only: a pasted token has no installation whose repo access
546
+ could be edited, so there is no page to send the user to. -->
547
+ <div v-if="manageInstallUrl" class="flex flex-wrap items-center gap-2">
525
548
  <UButton
526
- v-if="manageInstallUrl"
527
549
  color="neutral"
528
550
  variant="subtle"
529
551
  size="sm"
@@ -2,13 +2,9 @@
2
2
  // Hard onboarding gate shown after login when the VCS integration is enabled but the workspace
3
3
  // has no connection yet. cat-factory's whole flow runs on a connected repository host (agents
4
4
  // open pull/merge requests on the user's repos), so the board is withheld until the workspace
5
- // connects one. Renders the connect surfaces the deployment can actually serve: <GitHubConnect>
6
- // drives the account-level App install (https://github.com/apps/<slug>/installations/new the
7
- // user picks the account/org and grants all or a subset of repos) plus the
8
- // pick-an-existing-installation path, and <GitLabConnect> takes a pasted GitLab PAT. A "Sign
9
- // out" escape hatch avoids trapping a user who needs to switch accounts.
10
- import GitHubConnect from '~/components/github/GitHubConnect.vue'
11
- import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
5
+ // connects one. <VcsConnectSurfaces> renders whichever connect methods the deployment serves; a
6
+ // "Sign out" escape hatch avoids trapping a user who needs to switch accounts.
7
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
12
8
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
13
9
 
14
10
  const { t } = useI18n()
@@ -41,22 +37,7 @@ const title = computed(() =>
41
37
  </p>
42
38
  </div>
43
39
 
44
- <template v-if="github.canConnectGitHubApp">
45
- <p class="mb-3 text-sm text-slate-400">{{ t('github.onboarding.appIntro') }}</p>
46
- <GitHubConnect />
47
- </template>
48
- <USeparator
49
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
50
- class="my-4"
51
- :label="t('vcs.connect.or')"
52
- />
53
- <GitLabConnect v-if="github.canConnectGitLabPat" />
54
- <p
55
- v-if="!github.canConnectGitHubApp && !github.canConnectGitLabPat"
56
- class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
57
- >
58
- {{ t('vcs.connect.noneConfigured') }}
59
- </p>
40
+ <VcsConnectSurfaces :app-intro="t('github.onboarding.appIntro')" />
60
41
 
61
42
  <p
62
43
  v-if="auth.required && auth.user"
@@ -1,17 +1,14 @@
1
1
  <script setup lang="ts">
2
- // GitHub integration panel: connect the workspace's App installation, manage the
3
- // connection (disconnect / resync), and browse the projected repos, branches,
4
- // pull requests and issues the backend caches in D1. Mirrors the document-source
5
- // connect/import surface, but for GitHub. Writes (new branch, open/merge PR,
6
- // comment) go straight to the repo via the backend's installation token.
2
+ // Source-control panel: connect the workspace, manage the connection (disconnect / resync),
3
+ // and browse the projected repos, branches, pull requests and issues the backend caches.
4
+ // Mirrors the document-source connect/import surface. Writes (new branch, open/merge PR,
5
+ // comment) go straight to the repo via the backend's connection credential.
7
6
  import type { GitHubPullRequest, GitHubRepo, VcsProvider } from '~/types/domain'
8
- // Explicit import: the auto-import name for a component nested under a
9
- // like-named directory (github/GitHubConnect) doesn't match the `<GitHubConnect>`
10
- // tag, so it silently renders as an empty element. Importing it directly binds
11
- // the tag unambiguously.
12
- import GitHubConnect from './GitHubConnect.vue'
7
+ // Explicit imports: the auto-import name for a component nested under a like-named directory
8
+ // (github/BranchProtectionPreflight) doesn't match the tag used below, so it would silently
9
+ // render as an empty element. Importing by path binds each tag unambiguously.
13
10
  import BranchProtectionPreflight from './BranchProtectionPreflight.vue'
14
- import GitLabConnect from '~/components/vcs/GitLabConnect.vue'
11
+ import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
15
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
16
13
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
17
14
 
@@ -33,9 +30,7 @@ const back = useIntegrationBack(open)
33
30
  // Provider-aware chrome: once connected, the panel is titled for the provider the workspace
34
31
  // actually connected; before that it names the sole connectable provider, or stays neutral when
35
32
  // the deployment serves several. Brand names are verbatim in every locale (see `~/utils/vcs`).
36
- const chromeProvider = computed(() =>
37
- github.connected ? github.provider : github.soleConnectProvider,
38
- )
33
+ const chromeProvider = computed(() => github.surfaceProvider)
39
34
  const panelTitle = computed(() =>
40
35
  chromeProvider.value ? VCS_PROVIDER_LABELS[chromeProvider.value] : t('vcs.panel.title'),
41
36
  )
@@ -283,26 +278,7 @@ async function merge(pr: GitHubPullRequest) {
283
278
  <template #body>
284
279
  <div class="space-y-5">
285
280
  <!-- not connected: connect -->
286
- <template v-if="!github.connected">
287
- <!-- One connect surface per method the deployment actually serves: the App
288
- installation picker only where a GitHub App is configured, the PAT box only
289
- where the per-workspace GitLab connect is wired. -->
290
- <template v-if="github.canConnectGitHubApp">
291
- <p class="text-sm text-slate-400">{{ t('github.panel.connectIntro') }}</p>
292
- <GitHubConnect />
293
- </template>
294
- <USeparator
295
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
296
- :label="t('vcs.connect.or')"
297
- />
298
- <GitLabConnect v-if="github.canConnectGitLabPat" />
299
- <p
300
- v-if="!github.canConnectGitHubApp && !github.canConnectGitLabPat"
301
- class="rounded-md border border-dashed border-slate-800 px-3 py-3 text-sm text-slate-400"
302
- >
303
- {{ t('vcs.connect.noneConfigured') }}
304
- </p>
305
- </template>
281
+ <VcsConnectSurfaces v-if="!github.connected" :app-intro="t('github.panel.connectIntro')" />
306
282
 
307
283
  <!-- connected: manage + browse -->
308
284
  <template v-else>