@cat-factory/app 0.256.3 → 0.257.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.
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +34 -34
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
- package/app/components/pipeline/PipelineProgress.vue +37 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/useApi.ts +2 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.ts +48 -2
- package/app/utils/pipelineRender.spec.ts +46 -1
- package/app/utils/pipelineRender.ts +70 -2
- package/i18n/locales/de.json +78 -2
- package/i18n/locales/en.json +78 -2
- package/i18n/locales/es.json +78 -2
- package/i18n/locales/fr.json +78 -2
- package/i18n/locales/he.json +78 -2
- package/i18n/locales/it.json +78 -2
- package/i18n/locales/ja.json +78 -2
- package/i18n/locales/pl.json +78 -2
- package/i18n/locales/tr.json +78 -2
- package/i18n/locales/uk.json +78 -2
- package/i18n/plural-forms.spec.ts +15 -0
- package/package.json +2 -2
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Generated-candidate comparison window: the dedicated surface for a binary-output step that
|
|
3
|
+
// generated several candidates rather than committing to one producer unobserved
|
|
4
|
+
// (docs/initiatives/binary-output-foundational-storage.md).
|
|
5
|
+
//
|
|
6
|
+
// It reads the live state straight off the run's step (`step.binaryCandidates`, kept fresh by the
|
|
7
|
+
// execution stream) and lets a human KEEP one candidate, or several under distinct ids. Keeping
|
|
8
|
+
// re-runs the same step to deliver exactly what survived and clear the rest.
|
|
9
|
+
//
|
|
10
|
+
// Two properties the surface has to hold on to, both of which are the reason this feature exists:
|
|
11
|
+
//
|
|
12
|
+
// - A candidate WITHOUT a preview is still a candidate. An org's asset store may issue no public
|
|
13
|
+
// link, and the platform will not invent one, so such a row renders its details and says the
|
|
14
|
+
// preview is unavailable rather than disappearing or showing a broken image.
|
|
15
|
+
// - It doubles as the RECORD. Once the choice is made the window keeps rendering, marking what
|
|
16
|
+
// was kept and under which id, because the decision is the only place the run's own rationale
|
|
17
|
+
// lives. An AUTOMATIC keep says so: nobody looked at it.
|
|
18
|
+
import { computed, ref, watch } from 'vue'
|
|
19
|
+
import { useResultView } from '~/composables/useResultView'
|
|
20
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
21
|
+
import { useBoardStore } from '~/stores/board'
|
|
22
|
+
import { useBinaryCandidatesStore } from '~/stores/binaryCandidates'
|
|
23
|
+
import {
|
|
24
|
+
BINARY_CANDIDATE_NO_CHOICE_KEYS,
|
|
25
|
+
binaryCandidateHasWarnings,
|
|
26
|
+
binaryCandidateView,
|
|
27
|
+
} from '~/utils/binaryCandidates'
|
|
28
|
+
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
29
|
+
|
|
30
|
+
const execution = useExecutionStore()
|
|
31
|
+
const board = useBoardStore()
|
|
32
|
+
const candidates = useBinaryCandidatesStore()
|
|
33
|
+
const access = useWorkspaceAccess()
|
|
34
|
+
const { t } = useI18n()
|
|
35
|
+
|
|
36
|
+
// The warm-up read is keyed by the RUN, not the block: `load` calls
|
|
37
|
+
// `GET /workspaces/:ws/executions/:executionId/binary-candidates`, and a block id put in that
|
|
38
|
+
// position resolves to no run at all, so the fetch quietly returns nothing and the window shows
|
|
39
|
+
// whatever the stream happened to deliver. `instanceId` is nullable (a view can be opened without
|
|
40
|
+
// a resolved run), so the guard is the same one `PrReviewWindow` makes.
|
|
41
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('binary-candidates', {
|
|
42
|
+
onOpen: ({ instanceId }) => {
|
|
43
|
+
if (instanceId) void candidates.load(instanceId)
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
48
|
+
const headerTitle = computed(() =>
|
|
49
|
+
block.value
|
|
50
|
+
? t('binaryCandidates.titleWithBlock', { title: block.value.title })
|
|
51
|
+
: t('binaryCandidates.title'),
|
|
52
|
+
)
|
|
53
|
+
const instance = computed(() =>
|
|
54
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
55
|
+
)
|
|
56
|
+
const step = computed(() => {
|
|
57
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
58
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
59
|
+
})
|
|
60
|
+
const view = computed(() => binaryCandidateView(step.value))
|
|
61
|
+
const warnings = computed(() => (view.value ? binaryCandidateHasWarnings(view.value) : false))
|
|
62
|
+
const noChoiceKey = computed(() => {
|
|
63
|
+
const reason = view.value?.state.noChoiceReason
|
|
64
|
+
return reason ? BINARY_CANDIDATE_NO_CHOICE_KEYS[reason] : null
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** The ids the human has ticked, and the id each is to be stored under. */
|
|
68
|
+
const selected = ref<string[]>([])
|
|
69
|
+
const aliases = ref<Record<string, string>>({})
|
|
70
|
+
const note = ref('')
|
|
71
|
+
|
|
72
|
+
// Default the selection to the first candidate of the first subject whenever the candidate set
|
|
73
|
+
// changes. Something ticked is the honest default for a single-select comparison (the person is
|
|
74
|
+
// choosing between options, not deciding whether to have one) and it keeps the primary button
|
|
75
|
+
// meaningful from the first render.
|
|
76
|
+
watch(
|
|
77
|
+
() => view.value?.state.candidates.map((c) => c.id).join(','),
|
|
78
|
+
() => {
|
|
79
|
+
const first = view.value?.groups[0]?.rows[0]?.id
|
|
80
|
+
if (selected.value.some((id) => view.value?.state.candidates.some((c) => c.id === id))) return
|
|
81
|
+
selected.value = first ? [first] : []
|
|
82
|
+
},
|
|
83
|
+
{ immediate: true },
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
function toggle(id: string): void {
|
|
87
|
+
if (!view.value?.awaiting) return
|
|
88
|
+
if (view.value.multiSelect) {
|
|
89
|
+
selected.value = selected.value.includes(id)
|
|
90
|
+
? selected.value.filter((existing) => existing !== id)
|
|
91
|
+
: [...selected.value, id]
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
selected.value = [id]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether the request would be accepted. Mirrors the backend's own refusals rather than only
|
|
99
|
+
* disabling on emptiness: keeping several candidates under one name would store one artifact and
|
|
100
|
+
* report several, so the alias requirement is stated here, where it can be fixed, instead of
|
|
101
|
+
* arriving as a 422.
|
|
102
|
+
*/
|
|
103
|
+
const missingAliases = computed(() =>
|
|
104
|
+
selected.value.length > 1 ? selected.value.filter((id) => !(aliases.value[id] ?? '').trim()) : [],
|
|
105
|
+
)
|
|
106
|
+
const duplicateAliases = computed(() => {
|
|
107
|
+
const used = selected.value.map((id) => (aliases.value[id] ?? '').trim()).filter(Boolean)
|
|
108
|
+
return new Set(used).size !== used.length
|
|
109
|
+
})
|
|
110
|
+
const canKeep = computed(
|
|
111
|
+
() =>
|
|
112
|
+
view.value?.awaiting === true &&
|
|
113
|
+
!candidates.keeping &&
|
|
114
|
+
selected.value.length > 0 &&
|
|
115
|
+
missingAliases.value.length === 0 &&
|
|
116
|
+
!duplicateAliases.value,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async function onKeep() {
|
|
120
|
+
const id = instanceId.value
|
|
121
|
+
if (!id || !canKeep.value) return
|
|
122
|
+
const keep = selected.value.map((candidateId) => {
|
|
123
|
+
const alias = (aliases.value[candidateId] ?? '').trim()
|
|
124
|
+
return alias ? { candidateId, storeAs: alias } : { candidateId }
|
|
125
|
+
})
|
|
126
|
+
const text = note.value.trim()
|
|
127
|
+
await candidates.keep(id, { keep, ...(text ? { note: text } : {}) }).catch(() => {})
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<template>
|
|
132
|
+
<ResultWindowShell
|
|
133
|
+
:open="open"
|
|
134
|
+
icon="i-lucide-images"
|
|
135
|
+
icon-class="bg-sky-500/15 text-sky-300"
|
|
136
|
+
:title="headerTitle"
|
|
137
|
+
:subtitle="t('binaryCandidates.subtitle')"
|
|
138
|
+
width="5xl"
|
|
139
|
+
testid="binary-candidates-window"
|
|
140
|
+
@close="close"
|
|
141
|
+
>
|
|
142
|
+
<div v-if="view" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
143
|
+
<!-- Why there was nothing to choose between. Its own line per reason: a model that never
|
|
144
|
+
declared its candidates and one whose block was unreadable need different fixes. -->
|
|
145
|
+
<p
|
|
146
|
+
v-if="noChoiceKey"
|
|
147
|
+
class="mb-3 rounded border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200"
|
|
148
|
+
data-testid="binary-candidates-no-choice"
|
|
149
|
+
>
|
|
150
|
+
{{ t(noChoiceKey) }}
|
|
151
|
+
</p>
|
|
152
|
+
|
|
153
|
+
<!-- An automatic keep is NOT a review, and must never render as one. -->
|
|
154
|
+
<p
|
|
155
|
+
v-if="view.automatic"
|
|
156
|
+
class="mb-3 rounded border border-slate-600/40 bg-slate-800/40 px-3 py-2 text-xs text-slate-300"
|
|
157
|
+
data-testid="binary-candidates-automatic"
|
|
158
|
+
>
|
|
159
|
+
{{ t('binaryCandidates.automatic') }}
|
|
160
|
+
</p>
|
|
161
|
+
|
|
162
|
+
<!-- Every loss the parse counted, so a comparison over three of five cannot read as one
|
|
163
|
+
over all five. -->
|
|
164
|
+
<p
|
|
165
|
+
v-if="warnings"
|
|
166
|
+
class="mb-3 text-xs text-amber-300"
|
|
167
|
+
data-testid="binary-candidates-warnings"
|
|
168
|
+
>
|
|
169
|
+
<span v-if="view.state.omitted">{{
|
|
170
|
+
t('binaryCandidates.warning.omitted', { count: view.state.omitted })
|
|
171
|
+
}}</span>
|
|
172
|
+
<span v-if="view.state.invalidEntries">
|
|
173
|
+
{{ t('binaryCandidates.warning.invalid', { count: view.state.invalidEntries }) }}</span
|
|
174
|
+
>
|
|
175
|
+
<span v-if="view.state.unusablePreviews">
|
|
176
|
+
{{ t('binaryCandidates.warning.previews', { count: view.state.unusablePreviews }) }}</span
|
|
177
|
+
>
|
|
178
|
+
</p>
|
|
179
|
+
|
|
180
|
+
<div v-for="group in view.groups" :key="group.subject ?? '·'" class="mb-6">
|
|
181
|
+
<h3 class="mb-2 text-xs font-medium text-slate-400">
|
|
182
|
+
{{ group.subject ?? t('binaryCandidates.unlabelledSubject') }}
|
|
183
|
+
</h3>
|
|
184
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
185
|
+
<div
|
|
186
|
+
v-for="row in group.rows"
|
|
187
|
+
:key="row.id"
|
|
188
|
+
class="rounded border p-2 transition"
|
|
189
|
+
:class="
|
|
190
|
+
selected.includes(row.id) || row.kept
|
|
191
|
+
? 'border-sky-400/60 bg-sky-500/5'
|
|
192
|
+
: 'border-slate-700/60'
|
|
193
|
+
"
|
|
194
|
+
data-testid="binary-candidate-card"
|
|
195
|
+
@click="toggle(row.id)"
|
|
196
|
+
>
|
|
197
|
+
<img
|
|
198
|
+
v-if="row.previewUrl"
|
|
199
|
+
:src="row.previewUrl"
|
|
200
|
+
:alt="row.label ?? row.id"
|
|
201
|
+
class="mb-2 max-h-56 w-full rounded object-contain"
|
|
202
|
+
data-testid="binary-candidate-preview"
|
|
203
|
+
/>
|
|
204
|
+
<!-- No preview is ORDINARY (a private asset store issues no link), so it is stated
|
|
205
|
+
rather than left as an empty frame the reader reads as a failed generation. -->
|
|
206
|
+
<p
|
|
207
|
+
v-else
|
|
208
|
+
class="mb-2 flex h-24 items-center justify-center rounded bg-slate-800/60 px-2 text-center text-[10px] text-slate-400"
|
|
209
|
+
data-testid="binary-candidate-no-preview"
|
|
210
|
+
>
|
|
211
|
+
{{ t('binaryCandidates.noPreview') }}
|
|
212
|
+
</p>
|
|
213
|
+
<p class="text-xs text-slate-200">
|
|
214
|
+
{{
|
|
215
|
+
row.generator
|
|
216
|
+
? t('binaryCandidates.fromGenerator', { generator: row.generator })
|
|
217
|
+
: t('binaryCandidates.unattributed')
|
|
218
|
+
}}
|
|
219
|
+
</p>
|
|
220
|
+
<p v-if="row.note" class="mt-1 text-[11px] text-slate-400">{{ row.note }}</p>
|
|
221
|
+
<p class="mt-1 break-all text-[10px] text-slate-500">{{ row.location }}</p>
|
|
222
|
+
<p v-if="row.contentType" class="text-[10px] text-slate-500">{{ row.contentType }}</p>
|
|
223
|
+
<p v-if="row.kept" class="mt-1 text-[11px] text-emerald-300">
|
|
224
|
+
{{
|
|
225
|
+
row.storeAs
|
|
226
|
+
? t('binaryCandidates.keptAs', { id: row.storeAs })
|
|
227
|
+
: t('binaryCandidates.kept')
|
|
228
|
+
}}
|
|
229
|
+
</p>
|
|
230
|
+
<!-- The ALTERNATE ID, shown only where it is load-bearing: keeping two candidates
|
|
231
|
+
under one name stores one artifact and reports two. -->
|
|
232
|
+
<UInput
|
|
233
|
+
v-if="view.awaiting && view.multiSelect && selected.includes(row.id)"
|
|
234
|
+
class="mt-2"
|
|
235
|
+
size="xs"
|
|
236
|
+
:model-value="aliases[row.id] ?? ''"
|
|
237
|
+
:placeholder="t('binaryCandidates.storeAsPlaceholder')"
|
|
238
|
+
data-testid="binary-candidate-store-as"
|
|
239
|
+
@click.stop
|
|
240
|
+
@update:model-value="aliases[row.id] = String($event)"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div v-if="view.awaiting" class="mt-2">
|
|
247
|
+
<UTextarea
|
|
248
|
+
v-model="note"
|
|
249
|
+
:rows="2"
|
|
250
|
+
size="xs"
|
|
251
|
+
:placeholder="t('binaryCandidates.notePlaceholder')"
|
|
252
|
+
data-testid="binary-candidates-note"
|
|
253
|
+
/>
|
|
254
|
+
<p
|
|
255
|
+
v-if="missingAliases.length"
|
|
256
|
+
class="mt-1 text-[11px] text-amber-300"
|
|
257
|
+
data-testid="binary-candidates-missing-alias"
|
|
258
|
+
>
|
|
259
|
+
{{ t('binaryCandidates.missingAlias') }}
|
|
260
|
+
</p>
|
|
261
|
+
<p
|
|
262
|
+
v-else-if="duplicateAliases"
|
|
263
|
+
class="mt-1 text-[11px] text-amber-300"
|
|
264
|
+
data-testid="binary-candidates-duplicate-alias"
|
|
265
|
+
>
|
|
266
|
+
{{ t('binaryCandidates.duplicateAlias') }}
|
|
267
|
+
</p>
|
|
268
|
+
<p v-if="candidates.error" class="mt-1 text-[11px] text-red-300">{{ candidates.error }}</p>
|
|
269
|
+
<div class="mt-2 flex justify-end">
|
|
270
|
+
<UButton
|
|
271
|
+
size="xs"
|
|
272
|
+
:disabled="!canKeep || !access.canExecuteRuns.value"
|
|
273
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
274
|
+
:loading="candidates.keeping"
|
|
275
|
+
data-testid="binary-candidates-keep"
|
|
276
|
+
@click="onKeep"
|
|
277
|
+
>
|
|
278
|
+
{{ t('binaryCandidates.keepAction', { count: selected.length }) }}
|
|
279
|
+
</UButton>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</ResultWindowShell>
|
|
284
|
+
</template>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { computed } from 'vue'
|
|
15
15
|
import type { PipelineStep } from '~/types/execution'
|
|
16
16
|
import { BINARY_OUTPUT_STATE_KEYS, binaryOutputView } from '~/utils/binaryOutput'
|
|
17
|
+
import { binaryCandidateView } from '~/utils/binaryCandidates'
|
|
17
18
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
18
19
|
|
|
19
20
|
// Two callers, one renderer — the same split `StepEffortReport` makes: the generic step-detail
|
|
@@ -27,6 +28,16 @@ const { t } = useI18n()
|
|
|
27
28
|
|
|
28
29
|
const view = computed(() => binaryOutputView(props.step))
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The CANDIDATE decision, when this step compared before delivering.
|
|
33
|
+
*
|
|
34
|
+
* Rendered here rather than left to the comparison window, because that window is reachable only
|
|
35
|
+
* while the run is PARKED on it: once the choice is made, a step click routes to the kind's own
|
|
36
|
+
* result view and the record of what was compared, and by whom, would have nowhere to live. This
|
|
37
|
+
* is the same placement rule the artifacts above it follow, one decision earlier.
|
|
38
|
+
*/
|
|
39
|
+
const candidates = computed(() => binaryCandidateView(props.step))
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* The outcome's own copy, from the shared exhaustive map — shared so the collapsed section row
|
|
32
43
|
* above this panel and the sentence inside it can never claim different outcomes. `stored`
|
|
@@ -55,6 +66,28 @@ const state = computed(() => {
|
|
|
55
66
|
<span>{{ t('binaryOutput.heading') }}</span>
|
|
56
67
|
</div>
|
|
57
68
|
|
|
69
|
+
<!-- The CANDIDATE decision, when this step compared before delivering. It sits ABOVE the
|
|
70
|
+
delivery outcome because it happened first, and because "which of four was this" is the
|
|
71
|
+
question the artifacts below cannot answer. An AUTOMATIC keep says so: nobody looked. -->
|
|
72
|
+
<p
|
|
73
|
+
v-if="candidates && candidates.state.candidates.length > 0"
|
|
74
|
+
class="text-[12px] leading-relaxed text-slate-300"
|
|
75
|
+
data-testid="binary-output-candidate-decision"
|
|
76
|
+
>
|
|
77
|
+
{{
|
|
78
|
+
candidates.automatic
|
|
79
|
+
? t('binaryOutput.candidates.automatic', { total: candidates.state.candidates.length })
|
|
80
|
+
: candidates.state.choice
|
|
81
|
+
? t('binaryOutput.candidates.chosen', {
|
|
82
|
+
kept: candidates.state.choice.kept.length,
|
|
83
|
+
total: candidates.state.candidates.length,
|
|
84
|
+
})
|
|
85
|
+
: t('binaryOutput.candidates.awaiting', {
|
|
86
|
+
total: candidates.state.candidates.length,
|
|
87
|
+
})
|
|
88
|
+
}}
|
|
89
|
+
</p>
|
|
90
|
+
|
|
58
91
|
<!-- What happened, in one sentence, before any list. Four of the five states have no list
|
|
59
92
|
at all, and the fifth still needs its qualifications read alongside it. -->
|
|
60
93
|
<p
|
|
@@ -26,7 +26,9 @@ const { t } = useI18n()
|
|
|
26
26
|
// Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
|
|
27
27
|
// No `stepRef`: this is a pre-run decision, so there's no "restart from here".
|
|
28
28
|
const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
|
|
29
|
-
onOpen: ({
|
|
29
|
+
onOpen: ({ instanceId }) => {
|
|
30
|
+
if (instanceId) void forkDecision.load(instanceId)
|
|
31
|
+
},
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
@@ -21,7 +21,11 @@ import StepExecutionHistory from '~/components/board/StepExecutionHistory.vue'
|
|
|
21
21
|
import { useStepTimer } from '~/composables/useStepTimer'
|
|
22
22
|
import { useStepProse } from '~/composables/useStepProse'
|
|
23
23
|
import { useStepApproval } from '~/composables/useStepApproval'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
REDIRECT_PARK_PRESENTATION,
|
|
26
|
+
type RedirectParkView,
|
|
27
|
+
dedicatedParkView,
|
|
28
|
+
} from '~/utils/pipelineRender'
|
|
25
29
|
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
26
30
|
|
|
27
31
|
// Detail overlay for a single pipeline step. Opened by clicking an agent in the
|
|
@@ -191,14 +195,37 @@ const genericApprovalPending = computed(
|
|
|
191
195
|
() => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
|
|
192
196
|
)
|
|
193
197
|
|
|
194
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* How the park that holds this step presents itself (prose, icon, action label), or null when no
|
|
200
|
+
* window owns it. Read from the shared table rather than branched on here, so this overlay cannot
|
|
201
|
+
* go on naming the fork decision for a park that is not one.
|
|
202
|
+
*/
|
|
203
|
+
const parkPresentation = computed(() =>
|
|
204
|
+
dedicatedPark.value && dedicatedPark.value !== 'input-gate'
|
|
205
|
+
? REDIRECT_PARK_PRESENTATION[dedicatedPark.value]
|
|
206
|
+
: null,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Jump from this overlay to the window that can actually resolve the dedicated park.
|
|
211
|
+
*
|
|
212
|
+
* A `Record` over the vocabulary rather than an `if` chain, for the reason the presentation table
|
|
213
|
+
* gives: an unhandled member falls out of a chain as a button that closes this overlay and opens
|
|
214
|
+
* NOTHING, which is indistinguishable from a misclick. Here a new park fails to compile until it
|
|
215
|
+
* names its opener.
|
|
216
|
+
*/
|
|
217
|
+
const PARK_OPENERS: Record<RedirectParkView, (instanceId: string, stepIndex: number) => void> = {
|
|
218
|
+
'follow-ups': (id, idx) => ui.openFollowUps(id, idx),
|
|
219
|
+
'fork-decision': (id, idx) => ui.openForkDecision(id, idx),
|
|
220
|
+
'binary-candidates': (id, idx) => ui.openBinaryCandidates(id, idx),
|
|
221
|
+
}
|
|
222
|
+
|
|
195
223
|
function openDedicatedWindow() {
|
|
196
224
|
const c = ctx.value
|
|
197
225
|
const park = dedicatedPark.value
|
|
198
|
-
if (!c || !park) return
|
|
226
|
+
if (!c || !park || park === 'input-gate') return
|
|
199
227
|
close()
|
|
200
|
-
|
|
201
|
-
else if (park === 'fork-decision') ui.openForkDecision(c.instanceId, c.stepIndex)
|
|
228
|
+
PARK_OPENERS[park](c.instanceId, c.stepIndex)
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
// The GitHub-style approval/review state machine for a pending gate step. A park a
|
|
@@ -476,9 +503,11 @@ async function copyOutput() {
|
|
|
476
503
|
@resolve="resolveCompanionCap"
|
|
477
504
|
/>
|
|
478
505
|
|
|
479
|
-
<!-- a park a dedicated window owns (fork choice / follow-up triage
|
|
480
|
-
generic approval rail can't resolve it (the server refuses),
|
|
481
|
-
the human at the window that can
|
|
506
|
+
<!-- a park a dedicated window owns (fork choice / follow-up triage / candidate
|
|
507
|
+
comparison): the generic approval rail can't resolve it (the server refuses),
|
|
508
|
+
so point the human at the window that can. Copy and icon come from the shared
|
|
509
|
+
per-park table, so a park added to the vocabulary can never inherit another
|
|
510
|
+
one's wording here. -->
|
|
482
511
|
<!-- the pre-dispatch input gate holds this step: answered here, in place -->
|
|
483
512
|
<InputGateNotice
|
|
484
513
|
v-if="inputGateVerdict && instance"
|
|
@@ -489,30 +518,23 @@ async function copyOutput() {
|
|
|
489
518
|
/>
|
|
490
519
|
|
|
491
520
|
<div
|
|
492
|
-
v-if="
|
|
521
|
+
v-if="parkPresentation"
|
|
493
522
|
class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
|
|
494
523
|
data-testid="dedicated-park-redirect"
|
|
524
|
+
:data-park="dedicatedPark"
|
|
495
525
|
>
|
|
496
526
|
<p class="text-[13px] leading-relaxed text-amber-200/90">
|
|
497
|
-
{{
|
|
498
|
-
dedicatedPark === 'follow-ups'
|
|
499
|
-
? t('panels.stepDetail.followUpsParked')
|
|
500
|
-
: t('panels.stepDetail.forkParked')
|
|
501
|
-
}}
|
|
527
|
+
{{ t(parkPresentation.noticeKey) }}
|
|
502
528
|
</p>
|
|
503
529
|
<UButton
|
|
504
530
|
class="mt-3"
|
|
505
531
|
color="primary"
|
|
506
532
|
size="sm"
|
|
507
|
-
:icon="
|
|
533
|
+
:icon="parkPresentation.icon"
|
|
508
534
|
data-testid="dedicated-park-open"
|
|
509
535
|
@click="openDedicatedWindow"
|
|
510
536
|
>
|
|
511
|
-
{{
|
|
512
|
-
dedicatedPark === 'follow-ups'
|
|
513
|
-
? t('panels.stepDetail.openFollowUps')
|
|
514
|
-
: t('panels.stepDetail.chooseApproach')
|
|
515
|
-
}}
|
|
537
|
+
{{ t(parkPresentation.actionKey) }}
|
|
516
538
|
</UButton>
|
|
517
539
|
</div>
|
|
518
540
|
|
|
@@ -27,6 +27,10 @@ const SHELL_DEFAULT_WIDTH: ResultWindowWidth = '3xl'
|
|
|
27
27
|
* not made the decision.
|
|
28
28
|
*/
|
|
29
29
|
const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
|
|
30
|
+
'binaryCandidates/BinaryCandidatesWindow.vue': {
|
|
31
|
+
width: '5xl',
|
|
32
|
+
why: 'a preview grid of generated candidates grouped by subject, read side by side to be compared, with no rail beside it',
|
|
33
|
+
},
|
|
30
34
|
'brainstorm/BrainstormWindow.vue': {
|
|
31
35
|
width: 'full',
|
|
32
36
|
why: 'options column + the choose/dismiss action rail',
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
isCompanionKind,
|
|
9
9
|
containerPhaseLabel,
|
|
10
10
|
dedicatedParkView,
|
|
11
|
+
REDIRECT_PARK_PRESENTATION,
|
|
12
|
+
type RedirectParkView,
|
|
11
13
|
} from '~/utils/pipelineRender'
|
|
12
14
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
13
15
|
import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
|
|
@@ -184,15 +186,28 @@ function openStep(i: number) {
|
|
|
184
186
|
if (instance.value) ui.openStepDetail(instance.value.id, i)
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
/**
|
|
190
|
+
* The window-owned park holding a step, or null. `input-gate` is filtered out because it has no
|
|
191
|
+
* window: it is answered by the inline notice this list renders above itself, so offering a
|
|
192
|
+
* button here would send a human to an overlay that does not exist.
|
|
193
|
+
*/
|
|
194
|
+
function redirectPark(step: PipelineStep): RedirectParkView | null {
|
|
195
|
+
const park = dedicatedParkView(step, instance.value)
|
|
196
|
+
return park && park !== 'input-gate' ? park : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open the window that resolves a park. A `Record` over the vocabulary, so a park added to it
|
|
201
|
+
* fails to compile until it names its opener rather than rendering a button that does nothing.
|
|
202
|
+
*/
|
|
203
|
+
const PARK_OPENERS: Record<RedirectParkView, (instanceId: string, stepIndex: number) => void> = {
|
|
204
|
+
'follow-ups': (id, idx) => ui.openFollowUps(id, idx),
|
|
205
|
+
'fork-decision': (id, idx) => ui.openForkDecision(id, idx),
|
|
206
|
+
'binary-candidates': (id, idx) => ui.openBinaryCandidates(id, idx),
|
|
190
207
|
}
|
|
191
208
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
function openFollowUpsFor(i: number) {
|
|
195
|
-
if (instance.value) ui.openFollowUps(instance.value.id, i)
|
|
209
|
+
function openParkFor(park: RedirectParkView, i: number) {
|
|
210
|
+
if (instance.value) PARK_OPENERS[park](instance.value.id, i)
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
// Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
|
|
@@ -471,38 +486,23 @@ async function mergePr() {
|
|
|
471
486
|
>
|
|
472
487
|
{{ t('inspector.execution.decide') }}
|
|
473
488
|
</UButton>
|
|
474
|
-
<!-- A
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
dedicatedParkView(s, instance) === 'fork-decision'
|
|
481
|
-
"
|
|
482
|
-
color="primary"
|
|
483
|
-
variant="soft"
|
|
484
|
-
size="xs"
|
|
485
|
-
icon="i-lucide-git-fork"
|
|
486
|
-
@click="openForkFor(i)"
|
|
487
|
-
>
|
|
488
|
-
{{ t('inspector.execution.chooseApproach') }}
|
|
489
|
-
</UButton>
|
|
490
|
-
<!-- A coder step parked on undecided follow-up items: triage them (file /
|
|
491
|
-
send back / answer / dismiss) in the dedicated window, not a plain
|
|
492
|
-
approval — the generic approve resolver refuses this park. -->
|
|
489
|
+
<!-- A step parked on something a dedicated WINDOW answers: the implementation-fork
|
|
490
|
+
choice, undecided follow-up items, or a candidate comparison. None of them is a
|
|
491
|
+
plain approval (the generic resolver refuses all three server-side), and all
|
|
492
|
+
three present the same way here: one button into the window that can resolve
|
|
493
|
+
it. Driven by the shared per-park table rather than a branch each, because a
|
|
494
|
+
branch each is how the candidate park shipped with no button at all. -->
|
|
493
495
|
<UButton
|
|
494
|
-
v-else-if="
|
|
495
|
-
s.approval &&
|
|
496
|
-
s.approval.status === 'pending' &&
|
|
497
|
-
dedicatedParkView(s, instance) === 'follow-ups'
|
|
498
|
-
"
|
|
496
|
+
v-else-if="s.approval && s.approval.status === 'pending' && redirectPark(s)"
|
|
499
497
|
color="primary"
|
|
500
498
|
variant="soft"
|
|
501
499
|
size="xs"
|
|
502
|
-
icon="
|
|
503
|
-
|
|
500
|
+
:icon="REDIRECT_PARK_PRESENTATION[redirectPark(s)!].icon"
|
|
501
|
+
:data-park="redirectPark(s)"
|
|
502
|
+
data-testid="dedicated-park-open"
|
|
503
|
+
@click="openParkFor(redirectPark(s)!, i)"
|
|
504
504
|
>
|
|
505
|
-
{{ t(
|
|
505
|
+
{{ t(REDIRECT_PARK_PRESENTATION[redirectPark(s)!].railActionKey) }}
|
|
506
506
|
</UButton>
|
|
507
507
|
<!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
|
|
508
508
|
findings-selection window, not the generic approval gate. -->
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
formatReferenceImages,
|
|
4
|
+
generationControlOffer,
|
|
5
|
+
parseMediaTypeRequirement,
|
|
6
|
+
parseReferenceImages,
|
|
7
|
+
sameFormats,
|
|
8
|
+
} from './BinaryOutputStepPicker.logic'
|
|
3
9
|
|
|
4
10
|
describe('parseMediaTypeRequirement', () => {
|
|
5
11
|
it('stores the reduction the backend compares against, not what was typed', () => {
|
|
@@ -56,3 +62,86 @@ describe('sameFormats', () => {
|
|
|
56
62
|
expect(sameFormats(['a/b', 'c/d'], ['a/b', 'c/d'])).toBe(true)
|
|
57
63
|
})
|
|
58
64
|
})
|
|
65
|
+
|
|
66
|
+
describe('parseReferenceImages', () => {
|
|
67
|
+
it('reads the role, the location and the optional service off each line', () => {
|
|
68
|
+
const { usable, unusable } = parseReferenceImages(
|
|
69
|
+
['subject|assets/hero.png|asset-store', 'style|https://cdn.example/palette.png'].join('\n'),
|
|
70
|
+
)
|
|
71
|
+
expect(usable).toEqual([
|
|
72
|
+
{ role: 'subject', location: 'assets/hero.png', service: 'asset-store' },
|
|
73
|
+
{ role: 'style', location: 'https://cdn.example/palette.png' },
|
|
74
|
+
])
|
|
75
|
+
expect(unusable).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// A reference someone typed and the step does not carry is a generation that silently ignores
|
|
79
|
+
// it, which is the "absent reads as fine" failure the rest of this surface exists to avoid.
|
|
80
|
+
it('reports a refused line rather than dropping it', () => {
|
|
81
|
+
const { usable, unusable } = parseReferenceImages(
|
|
82
|
+
['mood|assets/hero.png', 'subject|', 'subject|assets/ok.png'].join('\n'),
|
|
83
|
+
)
|
|
84
|
+
expect(usable.map((ref) => ref.location)).toEqual(['assets/ok.png'])
|
|
85
|
+
expect(unusable).toEqual(['mood|assets/hero.png', 'subject|'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('round-trips through the text the field shows', () => {
|
|
89
|
+
const text = 'base|assets/hero.png|asset-store'
|
|
90
|
+
expect(formatReferenceImages(parseReferenceImages(text).usable)).toBe(text)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('generationControlOffer', () => {
|
|
95
|
+
const declaring = (...capabilities: string[]) => ({ capabilities }) as never
|
|
96
|
+
|
|
97
|
+
it('offers everything while a selected integration has declared nothing', () => {
|
|
98
|
+
// An integration that pinned nothing down is not a denial: hiding a control would be a claim
|
|
99
|
+
// about a vendor's API that nobody established. The advisory line says it is unconfirmed.
|
|
100
|
+
const offers = generationControlOffer([declaring('seed'), declaring()], undefined)
|
|
101
|
+
expect(offers('seed')).toBe(true)
|
|
102
|
+
expect(offers('tileable')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('offers everything when nothing is selected yet', () => {
|
|
106
|
+
expect(generationControlOffer([], undefined)('upscale')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('hides a control once every selection has declared and none has the capability', () => {
|
|
110
|
+
const offers = generationControlOffer([declaring('seed'), declaring('aspect-ratio')], undefined)
|
|
111
|
+
expect(offers('seed')).toBe(true)
|
|
112
|
+
expect(offers('aspect-ratio')).toBe(true)
|
|
113
|
+
expect(offers('tileable')).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The regression this pins. Changing the selection does not clear options authored against the
|
|
117
|
+
// old one, so a stored option whose capability nothing declares still REFUSES the run at
|
|
118
|
+
// admission. Hiding its control leaves the reader an error saying to remove an option and no
|
|
119
|
+
// control that removes it: a step that cannot be run and cannot be fixed from the surface that
|
|
120
|
+
// configures it.
|
|
121
|
+
it('keeps offering a control whose option is already SET, whatever the selection declares', () => {
|
|
122
|
+
const selection = [declaring('seed')]
|
|
123
|
+
expect(generationControlOffer(selection, {})('tileable')).toBe(false)
|
|
124
|
+
expect(generationControlOffer(selection, { tileable: true })('tileable')).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps the control for every option shape that carries a requirement', () => {
|
|
128
|
+
const selection = [declaring('seed')]
|
|
129
|
+
// Each of these is stored differently (a flag, a number that may be zero, a list, a mode), and
|
|
130
|
+
// the requirement is derived from the same helper admission uses rather than re-read here.
|
|
131
|
+
const offers = generationControlOffer(selection, {
|
|
132
|
+
seed: 0,
|
|
133
|
+
aspectRatio: '16:9',
|
|
134
|
+
negativePrompt: 'blurry',
|
|
135
|
+
edit: { mode: 'mask' },
|
|
136
|
+
referenceImages: [{ role: 'subject', location: 'assets/hero.png' }],
|
|
137
|
+
} as never)
|
|
138
|
+
for (const capability of [
|
|
139
|
+
'aspect-ratio',
|
|
140
|
+
'negative-prompt',
|
|
141
|
+
'mask-edit',
|
|
142
|
+
'reference-image',
|
|
143
|
+
] as const) {
|
|
144
|
+
expect(offers(capability)).toBe(true)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
})
|