@cat-factory/app 0.139.0 → 0.140.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.
@@ -35,7 +35,7 @@ const agentConfig = useAgentConfigStore()
35
35
  const toast = useToast()
36
36
  const { t } = useI18n()
37
37
 
38
- const { linkPending } = useContextLinking()
38
+ const { linkPending, presentLinkFailures } = useContextLinking()
39
39
 
40
40
  const open = computed({
41
41
  get: () => ui.addTaskContainerId !== null,
@@ -292,24 +292,6 @@ const selectedModelPresetLabel = computed(() => {
292
292
  const selectablePipelines = computed(() =>
293
293
  pipelines.pipelines.filter((p) => pipelineAllowedForManualStart(p, frame.value, board.blocks)),
294
294
  )
295
- const pipelineMenu = computed(() => [
296
- [
297
- {
298
- label: t('board.addTask.chooseAtRunTime'),
299
- icon: 'i-lucide-rotate-ccw',
300
- onSelect: () => (pipelineId.value = ''),
301
- },
302
- ...selectablePipelines.value.map((p) => ({
303
- label: p.name,
304
- icon: 'i-lucide-workflow',
305
- onSelect: () => (pipelineId.value = p.id),
306
- })),
307
- ],
308
- ])
309
- const selectedPipelineLabel = computed(
310
- () => pipelines.getPipeline(pipelineId.value)?.name ?? t('board.addTask.chooseAtRunTime'),
311
- )
312
-
313
295
  // Picking the Ralph loop task type auto-selects its pipeline, so the per-task validation
314
296
  // command + iteration budget (contributed by the `ralph` agent) surface immediately — the
315
297
  // loop is meaningless without them, so "choose at run time" would be a dead end here.
@@ -578,14 +560,10 @@ async function add() {
578
560
  ...(technical.value ? { technical: true } : {}),
579
561
  })
580
562
  if (block) {
581
- const failed = await linkPending(block.id, pendingContext.value)
582
- if (failed > 0) {
583
- toast.add({
584
- title: t('board.addTask.linkFailed', { count: failed }, failed),
585
- icon: 'i-lucide-triangle-alert',
586
- color: 'warning',
587
- })
588
- }
563
+ // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
564
+ // permission/visibility error, a not-found doc, …) instead of a bare count, plus a
565
+ // one-click "Copy details" for a bug report.
566
+ presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
589
567
  }
590
568
  ui.closeAddTask()
591
569
  } catch (e) {
@@ -895,18 +873,13 @@ async function add() {
895
873
 
896
874
  <div class="grid grid-cols-2 gap-3">
897
875
  <UFormField :label="t('board.addTask.pipeline')">
898
- <UDropdownMenu :items="pipelineMenu" class="w-full">
899
- <UButton
900
- color="neutral"
901
- variant="subtle"
902
- size="sm"
903
- icon="i-lucide-workflow"
904
- trailing-icon="i-lucide-chevron-down"
905
- class="w-full justify-between"
906
- >
907
- {{ selectedPipelineLabel }}
908
- </UButton>
909
- </UDropdownMenu>
876
+ <PipelinePicker
877
+ :model-value="pipelineId"
878
+ :options="selectablePipelines"
879
+ :none-label="t('board.addTask.chooseAtRunTime')"
880
+ trigger-class="w-full justify-between"
881
+ @update:model-value="pipelineId = $event"
882
+ />
910
883
  </UFormField>
911
884
 
912
885
  <UFormField :label="t('board.addTask.mergePolicy')">
@@ -10,6 +10,12 @@
10
10
  // it's fetched + persisted before linking. Mirrors ContextIssuePicker.
11
11
  import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
12
12
  import EmptyState from '~/components/common/EmptyState.vue'
13
+ import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
14
+
15
+ // Repo-backed document sources pick a FILE out of a repository (repo search → file
16
+ // search / tree browse) instead of the generic free-text catalogue search. Today only
17
+ // `github` (which transparently covers GitLab via the VCS adapter) is repo-backed.
18
+ const REPO_SOURCES = new Set<DocumentSourceKind>(['github'])
13
19
 
14
20
  const props = defineProps<{
15
21
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -32,6 +38,8 @@ const descriptor = computed(() =>
32
38
  source.value ? documents.descriptorFor(source.value) : undefined,
33
39
  )
34
40
  const searchable = computed(() => descriptor.value?.searchable ?? false)
41
+ // A repo-backed source swaps the whole free-text search body for the repo→file picker.
42
+ const isRepoSource = computed(() => !!source.value && REPO_SOURCES.has(source.value))
35
43
 
36
44
  const query = ref('')
37
45
  const results = ref<DocumentSearchResult[]>([])
@@ -176,81 +184,92 @@ onMounted(() => {
176
184
  class="w-full"
177
185
  />
178
186
 
179
- <UInput
180
- v-model="query"
181
- :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
182
- :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
183
- size="sm"
184
- class="w-full"
185
- :placeholder="
186
- searchable
187
- ? t('documents.picker.searchPlaceholder')
188
- : (descriptor?.refPlaceholder ?? t('documents.picker.refPlaceholder'))
189
- "
190
- @keydown.enter="refRow && pickRef(refRow)"
187
+ <!-- Repo-backed source (GitHub / GitLab): pick a repository, then a file. -->
188
+ <RepoContextDocPicker
189
+ v-if="isRepoSource && source"
190
+ :source="source!"
191
+ :icon="icon"
192
+ :chosen-keys="chosenKeys"
193
+ @pick="(item: PendingContext) => emit('pick', item)"
191
194
  />
192
195
 
193
- <p v-if="searchError" class="px-1 text-[11px] text-amber-400">
194
- {{ t('documents.picker.searchFailed', { error: searchError }) }}
195
- </p>
196
+ <template v-else>
197
+ <UInput
198
+ v-model="query"
199
+ :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
200
+ :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
201
+ size="sm"
202
+ class="w-full"
203
+ :placeholder="
204
+ searchable
205
+ ? t('documents.picker.searchPlaceholder')
206
+ : (descriptor?.refPlaceholder ?? t('documents.picker.refPlaceholder'))
207
+ "
208
+ @keydown.enter="refRow && pickRef(refRow)"
209
+ />
196
210
 
197
- <div class="max-h-56 space-y-0.5 overflow-y-auto">
198
- <!-- Already-imported documents (linked directly, no re-fetch). -->
199
- <button
200
- v-for="d in importedRows"
201
- :key="`imp:${d.externalId}`"
202
- type="button"
203
- class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
204
- @click="pickImported(d.externalId, d.title, d.excerpt)"
205
- >
206
- <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
207
- <span class="truncate">{{ d.title }}</span>
208
- <UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">{{
209
- t('documents.picker.importedBadge')
210
- }}</UBadge>
211
- </button>
211
+ <p v-if="searchError" class="px-1 text-[11px] text-amber-400">
212
+ {{ t('documents.picker.searchFailed', { error: searchError }) }}
213
+ </p>
212
214
 
213
- <!-- Source search hits (imported on add). -->
214
- <button
215
- v-for="r in searchRows"
216
- :key="`hit:${r.externalId}`"
217
- type="button"
218
- class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
219
- @click="pickSearch(r)"
220
- >
221
- <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
222
- <span class="truncate">{{ r.title }}</span>
223
- </button>
215
+ <div class="max-h-56 space-y-0.5 overflow-y-auto">
216
+ <!-- Already-imported documents (linked directly, no re-fetch). -->
217
+ <button
218
+ v-for="d in importedRows"
219
+ :key="`imp:${d.externalId}`"
220
+ type="button"
221
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
222
+ @click="pickImported(d.externalId, d.title, d.excerpt)"
223
+ >
224
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
225
+ <span class="truncate">{{ d.title }}</span>
226
+ <UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">{{
227
+ t('documents.picker.importedBadge')
228
+ }}</UBadge>
229
+ </button>
224
230
 
225
- <!-- Explicit URL/ID reference (imported on add). -->
226
- <button
227
- v-if="refRow"
228
- type="button"
229
- class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
230
- @click="pickRef(refRow)"
231
- >
232
- <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
233
- <span class="truncate">
234
- <i18n-t keypath="documents.picker.attachByReference" scope="global">
235
- <template #ref>
236
- <span class="text-slate-200">{{ refRow }}</span>
237
- </template>
238
- </i18n-t>
239
- </span>
240
- </button>
231
+ <!-- Source search hits (imported on add). -->
232
+ <button
233
+ v-for="r in searchRows"
234
+ :key="`hit:${r.externalId}`"
235
+ type="button"
236
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
237
+ @click="pickSearch(r)"
238
+ >
239
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
240
+ <span class="truncate">{{ r.title }}</span>
241
+ </button>
241
242
 
242
- <EmptyState
243
- v-if="empty"
244
- compact
245
- icon="i-lucide-file-search"
246
- :title="
247
- query.trim()
248
- ? t('documents.picker.noMatches')
249
- : searchable
250
- ? t('documents.picker.emptySearchable')
251
- : t('documents.picker.emptyRefOnly')
252
- "
253
- />
254
- </div>
243
+ <!-- Explicit URL/ID reference (imported on add). -->
244
+ <button
245
+ v-if="refRow"
246
+ type="button"
247
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
248
+ @click="pickRef(refRow)"
249
+ >
250
+ <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
251
+ <span class="truncate">
252
+ <i18n-t keypath="documents.picker.attachByReference" scope="global">
253
+ <template #ref>
254
+ <span class="text-slate-200">{{ refRow }}</span>
255
+ </template>
256
+ </i18n-t>
257
+ </span>
258
+ </button>
259
+
260
+ <EmptyState
261
+ v-if="empty"
262
+ compact
263
+ icon="i-lucide-file-search"
264
+ :title="
265
+ query.trim()
266
+ ? t('documents.picker.noMatches')
267
+ : searchable
268
+ ? t('documents.picker.emptySearchable')
269
+ : t('documents.picker.emptyRefOnly')
270
+ "
271
+ />
272
+ </div>
273
+ </template>
255
274
  </div>
256
275
  </template>
@@ -0,0 +1,277 @@
1
+ <script setup lang="ts">
2
+ // Repo-backed context-document picker: the GitHub / GitLab branch of
3
+ // ContextDocumentPicker. A repo-doc reference is a single file in a repository the
4
+ // workspace's App (or PAT) can reach, so instead of the generic free-text search
5
+ // this flow is: pick a repository, then pick one or more FILES from it — either by
6
+ // searching the whole tree by path, or by browsing it with the same tree browser the
7
+ // monorepo add-service flow uses. Each picked file is STAGED (emitted as a
8
+ // `PendingContext`, imported + linked once the block exists); already-staged files
9
+ // are shown "added" and can't be re-picked, mirroring the other context pickers.
10
+ //
11
+ // The repo search reuses `useRepoSearch` (debounce + stale-guard + per-instance
12
+ // results, so it never clobbers another picker's list) and the file search reuses the
13
+ // github store's recursive-tree cache (`loadRepoFiles`) so filtering is instant and
14
+ // client-side after one fetch per repo. Nothing here is GitHub-specific beyond the
15
+ // store it rides: the same store transparently returns GitLab projects via the VCS
16
+ // adapter, so this works unchanged on a GitLab deployment.
17
+ import type { DocumentSourceKind, GitHubAvailableRepo, RepoTreeEntry } from '~/types/domain'
18
+ import EmptyState from '~/components/common/EmptyState.vue'
19
+ import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
20
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
21
+
22
+ const props = defineProps<{
23
+ /** The repo-backed document source (`github`) whose files are being attached. */
24
+ source: DocumentSourceKind
25
+ /** Lucide icon for the source, used on each staged file row. */
26
+ icon: string
27
+ /** contextKeys already staged by the caller, so they're shown "added" / not re-offered. */
28
+ chosenKeys?: string[]
29
+ }>()
30
+ const emit = defineEmits<{ pick: [item: PendingContext] }>()
31
+
32
+ const { t } = useI18n()
33
+ const github = useGitHubStore()
34
+
35
+ const chosen = computed(() => new Set(props.chosenKeys ?? []))
36
+
37
+ // ---- repo search (reused pattern) ----------------------------------------
38
+ const {
39
+ search: repoSearch,
40
+ query: repoQueryRaw,
41
+ belowMinChars,
42
+ results: repoResults,
43
+ loading: repoLoading,
44
+ reset: resetRepoSearch,
45
+ } = useRepoSearch()
46
+
47
+ const selectedRepoId = ref<number | undefined>(undefined)
48
+ // Captured when picked — the results list is volatile (a later search replaces it), so
49
+ // the owner/name can't be recovered from it after the fact, and we need them to build
50
+ // the `owner/repo:path` external id.
51
+ const selectedRepo = ref<GitHubAvailableRepo | undefined>(undefined)
52
+
53
+ function toRepoItem(r: GitHubAvailableRepo) {
54
+ const suffix = r.private ? t('github.addService.repoLabel.private') : ''
55
+ return { label: `${r.owner}/${r.name}${suffix}`, value: r.githubId }
56
+ }
57
+ const queryMatches = computed(() => (belowMinChars.value ? [] : repoResults.value.map(toRepoItem)))
58
+ const repoMenuItems = computed(() => {
59
+ const matches = queryMatches.value
60
+ if (selectedRepoId.value === undefined) return matches
61
+ if (matches.some((r) => r.value === selectedRepoId.value)) return matches
62
+ return selectedRepo.value ? [toRepoItem(selectedRepo.value), ...matches] : matches
63
+ })
64
+
65
+ watch(selectedRepoId, (id) => {
66
+ if (id === undefined) {
67
+ selectedRepo.value = undefined
68
+ return
69
+ }
70
+ const found = repoResults.value.find((r) => r.githubId === id)
71
+ if (found) selectedRepo.value = found
72
+ })
73
+
74
+ function clearRepo() {
75
+ selectedRepoId.value = undefined
76
+ selectedRepo.value = undefined
77
+ resetRepoSearch()
78
+ fileQuery.value = ''
79
+ }
80
+
81
+ // ---- file selection (browse | search) ------------------------------------
82
+ type FileMode = 'search' | 'browse'
83
+ const fileMode = ref<FileMode>('search')
84
+
85
+ // Files already staged for THIS repo, as repo-root-relative paths — derived from the
86
+ // caller's chosen keys (`document:<source>:<owner>/<repo>:<path>`). Shown disabled in
87
+ // both the tree browser and the search list so a file can't be staged twice.
88
+ const addedPaths = computed<string[]>(() => {
89
+ const repo = selectedRepo.value
90
+ if (!repo) return []
91
+ const prefix = `document:${props.source}:${repo.owner}/${repo.name}:`
92
+ return [...chosen.value].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length))
93
+ })
94
+ const addedSet = computed(() => new Set(addedPaths.value.map(normalizeRepoPath)))
95
+
96
+ // The recursive file listing for the selected repo, loaded + cached on demand for the
97
+ // search box. Loaded lazily when the user first switches to / lands on search.
98
+ const files = computed<RepoTreeEntry[]>(() =>
99
+ selectedRepoId.value !== undefined ? (github.repoFiles[selectedRepoId.value] ?? []) : [],
100
+ )
101
+ const loadingFiles = ref(false)
102
+ const filesError = ref<string | null>(null)
103
+
104
+ async function ensureFilesLoaded() {
105
+ if (selectedRepoId.value === undefined) return
106
+ if (github.repoFiles[selectedRepoId.value]) return
107
+ loadingFiles.value = true
108
+ filesError.value = null
109
+ try {
110
+ await github.loadRepoFiles(selectedRepoId.value)
111
+ } catch (e) {
112
+ filesError.value = e instanceof Error ? e.message : String(e)
113
+ } finally {
114
+ loadingFiles.value = false
115
+ }
116
+ }
117
+
118
+ // Load the file list as soon as a repo is picked (search is the default tab); browsing
119
+ // uses the per-level tree endpoint and needs no preload.
120
+ watch(selectedRepoId, (id) => {
121
+ fileQuery.value = ''
122
+ fileMode.value = 'search'
123
+ if (id !== undefined) void ensureFilesLoaded()
124
+ })
125
+
126
+ const fileQuery = ref('')
127
+ // Matches are computed client-side from the cached tree (no per-keystroke server call).
128
+ // A query is required so a large repo never renders thousands of rows at once; results
129
+ // are capped for the same reason.
130
+ const FILE_RESULTS_CAP = 50
131
+ // Filter the cached tree ONCE per keystroke, then derive the capped view + the
132
+ // "truncated" flag from that single pass (a large monorepo tree is filtered twice
133
+ // otherwise, once per computed, on every keystroke).
134
+ const fileMatchesAll = computed(() => {
135
+ const q = fileQuery.value.trim().toLowerCase()
136
+ if (!q) return []
137
+ return files.value.filter(
138
+ (f) => !addedSet.value.has(normalizeRepoPath(f.path)) && f.path.toLowerCase().includes(q),
139
+ )
140
+ })
141
+ const fileMatches = computed(() => fileMatchesAll.value.slice(0, FILE_RESULTS_CAP))
142
+ const fileMatchesTruncated = computed(() => fileMatchesAll.value.length > FILE_RESULTS_CAP)
143
+
144
+ function pickFile(path: string) {
145
+ const repo = selectedRepo.value
146
+ if (!repo || addedSet.value.has(normalizeRepoPath(path))) return
147
+ const clean = normalizeRepoPath(path)
148
+ emit('pick', {
149
+ kind: 'document',
150
+ source: props.source,
151
+ // The GitHub/GitLab doc source's canonical `owner/repo:path` external id.
152
+ externalId: `${repo.owner}/${repo.name}:${clean}`,
153
+ title: clean.split('/').pop() || clean,
154
+ subtitle: `${repo.owner}/${repo.name} · ${clean}`,
155
+ icon: props.icon,
156
+ needsImport: true,
157
+ })
158
+ }
159
+
160
+ onMounted(() => {
161
+ // The repo search rides the github store; make sure the App connection is probed so
162
+ // `searchAvailableRepos` sees `connected` (single-flighted — cheap to call).
163
+ github.ensureProbed().catch(() => {})
164
+ })
165
+ </script>
166
+
167
+ <template>
168
+ <div class="space-y-2">
169
+ <!-- 1. pick a repository -->
170
+ <UInputMenu
171
+ v-model="selectedRepoId"
172
+ v-model:search-term="repoSearch"
173
+ :items="repoMenuItems"
174
+ :ignore-filter="true"
175
+ value-key="value"
176
+ :loading="repoLoading"
177
+ icon="i-lucide-search"
178
+ size="sm"
179
+ :placeholder="t('documents.repoPicker.searchRepoPlaceholder')"
180
+ class="w-full"
181
+ >
182
+ <template v-if="selectedRepoId !== undefined" #trailing>
183
+ <UButton
184
+ color="neutral"
185
+ variant="link"
186
+ size="sm"
187
+ icon="i-lucide-x"
188
+ :aria-label="t('documents.repoPicker.clearRepo')"
189
+ @click.stop="clearRepo"
190
+ />
191
+ </template>
192
+ <template #empty>
193
+ <RepoSearchEmpty
194
+ :below-min-chars="belowMinChars"
195
+ :loading="repoLoading"
196
+ :query="repoQueryRaw"
197
+ />
198
+ </template>
199
+ </UInputMenu>
200
+
201
+ <!-- 2. pick file(s) from the chosen repo -->
202
+ <template v-if="selectedRepoId !== undefined">
203
+ <div class="flex items-center gap-1">
204
+ <UButton
205
+ size="xs"
206
+ :color="fileMode === 'search' ? 'primary' : 'neutral'"
207
+ :variant="fileMode === 'search' ? 'soft' : 'ghost'"
208
+ icon="i-lucide-search"
209
+ @click="fileMode = 'search'"
210
+ >
211
+ {{ t('documents.repoPicker.searchTab') }}
212
+ </UButton>
213
+ <UButton
214
+ size="xs"
215
+ :color="fileMode === 'browse' ? 'primary' : 'neutral'"
216
+ :variant="fileMode === 'browse' ? 'soft' : 'ghost'"
217
+ icon="i-lucide-folder-tree"
218
+ @click="fileMode = 'browse'"
219
+ >
220
+ {{ t('documents.repoPicker.browseTab') }}
221
+ </UButton>
222
+ </div>
223
+
224
+ <!-- search files by path (client-side over the cached recursive tree) -->
225
+ <div v-if="fileMode === 'search'" class="space-y-2">
226
+ <UInput
227
+ v-model="fileQuery"
228
+ :icon="loadingFiles ? 'i-lucide-loader-circle' : 'i-lucide-file-search'"
229
+ :ui="{ leadingIcon: loadingFiles ? 'animate-spin' : '' }"
230
+ size="sm"
231
+ class="w-full"
232
+ :placeholder="t('documents.repoPicker.searchFilesPlaceholder')"
233
+ />
234
+ <p v-if="filesError" class="px-1 text-[11px] text-amber-400">
235
+ {{ t('documents.repoPicker.filesFailed', { error: filesError }) }}
236
+ </p>
237
+ <div class="max-h-56 space-y-0.5 overflow-y-auto">
238
+ <button
239
+ v-for="f in fileMatches"
240
+ :key="f.path"
241
+ type="button"
242
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
243
+ @click="pickFile(f.path)"
244
+ >
245
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
246
+ <span class="truncate">{{ f.path }}</span>
247
+ </button>
248
+ <p v-if="fileMatchesTruncated" class="px-2 py-1 text-[11px] text-slate-500">
249
+ {{ t('documents.repoPicker.moreFiles', { count: FILE_RESULTS_CAP }) }}
250
+ </p>
251
+ <EmptyState
252
+ v-if="!loadingFiles && !filesError && fileQuery.trim() && fileMatches.length === 0"
253
+ compact
254
+ icon="i-lucide-file-search"
255
+ :title="t('documents.repoPicker.noFileMatches')"
256
+ />
257
+ <p
258
+ v-else-if="!loadingFiles && !fileQuery.trim()"
259
+ class="px-2 py-1 text-[11px] text-slate-500"
260
+ >
261
+ {{ t('documents.repoPicker.searchFilesHint') }}
262
+ </p>
263
+ </div>
264
+ </div>
265
+
266
+ <!-- browse the tree, multi-pick files (same browser as the monorepo flow) -->
267
+ <RepoTreeBrowser
268
+ v-else
269
+ :repo-github-id="selectedRepoId"
270
+ mode="file"
271
+ multiple
272
+ :added-paths="addedPaths"
273
+ @toggle="pickFile"
274
+ />
275
+ </template>
276
+ </div>
277
+ </template>
@@ -7,12 +7,13 @@
7
7
  // via `v-model`. The component owns its own navigation/loading state so callers
8
8
  // just bind a repo id + mode; it self-loads on mount and when those change.
9
9
  //
10
- // `dir` mode additionally supports `multiple`: instead of the single `v-model`
10
+ // Both modes additionally support `multiple`: instead of the single `v-model`
11
11
  // path, the caller passes the current `selectedPaths` (a cart) + `addedPaths`
12
- // (directories already on the board, shown disabled) and handles the `toggle`
13
- // event to add/remove a directory. This lets one browse session accumulate
14
- // several services from ANY parent folder (the monorepo add flow) navigating
15
- // away never drops earlier picks.
12
+ // (paths already chosen elsewhere, shown disabled) and handles the `toggle`
13
+ // event to add/remove a path. In `dir` mode this accumulates service directories
14
+ // from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
15
+ // context-document files from anywhere in the tree — navigating away never drops
16
+ // earlier picks.
16
17
  import type { RepoTreeEntry } from '~/types/domain'
17
18
 
18
19
  const props = withDefaults(
@@ -23,11 +24,11 @@ const props = withDefaults(
23
24
  modelValue?: string
24
25
  /** Directory to open at (e.g. a monorepo service's subdirectory). */
25
26
  startPath?: string
26
- /** `dir` mode: accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. */
27
+ /** Accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. Either mode. */
27
28
  multiple?: boolean
28
- /** `dir` + `multiple`: the current cart of picked directories (repo-root-relative). */
29
+ /** `multiple`: the current cart of picked paths (repo-root-relative). */
29
30
  selectedPaths?: string[]
30
- /** `dir` + `multiple`: directories already on the board — listed but not selectable. */
31
+ /** `multiple`: paths already chosen elsewhere — listed but not selectable. */
31
32
  addedPaths?: string[]
32
33
  }>(),
33
34
  { mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
@@ -182,14 +183,22 @@ watch(
182
183
  <button
183
184
  type="button"
184
185
  class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
185
- :class="modelValue === entry.path ? 'text-primary-400' : 'text-slate-300'"
186
+ :class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
187
+ :disabled="isAdded(entry.path)"
186
188
  @click="pick(entry.path)"
187
189
  >
188
190
  <UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
189
191
  <span class="truncate">{{ entry.name }}</span>
190
192
  </button>
193
+ <span
194
+ v-if="isAdded(entry.path)"
195
+ class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
196
+ >
197
+ <UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
198
+ {{ t('github.repoTree.added') }}
199
+ </span>
191
200
  <UIcon
192
- v-if="modelValue === entry.path"
201
+ v-else-if="isPicked(entry.path)"
193
202
  name="i-lucide-check"
194
203
  class="h-4 w-4 shrink-0 text-primary-400"
195
204
  />
@@ -142,20 +142,6 @@ const selectablePipelines = computed(() =>
142
142
  pipelineAllowedForManualStart(p, taskFrame.value, board.blocks),
143
143
  ),
144
144
  )
145
- const pipelineMenu = computed(() => [
146
- [
147
- {
148
- label: t('inspector.runSettings.noDefault'),
149
- icon: 'i-lucide-rotate-ccw',
150
- onSelect: () => setPipeline(''),
151
- },
152
- ...selectablePipelines.value.map((p) => ({
153
- label: p.name,
154
- icon: 'i-lucide-workflow',
155
- onSelect: () => setPipeline(p.id),
156
- })),
157
- ],
158
- ])
159
145
  function setPipeline(id: string) {
160
146
  board.updateBlock(props.block.id, { pipelineId: id })
161
147
  }
@@ -271,15 +257,22 @@ const technicalLabel = computed(() => {
271
257
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
272
258
  {{ t('inspector.runSettings.pipeline') }}
273
259
  </span>
274
- <UDropdownMenu :items="pipelineMenu">
275
- <UButton
276
- size="xs"
277
- variant="ghost"
278
- color="neutral"
279
- icon="i-lucide-workflow"
280
- trailing-icon="i-lucide-chevron-down"
281
- />
282
- </UDropdownMenu>
260
+ <PipelinePicker
261
+ :model-value="block.pipelineId ?? ''"
262
+ :options="selectablePipelines"
263
+ :none-label="t('inspector.runSettings.noDefault')"
264
+ @update:model-value="setPipeline"
265
+ >
266
+ <template #trigger>
267
+ <UButton
268
+ size="xs"
269
+ variant="ghost"
270
+ color="neutral"
271
+ icon="i-lucide-workflow"
272
+ trailing-icon="i-lucide-chevron-down"
273
+ />
274
+ </template>
275
+ </PipelinePicker>
283
276
  </div>
284
277
  <div v-if="selectedPipeline" class="flex items-center gap-1">
285
278
  <UBadge