@cat-factory/app 0.256.2 → 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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
  4. package/app/components/board/nodes/BlockNode.vue +3 -7
  5. package/app/components/board/nodes/InitiativeCard.vue +1 -1
  6. package/app/components/focus/BlockFocusView.vue +1 -1
  7. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  8. package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
  9. package/app/components/panels/AgentStepDetail.vue +42 -20
  10. package/app/components/panels/InspectorPanel.vue +6 -11
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  13. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
  14. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
  15. package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
  16. package/app/components/pipeline/PipelineProgress.vue +48 -10
  17. package/app/components/settings/KubernetesEngineForm.vue +98 -46
  18. package/app/components/spec/ServiceSpecWindow.vue +5 -4
  19. package/app/composables/api/binaryCandidates.ts +36 -0
  20. package/app/composables/useApi.ts +2 -0
  21. package/app/docs/architecture.md +1 -1
  22. package/app/modular/panels/inspector.ts +3 -3
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/board/placement.ts +32 -8
  26. package/app/stores/ui/resultViews.ts +8 -6
  27. package/app/stores/ui/runStepOpeners.ts +23 -1
  28. package/app/types/execution.ts +5 -0
  29. package/app/utils/badge.ts +14 -0
  30. package/app/utils/binaryCandidates.spec.ts +110 -0
  31. package/app/utils/binaryCandidates.ts +126 -0
  32. package/app/utils/binaryOutput.ts +48 -2
  33. package/app/utils/catalog.ts +2 -1
  34. package/app/utils/initiative.ts +1 -4
  35. package/app/utils/pipelineRender.spec.ts +46 -1
  36. package/app/utils/pipelineRender.ts +70 -2
  37. package/i18n/locales/de.json +78 -2
  38. package/i18n/locales/en.json +78 -2
  39. package/i18n/locales/es.json +78 -2
  40. package/i18n/locales/fr.json +78 -2
  41. package/i18n/locales/he.json +78 -2
  42. package/i18n/locales/it.json +78 -2
  43. package/i18n/locales/ja.json +78 -2
  44. package/i18n/locales/pl.json +78 -2
  45. package/i18n/locales/tr.json +78 -2
  46. package/i18n/locales/uk.json +78 -2
  47. package/i18n/plural-forms.spec.ts +15 -0
  48. package/package.json +2 -2
package/README.md CHANGED
@@ -161,6 +161,10 @@ The failure is silent, which is why this is a rule rather than a preference. An
161
161
 
