@cat-factory/app 0.293.1 → 0.294.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 (37) 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 +43 -0
  4. package/app/components/bootstrap/BootstrapModal.logic.ts +28 -0
  5. package/app/components/bootstrap/BootstrapModal.vue +82 -15
  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/bugFishing.ts +143 -0
  19. package/app/stores/ui/resultViews.ts +14 -7
  20. package/app/stores/ui/runStepOpeners.ts +29 -1
  21. package/app/stores/workspaceSettings.ts +1 -0
  22. package/app/types/execution.ts +9 -0
  23. package/app/utils/catalog.spec.ts +1 -0
  24. package/app/utils/catalog.ts +25 -0
  25. package/app/utils/repoPath.spec.ts +49 -0
  26. package/app/utils/repoPath.ts +28 -0
  27. package/i18n/locales/de.json +103 -6
  28. package/i18n/locales/en.json +102 -5
  29. package/i18n/locales/es.json +103 -6
  30. package/i18n/locales/fr.json +103 -6
  31. package/i18n/locales/he.json +103 -6
  32. package/i18n/locales/it.json +103 -6
  33. package/i18n/locales/ja.json +103 -6
  34. package/i18n/locales/pl.json +103 -6
  35. package/i18n/locales/tr.json +103 -6
  36. package/i18n/locales/uk.json +103 -6
  37. package/package.json +2 -2
