@cat-factory/app 0.139.0 → 0.141.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,
@@ -288,35 +288,34 @@ const selectedModelPresetLabel = computed(() => {
288
288
 
289
289
  // Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
290
290
  // UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
291
- // hide `'recurring'`-only pipelines: a one-off task start of one is refused at run start.
291
+ // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and,
292
+ // for a `document` task, every non-document pipeline (it authors a doc — only document pipelines
293
+ // are relevant, per the `purpose` classifier). Re-filters as the chosen task type changes.
292
294
  const selectablePipelines = computed(() =>
293
- pipelines.pipelines.filter((p) => pipelineAllowedForManualStart(p, frame.value, board.blocks)),
295
+ pipelines.pipelines.filter((p) =>
296
+ pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
297
+ ),
294
298
  )
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
- // Picking the Ralph loop task type auto-selects its pipeline, so the per-task validation
314
- // command + iteration budget (contributed by the `ralph` agent) surface immediately — the
315
- // loop is meaningless without them, so "choose at run time" would be a dead end here.
299
+ // Some task types want their type-default pipeline surfaced in the modal up front, so picking the
300
+ // type auto-selects it (the user can still change it among the still-offered pipelines). This is a
301
+ // DELIBERATE SUBSET of the backend `defaultPipelineIdForTaskType` — only the types whose default
302
+ // must appear in the form BEFORE creation:
303
+ // - `ralph` needs its preset so the per-task validation command + iteration budget the `ralph`
304
+ // agent contributes surface for editing ("choose at run time" would be a dead end);
305
+ // - a `document` task defaults to `pl_document` so its document-only picker (the `purpose` gate
306
+ // hides every non-document pipeline) is never rendered empty.
307
+ // The other typed defaults (spike/review) carry no up-front config and don't narrow their picker,
308
+ // so the modal leaves `pipelineId` unset and `BoardService` applies the backend type-default at
309
+ // creation. Keep these ids in step with the backend helper.
310
+ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
311
+ ralph: 'pl_ralph',
312
+ document: 'pl_document',
313
+ }
316
314
  watch(taskType, (next) => {
317
- if (next !== 'ralph') return
318
- const ralph = pipelines.pipelines.find((p) => p.id === 'pl_ralph')
319
- if (ralph) pipelineId.value = ralph.id
315
+ const preset = DEFAULT_PIPELINE_FOR_TYPE[next]
316
+ if (!preset) return
317
+ const match = pipelines.pipelines.find((p) => p.id === preset)
318
+ if (match) pipelineId.value = match.id
320
319
  })
321
320
 
322
321
  // Task-level agent config contributed by the selected pipeline's agents (e.g. the
@@ -473,7 +472,10 @@ watch(open, (isOpen) => {
473
472
  delete docKindFieldValues[key]
474
473
  riskPolicyId.value = ''
475
474
  modelPresetId.value = ''
476
- pipelineId.value = ''
475
+ // Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document
476
+ // repo opens with `pl_document` pre-selected rather than empty. This runs AFTER the `taskType`
477
+ // watcher fired during this reset, so it is the authoritative default (see DEFAULT_PIPELINE_FOR_TYPE).
478
+ pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
477
479
  agentConfigValues.value = {}
478
480
  pendingContext.value = []
479
481
  showDocPicker.value = false
@@ -578,14 +580,10 @@ async function add() {
578
580
  ...(technical.value ? { technical: true } : {}),
579
581
  })
580
582
  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
- }
583
+ // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
584
+ // permission/visibility error, a not-found doc, …) instead of a bare count, plus a
585
+ // one-click "Copy details" for a bug report.
586
+ presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
589
587
  }
590
588
  ui.closeAddTask()
591
589
  } catch (e) {
@@ -895,18 +893,13 @@ async function add() {
895
893
 
896
894
  <div class="grid grid-cols-2 gap-3">
897
895
  <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>
896
+ <PipelinePicker
897
+ :model-value="pipelineId"
898
+ :options="selectablePipelines"
899
+ :none-label="t('board.addTask.chooseAtRunTime')"
900
+ trigger-class="w-full justify-between"
901
+ @update:model-value="pipelineId = $event"
902
+ />
910
903
  </UFormField>
911
904
 
912
905
  <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>
@@ -28,12 +28,13 @@ const deps = computed(() =>
28
28
  (block.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
29
29
  )
30
30
 
31
- // Hide UI-testing pipelines when this block's frame has no UI to exercise, and `'recurring'`-only
32
- // pipelines (a manual run of one is refused server-side) — see the backend gate.
31
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
32
+ // pipelines (a manual run of one is refused server-side), and for a `document` task — every
33
+ // non-document pipeline (per the `purpose` classifier) — see the backend gate.
33
34
  const runMenu = computed(() => {
34
35
  const frame = block.value ? board.serviceOf(block.value) : undefined
35
36
  return pipelines.pipelines
36
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks))
37
+ .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType))
37
38
  .map((p) => ({
38
39
  label: p.name,
39
40
  icon: 'i-lucide-play',