@cat-factory/app 0.201.1 → 0.204.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 (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. package/package.json +2 -2
package/README.md CHANGED
@@ -185,9 +185,58 @@ picking what each of them runs on are halves of the same job.
185
185
 
186
186
  On first launch (once the board is up and no other startup advisory is open) the app asks
187
187
  whether the user wants a guided tour. The answer is SAVED per browser (`stores/tutorial.ts`,
188
- persisted like the interface tier): "no thanks" stops the prompt for good, closing without
189
- answering defers it to the next launch, and the command palette's "Take a tour" entry is the
190
- way back either way.
188
+ persisted like the interface tier): "no thanks" stops the prompt for good, and closing without
189
+ answering defers it to the next launch.
190
+
191
+ **The prompt is the OFFER; the catalogue is the library.** `TutorialCatalogue.vue` — the
192
+ sidebar's Help section, the palette, and a button in the prompt's own footer — lists every tour
193
+ the deployment ships and lets any of them be started, resumed or repeated at any time. The two
194
+ surfaces exist separately because they answer different questions, and the split is what keeps
195
+ the prompt a short answerable one rather than a browsing surface. Start / Resume / Repeat /
196
+ Back-to-the-tour is decided ONCE for both (`useTutorialLaunch` over the pure `tourState` +
197
+ `launchActionFor`), or the same button would mean different things on two screens.
198
+
199
+ **The catalogue lists the tours it CANNOT start, and says what would unlock each.** That is the
200
+ reason a tour's preconditions are declared (`TutorialRequirement`: an id, a copy key, and the
201
+ gate predicate) rather than being an anonymous `when(gates)`. A predicate can only answer "no",
202
+ and a list that quietly omits four of six walkthroughs is indistinguishable from a deployment
203
+ that ships two — to exactly the user who came looking for the rest. It also forces the two
204
+ unavailable cases apart, because they need different reactions: `blocked` names something the
205
+ reader can go and do ("A service on the board"), while `not-applicable` — requirements met, but
206
+ every step is about a branch this board isn't on — names nothing at all, and telling them to fix
207
+ it would send them hunting for a control that was never missing.
208
+
209
+ **Tour gating therefore does NOT live in `navSlotFilter`**, unlike every other gated slot. A
210
+ `SlotFilter` maps slots to slots, so it can only drop; `resolveTourCatalogue` (pure,
211
+ gates-nullable, in `utils/tutorial.ts`) returns every tour with its availability and its unmet
212
+ requirements, and `useTutorialTours` runs it once — exposing `tours` (what can start now, which
213
+ is what the prompt and the overlay have always seen) and `catalogue` (everything, annotated). It
214
+ reads the SAME registered `gates` service the nav filter does, through the shared-dependency
215
+ `useOptional('gates')`, so the two can never disagree about what this board offers.
216
+
217
+ Progress is per tour id and per browser. The catalogue's counter is over the WHOLE catalog, not
218
+ the runnable part — counting only today's runnable tours would move the denominator every time a
219
+ repo was linked, and "2 of 2 completed" on a board with four walkthroughs still waiting reads as
220
+ a finished tutorial. `Reset progress` clears the completions, the resume point AND the saved
221
+ launch answer, because everyone who asks for it (demoing, handing the app to a colleague) wants
222
+ the first-launch experience back; it leaves a RUNNING tour alone, since a click about history
223
+ must not end the walkthrough in progress. **It is therefore offered whenever ANY of those three
224
+ is set, not only when a tour was taken** — someone who answered "No thanks" and stopped there has
225
+ nothing completed and nothing paused, and that saved answer is the whole of what stands between
226
+ them and the offer they came to restore.
227
+
228
+ **The coach marks stand down while a tutorial-owned window is open** (`ownWindowOpen`). The
229
+ overlay renders at `z-[70]`, above the app's own modals, because a step legitimately points INTO
230
+ one — but no step points into the prompt or the catalogue, so there the same rule would float a
231
+ highlight ring and a tooltip over the window the user just opened. The catalogue reaches that
232
+ state by design: it is openable mid-tour, which is what the `continue` action is for. The overlay
233
+ is SUPPRESSED rather than unmounted, because it holds the running tour's resolved script and a
234
+ remount would re-resolve it against gates that may have flipped since the tour started.
235
+
236
+ The arc this surface is being built along — what has landed, what each slice learned, and what
237
+ is still open — is tracked in
238
+ [`docs/initiatives/in-app-tutorials.md`](../../docs/initiatives/in-app-tutorials.md). This
239
+ section is the authority on how the thing WORKS.
191
240
 
192
241
  A tour is **data, not components**: an ordered list of steps, each pointing at an on-screen
