@cat-factory/app 0.93.0 → 0.95.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.
@@ -10,9 +10,9 @@
10
10
  // pinned to a subdirectory. When the selected repo is a monorepo, the user
11
11
  // browses its tree and picks the service's directory before adding (and may add
12
12
  // more than one, a subset of the repo's services).
13
- import { refDebounced } from '@vueuse/core'
14
13
  import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
15
14
  import GitHubConnect from '~/components/github/GitHubConnect.vue'
15
+ import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
16
16
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
17
17
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
18
18
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
@@ -89,33 +89,27 @@ function toRepoItem(r: GitHubAvailableRepo) {
89
89
  return { label: `${r.owner}/${r.name}${suffix}`, value: r.githubId, disabled: onBoard }
90
90
  }
91
91
 
92
- const repoItems = computed(() => github.availableRepos.map(toRepoItem))
93
-
94
- // A wide App install (or a PAT) can expose hundreds of repos, far too many to prefetch and
95
- // filter in the browser so the picker searches SERVER-SIDE. Nothing is fetched on open;
96
- // once the user types at least MIN_SEARCH_LEN characters the (debounced) query is sent to
97
- // the backend, which returns only the `owner/name` matches. Below the gate the list stays
98
- // empty and the field prompts for more characters.
99
- const MIN_SEARCH_LEN = 3
100
- const repoSearch = ref('')
101
- const repoSearchDebounced = refDebounced(repoSearch, 250)
102
- // Trimmed for display + the min-length gate; the backend matches case-insensitively.
103
- const repoQueryRaw = computed(() => repoSearchDebounced.value.trim())
104
-
105
- // True while the query is too short to search — the picker shows the "type N chars" hint.
106
- const belowMinChars = computed(() => repoQueryRaw.value.length < MIN_SEARCH_LEN)
92
+ // Server-side, debounced, min-length-gated repo search (a wide App install / PAT can expose
93
+ // hundreds of repos, too many to prefetch and filter in the browser). Shared with the doc-task
94
+ // reference-repo picker via `useRepoSearch`; `repoResults` is this picker's own result list, so
95
+ // the two never clobber each other. Nothing is fetched on open — the field prompts for more
96
+ // characters below the gate; granting the App access needs no manual refresh (the next search
97
+ // hits GitHub live).
98
+ const {
99
+ search: repoSearch,
100
+ query: repoQueryRaw,
101
+ belowMinChars,
102
+ results: repoResults,
103
+ loading: repoLoading,
104
+ reset: resetRepoSearch,
105
+ } = useRepoSearch()
106
+
107
+ const repoItems = computed(() => repoResults.value.map(toRepoItem))
107
108
 
108
109
  // The selected repo, captured when picked (below). The loaded list is volatile — a later
109
- // search replaces it — so the selection can't be derived from `availableRepos` after the fact.
110
+ // search replaces it — so the selection can't be derived from the results after the fact.
110
111
  const selectedRepo = ref<GitHubAvailableRepo | undefined>(undefined)
111
112
 
112
- // Fetch matches server-side as the debounced query changes; below the gate clear the list
113
- // so stale matches don't linger (the hint shows instead). Granting the App access needs no
114
- // manual refresh — the next search hits GitHub live, with no cached list to invalidate.
115
- watch(repoQueryRaw, (q) => {
116
- void github.loadAvailableRepos(q.length >= MIN_SEARCH_LEN ? q : '')
117
- })
118
-
119
113
  // The server already filtered, so the matches ARE the loaded repos (empty below the gate).
120
114
  const queryMatches = computed(() => (belowMinChars.value ? [] : repoItems.value))
121
115
 