162
162
  `scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
163
163
 
164
+ ### Type a chip map with `BadgeColor`, never `string`
165
+
166
+ A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
167
+
164
168
  ## Interface modes (basic / advanced)
165
169
 
166
170
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -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
@@ -392,13 +392,9 @@ const ITEM_ICON: Record<string, string> = {
392
392
  </div>
393
393
  </div>
394
394
  <div class="flex items-center gap-1">
395
- <UBadge
396
- :color="statusMeta.chip as any"
397
- variant="subtle"
398
- size="sm"
399
- :title="statusHint"
400
- >{{ statusLabel }}</UBadge
401
- >
395
+ <UBadge :color="statusMeta.chip" variant="subtle" size="sm" :title="statusHint">{{
396
+ statusLabel
397
+ }}</UBadge>
402
398
  <!-- Board-authoring buttons (create task / from issue / recurring / initiative)
403
399
  are `board.write`, hidden for a read-only viewer, who keeps the status badge
404
400
  (the one view-only affordance here). -->
@@ -97,7 +97,7 @@ function onHandle(e: PointerEvent) {
97
97
  <UIcon name="i-lucide-milestone" class="h-4 w-4 shrink-0 text-indigo-400" />
98
98
  <div class="text-xs font-semibold text-white">{{ block.title }}</div>
99
99
  </div>
100
- <UBadge :color="INITIATIVE_STATUS_CHIPS[status] as any" variant="subtle" size="sm">
100
+ <UBadge :color="INITIATIVE_STATUS_CHIPS[status]" variant="subtle" size="sm">
101
101
  {{ statusLabel }}
102
102
  </UBadge>
103
103
  </div>
@@ -124,7 +124,7 @@ function openApprovalFor(approvalId: string) {
124
124
  {{ t('focus.typeSubtitle', { type: typeMeta.label }) }}
125
125
  </div>
126
126
  </div>
127
- <UBadge :color="statusMeta.chip as any" variant="subtle" class="ms-2">
127
+ <UBadge :color="statusMeta.chip" variant="subtle" class="ms-2">
128
128
  {{ statusMeta.label }}
129
129
  </UBadge>
130
130
  <div class="ms-auto flex items-center gap-2">
@@ -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: ({ blockId }) => void forkDecision.load(blockId),
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))
@@ -39,6 +39,7 @@ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
39
39
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
40
40
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
41
41
  import EmptyState from '~/components/common/EmptyState.vue'
42
+ import type { BadgeColor } from '~/utils/badge'
42
43
 
43
44
  const board = useBoardStore()
44
45
  const documents = useDocumentsStore()
@@ -105,8 +106,6 @@ const DISPOSITION_KEYS: Record<OutcomeDisposition, string> = {
105
106
  not_run: 'outcome.disposition.not_run',
106
107
  unknown: 'outcome.disposition.unknown',
107
108
  }
108
- /** The badge palette, named once so every colour map below is checked against it. */
109
- type BadgeColor = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
110
109
 
111
110
  const DISPOSITION_COLOR: Record<OutcomeDisposition, BadgeColor> = {
112
111
  merged: 'success',
@@ -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 { dedicatedParkView } from '~/utils/pipelineRender'
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
- /** Jump from this overlay to the window that can actually resolve the dedicated park. */
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
- if (park === 'follow-ups') ui.openFollowUps(c.instanceId, c.stepIndex)
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): the
480
- generic approval rail can't resolve it (the server refuses), so point
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="dedicatedPark && dedicatedPark !== 'input-gate'"
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="dedicatedPark === 'follow-ups' ? 'i-lucide-compass' : 'i-lucide-git-fork'"
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
 
@@ -328,7 +328,7 @@ const showOriginalDescription = ref(false)
328
328
  <div>
329
329
  <div class="text-sm font-semibold text-white">{{ block.title }}</div>
330
330
  <div class="mt-0.5 flex items-center gap-1.5">
331
- <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">
331
+ <UBadge :color="statusMeta.chip" variant="subtle" size="sm">
332
332
  {{ statusLabel }}
333
333
  </UBadge>
334
334
  <span class="text-[10px] uppercase tracking-wide text-slate-500">{{ level }}</span>
@@ -521,18 +521,13 @@ const showOriginalDescription = ref(false)
521
521
  wrapper). Replaces the pre-slice-4 `v-if` fan; `subject-key` is the block
522
522
  id, so switching selections remounts panel content (matching the old
523
523
  per-panel `:key`). A consumer contributes its own panels to the SAME
524
- group via `registerAppModule`.
525
-
526
- The `subject` cast is an upstream typing quirk, not a modelling escape
527
- hatch: `<PanelsOutlet>` declares `subject` as `PropType<unknown>` with
528
- `default: null`, which Volar narrows to `null`, so passing a typed
529
- `Block | null` is rejected at compile time. `unknown` is the real
530
- runtime contract; `as any` is the minimal unblock until the binding
531
- types the prop explicitly (filed upstream — see the slice-4 residuals
532
- in backend/docs/adr/0049-modular-vue-adoption.md). -->
524
+ group via `registerAppModule`. `subject` used to need an `as any`: the
525
+ outlet's `default: null` narrowed the declared `PropType<unknown>` to
526
+ `null`, rejecting a typed `Block | null`. The published prop type now
527
+ resolves to `unknown`, so the binding passes through unasserted. -->
533
528
  <PanelsOutlet
534
529
  :group="inspectorPanels"
535
- :subject="(block ?? null) as any"
530
+ :subject="block ?? null"
536
531
  :subject-key="block?.id ?? ''"
537
532
  />
538
533
 
@@ -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',