@cat-factory/app 0.296.2 → 0.296.4

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 (50) hide show
  1. package/README.md +4 -3
  2. package/app/components/board/AddTaskModal.vue +63 -3
  3. package/app/components/board/BoardCanvas.vue +4 -92
  4. package/app/components/board/CreateInitiativeModal.vue +2 -2
  5. package/app/components/bugFishing/BugFishingWindow.vue +170 -48
  6. package/app/components/common/DescriptorFields.vue +7 -0
  7. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -12
  8. package/app/components/foundational/FoundationalServiceSources.vue +2 -11
  9. package/app/components/fragments/FragmentLibraryManager.vue +3 -12
  10. package/app/components/layout/AccountAuditLog.logic.ts +2 -2
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +7 -22
  12. package/app/components/panels/StepToolServers.logic.ts +2 -2
  13. package/app/components/settings/ConsensusGroupsSection.vue +1 -4
  14. package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +0 -1
  15. package/app/components/skills/SkillLibraryManager.vue +3 -12
  16. package/app/components/tutorial/TutorialOverlay.logic.ts +2 -2
  17. package/app/composables/api/client.spec.ts +0 -5
  18. package/app/composables/useBusyRows.ts +22 -0
  19. package/app/composables/useFrameResize.ts +0 -5
  20. package/app/composables/useSemanticZoom.ts +1 -1
  21. package/app/modular/panels/inspector.ts +1 -1
  22. package/app/modular/result-views.ts +4 -3
  23. package/app/stores/board/mutations.ts +1 -14
  24. package/app/stores/observability/agentContext.ts +1 -8
  25. package/app/stores/observability/toolCalls.ts +1 -8
  26. package/app/stores/observability/withFlag.ts +9 -0
  27. package/app/types/execution.ts +4 -0
  28. package/app/utils/blockRects.ts +1 -1
  29. package/app/utils/boardWakeGate.ts +1 -1
  30. package/app/utils/catalog.ts +1 -14
  31. package/app/utils/descriptorFields.spec.ts +0 -45
  32. package/app/utils/descriptorFields.ts +1 -13
  33. package/app/utils/dnd.ts +1 -25
  34. package/app/utils/initiative.ts +1 -1
  35. package/app/utils/settlingLoop.ts +1 -1
  36. package/app/utils/taskExpansionRanking.spec.ts +8 -17
  37. package/app/utils/tutorial.ts +1 -1
  38. package/app/utils/uiMode.spec.ts +0 -1
  39. package/app/utils/uiRole.ts +1 -1
  40. package/i18n/locales/de.json +19 -7
  41. package/i18n/locales/en.json +19 -7
  42. package/i18n/locales/es.json +19 -7
  43. package/i18n/locales/fr.json +19 -7
  44. package/i18n/locales/he.json +19 -7
  45. package/i18n/locales/it.json +19 -7
  46. package/i18n/locales/ja.json +19 -7
  47. package/i18n/locales/pl.json +19 -7
  48. package/i18n/locales/tr.json +19 -7
  49. package/i18n/locales/uk.json +19 -7
  50. package/package.json +2 -2
package/README.md CHANGED
@@ -203,9 +203,10 @@ reaches the wire. **Every string a descriptor carries is deployment-authored Eng
203
203
  verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
204
204
  platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
205
205
  a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
