@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.
- package/README.md +4 -3
- package/app/components/board/AddTaskModal.vue +63 -3
- package/app/components/board/BoardCanvas.vue +4 -92
- package/app/components/board/CreateInitiativeModal.vue +2 -2
- package/app/components/bugFishing/BugFishingWindow.vue +170 -48
- package/app/components/common/DescriptorFields.vue +7 -0
- package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -12
- package/app/components/foundational/FoundationalServiceSources.vue +2 -11
- package/app/components/fragments/FragmentLibraryManager.vue +3 -12
- package/app/components/layout/AccountAuditLog.logic.ts +2 -2
- package/app/components/panels/ResultWindowShell.logic.spec.ts +7 -22
- package/app/components/panels/StepToolServers.logic.ts +2 -2
- package/app/components/settings/ConsensusGroupsSection.vue +1 -4
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +0 -1
- package/app/components/skills/SkillLibraryManager.vue +3 -12
- package/app/components/tutorial/TutorialOverlay.logic.ts +2 -2
- package/app/composables/api/client.spec.ts +0 -5
- package/app/composables/useBusyRows.ts +22 -0
- package/app/composables/useFrameResize.ts +0 -5
- package/app/composables/useSemanticZoom.ts +1 -1
- package/app/modular/panels/inspector.ts +1 -1
- package/app/modular/result-views.ts +4 -3
- package/app/stores/board/mutations.ts +1 -14
- package/app/stores/observability/agentContext.ts +1 -8
- package/app/stores/observability/toolCalls.ts +1 -8
- package/app/stores/observability/withFlag.ts +9 -0
- package/app/types/execution.ts +4 -0
- package/app/utils/blockRects.ts +1 -1
- package/app/utils/boardWakeGate.ts +1 -1
- package/app/utils/catalog.ts +1 -14
- package/app/utils/descriptorFields.spec.ts +0 -45
- package/app/utils/descriptorFields.ts +1 -13
- package/app/utils/dnd.ts +1 -25
- package/app/utils/initiative.ts +1 -1
- package/app/utils/settlingLoop.ts +1 -1
- package/app/utils/taskExpansionRanking.spec.ts +8 -17
- package/app/utils/tutorial.ts +1 -1
- package/app/utils/uiMode.spec.ts +0 -1
- package/app/utils/uiRole.ts +1 -1
- package/i18n/locales/de.json +19 -7
- package/i18n/locales/en.json +19 -7
- package/i18n/locales/es.json +19 -7
- package/i18n/locales/fr.json +19 -7
- package/i18n/locales/he.json +19 -7
- package/i18n/locales/it.json +19 -7
- package/i18n/locales/ja.json +19 -7
- package/i18n/locales/pl.json +19 -7
- package/i18n/locales/tr.json +19 -7
- package/i18n/locales/uk.json +19 -7
- 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
|
-
(`
|
|
207
|
-
|
|
208
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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
|
|
28
|
+
const { onNodeDragStop, onViewportChange } = useVueFlow(BOARD_FLOW_ID)
|
|
33
29
|
const { draggingId } = useBlockDrag()
|
|
34
30
|
const { hoveredFrameId } = useFrameStacking()
|
|
35
|
-
const {
|
|
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 {
|
|
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 ?
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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
|
|
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
|
|
96
|
-
? findings.value.filter(
|
|
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 ?
|
|
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
|
|
144
|
+
/** The pass the rail has selected, when one is. */
|
|
110
145
|
const selectedPhase = computed(() =>
|
|
111
|
-
|
|
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
|
|
120
|
-
function phaseFindingCount(
|
|
121
|
-
|
|
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
|
-
|
|
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="
|
|
277
|
+
@click="selectedPassKey = null"
|
|
224
278
|
>
|
|
225
279
|
{{ t('bugFishing.phases.all', { count: findings.length }) }}
|
|
226
280
|
</button>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
class="
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
</
|
|
260
|
-
</
|
|
261
|
-
</
|
|
262
|
-
</
|
|
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,
|
|
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
|
|
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
|
|
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')
|