@cat-factory/app 0.293.1 → 0.295.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 (39) hide show
  1. package/app/components/board/AddTaskModal.vue +80 -0
  2. package/app/components/board/RecurringPipelineModal.vue +34 -20
  3. package/app/components/bootstrap/BootstrapModal.logic.spec.ts +54 -0
  4. package/app/components/bootstrap/BootstrapModal.logic.ts +44 -0
  5. package/app/components/bootstrap/BootstrapModal.vue +155 -16
  6. package/app/components/bugFishing/BugFishingWindow.vue +524 -0
  7. package/app/components/focus/BlockFocusView.vue +2 -0
  8. package/app/components/github/RepoTreeBrowser.vue +89 -6
  9. package/app/components/layout/NotificationsInbox.vue +15 -0
  10. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +25 -3
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/settings/WorkspaceSettingsPanel.vue +46 -1
  13. package/app/components/slack/SlackPanel.vue +1 -0
  14. package/app/composables/api/bugFishing.ts +52 -0
  15. package/app/composables/useApi.ts +2 -0
  16. package/app/composables/usePipelineErrorToast.ts +21 -0
  17. package/app/modular/result-views.ts +6 -0
  18. package/app/stores/agentRuns.spec.ts +1 -0
  19. package/app/stores/bugFishing.ts +143 -0
  20. package/app/stores/ui/resultViews.ts +14 -7
  21. package/app/stores/ui/runStepOpeners.ts +29 -1
  22. package/app/stores/workspaceSettings.ts +1 -0
  23. package/app/types/bootstrap.ts +1 -0
  24. package/app/types/execution.ts +9 -0
  25. package/app/utils/catalog.spec.ts +1 -0
  26. package/app/utils/catalog.ts +25 -0
  27. package/app/utils/repoPath.spec.ts +49 -0
  28. package/app/utils/repoPath.ts +28 -0
  29. package/i18n/locales/de.json +121 -10
  30. package/i18n/locales/en.json +120 -9
  31. package/i18n/locales/es.json +121 -10
  32. package/i18n/locales/fr.json +121 -10
  33. package/i18n/locales/he.json +121 -10
  34. package/i18n/locales/it.json +121 -10
  35. package/i18n/locales/ja.json +121 -10
  36. package/i18n/locales/pl.json +121 -10
  37. package/i18n/locales/tr.json +121 -10
  38. package/i18n/locales/uk.json +121 -10
  39. package/package.json +2 -2
@@ -22,6 +22,7 @@ import type {
22
22
  TaskTypeFields,
23
23
  } from '~/types/domain'
24
24
  import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
25
+ import { BUG_FISHING_PHASES } from '@cat-factory/contracts'
25
26
  import { resolveComponentRegistry } from '@modular-vue/core'
26
27
  import { useReactiveSlots } from '@modular-vue/runtime'