193
242
  control by its `data-testid` (the e2e anchor vocabulary — cover a control that has none by
@@ -202,16 +251,16 @@ in to type. A step whose anchor never appears within its wait is SKIPPED, becaus
202
251
  come and go with RBAC, tier, and deployment wiring: a tour is a set of opportunities, not a
203
252
  fixed script. Reaching the end having skipped steps is reported on the final card rather than
204
253
  congratulating the user on a walkthrough they did not see — and a tour that could only ever
205
- be abridged should not be offered at all, which is what each tour's `when(gates)` is for (the
206
- task-creation tour requires board write AND a service frame to add a task to).
254
+ be abridged should not be offered at all, which is what each tour's `requires` is for (the
255
+ task-creation tour needs board write AND a service frame to add a task to).
207
256
 
208
257
  **A step carries its own `when(gates)` when its BRANCH, not its control, is the thing that
209
258
  may not apply.** The two are different facts and only one of them is a defect: a skip means
210
259
  the control should be here and isn't, while a `when` means this board is not on that branch
211
260
  of the flow (a run parked on a decision has no approval gate, and the reverse). Reporting the
212
261
  second as an abridged tour would tell a user who saw exactly the right walkthrough that they
213
- missed half of it, every time. `resolveTours` (in `utils/tutorial.ts`, applied by
214
- `navSlotFilter`) drops the rejected steps and then drops a tour left with none, so a tour
262
+ missed half of it, every time. `resolveTourCatalogue` (in `utils/tutorial.ts`) drops the
263
+ rejected steps and marks a tour left with none `not-applicable` rather than ready, so a tour
215
264
  whose every step is branch-specific can never open on an empty cursor. With no gates service
216
265
  wired at all (a bare install withholds nothing) every branch survives instead, and only one
217
266
  of them can anchor — so the abridged notice ignores any skipped step that carries a `when`,
@@ -258,11 +307,82 @@ several anchors (`task-card`, `task-resolve`, `run-step`) render once per board
258
307
  ring can only sit on one of them, so requiring the click to land on that one left a user who
259
308
  clicked the card the copy asked for with no way forward — such a step renders no Next.
260
309
 
310
+ **An anchor that is on the page but off SCREEN is revealed before it is pointed at.** An
311
+ element scrolled out of a panel or panned off the board still HAS layout boxes, so it passes
312
+ the visibility check — and the ring was drawn at off-screen coordinates while the tooltip
313
+ clamped to a viewport edge, leaving the user reading "click this" beside nothing. The two
314
+ `task-card` steps hit this hardest, since they anchor whichever card is first in the DOM.
315
+ `needsReveal` (in `utils/tutorial.ts`, unit-tested) decides, measuring against
316
+ `min(anchorArea, viewportArea)` so a control bigger than the viewport — `board-canvas`,
317
+ `sidebar` — is judged on how much of the SCREEN it fills rather than on a fraction of its own
318
+ area it could never clear. The mechanism then depends on the container: the board is a
319
+ transform-panned Vue Flow canvas, where `scrollIntoView` does nothing and the camera has to
320
+ move instead (clamped to the current zoom, or fitting one button would throw away the user's
321
+ view of their board), and everything else is an ordinary scroll. `boardNodeIdFor` asks the
322
+ DOM which it is, rather than keying off the target id — the same id is a canvas node on the
323
+ board and a plain row in a panel. A reveal is attempted at most once per step, because both
324
+ mechanisms are animations longer than a tracking tick.
325
+
326
+ **Tracking is event-driven once an anchor is held**: only the hunt for a not-yet-mounted
327
+ anchor polls fast, and it is bounded by the step's wait budget. Movement arrives from scroll
328
+ (capture phase, so every scroll container counts), window resize, a `ResizeObserver` on the
329
+ anchor, and the board camera — with a slow backstop tick that also RE-RESOLVES the selector,
330
+ which is what lets a step re-anchor when its control is replaced underneath it. Every one of
331
+ those re-measures is coalesced into one per animation frame, because `measure()` reads layout
332
+ and then writes it, and capture-phase scroll fires for every container many times a frame.
333
+
334
+ **Accessibility.** The card is a non-modal `dialog` and deliberately not a focus trap: half
335
+ the catalog asks the user to operate the real control behind it. Focus moves onto the card
336
+ when the tour starts and on every Next/Back — without that a keyboard user has to tab the
337
+ whole page, since the overlay is teleported to the end of `body` — but never on a
338
+ `target-click` advance, where the app is opening a modal that rightly autofocuses its own
339
+ first field and the NEXT step is usually the one telling the user to type in it. That is a
340
+ decision, so it is `shouldFocusCard` in the logic module with a test on it, not an `if` at
341
+ the call site — it was an inline one, and a call site that forgot it is exactly how the card
342
+ came to steal focus from the modal it had just opened.
343
+
344
+ Step changes are announced through a separate `role="status"` region rather than `aria-live`
345
+ on the card, because the card's entire contents are replaced per step and a wholesale subtree
346
+ swap inside a dialog is not reliably announced. Two things about that region are load-bearing
347
+ and easy to undo by accident: it lives OUTSIDE the overlay's `v-if` and its text lands a tick
348
+ after the node does, because assistive tech announces a CHANGE to a live region and routinely
349
+ says nothing about one that was inserted already populated — which would silently cost the
350
+ first step of every tour. And it is the SOLE announcement: the card carries no
351
+ `aria-describedby`, or the body would be read a second time on every focus move.
352
+
353
+ Motion is honoured on both sides: `motion-safe:` on the ring transition and the searching
354
+ spinner, and an instant scroll and camera move under `prefers-reduced-motion`.
355
+
356
+ **Breaking off a tour leaves a resume point.** Esc and Skip are both easy to reach — one by
357
+ accident, one to get the overlay out of the way for a moment — and what they discarded was
358
+ the whole walkthrough. `stopTour()` records where it stopped and the prompt offers Resume
359
+ instead of only Start. Session-only, like the cursor itself: within a session the board is
360
+ still in the state the tour left it in, which is exactly what a DOM-anchored position needs.
361
+ The store validates no index (it knows nothing about which tours exist), so the overlay
362
+ clamps a resume that lands past the end of a script the gates have thinned since — and the
363
+ runtime's own bail-out on an unresolvable tour passes `resumable: false`, or resuming would
364
+ put the user straight back into the same dead overlay. There is ONE slot, and starting a tour
365
+ clears only that tour's own entry: another tour's position is not this action's to discard,
366
+ and it loses the slot soon enough — when this one is broken off past step 0.
367
+
261
368
  The catalog is the `tutorialTours` slot: first-party tours live in