@@ -148,7 +142,7 @@ function toggleMonorepo(value: boolean) {
148
142
  watch(selectedRepoId, (id) => {
149
143
  if (id === undefined) selectedRepo.value = undefined
150
144
  else {
151
- const found = github.availableRepos.find((r) => r.githubId === id)
145
+ const found = repoResults.value.find((r) => r.githubId === id)
152
146
  if (found) selectedRepo.value = found
153
147
  }
154
148
  isMonorepo.value = selectedRepo.value?.isMonorepo === true
@@ -161,7 +155,7 @@ function resetSelection() {
161
155
  selectedDirectory.value = undefined
162
156
  isMonorepo.value = false
163
157
  configuredBlockId.value = undefined
164
- repoSearch.value = ''
158
+ resetRepoSearch()
165
159
  selectedType.value = 'service'
166
160
  }
167
161
 
@@ -296,7 +290,7 @@ function done() {
296
290
  :items="repoMenuItems"
297
291
  :ignore-filter="true"
298
292
  value-key="value"
299
- :loading="github.loadingAvailable"
293
+ :loading="repoLoading"
300
294
  icon="i-lucide-search"
301
295
  :placeholder="t('github.addService.searchPlaceholder')"
302
296
  class="w-full"
@@ -312,14 +306,11 @@ function done() {
312
306
  />
313
307
  </template>
314
308
  <template #empty>
315
- <span v-if="belowMinChars">
316
- {{
317
- t('github.addService.searchMinChars', { min: MIN_SEARCH_LEN }, MIN_SEARCH_LEN)
318
- }}
319
- </span>
320
- <span v-else-if="!github.loadingAvailable">{{
321
- t('github.addService.noMatches', { query: repoQueryRaw })
322
- }}</span>
309
+ <RepoSearchEmpty
310
+ :below-min-chars="belowMinChars"
311
+ :loading="repoLoading"
312
+ :query="repoQueryRaw"
313
+ />
323
314
  </template>
324
315
  </UInputMenu>
325
316
  </div>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ // The empty-state body shared by the GitHub repo pickers (the add-service modal and the doc-task
3
+ // reference-repo picker), both driven by `useRepoSearch`: below the min-length gate it prompts for
4
+ // more characters; otherwise (once a search has settled) it reports no matches. Kept as one
5
+ // component so the two pickers can't drift on the empty-state copy or behaviour.
6
+ import { REPO_SEARCH_MIN_LEN } from '~/composables/useRepoSearch'
7
+
8
+ defineProps<{ belowMinChars: boolean; loading: boolean; query: string }>()
9
+
10
+ const { t } = useI18n()
11
+ </script>
12
+
13
+ <template>
14
+ <span v-if="belowMinChars">
15
+ {{ t('github.addService.searchMinChars', { min: REPO_SEARCH_MIN_LEN }, REPO_SEARCH_MIN_LEN) }}
16
+ </span>
17
+ <span v-else-if="!loading">{{ t('github.addService.noMatches', { query }) }}</span>
18
+ </template>
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ // Reference repositories for a document-authoring task. The doc-writer agent clones each attached
3
+ // repo READ-ONLY as a sibling checkout it may read (to reuse existing solutions as a reference)
4
+ // while drafting — it never writes to them. Any repo the workspace's GitHub App (or the signed-in
5
+ // user's PAT) can reach may be attached, so this reuses the SAME server-side, debounced repo
6
+ // search as the add-service picker (`useRepoSearch`), not a filter over the synced projection.
7
+ import type { Block, GitHubAvailableRepo, ReferenceRepo } from '~/types/domain'
8
+ import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
9
+
10
+ const props = defineProps<{ block: Block }>()
11
+
12
+ const { t } = useI18n()
13
+ const github = useGitHubStore()
14
+ const board = useBoardStore()
15
+
16
+ const {
17
+ search: repoSearch,
18
+ query: repoQuery,
19
+ belowMinChars,
20
+ results: repoResults,
21
+ loading: repoLoading,
22
+ reset: resetRepoSearch,
23
+ } = useRepoSearch()
24
+
25
+ const attached = computed<ReferenceRepo[]>(() => props.block.referenceRepos ?? [])
26
+ // Keyed by the provider-neutral repo id. The search results are `GitHubAvailableRepo`s (whose id
27
+ // field is `githubId`), so a menu item's `githubId` is compared against this set of `repoId`s —
28
+ // the same numeric id space on every provider.
29
+ const attachedIds = computed(() => new Set(attached.value.map((r) => r.repoId)))
30
+
31
+ // Menu items: the searched repos, an already-attached one disabled so it can't be added twice.
32
+ const repoItems = computed(() =>
33
+ belowMinChars.value
34
+ ? []
35
+ : repoResults.value.map((r) => ({
36
+ label: `${r.owner}/${r.name}${r.personal ? t('github.addService.repoLabel.personal') : ''}`,
37
+ value: r.githubId,
38
+ disabled: attachedIds.value.has(r.githubId),
39
+ })),
40
+ )
41
+
42
+ // The picked repo id; watched to attach then clear (the combobox has no "add" affordance of its
43
+ // own, so selecting a repo IS the attach action).
44
+ const pickedId = ref<number | undefined>(undefined)
45
+
46
+ watch(pickedId, (id) => {
47
+ if (id === undefined) return
48
+ const repo = repoResults.value.find((r) => r.githubId === id)
49
+ pickedId.value = undefined
50
+ if (!repo || attachedIds.value.has(id)) return
51
+ attach(repo)
52
+ })
53
+
54
+ function toReference(repo: GitHubAvailableRepo): ReferenceRepo {
55
+ return {
56
+ repoId: repo.githubId,
57
+ owner: repo.owner,
58
+ name: repo.name,
59
+ // A repo with no reported default branch is rare; fall back to `main` so the clone has a ref.
60
+ defaultBranch: repo.defaultBranch ?? 'main',
61
+ // A repo the workspace connection reaches carries that connection; a PAT-only (`personal`) repo
62
+ // has none, so the run clones it with the initiator's own token instead.
63
+ ...(repo.personal || !github.connection
64
+ ? {}
65
+ : { connectionId: github.connection.installationId }),
66
+ }
67
+ }
68
+
69
+ function attach(repo: GitHubAvailableRepo) {
70
+ board.updateBlock(props.block.id, {
71
+ referenceRepos: [...attached.value, toReference(repo)],
72
+ })
73
+ resetRepoSearch()
74
+ }
75
+
76
+ function detach(repoId: number) {
77
+ board.updateBlock(props.block.id, {
78
+ referenceRepos: attached.value.filter((r) => r.repoId !== repoId),
79
+ })
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div data-testid="doc-reference-repos">
85
+ <div class="mb-1 flex items-center justify-between">
86
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
87
+ {{ t('inspector.referenceRepos.title') }}
88
+ </span>
89
+ </div>
90
+
91
+ <!-- Attached reference repos: chips with a remove control. -->
92
+ <div v-if="attached.length" class="mb-1.5 flex flex-wrap gap-1">
93
+ <UBadge
94
+ v-for="r in attached"
95
+ :key="r.repoId"
96
+ size="sm"
97
+ variant="soft"
98
+ color="neutral"
99
+ data-testid="reference-repo-chip"
100
+ >
101
+ {{ r.owner }}/{{ r.name }}
102
+ <UButton
103
+ color="neutral"
104
+ variant="link"
105
+ size="xs"
106
+ icon="i-lucide-x"
107
+ :aria-label="t('inspector.referenceRepos.remove', { repo: `${r.owner}/${r.name}` })"
108
+ data-testid="reference-repo-remove"
109
+ @click="detach(r.repoId)"
110
+ />
111
+ </UBadge>
112
+ </div>
113
+
114
+ <!-- The picker: only usable once the workspace's GitHub App is connected. -->
115
+ <UInputMenu
116
+ v-if="github.connected"
117
+ v-model="pickedId"
118
+ v-model:search-term="repoSearch"
119
+ :items="repoItems"
120
+ :ignore-filter="true"
121
+ value-key="value"
122
+ :loading="repoLoading"
123
+ icon="i-lucide-search"
124
+ :placeholder="t('github.addService.searchPlaceholder')"
125
+ class="w-full"
126
+ data-testid="reference-repo-search"
127
+ >
128
+ <template #empty>
129
+ <RepoSearchEmpty
130
+ :below-min-chars="belowMinChars"
131
+ :loading="repoLoading"
132
+ :query="repoQuery"
133
+ />
134
+ </template>
135
+ </UInputMenu>
136
+ <div v-else class="text-[11px] text-slate-500">
137
+ {{ t('inspector.referenceRepos.connectFirst') }}
138
+ </div>
139
+
140
+ <div class="mt-1 text-[11px] text-slate-500">
141
+ {{ t('inspector.referenceRepos.hint') }}
142
+ </div>
143
+ </div>
144
+ </template>
@@ -480,6 +480,9 @@ const technicalLabel = computed(() => {
480
480
  </div>
481
481
  </div>
482
482
 
483
+ <!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
484
+ <DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
485
+
483
486
  <!-- issue-tracker writeback overrides -->
484
487
  <div>
485
488
  <div class="mb-1 flex items-center justify-between">
@@ -14,6 +14,10 @@ import type { ProviderConnectionKind } from '~/types/providerConnections'
14
14
  import InfrastructureBackendPicker from '~/components/settings/InfrastructureBackendPicker.vue'
15
15
  import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfigurator.vue'
16
16
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
17
+ import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
18
+
19
+ // The shared-stacks tab uses its own slot key beyond the provider-connection kinds.
20
+ type InfraTabValue = ProviderConnectionKind | 'shared-stacks'
17
21
 
18
22
  const { t } = useI18n()
19
23
  const ui = useUiStore()
@@ -37,7 +41,7 @@ const agentsAvailable = computed(() => (auth.infrastructure?.execution.available
37
41
  const envsAvailable = computed(() => (auth.infrastructure?.testEnv.available.length ?? 0) > 0)
38
42
 
39
43
  const tabs = computed(() => {
40
- const out: { value: ProviderConnectionKind; label: string; icon: string; slot: string }[] = []
44
+ const out: { value: InfraTabValue; label: string; icon: string; slot: string }[] = []
41
45
  if (agentsAvailable.value)
42
46
  out.push({
43
47
  value: 'runner-pool',
@@ -52,10 +56,19 @@ const tabs = computed(() => {
52
56
  icon: 'i-lucide-cloud',
53
57
  slot: 'environment',
54
58
  })
59
+ // Shared stacks are long-lived compose infra a Tester environment attaches to, so they live
60
+ // alongside the test-environment config (shown wherever an environment backend is available).
61
+ if (envsAvailable.value)
62
+ out.push({
63
+ value: 'shared-stacks',
64
+ label: t('settings.sharedStacks.tab'),
65
+ icon: 'i-lucide-layers',
66
+ slot: 'shared-stacks',
67
+ })
55
68
  return out
56
69
  })
57
70
 
58
- const activeTab = ref<ProviderConnectionKind>(ui.infrastructureTab)
71
+ const activeTab = ref<InfraTabValue>(ui.infrastructureTab)
59
72
 
60
73
  // Honour the deep-linked tab each time the window opens, falling back to the first available
61
74
  // tab if the requested one is off.
@@ -124,6 +137,9 @@ watch([tabs, () => store.loaded], () => {
124
137
  <InfraHandlersConfigurator />
125
138
  </div>
126
139
  </template>
140
+ <template #shared-stacks>
141
+ <SharedStacksPanel />
142
+ </template>
127
143
  </UTabs>
128
144
 
129
145
  <p v-else class="px-1 py-6 text-center text-sm text-slate-500">