206
- (`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
207
- `toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
208
- must be able to reach, and a rule inside a component is only reachable by mounting one.
206
+ (`setDescriptorValue` / `setDescriptorCheckbox` / `toggleDescriptorGroupValue` for one edit; the
207
+ initial values come straight from contracts' `descriptorFieldDefaults`, the same helper the server
208
+ folds in at the creation door): what an edit freezes on an entity is what a unit test must be able
209
+ to reach, and a rule inside a component is only reachable by mounting one.
209
210
 
210
211
  Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can
211
212
  re-register the descriptor while the dialog sits open, so a create can come back `422` with
@@ -22,7 +22,11 @@ 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
+ import {
26
+ BUG_FISHING_DEFAULT_PASS_BUDGET,
27
+ BUG_FISHING_MAX_PASS_BUDGET,
28
+ BUG_FISHING_PHASES,
29
+ } from '@cat-factory/contracts'
26
30
  import { resolveComponentRegistry } from '@modular-vue/core'
27
31
  import { useReactiveSlots } from '@modular-vue/runtime'
28
32
  import type { AppSlots, ResultViewContribution } from '~/modular/slots'
@@ -39,7 +43,7 @@ import {
39
43
  sanitizeDescriptorFields,
40
44
  validateDescriptorFields,
41
45
  } from '@cat-factory/contracts'
42
- import { defaultDescriptorValues } from '~/utils/descriptorFields'
46
+ import { descriptorFieldDefaults } from '@cat-factory/contracts'
43
47
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
44
48
  import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
45
49
 
@@ -138,6 +142,8 @@ const timeboxHours = ref<number | undefined>(undefined)
138
142
  // the deliberate act) plus an optional focus folded into every angle's prompt.
139
143
  const fishingPhaseIds = ref<string[]>([])
140
144
  const fishingFocus = ref('')
145
+ /** Held as a string because the input is a text field; parsed at submit, blank ⇒ the default. */
146
+ const fishingMaxPasses = ref('')
141
147
  // Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
142
148
  const spikeResearchQuestion = ref('')
143
149
  const spikeSuccessCriteria = ref('')
@@ -304,12 +310,37 @@ function buildCustomTypeFields(): TaskTypeFields | undefined {
304
310
  * Its own function rather than another arm of {@link buildTypeFields}, whose per-type chain is at
305
311
  * its complexity ceiling — a budget is a split trigger, not a number to raise.
306
312
  */
313
+ /**
314
+ * The typed pass budget, or undefined when the field is blank.
315
+ *
316
+ * `null` is the third answer: something was typed that is not a budget. Kept distinct from blank
317
+ * so {@link fishingMaxPassesProblem} can refuse it HERE, where the person can see which field is
318
+ * wrong, rather than letting the create call come back as a generic 422 whose only detail is a
319
+ * valibot path.
320
+ */
321
+ const fishingMaxPassesValue = computed<number | null | undefined>(() => {
322
+ const raw = fishingMaxPasses.value.trim()
323
+ if (!raw) return undefined
324
+ const parsed = Number(raw)
325
+ if (!Number.isInteger(parsed)) return null
326
+ return parsed >= 1 && parsed <= BUG_FISHING_MAX_PASS_BUDGET ? parsed : null
327
+ })
328
+
329
+ /** The message shown under the pass-budget field, or null when it is fine. */
330
+ const fishingMaxPassesProblem = computed(() =>
331
+ fishingMaxPassesValue.value === null
332
+ ? t('board.addTask.bugFishingFields.maxPasses.problem', { max: BUG_FISHING_MAX_PASS_BUDGET })
333
+ : null,
334
+ )
335
+
307
336
  function buildBugFishingFields(): TaskTypeFields | undefined {
308
337
  const f: TaskTypeFields = {}
309
338
  if (fishingPhaseIds.value.length && fishingPhaseIds.value.length < BUG_FISHING_PHASES.length) {
310
339
  f.fishingPhaseIds = [...fishingPhaseIds.value]
311
340
  }
312
341
  if (fishingFocus.value.trim()) f.fishingFocus = fishingFocus.value.trim()
342
+ const maxPasses = fishingMaxPassesValue.value
343
+ if (typeof maxPasses === 'number') f.fishingMaxPasses = maxPasses
313
344
  return Object.keys(f).length ? f : undefined
314
345
  }
315
346
 
@@ -493,7 +524,7 @@ watch(taskType, (next) => {
493
524
  const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
494
525
  // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
495
526
  // to whatever defaults the new type declares.
496
- customFieldValues.value = defaultDescriptorValues(custom?.fields ?? [])
527
+ customFieldValues.value = descriptorFieldDefaults(custom?.fields ?? [])
497
528
  // An unresolvable preset leaves the current selection alone rather than blanking it: a type
498
529
  // switch is an edit to a form the user is already filling in, not a reset.
499
530
  const preset = defaultPipelineIdFor(next)
@@ -597,6 +628,7 @@ watch(open, (isOpen) => {
597
628
  timeboxHours.value = undefined
598
629
  fishingPhaseIds.value = []
599
630
  fishingFocus.value = ''
631
+ fishingMaxPasses.value = ''
600
632
  spikeResearchQuestion.value = ''
601
633
  spikeSuccessCriteria.value = ''
602
634
  spikeOptionsToCompare.value = ''
@@ -708,6 +740,10 @@ const canAdd = computed(() => {
708
740
  return false
709
741
  // A custom type's collected form must satisfy its descriptor (the same rule the server enforces).
710
742
  if (customFieldProblems.value.length > 0) return false
743
+ // The pass budget is bounded by `taskTypeFieldsSchema`, so a value outside it is refused at
744
+ // creation whatever this form does. Refusing it here is what turns that into a message beside
745
+ // the field rather than a generic failure toast.
746
+ if (taskType.value === 'bug-fishing' && fishingMaxPassesProblem.value) return false
711
747
  return true
712
748
  })
713
749
 
@@ -1105,6 +1141,30 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1105
1141
  class="w-full"
1106
1142
  />
1107
1143
  </UFormField>
1144
+ <!-- An OVERRIDE, so it is hidden at the basic tier and what remains is exactly the
1145
+ shipped default it would have shown. It bites only on a codebase large enough to
1146
+ be split into territories, where the plan is territories x angles. -->
1147
+ <UFormField
1148
+ v-if="uiMode.isAdvanced"
1149
+ :label="t('board.addTask.bugFishingFields.maxPasses.label')"
1150
+ :hint="t('board.addTask.optional')"
1151
+ :description="
1152
+ t('board.addTask.bugFishingFields.maxPasses.hint', {
1153
+ count: BUG_FISHING_DEFAULT_PASS_BUDGET,
1154
+ })
1155
+ "
1156
+ :error="fishingMaxPassesProblem ?? undefined"
1157
+ >
1158
+ <UInput
1159
+ v-model="fishingMaxPasses"
1160
+ type="number"
1161
+ :min="1"
1162
+ :max="BUG_FISHING_MAX_PASS_BUDGET"
1163
+ :step="1"
1164
+ :placeholder="String(BUG_FISHING_DEFAULT_PASS_BUDGET)"
1165
+ data-testid="add-task-fishing-max-passes"
1166
+ />
1167
+ </UFormField>
1108
1168
  </div>
1109
1169
 
1110
1170
  <div v-else-if="taskType === 'spike'" class="space-y-3">
@@ -5,7 +5,7 @@ import BlockNode from './nodes/BlockNode.vue'
5
5
  import EpicNode from './nodes/EpicNode.vue'
6
6
  import TaskDependencyEdges from './TaskDependencyEdges.vue'
7
7
  import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
- import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
8
+ import { blockIdFromEvent } from '~/utils/dnd'
9
9
  import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
10
10
  import { provideBoardActivity } from '~/composables/useBoardActivity'
11
11
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
@@ -18,21 +18,17 @@ import { boardPanMode } from '~/utils/boardPanMode'
18
18
  import { createBoardNodeProjection } from './BoardCanvas.logic'
19
19
 
20
20
  const board = useBoardStore()
21
- const pipelines = usePipelinesStore()
22
- const execution = useExecutionStore()
23
21
  const ui = useUiStore()
24
22
  const github = useGitHubStore()
25
- const toast = useToast()
26
23
  // The drop resolves its target task at the moment of the drop, so the sandbox reading is a
27
24
  // function of that id rather than a computed over a bound block.
28
- const { forcedFor } = useDryRunPolicy()
29
25
  const access = useWorkspaceAccess()
30
26
  const { t } = useI18n()
31
27
 
32
- const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
28
+ const { onNodeDragStop, onViewportChange } = useVueFlow(BOARD_FLOW_ID)
33
29
  const { draggingId } = useBlockDrag()
34
30
  const { hoveredFrameId } = useFrameStacking()
35
- const { freeFramePosition, focusFrame } = useFramePlacement()
31
+ const { focusFrame } = useFramePlacement()
36
32
  // The board's "no two top-level nodes overlap" invariant: whenever a frame comes to overlap a
37
33
  // neighbour (dragged onto it, grown into it by a border drag, or grown by its first task), the two
38
34
  // are bounced apart. Every client draws that correction; only the one whose own gesture caused the
@@ -127,94 +123,10 @@ function onNodeDoubleClick({ event, node }: NodeMouseEvent) {
127
123
  function onPaneClick() {
128
124
  ui.select(null)
129
125
  }
130
-
131
- // ---- palette drag & drop onto the canvas ----------------------------------
132
- function onDragOver(event: DragEvent) {
133
- event.preventDefault()
134
- if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
135
- }
136
-
137
- async function onDrop(event: DragEvent) {
138
- event.preventDefault()
139
- const payload = readDndPayload(event)
140
- if (!payload) return
141
-
142
- if (payload.kind === 'block') {
143
- // Drop where the cursor is, but nudge off any existing frame it lands on so a new
144
- // frame never overlaps a neighbour; then centre the camera on it.
145
- const dropped = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
146
- const position = freeFramePosition({ near: dropped })
147
- try {
148
- const block = await board.addBlock(payload.blockType, position)
149
- ui.select(block.id)
150
- await focusFrame(block.id)
151
- } catch {
152
- toast.add({
153
- title: t('board.canvas.addBlockFailedTitle'),
154
- description: t('board.canvas.addBlockFailedBody'),
155
- color: 'error',
156
- })
157
- }
158
- return
159
- }
160
-
161
- if (payload.kind === 'pipeline') {
162
- // Pipelines run against tasks, not frames. The nearest [data-block-id] under
163
- // the cursor is the task card when dropped inside an expanded frame.
164
- const blockId = blockIdFromEvent(event)
165
- const target = blockId ? board.getBlock(blockId) : undefined
166
- const pipeline = pipelines.getPipeline(payload.pipelineId)
167
- // Unknown pipeline id is an internal glitch (nothing the user can act on); a drop
168
- // onto blank canvas / a non-block, though, needs the same "aim at a task" nudge the
169
- // wrong-level path gives — otherwise the drop just vanishes (UX-07).
170
- if (!pipeline) return
171
- if (!target) {
172
- toast.add({
173
- title: t('board.canvas.dropOntoTaskTitle'),
174
- description: t('board.canvas.dropOntoTaskBody'),
175
- })
176
- return
177
- }
178
- if (target.level !== 'task') {
179
- toast.add({
180
- title: t('board.canvas.dropOntoTaskTitle'),
181
- description: t('board.canvas.dropOntoTaskBody'),
182
- })
183
- return
184
- }
185
- if (!board.isRunnable(target.id)) {
186
- toast.add({
187
- title: t('board.canvas.taskBlockedTitle'),
188
- description: t('board.canvas.taskBlockedBody'),
189
- })
190
- return
191
- }
192
- // A dropped pipeline starts live and has nothing to ask for, but a preset that sandboxes the
193
- // dropper's role means this run merges nothing. Said HERE, because the drop is the only
194
- // moment this surface has: the ordinary start stays silent (it is already visible on the
195
- // card it landed on), and only the sandbox, which is not, earns a toast.
196
- if (forcedFor(target.id)) {
197
- toast.add({
198
- title: t('board.dryRunToast.title'),
199
- description: t('board.dryRunToast.body', { name: pipeline.name }),
200
- color: 'warning',
201
- icon: 'i-lucide-shield',
202
- })
203
- }
204
- execution.start(target.id, pipeline)
205
- ui.select(target.id)
206
- }
207
- }
208
126
  </script>
209
127
 
210
128
  <template>
211
- <div
212
- ref="boardEl"
213
- data-testid="board-canvas"
214
- class="relative h-full w-full"
215
- @drop="onDrop"
216
- @dragover="onDragOver"
217
- >
129
+ <div ref="boardEl" data-testid="board-canvas" class="relative h-full w-full">
218
130
  <VueFlow
219
131
  :id="BOARD_FLOW_ID"
220
132
  :nodes="nodes"
@@ -21,7 +21,7 @@ import {
21
21
  validateInitiativePresetInputs,
22
22
  } from '@cat-factory/contracts'
23
23
  import type { InitiativePresetInputs, InitiativePresetInputValue } from '~/types/domain'
24
- import { defaultDescriptorValues } from '~/utils/descriptorFields'
24
+ import { descriptorFieldDefaults } from '@cat-factory/contracts'
25
25
  import { GENERIC_PRESET_ID } from '~/stores/initiative'
26
26
  import DescriptorFields from '~/components/common/DescriptorFields.vue'
27
27
  import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
@@ -71,7 +71,7 @@ let probeSeq = 0
71
71
  /** Seed the form to the selected preset's descriptor defaults, then fire its detection probe. */
72
72
  function applyPreset(): void {
73
73
  const descriptor = selectedPreset.value
74
- inputs.value = descriptor ? defaultDescriptorValues(descriptor.fields) : {}
74
+ inputs.value = descriptor ? descriptorFieldDefaults(descriptor.fields) : {}
75
75
  void runProbe()
76
76
  }
77
77
 
@@ -68,11 +68,44 @@ const severityRank = (s: BugFishingSeverity) => {
68
68
  }
69
69
 
70
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
71
+ * A pass is an ANGLE over a TERRITORY, so a phase id alone no longer identifies one: on a
72
+ * partitioned codebase the same angle runs once per territory. The rail selects, filters and
73
+ * counts by this pair, which is also why it is derived in one place rather than spelled at each
74
+ * site.
75
+ */
76
+ function passKey(pass: { id: string; territoryId?: string | null }): string {
77
+ return `${pass.territoryId ?? ''}::${pass.id}`
78
+ }
79
+
80
+ /**
81
+ * Which pass's findings the reader is looking at. `null` is "everything caught so far", which
82
+ * is the default because an expedition's value is the whole catch — the per-pass filter exists
73
83
  * for someone working through one angle at a time, not as the primary reading.
74
84
  */
75
- const selectedPhaseId = ref<string | null>(null)
85
+ const selectedPassKey = ref<string | null>(null)
86
+
87
+ /**
88
+ * The passes grouped by the territory they fished, in plan order.
89
+ *
90
+ * One group with no territory is the pass-through: a codebase small enough to fish whole (or an
91
+ * expedition planned before territories existed) renders exactly the flat angle rail it always
92
+ * did, because its group has no header to show.
93
+ */
94
+ const territoryGroups = computed(() => {
95
+ const groups: { id: string | null; label: string | null; passes: typeof phases.value }[] = []
96
+ for (const phase of phases.value) {
97
+ const id = phase.territoryId ?? null
98
+ const last = groups[groups.length - 1]
99
+ if (last && last.id === id) last.passes.push(phase)
100
+ else groups.push({ id, label: phase.territoryLabel ?? null, passes: [phase] })
101
+ }
102
+ return groups
103
+ })
104
+
105
+ /** The expedition's plan: the budget, what it cut, and what the survey could not see. */
106
+ const plan = computed(() => state.value?.plan ?? null)
107
+ /** The cells the pass budget cut, so the window can name the ground nobody looked at. */
108
+ const unfished = computed(() => plan.value?.unfished ?? [])
76
109
 
77
110
  /** Whether findings whose decision has been made are shown. Off by default: what is left to
78
111
  * decide is the working list, and a triaged finding that stays in it reads as untriaged. */
@@ -92,10 +125,12 @@ function isOpen(f: BugFishingFinding): boolean {
92
125
  }
93
126
 
94
127
  const visibleFindings = computed<BugFishingFinding[]>(() => {
95
- const byPhase = selectedPhaseId.value
96
- ? findings.value.filter((f) => f.phaseId === selectedPhaseId.value)
128
+ const byPass = selectedPassKey.value
129
+ ? findings.value.filter(
130
+ (f) => passKey({ id: f.phaseId, territoryId: f.territoryId }) === selectedPassKey.value,
131
+ )
97
132
  : findings.value
98
- const triaged = showTriaged.value ? byPhase : byPhase.filter(isOpen)
133
+ const triaged = showTriaged.value ? byPass : byPass.filter(isOpen)
99
134
  return [...triaged].sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
100
135
  })
101
136
 
@@ -106,19 +141,38 @@ const spawnedCount = computed(
106
141
  () => findings.value.filter((f) => f.spawn?.status === 'spawned').length,
107
142
  )
108
143
 
109
- /** The phase the rail has selected, when one is. */
144
+ /** The pass the rail has selected, when one is. */
110
145
  const selectedPhase = computed(() =>
111
- selectedPhaseId.value ? (phases.value.find((p) => p.id === selectedPhaseId.value) ?? null) : null,
146
+ selectedPassKey.value
147
+ ? (phases.value.find((p) => passKey(p) === selectedPassKey.value) ?? null)
148
+ : null,
112
149
  )
113
150
 
151
+ /**
152
+ * What share of its territory's manifest a settled pass reported reading, as a percentage, or
153
+ * null when there is nothing honest to show.
154
+ *
155
+ * Null covers two different absences on purpose, and neither renders as 0%. A pass that reported
156
+ * no paths said nothing about what it read, and a whole-codebase pass had no manifest to be a
157
+ * share of; showing either as "0% covered" would accuse a pass that may have read everything.
158
+ */
159
+ function coverageShare(pass: { coverage?: { filesRead: number; manifestFiles: number } | null }) {
160
+ const coverage = pass.coverage
161
+ if (!coverage || coverage.manifestFiles <= 0) return null
162
+ return Math.min(100, Math.round((coverage.filesRead / coverage.manifestFiles) * 100))
163
+ }
164
+
114
165
  /** How many angles have settled (completed or failed) — what the still-fishing banner counts. */
115
166
  const settledPhaseCount = computed(
116
167
  () => phases.value.filter((p) => p.status === 'completed' || p.status === 'failed').length,
117
168
  )
118
169
 
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
170
+ /** How many findings each pass contributed, for the rail's per-pass count. */
171
+ function phaseFindingCount(pass: { id: string; territoryId?: string | null }): number {
172
+ const key = passKey(pass)
173
+ return findings.value.filter(
174
+ (f) => passKey({ id: f.phaseId, territoryId: f.territoryId }) === key,
175
+ ).length
122
176
  }
123
177
 
124
178
  /**
@@ -216,50 +270,80 @@ const PHASE_ICON: Record<string, string> = {
216
270
  type="button"
217
271
  class="mb-1 w-full rounded-md px-2 py-1.5 text-left text-[12px]"
218
272
  :class="
219
- selectedPhaseId === null
273
+ selectedPassKey === null
220
274
  ? 'bg-slate-800 text-slate-100'
221
275
  : 'text-slate-400 hover:bg-slate-800/60'
222
276
  "
223
- @click="selectedPhaseId = null"
277
+ @click="selectedPassKey = null"
224
278
  >
225
279
  {{ t('bugFishing.phases.all', { count: findings.length }) }}
226
280
  </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
- }}
281
+ <!-- Grouped by TERRITORY on a partitioned codebase. A codebase small enough to fish
282
+ whole has one group with no label, which renders as the flat angle rail. -->
283
+ <div v-for="group in territoryGroups" :key="group.id ?? 'whole'" class="mb-2">
284
+ <p
285
+ v-if="group.label"
286
+ class="mb-1 mt-2 truncate px-1 text-[10px] font-semibold uppercase tracking-wide text-sky-400/80"
287
+ :title="group.label"
288
+ >
289
+ {{ group.label }}
290
+ </p>
291
+ <ul class="space-y-0.5">
292
+ <li v-for="phase in group.passes" :key="passKey(phase)">
293
+ <button
294
+ type="button"
295
+ class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left"
296
+ :class="
297
+ selectedPassKey === passKey(phase)
298
+ ? 'bg-slate-800 text-slate-100'
299
+ : 'text-slate-400 hover:bg-slate-800/60'
300
+ "
301
+ :data-testid="`bug-fishing-phase-${phase.id}`"
302
+ @click="selectedPassKey = passKey(phase)"
303
+ >
304
+ <UIcon
305
+ :name="PHASE_ICON[phase.status] ?? 'i-lucide-circle-dashed'"
306
+ class="mt-0.5 h-3.5 w-3.5 shrink-0"
307
+ :class="{
308
+ 'animate-spin text-sky-300': phase.status === 'fishing',
309
+ 'text-emerald-400': phase.status === 'completed',
310
+ 'text-amber-400': phase.status === 'failed',
311
+ 'text-slate-600': phase.status === 'pending',
312
+ }"
313
+ />
314
+ <span class="min-w-0 flex-1">
315
+ <span class="block truncate text-[12px]">{{ phase.title }}</span>
316
+ <span class="block text-[10px] text-slate-500">
317
+ {{
318
+ phase.status === 'completed' || phase.status === 'failed'
319
+ ? t('bugFishing.phases.found', { count: phaseFindingCount(phase) })
320
+ : t(`bugFishing.phases.status.${phase.status}`)
321
+ }}
322
+ </span>
323
+ <!-- The coverage rail. A LOW share is what tells a reader that "found nothing"
324
+ here means "did not look"; absent (a pass that reported no reads, or one
325
+ with no manifest to be a share of) shows nothing at all rather than 0%. -->
326
+ <span
327
+ v-if="coverageShare(phase) !== null"
328
+ class="mt-1 block"
329
+ :data-testid="`bug-fishing-coverage-${phase.id}`"
330
+ :title="t('bugFishing.coverage.tooltip', { percent: coverageShare(phase) })"
331
+ >
332
+ <span class="block h-0.5 w-full rounded-full bg-slate-700">
333
+ <span
334
+ class="block h-0.5 rounded-full bg-sky-500/70"
335
+ :style="{ width: `${coverageShare(phase)}%` }"
336
+ />
337
+ </span>
338
+ <span class="mt-0.5 block text-[10px] text-slate-600">
339
+ {{ t('bugFishing.coverage.share', { percent: coverageShare(phase) }) }}
340
+ </span>
341
+ </span>
258
342
  </span>
259
- </span>
260
- </button>
261
- </li>
262
- </ul>
343
+ </button>
344
+ </li>
345
+ </ul>
346
+ </div>
263
347
  </aside>
264
348
 
265
349
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
@@ -303,6 +387,44 @@ const PHASE_ICON: Record<string, string> = {
303
387
  </span>
304
388
  </div>
305
389
 
390
+ <!-- What this expedition did NOT cover. Three separate facts, each with its own fix, so
391
+ none of them is folded into the others: the pass budget cut cells nobody fished, the
392
+ provider truncated the tree the territories were computed from, or the codebase could
393
+ not be surveyed at all. A cap silent about its tail teaches the reader that the tail
394
+ was clean. -->
395
+ <div
396
+ v-if="plan && (unfished.length > 0 || plan.treeTruncated || plan.surveyUnavailableReason)"
397
+ data-testid="bug-fishing-plan"
398
+ class="mb-4 rounded-lg border border-amber-500/25 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-200"
399
+ >
400
+ <p v-if="plan.surveyUnavailableReason" data-testid="bug-fishing-survey-unavailable">
401
+ {{ t('bugFishing.plan.surveyUnavailable', { reason: plan.surveyUnavailableReason }) }}
402
+ </p>
403
+ <p v-if="plan.treeTruncated" data-testid="bug-fishing-tree-truncated" class="mt-1">
404
+ {{ t('bugFishing.plan.treeTruncated') }}
405
+ </p>
406
+ <template v-if="unfished.length > 0">
407
+ <p class="mt-1" data-testid="bug-fishing-unfished">
408
+ {{
409
+ t('bugFishing.plan.unfished', {
410
+ fished: phases.length,
411
+ planned: plan.plannedCells,
412
+ })
413
+ }}
414
+ </p>
415
+ <ul class="mt-1 space-y-0.5 text-[11px] text-amber-200/80">
416
+ <li v-for="cell in unfished" :key="`${cell.territoryId}::${cell.phaseId}`">
417
+ {{
418
+ t('bugFishing.plan.unfishedCell', {
419
+ territory: cell.territoryLabel,
420
+ angle: cell.phaseTitle,
421
+ })
422
+ }}
423
+ </li>
424
+ </ul>
425
+ </template>
426
+ </div>
427
+
306
428
  <!-- What the marks will run. Stated before anything is created, because the pipeline is
307
429
  the shape of the work a mark causes. -->
308
430
  <div
@@ -166,11 +166,18 @@ function selectItems(field: DescriptorField) {
166
166
  @update:model-value="(v: string) => set(field.key, v)"
167
167
  />
168
168
 
169
+ <!-- The declared bounds are INPUT HINTS only: `validateDescriptorFields` is what actually
170
+ refuses a value, here and at every other door, so a stepper that respects them is a
171
+ convenience rather than the check. `integer` steps by one; a field that admits
172
+ fractions leaves `step` unset so the browser does not round the value away. -->
169
173
  <UInput
170
174
  v-else-if="field.type === 'number'"
171
175
  :model-value="numberStr(field.key)"
172
176
  type="number"
173
177
  class="w-full font-mono"
178
+ :min="field.min"
179
+ :max="field.max"
180
+ :step="field.integer ? 1 : undefined"
174
181
  :placeholder="field.placeholder"
175
182
  @update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
176
183
  />
@@ -14,7 +14,8 @@
14
14
  // A contract body is fetched only when a human expands one, through the SAME lazy read a consumer
15
15
  // dispatch makes — so what is inspected here is what an agent would be given, and merely opening
16
16
  // the catalog transfers no documents.
17
- import { computed, reactive, ref } from 'vue'
17
+ import { computed, ref } from 'vue'
18
+ import { useBusyRows } from '~/composables/useBusyRows'
18
19
  import type { ApiContractFormat, FoundationalServiceTier } from '~/types/domain'
19
20
  import { useFoundationalServicesStore } from '~/stores/foundationalServices'
20
21
  import FoundationalContractSummary from '~/components/foundational/FoundationalContractSummary.vue'
@@ -45,17 +46,7 @@ const tierColor = {
45
46
  workspace: 'primary',
46
47
  } as const satisfies Record<FoundationalServiceTier, string>
47
48
 
48
- const busyRows = reactive(new Set<string>())
49
- const rowBusy = (key: string) => busyRows.has(key)
50
- async function withRow(key: string, fn: () => Promise<void>) {
51
- if (busyRows.has(key)) return
52
- busyRows.add(key)
53
- try {
54
- await fn()
55
- } finally {
56
- busyRows.delete(key)
57
- }
58
- }
49
+ const { rowBusy, withRow } = useBusyRows()
59
50
 
60
51
  /** Service ids whose contract documents the user has expanded. */
61
52
  const expanded = ref<string[]>([])
@@ -14,6 +14,7 @@
14
14
  // Mirrors the skill library's sources UI: with the GitHub App connected the user searches a repo
15
15
  // and browses to a path; otherwise the manual owner/name fields are the fallback.
16
16
  import { computed, reactive, ref, watch } from 'vue'
17
+ import { useBusyRows } from '~/composables/useBusyRows'
17
18
  import type {
18
19
  FolderScanCoverage,
19
20
  FoundationalServiceOwnerKind,
@@ -44,17 +45,7 @@ const { confirm } = useConfirm()
44
45
  const githubReady = computed(() => github.available === true && github.connected)
45
46
 
46
47
  // Per-row in-flight tracking so only the control that triggered an action spins.
47
- const busyRows = reactive(new Set<string>())
48
- const rowBusy = (key: string) => busyRows.has(key)
49
- async function withRow(key: string, fn: () => Promise<void>) {
50
- if (busyRows.has(key)) return
51
- busyRows.add(key)
52
- try {
53
- await fn()
54
- } finally {
55
- busyRows.delete(key)
56
- }
57
- }
48
+ const { rowBusy, withRow } = useBusyRows()
58
49
 
59
50
  // ---- link a repo source ----------------------------------------------------
60
51
  const mode = ref<FoundationalServiceSourceMode>('directory')