262
369
  `modular/tutorial-tours.ts`, and a consumer deployment contributes its own through
263
- `registerAppModule` — they appear in the launch prompt beside the built-ins, gated per tour
264
- by its `when(gates)` predicate (the same reactive gates service the nav uses, filtered in
265
- `navSlotFilter`). Completion is persisted per tour id, so renaming an id resets its state.
370
+ `registerAppModule` — they appear in the prompt and the catalogue beside the built-ins, held
371
+ back per tour by its own `requires` (resolved against the same reactive gates service the nav
372
+ uses). A consumer writes its own requirement objects with its own copy keys; the first-party
373
+ ones are shared constants (`TUTORIAL_REQUIREMENTS`), because a second copy of "a service on the
374
+ board" is a second sentence to keep in step with the gate it describes. Completion is persisted
375
+ per tour id, so renaming an id resets its state.
376
+
377
+ **A built-in tour's anchors are drift-guarded** (`tutorial-tours.spec.ts`), because they are
378
+ the one thing about a tour that nothing else in the build checks: a renamed `data-testid`
379
+ passes typecheck, lint and the whole e2e suite, and several anchors have no other consumer at
380
+ all. The failure it prevents is worse than a dead step — those steps carry no `when`, so the
381
+ miss counts as an unexpected skip and every user lands on a permanent "you missed N steps"
382
+ notice, a false claim the tour goes on making in production with nothing red anywhere. The
383
+ guard scans the layer for both ways an id is named: written onto an element, or declared as a
384
+ `testId` field on a data contribution (the whole `nav-*` family reaches the DOM that way). It
385
+ is scoped to the built-in catalog, since a consumer's tours anchor on its own layer.
266
386
 
267
387
  ## Extending the layer (consumer modules)
268
388
 