@@ -0,0 +1,524 @@
1
+ <script setup lang="ts">
2
+ // Bug-fishing expedition window — the dedicated surface for the read-only `bug-fisher`'s
3
+ // multi-angle catch, opened via the universal result-view host. It reads the live expedition
4
+ // state straight off the run's `bug-fisher` step (`step.bugFishing`, kept fresh by the
5
+ // execution stream) and lets a human triage what each angle caught.
6
+ //
7
+ // The one thing that makes this window different from the PR-review window it is otherwise
8
+ // modelled on: triage is available WHILE the expedition is still fishing. Each angle is its own
9
+ // container dispatch, so a completed phase's findings are final the moment they land, and
10
+ // marking one spawns its bug-fix task immediately rather than at the end. The phase rail on the
11
+ // left is what makes that legible: it shows which angles have landed, which is being fished now,
12
+ // and which are still queued.
13
+ //
14
+ // Marking is per-finding rather than a multi-select-then-resolve, because each mark is a
15
+ // side effect (a task is created and its run started) rather than a selection to be applied
16
+ // later. The pipeline override applies to the marks made after it is changed, and the window
17
+ // says which pipeline it will use before anything is created.
18
+ import { computed, ref } from 'vue'
19
+ import { useResultView } from '~/composables/useResultView'
20
+ import { useExecutionStore } from '~/stores/execution'
21
+ import { useBoardStore } from '~/stores/board'
22
+ import { usePipelinesStore } from '~/stores/pipelines'
23
+ import { useUiStore } from '~/stores/ui'
24
+ import { pipelineAllowedForTaskType } from '~/utils/pipeline'
25
+ import { useBugFishingStore } from '~/stores/bugFishing'
26
+ import { bugFishingSpawnIsClaimable } from '@cat-factory/contracts'
27
+ import type { BugFishingFinding, BugFishingSeverity, BugFishingStepState } from '~/types/execution'
28
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
29
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
30
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
31
+
32
+ const execution = useExecutionStore()
33
+ const board = useBoardStore()
34
+ const pipelines = usePipelinesStore()
35
+ const bugFishing = useBugFishingStore()
36
+ const ui = useUiStore()
37
+ const access = useWorkspaceAccess()
38
+
39
+ const { t } = useI18n()
40
+
41
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('bug-fishing', {
42
+ onOpen: ({ instanceId }) => {
43
+ if (instanceId) void bugFishing.load(instanceId)
44
+ },
45
+ })
46
+
47
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
48
+ const instance = computed(() =>
49
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
50
+ )
51
+ const step = computed(() => {
52
+ if (instance.value === null || stepIndex.value === null) return null
53
+ return instance.value.steps[stepIndex.value] ?? null
54
+ })
55
+ const state = computed<BugFishingStepState | null>(() => step.value?.bugFishing ?? null)
56
+ const status = computed(() => state.value?.status ?? null)
57
+ const awaiting = computed(() => status.value === 'awaiting_triage')
58
+ const fishing = computed(() => status.value === 'fishing')
59
+
60
+ const phases = computed(() => state.value?.phases ?? [])
61
+ const findings = computed(() => state.value?.findings ?? [])
62
+
63
+ /** Severity order, most severe first — the one place the window's ranking is stated. */
64
+ const SEVERITY_ORDER: BugFishingSeverity[] = ['critical', 'high', 'medium', 'low']
65
+ const severityRank = (s: BugFishingSeverity) => {
66
+ const i = SEVERITY_ORDER.indexOf(s)
67
+ return i === -1 ? SEVERITY_ORDER.length : i
68
+ }
69
+
70
+ /**
71
+ * Which phase's findings the reader is looking at. `null` is "everything caught so far", which
72
+ * is the default because an expedition's value is the whole catch — the per-phase filter exists
73
+ * for someone working through one angle at a time, not as the primary reading.
74
+ */
75
+ const selectedPhaseId = ref<string | null>(null)
76
+
77
+ /** Whether findings whose decision has been made are shown. Off by default: what is left to
78
+ * decide is the working list, and a triaged finding that stays in it reads as untriaged. */
79
+ const showTriaged = ref(false)
80
+
81
+ /**
82
+ * Whether a finding is still OPEN — nothing is being done about it, so it belongs in the working
83
+ * list and the Fix button applies to it.
84
+ *
85
+ * Never `!f.spawn`: a spawn record whose status is `failed` is a mark the platform did not carry
86
+ * out, and reading the record's mere PRESENCE as "being fixed" is how a finding nobody is working
87
+ * on would drop out of the list of things left to decide. The rule is the engine's own
88
+ * (`@cat-factory/contracts`), so this window cannot come to offer a mark the engine refuses.
89
+ */
90
+ function isOpen(f: BugFishingFinding): boolean {
91
+ return !f.dismissed && bugFishingSpawnIsClaimable(f.spawn, Date.now())
92
+ }
93
+
94
+ const visibleFindings = computed<BugFishingFinding[]>(() => {
95
+ const byPhase = selectedPhaseId.value
96
+ ? findings.value.filter((f) => f.phaseId === selectedPhaseId.value)
97
+ : findings.value
98
+ const triaged = showTriaged.value ? byPhase : byPhase.filter(isOpen)
99
+ return [...triaged].sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
100
+ })
101
+
102
+ /** Findings with no decision yet, across every phase — what the header counts. */
103
+ const untriagedCount = computed(() => findings.value.filter(isOpen).length)
104
+ /** Findings whose fix task EXISTS. A claim still in flight is not one, and a failed one is not. */
105
+ const spawnedCount = computed(
106
+ () => findings.value.filter((f) => f.spawn?.status === 'spawned').length,
107
+ )
108
+
109
+ /** The phase the rail has selected, when one is. */
110
+ const selectedPhase = computed(() =>
111
+ selectedPhaseId.value ? (phases.value.find((p) => p.id === selectedPhaseId.value) ?? null) : null,
112
+ )
113
+
114
+ /** How many angles have settled (completed or failed) — what the still-fishing banner counts. */
115
+ const settledPhaseCount = computed(
116
+ () => phases.value.filter((p) => p.status === 'completed' || p.status === 'failed').length,
117
+ )
118
+
119
+ /** How many findings each phase contributed, for the rail's per-angle count. */
120
+ function phaseFindingCount(phaseId: string): number {
121
+ return findings.value.filter((f) => f.phaseId === phaseId).length
122
+ }
123
+
124
+ /**
125
+ * The pipeline spawned fix tasks will run: the board's configured default, overridden here for
126
+ * the marks made while the override is set. Stated in the window BEFORE anything is created,
127
+ * because the pipeline is the whole shape of the work a mark causes.
128
+ */
129
+ /** '' means "use the board's default", the same spelling the workspace settings field uses. */
130
+ const pipelineOverride = ref('')
131
+ const defaultPipelineId = computed(() => state.value?.defaultFixPipelineId ?? null)
132
+ /** What a mark made right now would run on — the override if one is set, else the board default. */
133
+ const effectivePipelineName = computed(() => {
134
+ const id = pipelineOverride.value || defaultPipelineId.value
135
+ if (!id) return null
136
+ return pipelines.getPipeline(id)?.name ?? id
137
+ })
138
+ /**
139
+ * The pipelines a spawned fix task may run: the ones this board offers a `bug` task, since that is
140
+ * exactly what a spawned fix IS. Narrowed by the same predicate the create form uses, so the two
141
+ * screens cannot disagree about what a bug task may run. The leading row carries '' so "the
142
+ * board's default" is a value someone can pick back to rather than only a starting state.
143
+ */
144
+ const pipelineOptions = computed(() => [
145
+ { value: '', label: t('bugFishing.fixPipeline.boardDefault') },
146
+ ...pipelines.pipelines
147
+ .filter((p) => pipelineAllowedForTaskType(p, 'bug'))
148
+ .map((p) => ({ label: p.name, value: p.id })),
149
+ ])
150
+
151
+ /** Marking a finding STARTS a run, so it takes the run-execution permission, not board write. */
152
+ const canAct = computed(() => access.canExecuteRuns.value)
153
+
154
+ async function mark(finding: BugFishingFinding): Promise<void> {
155
+ if (!instanceId.value) return
156
+ await bugFishing.address(instanceId.value, [finding.id], pipelineOverride.value || undefined)
157
+ }
158
+
159
+ async function dismiss(finding: BugFishingFinding): Promise<void> {
160
+ if (!instanceId.value) return
161
+ await bugFishing.dismiss(instanceId.value, finding.id)
162
+ }
163
+
164
+ async function finish(): Promise<void> {
165
+ if (!instanceId.value) return
166
+ await bugFishing.resolve(instanceId.value)
167
+ close()
168
+ }
169
+
170
+ /** Open the task a marked finding spawned, so the reader can follow the work they caused. */
171
+ function openSpawnedTask(taskId: string): void {
172
+ close()
173
+ ui.select(taskId)
174
+ ui.focus(taskId)
175
+ }
176
+
177
+ const SEVERITY_CLASS: Record<BugFishingSeverity, string> = {
178
+ critical: 'bg-red-500/15 text-red-300 border-red-500/30',
179
+ high: 'bg-orange-500/15 text-orange-300 border-orange-500/30',
180
+ medium: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
181
+ low: 'bg-slate-500/15 text-slate-300 border-slate-600/40',
182
+ }
183
+
184
+ const PHASE_ICON: Record<string, string> = {
185
+ pending: 'i-lucide-circle-dashed',
186
+ fishing: 'i-lucide-loader-circle',
187
+ completed: 'i-lucide-check',
188
+ failed: 'i-lucide-triangle-alert',
189
+ }
190
+ </script>
191
+
192
+ <template>
193
+ <ResultWindowShell
194
+ :open="open"
195
+ icon="i-lucide-fish"
196
+ icon-class="bg-sky-500/15 text-sky-300"
197
+ :title="block ? t('bugFishing.titleWithBlock', { title: block.title }) : t('bugFishing.title')"
198
+ :subtitle="t('bugFishing.subtitle')"
199
+ width="full"
200
+ testid="bug-fishing-window"
201
+ :step="{ instanceId, stepIndex }"
202
+ @close="close"
203
+ >
204
+ <div class="flex min-h-0 flex-1">
205
+ <!-- The phase rail: which angles have landed, which is being fished, which are queued.
206
+ It is what makes mid-expedition triage legible — a reader has to be able to tell a
207
+ phase that found nothing from one that has not run yet. -->
208
+ <aside
209
+ data-testid="bug-fishing-phases"
210
+ class="w-60 shrink-0 overflow-y-auto border-r border-slate-800 px-3 py-4"
211
+ >
212
+ <p class="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
213
+ {{ t('bugFishing.phases.heading') }}
214
+ </p>
215
+ <button
216
+ type="button"
217
+ class="mb-1 w-full rounded-md px-2 py-1.5 text-left text-[12px]"
218
+ :class="
219
+ selectedPhaseId === null
220
+ ? 'bg-slate-800 text-slate-100'
221
+ : 'text-slate-400 hover:bg-slate-800/60'
222
+ "
223
+ @click="selectedPhaseId = null"
224
+ >
225
+ {{ t('bugFishing.phases.all', { count: findings.length }) }}
226
+ </button>
227
+ <ul class="space-y-0.5">
228
+ <li v-for="phase in phases" :key="phase.id">
229
+ <button
230
+ type="button"
231
+ class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left"
232
+ :class="
233
+ selectedPhaseId === phase.id
234
+ ? 'bg-slate-800 text-slate-100'
235
+ : 'text-slate-400 hover:bg-slate-800/60'
236
+ "
237
+ :data-testid="`bug-fishing-phase-${phase.id}`"
238
+ @click="selectedPhaseId = phase.id"
239
+ >
240
+ <UIcon
241
+ :name="PHASE_ICON[phase.status] ?? 'i-lucide-circle-dashed'"
242
+ class="mt-0.5 h-3.5 w-3.5 shrink-0"
243
+ :class="{
244
+ 'animate-spin text-sky-300': phase.status === 'fishing',
245
+ 'text-emerald-400': phase.status === 'completed',
246
+ 'text-amber-400': phase.status === 'failed',
247
+ 'text-slate-600': phase.status === 'pending',
248
+ }"
249
+ />
250
+ <span class="min-w-0 flex-1">
251
+ <span class="block truncate text-[12px]">{{ phase.title }}</span>
252
+ <span class="block text-[10px] text-slate-500">
253
+ {{
254
+ phase.status === 'completed' || phase.status === 'failed'
255
+ ? t('bugFishing.phases.found', { count: phaseFindingCount(phase.id) })
256
+ : t(`bugFishing.phases.status.${phase.status}`)
257
+ }}
258
+ </span>
259
+ </span>
260
+ </button>
261
+ </li>
262
+ </ul>
263
+ </aside>
264
+
265
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
266
+ <!-- The selected phase's own account of what it covered. Rendered for a FAILED phase
267
+ too, carrying its reason: a phase that reported nothing because it crashed and one
268
+ that reported nothing because it found nothing are different facts. -->
269
+ <div
270
+ v-if="selectedPhase"
271
+ class="mb-4 rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2.5"
272
+ >
273
+ <p class="text-[11px] text-slate-400">{{ selectedPhase.goal }}</p>
274
+ <p
275
+ v-if="selectedPhase.status === 'failed'"
276
+ data-testid="bug-fishing-phase-failed"
277
+ class="mt-2 text-[12px] text-amber-300"
278
+ >
279
+ {{ t('bugFishing.phases.failedNote', { reason: selectedPhase.failureReason ?? '' }) }}
280
+ </p>
281
+ <MarkdownProse
282
+ v-else-if="selectedPhase.summary"
283
+ :text="selectedPhase.summary"
284
+ class="mt-2 max-w-3xl text-[12px]"
285
+ />
286
+ </div>
287
+
288
+ <!-- The still-fishing banner. Deliberately shown ABOVE the findings rather than in place
289
+ of them: everything already caught is actionable now, which is the whole design. -->
290
+ <div
291
+ v-if="fishing"
292
+ data-testid="bug-fishing-in-progress"
293
+ class="mb-4 flex items-center gap-2 rounded-lg border border-sky-500/25 bg-sky-500/5 px-3 py-2 text-[12px] text-sky-200"
294
+ >
295
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 shrink-0 animate-spin" />
296
+ <span>
297
+ {{
298
+ t('bugFishing.stillFishing', {
299
+ done: settledPhaseCount,
300
+ total: phases.length,
301
+ })
302
+ }}
303
+ </span>
304
+ </div>
305
+
306
+ <!-- What the marks will run. Stated before anything is created, because the pipeline is
307
+ the shape of the work a mark causes. -->
308
+ <div
309
+ v-if="canAct"
310
+ data-testid="bug-fishing-pipeline"
311
+ class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2"
312
+ >
313
+ <span class="text-[11px] text-slate-400">{{ t('bugFishing.fixPipeline.label') }}</span>
314
+ <USelectMenu
315
+ v-model="pipelineOverride"
316
+ :items="pipelineOptions"
317
+ value-key="value"
318
+ size="xs"
319
+ class="min-w-52"
320
+ />
321
+ <span class="text-[11px] text-slate-500">
322
+ {{ t('bugFishing.fixPipeline.hint', { pipeline: effectivePipelineName ?? '—' }) }}
323
+ </span>
324
+ </div>
325
+
326
+ <div class="mb-2 flex items-center justify-between">
327
+ <p class="text-[11px] text-slate-400">
328
+ {{ t('bugFishing.counts', { untriaged: untriagedCount, spawned: spawnedCount }) }}
329
+ </p>
330
+ <label class="flex items-center gap-1.5 text-[11px] text-slate-400">
331
+ <input v-model="showTriaged" type="checkbox" class="accent-sky-500" />
332
+ {{ t('bugFishing.showTriaged') }}
333
+ </label>
334
+ </div>
335
+
336
+ <p v-if="bugFishing.error" class="mb-3 text-[12px] text-red-300">
337
+ {{ bugFishing.error }}
338
+ </p>
339
+
340
+ <!-- An expedition that caught nothing is a real answer, not an empty state. The copy
341
+ says which of the two it is, because "nothing found" and "nothing left to triage"
342
+ are different things to be told. -->
343
+ <div
344
+ v-if="visibleFindings.length === 0"
345
+ data-testid="bug-fishing-empty"
346
+ class="rounded-lg border border-slate-800 bg-slate-900/40 px-4 py-8 text-center text-[12px] text-slate-400"
347
+ >
348
+ {{
349
+ findings.length === 0
350
+ ? t('bugFishing.empty.nothingCaught')
351
+ : t('bugFishing.empty.allTriaged')
352
+ }}
353
+ </div>
354
+
355
+ <ul v-else class="space-y-2">
356
+ <li
357
+ v-for="finding in visibleFindings"
358
+ :key="finding.id"
359
+ :data-testid="`bug-fishing-finding-${finding.id}`"
360
+ class="rounded-lg border border-slate-800 bg-slate-900/40 px-3 py-2.5"
361
+ :class="{ 'opacity-60': finding.dismissed }"
362
+ >
363
+ <div class="flex flex-wrap items-center gap-2">
364
+ <span
365
+ class="rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase"
366
+ :class="SEVERITY_CLASS[finding.severity]"
367
+ >
368
+ {{ t(`bugFishing.severity.${finding.severity}`) }}
369
+ </span>
370
+ <span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
371
+ {{ t(`bugFishing.kind.${finding.kind}`) }}
372
+ </span>
373
+ <span class="text-[10px] text-slate-500">
374
+ {{ t(`bugFishing.confidence.${finding.confidence}`) }}
375
+ </span>
376
+ <span
377
+ class="min-w-0 flex-1 text-[13px] text-slate-100"
378
+ :class="{ 'line-through': finding.dismissed }"
379
+ >
380
+ {{ finding.title }}
381
+ </span>
382
+ </div>
383
+
384
+ <p v-if="finding.path" class="mt-1 font-mono text-[11px] text-slate-500">
385
+ {{ finding.path }}<span v-if="finding.line">:{{ finding.line }}</span>
386
+ </p>
387
+
388
+ <MarkdownProse :text="finding.detail" class="mt-2 max-w-3xl text-[12px]" />
389
+
390
+ <div v-if="finding.failureScenario" class="mt-2 text-[12px] text-slate-300">
391
+ <span class="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
392
+ {{ t('bugFishing.finding.failureScenario') }}
393
+ </span>
394
+ <MarkdownProse :text="finding.failureScenario" class="mt-0.5 max-w-3xl" />
395
+ </div>
396
+
397
+ <!-- Evidence is rendered apart from the detail for the reason the contract keeps them
398
+ apart: a finding that cannot point at the code it describes is speculating, and
399
+ that should be visible without reading the prose for it. -->
400
+ <details v-if="finding.evidence" class="mt-2">
401
+ <summary class="cursor-pointer text-[11px] text-slate-400 hover:text-slate-200">
402
+ {{ t('bugFishing.finding.evidence') }}
403
+ </summary>
404
+ <MarkdownProse :text="finding.evidence" class="mt-1 max-w-3xl text-[12px]" />
405
+ </details>
406
+
407
+ <details v-if="finding.suggestedFix" class="mt-1">
408
+ <summary class="cursor-pointer text-[11px] text-slate-400 hover:text-slate-200">
409
+ {{ t('bugFishing.finding.suggestedFix') }}
410
+ </summary>
411
+ <MarkdownProse :text="finding.suggestedFix" class="mt-1 max-w-3xl text-[12px]" />
412
+ </details>
413
+
414
+ <!-- Already marked: say what was created and let the reader follow it. The three
415
+ spawn states are rendered apart because they are three different facts — a task
416
+ that exists, one being made, and a mark that did not land — and only the last
417
+ one is something the reader has to do again. -->
418
+ <div
419
+ v-if="finding.spawn?.status === 'spawned'"
420
+ data-testid="bug-fishing-finding-spawned"
421
+ class="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-emerald-300"
422
+ >
423
+ <UIcon name="i-lucide-check-circle-2" class="h-3.5 w-3.5" />
424
+ <span>
425
+ {{
426
+ t('bugFishing.finding.spawned', {
427
+ pipeline:
428
+ pipelines.getPipeline(finding.spawn.pipelineId)?.name ??
429
+ finding.spawn.pipelineId,
430
+ })
431
+ }}
432
+ </span>
433
+ <button
434
+ type="button"
435
+ class="underline hover:text-emerald-200"
436
+ @click="openSpawnedTask(finding.spawn.taskId)"
437
+ >
438
+ {{ t('bugFishing.finding.openTask') }}
439
+ </button>
440
+ </div>
441
+
442
+ <!-- A claim held by a marking still in flight. No task to link yet, and no Fix
443
+ button: pressing it again is exactly the double-spawn the claim prevents. -->
444
+ <div
445
+ v-else-if="finding.spawn?.status === 'pending'"
446
+ data-testid="bug-fishing-finding-spawning"
447
+ class="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-400"
448
+ >
449
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
450
+ <span>{{ t('bugFishing.finding.spawning') }}</span>
451
+ </div>
452
+
453
+ <!-- A mark that did not land. Says so and carries the cause, because the finding is
454
+ markable again and the reader is the one who has to decide to try. -->
455
+ <p
456
+ v-else-if="finding.spawn?.status === 'failed'"
457
+ data-testid="bug-fishing-finding-spawn-failed"
458
+ class="mt-2 max-w-3xl text-[11px] text-amber-300"
459
+ >
460
+ {{
461
+ t('bugFishing.finding.spawnFailed', {
462
+ reason: finding.spawn.failureReason ?? '',
463
+ })
464
+ }}
465
+ </p>
466
+
467
+ <div v-if="isOpen(finding) && canAct" class="mt-2 flex items-center gap-2">
468
+ <UButton
469
+ size="xs"
470
+ color="primary"
471
+ icon="i-lucide-wrench"
472
+ :loading="bugFishing.spawning.has(finding.id)"
473
+ :disabled="finding.dismissed || bugFishing.spawning.has(finding.id)"
474
+ :data-testid="`bug-fishing-fix-${finding.id}`"
475
+ @click="mark(finding)"
476
+ >
477
+ {{ t('bugFishing.finding.fixThis') }}
478
+ </UButton>
479
+ <UButton
480
+ v-if="!finding.dismissed"
481
+ size="xs"
482
+ color="neutral"
483
+ variant="ghost"
484
+ :disabled="bugFishing.spawning.has(finding.id)"
485
+ @click="dismiss(finding)"
486
+ >
487
+ {{ t('bugFishing.finding.dismiss') }}
488
+ </UButton>
489
+ </div>
490
+ </li>
491
+ </ul>
492
+
493
+ <StepRunMeta v-if="step" :step="step" class="mt-5" />
494
+ </div>
495
+ </div>
496
+
497
+ <!-- Footer. Rendered only once every angle has settled: while the expedition is still
498
+ fishing there is nothing to finish, and offering it would read as a way to stop the hunt
499
+ (it is not — the run advances past the step and the remaining angles never run). -->
500
+ <footer
501
+ v-if="awaiting"
502
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
503
+ >
504
+ <p class="text-[11px] text-slate-500">
505
+ {{
506
+ untriagedCount > 0
507
+ ? t('bugFishing.footer.parkedWithUntriaged', { count: untriagedCount })
508
+ : t('bugFishing.footer.parked')
509
+ }}
510
+ </p>
511
+ <UButton
512
+ color="primary"
513
+ icon="i-lucide-check"
514
+ :loading="bugFishing.resolving"
515
+ :disabled="!canAct"
516
+ :title="canAct ? undefined : t('access.noRunExecute')"
517
+ data-testid="bug-fishing-finish"
518
+ @click="finish"
519
+ >
520
+ {{ t('bugFishing.footer.finish') }}
521
+ </UButton>
522
+ </footer>
523
+ </ResultWindowShell>
524
+ </template>
@@ -98,6 +98,8 @@ function openApprovalFor(approvalId: string) {
98
98
  class="absolute inset-0 z-30 flex flex-col bg-slate-950/95 backdrop-blur"
99
99
  role="dialog"
100
100
  aria-modal="true"
101
+ data-testid="block-focus-view"
102
+ :data-focus-block="block.id"
101
103
  >
102
104
  <!-- header / breadcrumb -->
103
105
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -14,6 +14,10 @@
14
14
  // from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
15
15
  // context-document files from anywhere in the tree — navigating away never drops
16
16
  // earlier picks.
17
+ //
18
+ // `dir` mode also PLACES a directory that does not exist yet (`newDirName`: the bootstrap
19
+ // service-directory field). Nothing in the tree can be the target then, so the pick is the
20
+ // folder the caller is standing in and the emitted value is that folder plus the new name.
17
21
  import type { RepoTreeEntry } from '~/types/domain'
18
22
 
19
23
  const props = withDefaults(
@@ -30,8 +34,23 @@ const props = withDefaults(
30
34
  selectedPaths?: string[]
31
35
  /** `multiple`: paths already chosen elsewhere — listed but not selectable. */
32
36
  addedPaths?: string[]
37
+ /**
38
+ * `dir`, single-select: the name of a directory that DOES NOT EXIST YET, which this
39
+ * browser is choosing a home for. Picking a folder then emits `<folder>/<newDirName>`
40
+ * (the repo root included, so the footer is offered there too), and a listing that
41
+ * already holds the name says so and offers nothing: "the target must not exist" is the
42
+ * one half of the API's refusal the listing in front of the user can answer first.
43
+ */
44
+ newDirName?: string
33
45
  }>(),
34
- { mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
46
+ {
47
+ mode: 'dir',
48
+ startPath: '',
49
+ multiple: false,
50
+ selectedPaths: () => [],
51
+ addedPaths: () => [],
52
+ newDirName: '',
53
+ },
35
54
  )
36
55
  const emit = defineEmits<{
37
56
  'update:modelValue': [string | undefined]
@@ -49,11 +68,38 @@ const loading = ref(false)
49
68
 
50
69
  const selectedSet = computed(() => new Set(props.selectedPaths.map(normalizeRepoPath)))
51
70
  const addedSet = computed(() => new Set(props.addedPaths.map(normalizeRepoPath)))
71
+ // Placing a new directory is single-target by construction: a cart of not-yet-existing
72
+ // siblings has no caller, and the pick copy below reads as one target.
73
+ const placingNewDir = computed(() => props.mode === 'dir' && !props.multiple && !!props.newDirName)
74
+
75
+ /** What a pick on `folder` yields: the new directory's path, or the folder itself. */
76
+ function pickedPathFor(folder: string): string {
77
+ return placingNewDir.value ? joinRepoPath(folder, props.newDirName) : folder
78
+ }
79
+
52
80
  function isAdded(path: string): boolean {
53
81
  return props.multiple && addedSet.value.has(normalizeRepoPath(path))
54
82
  }
55
83
  function isPicked(path: string): boolean {
56
- return props.multiple ? selectedSet.value.has(normalizeRepoPath(path)) : props.modelValue === path
84
+ if (props.multiple) return selectedSet.value.has(normalizeRepoPath(path))
85
+ return normalizeRepoPath(props.modelValue ?? '') === normalizeRepoPath(pickedPathFor(path))
86
+ }
87
+
88
+ // The CURRENT listing already holds the name, so the new directory cannot go here. Only the
89
+ // folder the browser has actually LISTED can be judged: answering for a child would mean
90
+ // listing every one of them, so navigating in IS how you ask about a child. `loading` is part
91
+ // of that reading, because `browseTo` moves `currentPath` before its fetch settles and the
92
+ // entries still in hand are the folder the user has already left.
93
+ const nameTakenHere = computed(
94
+ () =>
95
+ placingNewDir.value &&
96
+ !loading.value &&
97
+ treeEntries.value.some((e) => e.name === props.newDirName),
98
+ )
99
+
100
+ /** The listed entry that IS the clash, flagged in place so the reason sits where the eye is. */
101
+ function clashes(entry: RepoTreeEntry): boolean {
102
+ return placingNewDir.value && entry.name === props.newDirName
57
103
  }
58
104
 
59
105
  const dirEntries = computed(() => treeEntries.value.filter((e) => e.type === 'dir'))
@@ -106,7 +152,7 @@ function pick(path: string) {
106
152
  if (addedSet.value.has(normalizeRepoPath(path))) return
107
153
  emit('toggle', path)
108
154
  } else {
109
- emit('update:modelValue', path)
155
+ emit('update:modelValue', pickedPathFor(path))
110
156
  }
111
157
  }
112
158
 
@@ -169,14 +215,21 @@ watch(
169
215
  <span class="truncate">{{ entry.name }}</span>
170
216
  </button>
171
217
  <span
172
- v-if="mode === 'dir' && isAdded(entry.path)"
218
+ v-if="clashes(entry)"
219
+ class="flex shrink-0 items-center gap-1 text-xs text-amber-400"
220
+ >
221
+ <UIcon name="i-lucide-circle-alert" class="h-3.5 w-3.5" />
222
+ {{ t('github.repoTree.exists') }}
223
+ </span>
224
+ <span
225
+ v-else-if="mode === 'dir' && isAdded(entry.path)"
173
226
  class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
174
227
  >
175
228
  <UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
176
229
  {{ t('github.repoTree.added') }}
177
230
  </span>
178
231
  <UButton
179
- v-else-if="mode === 'dir'"
232
+ v-else-if="mode === 'dir' && !placingNewDir"
180
233
  size="xs"
181
234
  variant="soft"
182
235
  :color="isPicked(entry.path) ? 'primary' : 'neutral'"
@@ -257,9 +310,39 @@ watch(
257
310
  </ul>
258
311
  </div>
259
312
 
313
+ <!-- placing a new directory: the pick is a LOCATION, so the repo root is a valid answer
314
+ (unlike a plain dir pick, where the root means "the whole repo") and the target path
315
+ is spelled out beside the button rather than left to be inferred from the crumbs -->
316
+ <div v-if="placingNewDir" class="mt-2 flex items-center justify-between gap-2">
317
+ <p
318
+ class="min-w-0 truncate text-xs"
319
+ :class="nameTakenHere ? 'text-amber-400' : 'text-slate-400'"
320
+ >
321
+ <template v-if="nameTakenHere">
322
+ {{ t('github.repoTree.nameTaken', { name: newDirName }) }}
323
+ </template>
324
+ <template v-else>
325
+ {{ t('github.repoTree.newDirTarget') }}
326
+ <code class="text-slate-200">{{ pickedPathFor(currentPath) }}</code>
327
+ </template>
328
+ </p>
329
+ <UButton
330
+ size="xs"
331
+ variant="soft"
332
+ :color="isPicked(currentPath) ? 'primary' : 'neutral'"
333
+ :disabled="loading || nameTakenHere"
334
+ data-testid="repo-tree-create-here"
335
+ @click="pick(currentPath)"
336
+ >
337
+ {{
338
+ isPicked(currentPath) ? t('github.repoTree.selected') : t('github.repoTree.createHere')
339
+ }}
340
+ </UButton>
341
+ </div>
342
+
260
343
  <!-- dir mode: pin the current folder without descending into a child -->
261
344
  <div
262
- v-if="mode === 'dir' && currentPath && !isAdded(currentPath)"
345
+ v-else-if="mode === 'dir' && currentPath && !isAdded(currentPath)"
263
346
  class="mt-2 flex justify-end"
264
347
  >
265
348
  <UButton