27
28
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
@@ -97,6 +98,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
97
98
  const all: { value: TaskTypeChoice; label: string; icon: string }[] = [
98
99
  { value: 'feature', label: t('board.addTask.types.feature'), icon: 'i-lucide-sparkles' },
99
100
  { value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
101
+ {
102
+ value: 'bug-fishing',
103
+ label: t('board.addTask.types.bugFishing'),
104
+ icon: 'i-lucide-fish',
105
+ },
100
106
  { value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
101
107
  { value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
102
108
  {
@@ -127,6 +133,11 @@ const isRecurring = computed(() => taskType.value === 'recurring')
127
133
  const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
128
134
  const stepsToReproduce = ref('')
129
135
  const timeboxHours = ref<number | undefined>(undefined)
136
+ // Bug-fishing expedition: which ANGLES to fish (empty ⇒ every shipped angle, the intended
137
+ // default — an expedition exists to cover ground nobody thought to look at, so narrowing it is
138
+ // the deliberate act) plus an optional focus folded into every angle's prompt.
139
+ const fishingPhaseIds = ref<string[]>([])
140
+ const fishingFocus = ref('')
130
141
  // Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
131
142
  const spikeResearchQuestion = ref('')
132
143
  const spikeSuccessCriteria = ref('')
@@ -284,6 +295,24 @@ function buildCustomTypeFields(): TaskTypeFields | undefined {
284
295
  return Object.keys(bag).length ? { custom: bag } : undefined
285
296
  }
286
297
 
298
+ /**
299
+ * The bug-fishing expedition's creation fields. Both NARROW a hunt that otherwise covers every
300
+ * angle, so both are omitted when they narrow nothing: an empty selection and "every angle" are
301
+ * the same run, and storing the full catalog would freeze today's angle list onto a task that
302
+ * runs next quarter.
303
+ *
304
+ * Its own function rather than another arm of {@link buildTypeFields}, whose per-type chain is at
305
+ * its complexity ceiling — a budget is a split trigger, not a number to raise.
306
+ */
307
+ function buildBugFishingFields(): TaskTypeFields | undefined {
308
+ const f: TaskTypeFields = {}
309
+ if (fishingPhaseIds.value.length && fishingPhaseIds.value.length < BUG_FISHING_PHASES.length) {
310
+ f.fishingPhaseIds = [...fishingPhaseIds.value]
311
+ }
312
+ if (fishingFocus.value.trim()) f.fishingFocus = fishingFocus.value.trim()
313
+ return Object.keys(f).length ? f : undefined
314
+ }
315
+
287
316
  function buildTypeFields(): TaskTypeFields | undefined {
288
317
  if (taskType.value === 'bug') {
289
318
  const f: TaskTypeFields = {}
@@ -291,6 +320,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
291
320
  if (stepsToReproduce.value.trim()) f.stepsToReproduce = stepsToReproduce.value.trim()
292
321
  return Object.keys(f).length ? f : undefined
293
322
  }
323
+ if (taskType.value === 'bug-fishing') return buildBugFishingFields()
294
324
  if (taskType.value === 'spike') {
295
325
  const f: TaskTypeFields = {}
296
326
  // `v-model.number` on a cleared number input yields '' (not undefined), which would
@@ -420,6 +450,7 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
420
450
  document: 'pl_document',
421
451
  review: 'pl_review',
422
452
  media: 'pl_media',
453
+ 'bug-fishing': 'pl_bug_fishing',
423
454
  }
424
455
  /**
425
456
  * The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
@@ -564,6 +595,8 @@ watch(open, (isOpen) => {
564
595
  severity.value = ''
565
596
  stepsToReproduce.value = ''
566
597
  timeboxHours.value = undefined
598
+ fishingPhaseIds.value = []
599
+ fishingFocus.value = ''
567
600
  spikeResearchQuestion.value = ''
568
601
  spikeSuccessCriteria.value = ''
569
602
  spikeOptionsToCompare.value = ''
@@ -1027,6 +1060,53 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1027
1060
  </UFormField>
1028
1061
  </div>
1029
1062
 
1063
+ <!-- Bug-fishing expedition. Both fields NARROW a hunt that otherwise covers every
1064
+ angle, which is why neither is required and why the angle list is rendered as
1065
+ opt-OUT checkboxes rather than an empty multi-select: leaving it alone has to
1066
+ mean "fish everything", not "fish nothing". -->
1067
+ <div v-else-if="taskType === 'bug-fishing'" class="space-y-3">
1068
+ <UFormField
1069
+ :label="t('board.addTask.bugFishingFields.angles.label')"
1070
+ :hint="t('board.addTask.optional')"
1071
+ :description="t('board.addTask.bugFishingFields.angles.hint')"
1072
+ >
1073
+ <div class="grid gap-1.5 sm:grid-cols-2">
1074
+ <label
1075
+ v-for="phase in BUG_FISHING_PHASES"
1076
+ :key="phase.id"
1077
+ class="flex items-start gap-2 rounded-md px-1.5 py-1 text-[12px] hover:bg-slate-800/40"
1078
+ >
1079
+ <input
1080
+ v-model="fishingPhaseIds"
1081
+ type="checkbox"
1082
+ :value="phase.id"
1083
+ class="mt-0.5 accent-sky-500"
1084
+ :data-testid="`add-task-fishing-angle-${phase.id}`"
1085
+ />
1086
+ <span class="min-w-0">
1087
+ <span class="block text-slate-200">{{ phase.title }}</span>
1088
+ <span class="block text-[11px] text-slate-500">{{ phase.goal }}</span>
1089
+ </span>
1090
+ </label>
1091
+ </div>
1092
+ <p v-if="fishingPhaseIds.length === 0" class="mt-1.5 text-[11px] text-slate-500">
1093
+ {{ t('board.addTask.bugFishingFields.angles.allSelected') }}
1094
+ </p>
1095
+ </UFormField>
1096
+ <UFormField
1097
+ :label="t('board.addTask.bugFishingFields.focus.label')"
1098
+ :hint="t('board.addTask.optional')"
1099
+ >
1100
+ <UTextarea
1101
+ v-model="fishingFocus"
1102
+ :rows="2"
1103
+ autoresize
1104
+ :placeholder="t('board.addTask.bugFishingFields.focus.placeholder')"
1105
+ class="w-full"
1106
+ />
1107
+ </UFormField>
1108
+ </div>
1109
+
1030
1110
  <div v-else-if="taskType === 'spike'" class="space-y-3">
1031
1111
  <UFormField :label="t('board.addTask.timebox')">
1032
1112
  <UInput
@@ -139,17 +139,43 @@ const selectablePipelines = computed(() =>
139
139
  )
140
140
  const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
141
141
 
142
+ /**
143
+ * Whether a pipeline RUNS a step of this kind: present in its `agentKinds` AND not disabled.
144
+ *
145
+ * The three questions this modal asks about the picked pipeline (does it fish, does it file a
146
+ * ticket, does it pull from the tracker) are the same question about three kinds, and each of
147
+ * them has to honour the disabled half: a step somebody turned off imposes nothing, so demanding
148
+ * its configuration would block a schedule on a step that will not run.
149
+ */
150
+ function hasEnabledStep(
151
+ pipeline: { agentKinds: string[]; enabled?: boolean[] } | null | undefined,
152
+ kind: string,
153
+ ): boolean {
154
+ if (!pipeline) return false
155
+ return pipeline.agentKinds.some((k, i) => k === kind && pipeline.enabled?.[i] !== false)
156
+ }
157
+
142
158
  // Infer the template from the picked pipeline so the backend seeds the right block
143
159
  // description (and so we know to show the tracker config).
144
160
  //
145
161
  // Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
146
- // way, and `bug-triage` is now the only one: `dep-update` and `tech-debt` were both retired from
147
- // the catalog (the first was the ordinary build tail under a recurring name, the second that tail
148
- // behind an audit head), so those schedules now run an ordinary build rung — which is also what
149
- // every generic schedule runs, so inferring a template from it would mislabel all of them. Both
150
- // templates survive for an explicit API caller; see `scheduleTemplateSchema`.
162
+ // way. `dep-update` and `tech-debt` were both retired from the catalog (the first was the
163
+ // ordinary build tail under a recurring name, the second that tail behind an audit head), so
164
+ // those schedules now run an ordinary build rung — which is also what every generic schedule
165
+ // runs, so inferring a template from it would mislabel all of them. Both templates survive for an
166
+ // explicit API caller; see `scheduleTemplateSchema`.
167
+ //
168
+ // The expedition is read off its own step KIND rather than off `pl_bug_fishing`'s id, for the
169
+ // reason `filesTicket` below states: an id keys on the one preset that ships today and misses
170
+ // every pipeline a workspace composes around the same step, and the whole seed description is
171
+ // about what a `bug-fisher` pass does.
172
+ const isBugFishing = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-fisher'))
151
173
  const template = computed<ScheduleTemplate>(() =>
152
- pipelineId.value === 'pl_bug_triage' ? 'bug-triage' : 'custom',
174
+ isBugFishing.value
175
+ ? 'bug-fishing'
176
+ : pipelineId.value === 'pl_bug_triage'
177
+ ? 'bug-triage'
178
+ : 'custom',
153
179
  )
154
180
  /**
155
181
  * Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
@@ -159,24 +185,12 @@ const template = computed<ScheduleTemplate>(() =>
159
185
  * `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
160
186
  * the one pipeline that no longer exists, and to none of the pipelines that now do this work.
161
187
  */
162
- const filesTicket = computed(() => {
163
- const pipeline = selectedPipeline.value
164
- if (!pipeline) return false
165
- return pipeline.agentKinds.some(
166
- (kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
167
- )
168
- })
188
+ const filesTicket = computed(() => hasEnabledStep(selectedPipeline.value, 'tracker'))
169
189
 
170
190
  // A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
171
191
  // the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
172
192
  // (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
173
- const isBugIntake = computed(() => {
174
- const pipeline = selectedPipeline.value
175
- if (!pipeline) return false
176
- return pipeline.agentKinds.some(
177
- (kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
178
- )
179
- })
193
+ const isBugIntake = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-intake'))
180
194
  /**
181
195
  * Whether the intake section is shown, and in which DISPATCH mode — both DERIVED from the picked
182
196
  * pipeline rather than chosen, because the two modes are not interchangeable:
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ defaultBootstrapDelivery,
4
+ serviceDirectoryLeaf,
5
+ serviceDirectoryParent,
6
+ } from '~/components/bootstrap/BootstrapModal.logic'
7
+
8
+ // The rule these two pin is what makes the field browsable AND typable at once: the tree hands
9
+ // back the folder it was standing in plus the leaf, so a name someone typed has to survive a
10
+ // trip through the tree. Reading the leaf off the service name instead would silently discard it.
11
+
12
+ describe('serviceDirectoryLeaf', () => {
13
+ it('is the typed path’s own last segment, not the service name', () => {
14
+ expect(serviceDirectoryLeaf('services/billing', 'payments')).toBe('billing')
15
+ })
16
+
17
+ it('treats a bare name as the leaf (a directory at the repo root)', () => {
18
+ expect(serviceDirectoryLeaf('billing', 'payments')).toBe('billing')
19
+ })
20
+
21
+ it('survives a trailing slash rather than reading as an empty leaf', () => {
22
+ expect(serviceDirectoryLeaf('services/billing/', 'payments')).toBe('billing')
23
+ })
24
+
25
+ it('falls back to the service name while the field is still blank', () => {
26
+ expect(serviceDirectoryLeaf('', 'payments')).toBe('payments')
27
+ expect(serviceDirectoryLeaf(' ', ' payments ')).toBe('payments')
28
+ })
29
+
30
+ it('is empty when neither is known, so the tree can say it has nothing to place', () => {
31
+ expect(serviceDirectoryLeaf('', '')).toBe('')
32
+ })
33
+ })
34
+
35
+ describe('serviceDirectoryParent', () => {
36
+ it('is the folder the typed path sits in', () => {
37
+ expect(serviceDirectoryParent('packages/services/billing')).toBe('packages/services')
38
+ })
39
+
40
+ it('is the repo root for a bare name', () => {
41
+ expect(serviceDirectoryParent('billing')).toBe('')
42
+ expect(serviceDirectoryParent('')).toBe('')
43
+ })
44
+ })
45
+
46
+ describe('defaultBootstrapDelivery', () => {
47
+ it('reviews a monorepo and pushes a repository being created', () => {
48
+ // The form has to SHOW the default it is about to send, and the two targets want opposite
49
+ // ones, so a constant would render the wrong answer for one of them and ask the person to
50
+ // correct a choice they never made. Same rule the backend applies to a request naming none.
51
+ expect(defaultBootstrapDelivery(true)).toBe('pull_request')
52
+ expect(defaultBootstrapDelivery(false)).toBe('direct_push')
53
+ })
54
+ })
@@ -0,0 +1,44 @@
1
+ import type { BootstrapDelivery } from '~/types/domain'
2
+ import { repoPathSegments } from '~/utils/repoPath'
3
+
4
+ // The pure half of the bootstrap launch form's monorepo service-directory field. That field
5
+ // holds one string but carries two decisions: what the new directory is CALLED and WHERE in the
6
+ // repo it sits. Browsing the repo tree answers only the second, so it rewrites the parent and
7
+ // keeps the leaf, which means both halves have to be readable off the typed value on their own.
8
+ // Extracted for the reason every `*.logic.ts` here is: a decision worth a test should not need a
9
+ // mounted component to reach.
10
+
11
+ /**
12
+ * What the new directory is called: the last segment of the typed path.
13
+ *
14
+ * Falls back to the service name, which is what the field's own seeding watcher would have put
15
+ * there anyway, so opening the tree before typing a path still has a name to place. Empty only
16
+ * when NEITHER is known, and that answer is load-bearing: with nothing to place, the tree can
17
+ * decide nothing and says so rather than offering picks that compose a bare folder path.
18
+ */
19
+ export function serviceDirectoryLeaf(directory: string, serviceName: string): string {
20
+ return repoPathSegments(directory).at(-1) ?? serviceName.trim()
21
+ }
22
+
23
+ /**
24
+ * The folder the typed path sits in, and so where the tree should OPEN: empty for a bare name,
25
+ * because a name with no parent is a directory at the repo root.
26
+ */
27
+ export function serviceDirectoryParent(directory: string): string {
28
+ return repoPathSegments(directory).slice(0, -1).join('/')
29
+ }
30
+
31
+ /**
32
+ * The delivery a target takes when nobody has answered the question.
33
+ *
34
+ * The backend applies the same rule for a request that names none, and it is stated on both
35
+ * sides deliberately: the form has to SHOW the default it is about to send, and a control
36
+ * rendering the wrong one asks the person to correct something they never chose. The two targets
37
+ * want opposite answers, which is why it is a function of the target rather than a constant.
38
+ *
39
+ * Also what the form RESETS to after a launch: an explicit choice binds the run it was made for,
40
+ * never every later one, so the reset restores the default for whatever target is still selected.
41
+ */
42
+ export function defaultBootstrapDelivery(intoMonorepo: boolean): BootstrapDelivery {
43
+ return intoMonorepo ? 'pull_request' : 'direct_push'
44
+ }
@@ -4,7 +4,18 @@
4
4
  // adapt it (in a sandbox container) — either by cloning a chosen reference
5
5
  // architecture, or from scratch following a freeform prompt. The modal pairs the
6
6
  // launch form with the managed base list.
7
- import type { BootstrapStatus, FrameRepoType, ReferenceArchitecture } from '~/types/domain'
7
+ import type {
8
+ BootstrapDelivery,
9
+ BootstrapStatus,
10
+ FrameRepoType,
11
+ ReferenceArchitecture,
12
+ } from '~/types/domain'
13
+ import {
14
+ defaultBootstrapDelivery,
15
+ serviceDirectoryLeaf,
16
+ serviceDirectoryParent,
17
+ } from '~/components/bootstrap/BootstrapModal.logic'
18
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
8
19
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
9
20
  import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
10
21
 
@@ -100,6 +111,42 @@ const targetItems = computed(() => [
100
111
  ])
101
112
  const intoMonorepo = computed(() => target.value === 'monorepo')
102
113
 
114
+ // ---- how the work LANDS ----------------------------------------------------
115
+ // A third axis, orthogonal to both of the above: the same service, written the same way, either
116
+ // arrives as a pull request somebody reviews or straight on the default branch. The two targets
117
+ // want opposite defaults (a repository being created has nobody to review its first commit; a
118
+ // monorepo's default branch is the branch every other service builds from), which is exactly why
119
+ // this is a control and not a constant.
120
+ const delivery = ref<BootstrapDelivery>(defaultBootstrapDelivery(false))
121
+ // Whether the person has answered this question themselves. Until they have, switching target
122
+ // re-defaults; once they have, their answer stands, because re-defaulting over an explicit
123
+ // choice is how a run they asked to review lands unreviewed. Cleared after a launch, so the
124
+ // answer binds the run it was given for rather than every later one (see `launch`).
125
+ const deliveryTouched = ref(false)
126
+ watch(intoMonorepo, (into) => {
127
+ if (!deliveryTouched.value) delivery.value = defaultBootstrapDelivery(into)
128
+ })
129
+ function chooseDelivery(value: BootstrapDelivery) {
130
+ deliveryTouched.value = true
131
+ delivery.value = value
132
+ }
133
+ const deliveryItems = computed(() => [
134
+ {
135
+ label: t('bootstrap.delivery.pullRequest.label'),
136
+ value: 'pull_request' as const,
137
+ description: intoMonorepo.value
138
+ ? t('bootstrap.delivery.pullRequest.descMonorepo')
139
+ : t('bootstrap.delivery.pullRequest.descNewRepo'),
140
+ },
141
+ {
142
+ label: t('bootstrap.delivery.directPush.label'),
143
+ value: 'direct_push' as const,
144
+ description: intoMonorepo.value
145
+ ? t('bootstrap.delivery.directPush.descMonorepo')
146
+ : t('bootstrap.delivery.directPush.descNewRepo'),
147
+ },
148
+ ])
149
+
103
150
  /** The projected repo the new service lands in, by numeric id. */
104
151
  const monorepoRepoId = ref<number | undefined>(undefined)
105
152
  const monorepoDirectory = ref('')
@@ -108,20 +155,43 @@ const monorepoRepoItems = computed(() =>
108
155
  github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
109
156
  )
110
157
 
111
- // Mirrors the backend's `normalizeServiceDirectory`: the path becomes an agent's working
112
- // directory, so a value that could escape the checkout is refused here rather than at the API.
158
+ // `repoPathSegments` is the backend's `normalizeServiceDirectory` reduction: the path becomes
159
+ // an agent's working directory, so a value that could escape the checkout is refused here
160
+ // rather than at the API.
161
+ const directorySegments = computed(() => repoPathSegments(monorepoDirectory.value))
113
162
  const directoryError = computed<string | undefined>(() => {
114
- const value = monorepoDirectory.value.trim()
115
- if (!value) return undefined
116
- const segments = value
117
- .replace(/\\/g, '/')
118
- .split('/')
119
- .filter((s) => s && s !== '.')
120
- if (!segments.length) return t('bootstrap.monorepo.directory.error.empty')
121
- if (segments.some((s) => s === '..')) return t('bootstrap.monorepo.directory.error.escapes')
163
+ if (!monorepoDirectory.value.trim()) return undefined
164
+ if (!directorySegments.value.length) return t('bootstrap.monorepo.directory.error.empty')
165
+ if (directorySegments.value.some((seg) => seg === '..')) {
166
+ return t('bootstrap.monorepo.directory.error.escapes')
167
+ }
122
168
  return undefined
123
169
  })
124
170
 
171
+ // ---- exploring the monorepo for the directory's home -----------------------
172
+ // The target must NOT exist, so nothing in the tree can BE it: the tree picks the enclosing
173
+ // folder and hands back that folder plus the leaf (see `BootstrapModal.logic`, which owns the
174
+ // two readings of the typed value).
175
+ const directoryLeaf = computed(() => serviceDirectoryLeaf(monorepoDirectory.value, repoName.value))
176
+ const browsingDirectory = ref(false)
177
+ // The folder the tree opens at, captured when the browser is OPENED rather than read live off
178
+ // the field: as a computed it would re-navigate the listing on every keystroke in the input.
179
+ const directoryBrowseStart = ref('')
180
+
181
+ function toggleDirectoryBrowse() {
182
+ if (!browsingDirectory.value) {
183
+ directoryBrowseStart.value = serviceDirectoryParent(monorepoDirectory.value)
184
+ }
185
+ browsingDirectory.value = !browsingDirectory.value
186
+ }
187
+
188
+ /** The tree emits the composed path: the folder it was standing in plus the leaf it was given. */
189
+ function placeDirectory(path: string | undefined) {
190
+ if (!path) return
191
+ monorepoDirectory.value = path
192
+ browsingDirectory.value = false
193
+ }
194
+
125
195
  // Landing in a monorepo needs no NEW repository, so the repo name is the SERVICE's name (and
126
196
  // seeds the directory's leaf); the create-repo affordances below are for the other target.
127
197
  watch([intoMonorepo, repoName], ([into, name]) => {
@@ -295,6 +365,7 @@ async function launch() {
295
365
  private: isPrivate.value,
296
366
  instructions: instructions.value.trim(),
297
367
  type: selectedType.value,
368
+ delivery: delivery.value,
298
369
  ...(intoMonorepo.value && monorepoRepoId.value
299
370
  ? {
300
371
  monorepo: {
@@ -332,8 +403,15 @@ async function launch() {
332
403
  description.value = ''
333
404
  instructions.value = ''
334
405
  monorepoDirectory.value = ''
406
+ browsingDirectory.value = false
335
407
  // Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
336
408
  selectedType.value = 'service'
409
+ // And the delivery, which has to reset the ANSWERED flag with it: leaving that set disarms
410
+ // the per-target default for good, so a "push directly" picked deliberately for one
411
+ // monorepo would go on governing the next bootstrap, into a different repository, without
412
+ // the person having been asked about that one. Back to the current target's own default.
413
+ deliveryTouched.value = false
414
+ delivery.value = defaultBootstrapDelivery(intoMonorepo.value)
337
415
  // The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
338
416
  // free space so it never overlaps an existing service — the backend places it on a
339
417
  // fixed diagonal stagger that can land on top of a large neighbour — then centre the
@@ -524,6 +602,17 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
524
602
  <URadioGroup v-model="target" :items="targetItems" />
525
603
  </UFormField>
526
604
 
605
+ <!-- Where the service goes and how it gets there are two questions, and the second
606
+ has no answer that is right for both targets. Its descriptions therefore change
607
+ with the target rather than the control being duplicated per target. -->
608
+ <UFormField :label="t('bootstrap.delivery.label')" required>
609
+ <URadioGroup
610
+ :model-value="delivery"
611
+ :items="deliveryItems"
612
+ @update:model-value="chooseDelivery($event as BootstrapDelivery)"
613
+ />
614
+ </UFormField>
615
+
527
616
  <!-- Landing in an existing monorepo: pick the repository and the subdirectory. The
528
617
  run surveys the monorepo's conventions against the template's and PARKS for a
529
618
  human adoption review before it writes anything. -->
@@ -550,11 +639,49 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
550
639
  required
551
640
  :error="directoryError"
552
641
  >
553
- <UInput
554
- v-model="monorepoDirectory"
555
- :placeholder="t('bootstrap.monorepo.directory.placeholder')"
556
- class="w-full"
557
- />
642
+ <div class="space-y-2">
643
+ <div class="flex items-center gap-2">
644
+ <UInput
645
+ v-model="monorepoDirectory"
646
+ :placeholder="t('bootstrap.monorepo.directory.placeholder')"
647
+ class="flex-1"
648
+ />
649
+ <UButton
650
+ v-if="monorepoRepoId !== undefined"
651
+ variant="soft"
652
+ color="neutral"
653
+ icon="i-lucide-folder-search"
654
+ :title="t('bootstrap.monorepo.directory.browse')"
655
+ :aria-label="t('bootstrap.monorepo.directory.browse')"
656
+ data-testid="bootstrap-directory-browse"
657
+ @click="toggleDirectoryBrowse()"
658
+ />
659
+ </div>
660
+
661
+ <!-- The tree answers WHERE, never WHAT: with no name to place yet it could
662
+ decide nothing, so say that instead of listing a repo for nothing. -->
663
+ <div
664
+ v-if="browsingDirectory && monorepoRepoId !== undefined"
665
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
666
+ >
667
+ <p class="mb-2 text-xs text-slate-400">
668
+ {{
669
+ directoryLeaf
670
+ ? t('bootstrap.monorepo.directory.browseHint')
671
+ : t('bootstrap.monorepo.directory.browseNeedsName')
672
+ }}
673
+ </p>
674
+ <RepoTreeBrowser
675
+ v-if="directoryLeaf"
676
+ :repo-github-id="monorepoRepoId"
677
+ mode="dir"
678
+ :new-dir-name="directoryLeaf"
679
+ :model-value="monorepoDirectory"
680
+ :start-path="directoryBrowseStart"
681
+ @update:model-value="placeDirectory"
682
+ />
683
+ </div>
684
+ </div>
558
685
  </UFormField>
559
686
  </template>
560
687
 
@@ -733,6 +860,18 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
733
860
  >
734
861
  {{ t('bootstrap.recent.open') }}
735
862
  </ULink>
863
+ <!-- The deliverable of a `pull_request` run, and the only thing it produced that
864
+ the user still has to act on. A monorepo run has no `repoUrl` at all, so
865
+ without this the run's whole output is unreachable from the list that
866
+ offered the choice. -->
867
+ <ULink
868
+ v-if="job.prUrl"
869
+ :to="job.prUrl"
870
+ target="_blank"
871
+ class="text-[11px] text-indigo-400 hover:underline"
872
+ >
873
+ {{ t('bootstrap.recent.openPr') }}
874
+ </ULink>
736
875
  <UBadge :color="statusColor[job.status]" variant="subtle" size="sm">
737
876
  {{ statusLabel[job.status] }}
738
877
  </UBadge>