@@ -0,0 +1,186 @@
1
+ <script setup lang="ts">
2
+ // What a BINARY-OUTPUT step delivered: the artifacts it declared it stored through the
3
+ // foundational service the step selected, and — with equal weight — every way that record is
4
+ // incomplete. See docs/initiatives/binary-output-foundational-storage.md.
5
+ //
6
+ // The engine's parse keeps six outcomes apart on purpose (not started, still running, no
7
+ // declaration, an unreadable one, an explicit "stored nothing", and actual artifacts), because
8
+ // each needs a different reaction and five of them are NOT "an empty list". This renders the
9
+ // discriminant `binaryOutputView` derives, never a list that happens to be empty.
10
+ //
11
+ // It is a plain presenter over the STEP's own record — no fetch, no catalog read — so it drops
12
+ // into both of the surfaces a step opens on (the result-window shell's trailing section and the
13
+ // generic step-detail panel) and reads identically on a run whose services were withdrawn since.
14
+ import { computed } from 'vue'
15
+ import type { PipelineStep } from '~/types/execution'
16
+ import { BINARY_OUTPUT_STATE_KEYS, binaryOutputView } from '~/utils/binaryOutput'
17
+ import CopyButton from '~/components/common/CopyButton.vue'
18
+
19
+ // Two callers, one renderer — the same split `StepEffortReport` makes: the generic step-detail
20
+ // panel drops it in as a `card` (its own heading + border, among the other detail sections),
21
+ // and `ResultWindowShell`'s collapsible footer embeds it `flat`, where the disclosure row is
22
+ // already the heading and a second one would be chrome inside chrome.
23
+ const props = withDefaults(defineProps<{ step: PipelineStep; variant?: 'card' | 'flat' }>(), {
24
+ variant: 'flat',
25
+ })
26
+ const { t } = useI18n()
27
+
28
+ const view = computed(() => binaryOutputView(props.step))
29
+
30
+ /**
31
+ * The outcome's own copy, from the shared exhaustive map — shared so the collapsed section row
32
+ * above this panel and the sentence inside it can never claim different outcomes. `stored`
33
+ * carries no detail (the artifacts are the statement), so it falls back to its summary.
34
+ */
35
+ const state = computed(() => {
36
+ const keys = BINARY_OUTPUT_STATE_KEYS[view.value?.state ?? 'configured']
37
+ return { ...keys, text: t(keys.detail || keys.summary) }
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <section
43
+ v-if="view"
44
+ class="space-y-3"
45
+ :class="
46
+ variant === 'card' ? 'scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4' : ''
47
+ "
48
+ data-testid="binary-output-report"
49
+ >
50
+ <div
51
+ v-if="variant === 'card'"
52
+ class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
53
+ >
54
+ <UIcon name="i-lucide-image" class="h-3.5 w-3.5" />
55
+ <span>{{ t('binaryOutput.heading') }}</span>
56
+ </div>
57
+
58
+ <!-- What happened, in one sentence, before any list. Four of the five states have no list
59
+ at all, and the fifth still needs its qualifications read alongside it. -->
60
+ <p
61
+ class="flex items-start gap-2 text-[12px] leading-relaxed"
62
+ :class="state.tone"
63
+ data-testid="binary-output-state"
64
+ :data-state="view.state"
65
+ >
66
+ <UIcon :name="state.icon" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
67
+ <span>{{ state.text }}</span>
68
+ </p>
69
+
70
+ <!-- The step's own selection: the target every artifact was supposed to go through, and
71
+ the services it was told to scope the generation from. A step with NO selection says
72
+ so — it is a real state (a trait-carrying kind dispatched under an overriding kind
73
+ records a declaration against a step that never held one), and rendering a blank
74
+ would read as a missing value rather than an absent comparison. -->
75
+ <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
76
+ <dt class="text-slate-500">{{ t('binaryOutput.target') }}</dt>
77
+ <dd
78
+ v-if="view.target"
79
+ class="min-w-0 font-mono text-slate-300"
80
+ data-testid="binary-output-target"
81
+ >
82
+ {{ view.target }}
83
+ </dd>
84
+ <dd v-else class="min-w-0 text-slate-400" data-testid="binary-output-target">
85
+ {{ t('binaryOutput.targetNone') }}
86
+ </dd>
87
+ <template v-if="view.contextServices.length">
88
+ <dt class="text-slate-500">{{ t('binaryOutput.contextServices') }}</dt>
89
+ <dd class="min-w-0 font-mono text-slate-400">{{ view.contextServices.join(', ') }}</dd>
90
+ </template>
91
+ </dl>
92
+
93
+ <!-- The artifacts. `location` is the service's OWN addressing — an object key, a path, a
94
+ URL — recorded verbatim and never interpreted, so it renders as copyable text and
95
+ never as a link. -->
96
+ <ul v-if="view.rows.length" class="space-y-1.5">
97
+ <li
98
+ v-for="(row, i) in view.rows"
99
+ :key="`${row.service}:${row.location}:${i}`"
100
+ class="relative rounded-md border border-slate-800 bg-slate-950/40 px-2.5 py-2"
101
+ data-testid="binary-output-artifact"
102
+ >
103
+ <CopyButton :text="row.location" class="absolute end-1 top-1" />
104
+ <code class="block break-all pe-8 font-mono text-[11px] text-slate-200">{{
105
+ row.location
106
+ }}</code>
107
+ <div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-slate-500">
108
+ <span class="font-mono">{{ row.service }}</span>
109
+ <!-- The join the report cannot make on its own, and the question a human opens this
110
+ for: did it go where the step pointed it? -->
111
+ <UBadge
112
+ v-if="row.misdirected"
113
+ color="warning"
114
+ variant="subtle"
115
+ size="sm"
116
+ data-testid="binary-output-misdirected"
117
+ >
118
+ {{ t('binaryOutput.misdirectedBadge') }}
119
+ </UBadge>
120
+ <UBadge
121
+ v-if="row.unknown"
122
+ color="warning"
123
+ variant="subtle"
124
+ size="sm"
125
+ data-testid="binary-output-unknown-badge"
126
+ >
127
+ {{ t('binaryOutput.unknownBadge') }}
128
+ </UBadge>
129
+ <span v-if="row.entity">{{ row.entity }}</span>
130
+ <span v-if="row.contentType" class="font-mono">{{ row.contentType }}</span>
131
+ </div>
132
+ <p v-if="row.description" class="mt-1 text-[11px] leading-relaxed text-slate-400">
133
+ {{ row.description }}
134
+ </p>
135
+ </li>
136
+ </ul>
137
+
138
+ <!-- Every qualification the report counted, each naming its own number. Never folded into
139
+ one "some entries were dropped": the fix for an unknown service id is not the fix for
140
+ a malformed entry, and neither is the fix for a list that stops short of the tail. -->
141
+ <ul class="space-y-1 text-[11px] text-amber-400">
142
+ <!-- The step's OWN target went missing from the catalog, and an id the AGENT invented,
143
+ are two different failures with two different fixes (re-register it, versus correct
144
+ the declaration). `binaryOutputView` returns them as DISJOINT fields precisely so
145
+ these can be two independent lines: sharing one line meant the lost target's message
146
+ named every unknown id as if it were the step's own service, and silently dropped
147
+ the invented ones. -->
148
+ <li v-if="view.targetUnknown" data-testid="binary-output-target-unknown">
149
+ {{ t('binaryOutput.warning.targetUnknown', { id: view.target }) }}
150
+ </li>
151
+ <li v-if="view.unknownDeclaredServices.length" data-testid="binary-output-unknown-services">
152
+ {{
153
+ t(
154
+ 'binaryOutput.warning.unknownServices',
155
+ {
156
+ ids: view.unknownDeclaredServices.join(', '),
157
+ count: view.unknownDeclaredServices.length,
158
+ },
159
+ view.unknownDeclaredServices.length,
160
+ )
161
+ }}
162
+ </li>
163
+ <li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
164
+ {{
165
+ t(
166
+ 'binaryOutput.warning.misdirected',
167
+ { count: view.misdirected, target: view.target },
168
+ view.misdirected,
169
+ )
170
+ }}
171
+ </li>
172
+ <li v-if="view.invalidEntries" data-testid="binary-output-invalid">
173
+ {{
174
+ t(
175
+ 'binaryOutput.warning.invalidEntries',
176
+ { count: view.invalidEntries },
177
+ view.invalidEntries,
178
+ )
179
+ }}
180
+ </li>
181
+ <li v-if="view.omitted" data-testid="binary-output-omitted">
182
+ {{ t('binaryOutput.warning.omitted', { count: view.omitted }, view.omitted) }}
183
+ </li>
184
+ </ul>
185
+ </section>
186
+ </template>
@@ -107,7 +107,17 @@ async function copyPlan() {
107
107
  </script>
108
108
 
109
109
  <template>
110
- <div class="flex min-h-0 flex-1 flex-col lg:flex-row" data-testid="initiative-plan-review">
110
+ <!-- The gate's id is published because a send-back is only observable through it. The rail is
111
+ torn down and rebuilt for the re-plan's NEW gate, but the gap between the two is not a state
112
+ the SPA is guaranteed to see: the send-back's own `ws.refresh()` races the re-plan, which
113
+ with a fast planner can park again first, so the rail goes straight from one gate to the
114
+ next. Asserting the absence was a race; asserting WHICH gate is on screen is the invariant
115
+ underneath it. Read by `initiative-plan-review.spec.ts`. -->
116
+ <div
117
+ class="flex min-h-0 flex-1 flex-col lg:flex-row"
118
+ data-testid="initiative-plan-review"
119
+ :data-approval-id="approval.id"
120
+ >
111
121
  <!-- Navigation column: the outline OUTSIDE the document rather than splitting its width — a
112
122
  sidebar of the window, as the step reader's is. Narrower than the reader's (`w-52` against
113
123
  its `w-72`) and held back to `lg` rather than its `md`, because this is a THREE-column
@@ -8,6 +8,7 @@ import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
8
8
  import StepTestReport from '~/components/panels/StepTestReport.vue'
9
9
  import StepEffortReport from '~/components/panels/StepEffortReport.vue'
10
10
  import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
11
+ import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
11
12
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
12
13
  import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
13
14
  import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
@@ -557,6 +558,15 @@ async function copyOutput() {
557
558
  effectiveness, key obstacles). Only when the agent reported one. -->
558
559
  <StepEffortReport v-if="step.effortReport" :report="step.effortReport" />
559
560
 
561
+ <!-- what the step declared it stored through a foundational storage service, and
562
+ every way that record is incomplete. This panel is the OTHER half of the
563
+ result-window shell's trailing section: a step whose kind declares no
564
+ dedicated result view opens here instead, and the shell is not involved — so
565
+ without this the artifacts of exactly the kinds that need no bespoke window
566
+ would have nowhere to appear. Self-hiding when the step has neither a report
567
+ nor a selection, which is every step of every stock pipeline. -->
568
+ <BinaryOutputReport :step="step" variant="card" />
569
+
560
570
  <!-- edit-then-approve: a direct editor over the raw conclusions; the
561
571
  edits become the approved proposal that flows to the next step -->
562
572
  <section v-if="editing" class="scroll-mt-4">
@@ -25,6 +25,12 @@ import { useModalBehavior } from '@modular-vue/core'
25
25
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
26
26
  import StepEffortReport from '~/components/panels/StepEffortReport.vue'
27
27
  import StepValidationReport from '~/components/panels/StepValidationReport.vue'
28
+ import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
29
+ import {
30
+ BINARY_OUTPUT_STATE_KEYS,
31
+ binaryOutputHasWarnings,
32
+ binaryOutputView,
33
+ } from '~/utils/binaryOutput'
28
34
  import { effortBand, effortHint } from '~/utils/effort'
29
35
  import {
30
36
  RESULT_WINDOW_WIDTH_CLASS,
@@ -140,6 +146,52 @@ watch(
140
146
  },
141
147
  { immediate: true },
142
148
  )
149
+ /**
150
+ * The step's BINARY-OUTPUT record — the third universal trailing section, and here for the same
151
+ * reason as the two above: it is a by-product recorded on the STEP, not the deliverable of any
152
+ * one window, so no window may be able to opt out of it.
153
+ *
154
+ * The alternative — a dedicated `binary-outputs` result view a generator kind declares — cannot
155
+ * cover the record's own scope. The engine writes this report whenever the step's kind carries
156
+ * the trait OR the step carries a selection (`stepMayDeclareBinaryOutputs`, the deliberate UNION),
157
+ * precisely so a trait-carrying kind dispatched under an OVERRIDING kind still has its artifacts
158
+ * recorded. A kind-declared view is by construction blind to that case: the step's own kind
159
+ * declares a different window, and the artifacts exist with nowhere showing them. Resolving off
160
+ * the active step instead makes the surface follow the record rather than the catalog — and it
161
+ * leaves a generator free to declare a result view for its OWN output, which is what a generator
162
+ * that produces prose AND artifacts actually wants.
163
+ *
164
+ * Absent for every stock step (no report, no selection) and the section disappears, exactly as
165
+ * the two above do. That absence is the honest "this step had no binary-output story" — the
166
+ * distinct states that ARE a story (declared nothing / never declared / unreadable) all render.
167
+ */
168
+ const binaryOutputs = computed(() => binaryOutputView(activeStep.value))
169
+ const binaryOutputsOpen = ref(false)
170
+ // A record carrying losses (unknown ids, dropped entries, a truncated list, an artifact that
171
+ // went somewhere else) opens expanded — collapsed, it would read exactly like a clean one.
172
+ watch(
173
+ binaryOutputs,
174
+ (view) => {
175
+ if (view && binaryOutputHasWarnings(view)) binaryOutputsOpen.value = true
176
+ },
177
+ { immediate: true },
178
+ )
179
+
180
+ /**
181
+ * The collapsed row's line: the OUTCOME, plus the artifact count only when there are artifacts.
182
+ * Deliberately not "N artifacts" for every state — five of the six have none, and a `0` there
183
+ * would render "declared nothing", "never declared" and "unreadable declaration" identically,
184
+ * which is the exact conflation the report's bookkeeping exists to prevent.
185
+ */
186
+ const binaryOutputSummary = computed(() => {
187
+ const view = binaryOutputs.value
188
+ if (!view) return ''
189
+ const outcome = t(BINARY_OUTPUT_STATE_KEYS[view.state].summary)
190
+ return view.state === 'stored'
191
+ ? t('binaryOutput.storedCount', { outcome, count: view.rows.length }, view.rows.length)
192
+ : outcome
193
+ })
194
+
143
195
  // Collapsed by default — the windows own the vertical space, and the row already carries the
144
196
  // difficulty plus the gist of what held the agent back.
145
197
  const effortOpen = ref(false)
@@ -304,6 +356,40 @@ const panelClass = computed(() => [
304
356
  <StepValidationReport :report="validationReport" />
305
357
  </div>
306
358
  </section>
359
+
360
+ <!-- Shared trailing section: what this step's agent declared it stored through a
361
+ foundational storage service (see `binaryOutputs` above for why it lives here and
362
+ not in a window). The collapsed row states the OUTCOME, never a count — five of
363
+ the six outcomes have no artifacts to count, and "0" is the one thing they must
364
+ not all read as. -->
365
+ <section
366
+ v-if="binaryOutputs"
367
+ class="shrink-0 border-t border-slate-800 bg-slate-900/60"
368
+ data-testid="result-window-binary-outputs"
369
+ >
370
+ <button
371
+ type="button"
372
+ class="flex w-full items-center gap-2 px-5 py-2 text-start hover:bg-slate-800/40"
373
+ :aria-expanded="binaryOutputsOpen"
374
+ data-testid="result-window-binary-outputs-toggle"
375
+ @click="binaryOutputsOpen = !binaryOutputsOpen"
376
+ >
377
+ <UIcon name="i-lucide-image" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
378
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
379
+ {{ t('binaryOutput.heading') }}
380
+ </span>
381
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-400">
382
+ {{ binaryOutputSummary }}
383
+ </span>
384
+ <UIcon
385
+ :name="binaryOutputsOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
386
+ class="ms-auto h-3.5 w-3.5 shrink-0 text-slate-500"
387
+ />
388
+ </button>
389
+ <div v-if="binaryOutputsOpen && activeStep" class="max-h-72 overflow-y-auto px-5 pb-3">
390
+ <BinaryOutputReport :step="activeStep" />
391
+ </div>
392
+ </section>
307
393
  </div>
308
394
  </div>
309
395
  </Teleport>
@@ -0,0 +1,147 @@
1
+ <script setup lang="ts">
2
+ // The per-step storage + context selection for a BINARY-OUTPUT kind — a generator whose
3
+ // deliverable is binary artifacts stored through a foundational service the org already runs
4
+ // (docs/initiatives/binary-output-foundational-storage.md).
5
+ //
6
+ // Shown on a step whose kind carries the `binary-output` trait, projected onto the workspace
7
+ // snapshot as `CustomAgentKind.binaryOutput`. Unlike the variant picker beside it this is NOT
8
+ // an override of a default: the selection is REQUIRED — an enabled generator step without one
9
+ // is refused at pipeline save AND at run start — so it stays in BOTH interface tiers. Hiding a
10
+ // required input in basic mode leaves a step that cannot be saved and no way to find out why.
11
+ //
12
+ // The storage half offers only services from the RESOLVED catalog that declare the
13
+ // `asset-storage` capability, because that is exactly what run admission re-validates against
14
+ // at every start/retry/restart. Offering an id from a stale client copy would let a step save
15
+ // clean and fail one refusal cycle later.
16
+ import { computed } from 'vue'
17
+ import { ASSET_STORAGE_CAPABILITY, GENERATION_CONTEXT_CAPABILITY } from '@cat-factory/contracts'
18
+ import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
19
+
20
+ const props = defineProps<{ index: number }>()
21
+
22
+ const pipelines = usePipelinesStore()
23
+ const catalog = useFoundationalServicesStore()
24
+ const { t } = useI18n()
25
+
26
+ const config = computed(() => pipelines.draftBinaryOutput(props.index))
27
+
28
+ /** Storage candidates: the capability tag is a REQUIREMENT here, enforced by admission. */
29
+ const storageItems = computed(() =>
30
+ catalog.resolved
31
+ .filter((service) => service.capabilities.includes(ASSET_STORAGE_CAPABILITY))
32
+ .map((service) => ({ label: service.name, value: service.id })),
33
+ )
34
+
35
+ /**
36
+ * Context candidates: the WHOLE resolved catalog, with `generation-context`-tagged services
37
+ * ordered first. The tag is conventional and never a filter — any service with a readable
38
+ * contract can inform scope, and admission enforces existence only — so filtering on it here
39
+ * would hide a valid choice the backend would happily accept.
40
+ */
41
+ const contextItems = computed(() =>
42
+ [...catalog.resolved]
43
+ .sort((a, b) => {
44
+ const rank = (tags: readonly string[]) =>
45
+ tags.includes(GENERATION_CONTEXT_CAPABILITY) ? 0 : 1
46
+ return rank(a.capabilities) - rank(b.capabilities) || a.name.localeCompare(b.name)
47
+ })
48
+ .map((service) => ({ label: service.name, value: service.id })),
49
+ )
50
+
51
+ const pick = computed(() =>
52
+ binaryOutputPickIssues(config.value, catalog.resolved, catalog.available),
53
+ )
54
+ function has(issue: BinaryOutputPickIssue): boolean {
55
+ return pick.value.issues.includes(issue)
56
+ }
57
+
58
+ /**
59
+ * Clearing the storage target drops the WHOLE selection, context included: the context ids
60
+ * only mean anything as scope for a generation that has somewhere to land, and a step carrying
61
+ * context alone would persist a shape the backend has no rule for.
62
+ */
63
+ function setStorage(storageServiceId: string | undefined) {
64
+ const contextServiceIds = config.value?.contextServiceIds
65
+ pipelines.setDraftBinaryOutput(
66
+ props.index,
67
+ storageServiceId
68
+ ? { storageServiceId, ...(contextServiceIds?.length ? { contextServiceIds } : {}) }
69
+ : undefined,
70
+ )
71
+ }
72
+
73
+ function setContext(ids: string[]) {
74
+ const storageServiceId = config.value?.storageServiceId
75
+ if (!storageServiceId) return
76
+ pipelines.setDraftBinaryOutput(props.index, { storageServiceId, contextServiceIds: ids })
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div class="ms-6 flex flex-col gap-1.5" data-testid="binary-output-picker">
82
+ <div class="flex items-center gap-2">
83
+ <span class="text-[10px] text-slate-500">{{
84
+ t('pipeline.builder.binaryOutputStorage')
85
+ }}</span>
86
+ <USelect
87
+ class="w-56"
88
+ :model-value="config?.storageServiceId ?? ''"
89
+ :items="storageItems"
90
+ value-key="value"
91
+ size="xs"
92
+ :placeholder="t('pipeline.builder.binaryOutputPlaceholder')"
93
+ :disabled="!storageItems.length"
94
+ data-testid="binary-output-storage-select"
95
+ @update:model-value="setStorage($event)"
96
+ />
97
+ </div>
98
+
99
+ <div v-if="config?.storageServiceId" class="flex items-center gap-2">
100
+ <span class="text-[10px] text-slate-500">{{
101
+ t('pipeline.builder.binaryOutputContext')
102
+ }}</span>
103
+ <USelectMenu
104
+ class="w-56"
105
+ multiple
106
+ :model-value="config.contextServiceIds ?? []"
107
+ :items="contextItems"
108
+ value-key="value"
109
+ size="xs"
110
+ :placeholder="t('pipeline.builder.binaryOutputContextPlaceholder')"
111
+ data-testid="binary-output-context-select"
112
+ @update:model-value="setContext($event)"
113
+ />
114
+ </div>
115
+
116
+ <!-- Every refusal this step would hit, named where it is fixable. Each is its own line
117
+ with its own remedy: an unreachable catalog is not an empty one, a lost service is not
118
+ an untagged one, and a lost CONTEXT service is not a lost storage target. -->
119
+ <p
120
+ v-if="has('catalog_unavailable')"
121
+ class="text-[10px] text-amber-400"
122
+ data-testid="binary-output-unavailable"
123
+ >
124
+ {{ t('pipeline.builder.binaryOutputUnavailable') }}
125
+ </p>
126
+ <p
127
+ v-else-if="has('no_storage_service')"
128
+ class="text-[10px] text-amber-400"
129
+ data-testid="binary-output-no-storage"
130
+ >
131
+ {{ t('pipeline.builder.binaryOutputNoStorage', { capability: ASSET_STORAGE_CAPABILITY }) }}
132
+ </p>
133
+ <p v-if="has('unknown_service')" class="text-[10px] text-amber-400">
134
+ {{ t('pipeline.builder.binaryOutputMissing') }}
135
+ </p>
136
+ <p v-if="has('not_storage_capable')" class="text-[10px] text-amber-400">
137
+ {{ t('pipeline.builder.binaryOutputNotStorage', { capability: ASSET_STORAGE_CAPABILITY }) }}
138
+ </p>
139
+ <p v-if="has('unknown_context_service')" class="text-[10px] text-amber-400">
140
+ {{
141
+ t('pipeline.builder.binaryOutputContextMissing', {
142
+ ids: pick.unknownContextIds.join(', '),
143
+ })
144
+ }}
145
+ </p>
146
+ </div>
147
+ </template>