@cat-factory/app 0.202.0 → 0.205.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/README.md +62 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +220 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +274 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +40 -33
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +92 -43
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/agents.ts +20 -0
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +44 -2
- package/app/stores/tutorial.spec.ts +75 -0
- package/app/stores/tutorial.ts +66 -1
- package/app/stores/workspace/hydrate.ts +3 -0
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +421 -0
- package/app/utils/binaryOutput.ts +444 -0
- package/app/utils/tutorial.spec.ts +120 -8
- package/app/utils/tutorial.ts +166 -21
- package/i18n/locales/de.json +105 -8
- package/i18n/locales/en.json +111 -8
- package/i18n/locales/es.json +105 -8
- package/i18n/locales/fr.json +105 -8
- package/i18n/locales/he.json +105 -8
- package/i18n/locales/it.json +105 -8
- package/i18n/locales/ja.json +105 -8
- package/i18n/locales/pl.json +105 -8
- package/i18n/locales/tr.json +105 -8
- package/i18n/locales/uk.json +105 -8
- package/package.json +2 -2
|
@@ -2,10 +2,14 @@ import { readdirSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
import en from '../../i18n/locales/en.json'
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
TUTORIAL_REQUIREMENTS,
|
|
7
|
+
TUTORIAL_TOURS,
|
|
8
|
+
tutorialToursModule,
|
|
9
|
+
} from '~/modular/tutorial-tours'
|
|
10
|
+
import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
7
11
|
import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
|
|
8
|
-
import type {
|
|
12
|
+
import type { NavGates } from '~/modular/nav-contributions'
|
|
9
13
|
|
|
10
14
|
const ALL_GATES: NavGates = {
|
|
11
15
|
canWriteBoard: true,
|
|
@@ -40,12 +44,6 @@ const FRESH_BOARD: NavGates = {
|
|
|
40
44
|
boardHasFinishedRun: false,
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
const slots = (): AppSlots =>
|
|
44
|
-
({
|
|
45
|
-
nav: [...NAV_CONTRIBUTIONS],
|
|
46
|
-
tutorialTours: [...TUTORIAL_TOURS],
|
|
47
|
-
}) as unknown as AppSlots
|
|
48
|
-
|
|
49
47
|
/** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
|
|
50
48
|
function lookupKey(key: string): unknown {
|
|
51
49
|
return key
|
|
@@ -197,73 +195,124 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
197
195
|
})
|
|
198
196
|
})
|
|
199
197
|
|
|
200
|
-
describe('
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
describe('tour availability across the catalog', () => {
|
|
199
|
+
/** The ids a board can START right now — what the launch prompt offers. */
|
|
200
|
+
const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
|
|
201
|
+
/** The catalogue's own view: every tour, with what is holding each one back. */
|
|
202
|
+
const entry = (gates: NavGates, tourId: string) =>
|
|
203
|
+
resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
|
|
204
|
+
|
|
205
|
+
it('offers every tour to a fully-gated user on a fully-populated board', () => {
|
|
206
|
+
expect(ready(ALL_GATES)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
204
207
|
})
|
|
205
208
|
|
|
206
|
-
it('
|
|
209
|
+
it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
|
|
210
|
+
// The catalogue surface's contract. A fresh board can run two of the six walkthroughs;
|
|
211
|
+
// dropping the other four (all a slot filter could do) would misrepresent the product as
|
|
212
|
+
// shipping two, to exactly the user who came looking for the rest.
|
|
213
|
+
const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
|
|
214
|
+
expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
215
|
+
expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
|
|
216
|
+
'board-basics',
|
|
217
|
+
'add-service',
|
|
218
|
+
])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('holds the task-creating tour back from a read-only viewer, and says why', () => {
|
|
207
222
|
const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
expect(
|
|
211
|
-
expect(ids).not.toContain('first-task')
|
|
223
|
+
expect(ready(viewer)).toContain('board-basics')
|
|
224
|
+
expect(ready(viewer)).not.toContain('first-task')
|
|
225
|
+
expect(entry(viewer, 'first-task')?.unmet.map((r) => r.id)).toEqual(['board-write'])
|
|
212
226
|
})
|
|
213
227
|
|
|
214
|
-
it('
|
|
228
|
+
it('holds the task-creating tour back on a board with no service to add a task to', () => {
|
|
215
229
|
// Every targeted step of that tour would time out in turn and it would then claim to
|
|
216
230
|
// have taught the core loop; `board-basics` is what an empty board can deliver.
|
|
217
231
|
const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
|
|
218
|
-
|
|
219
|
-
expect(
|
|
232
|
+
expect(ready(emptyBoard)).not.toContain('first-task')
|
|
233
|
+
expect(entry(emptyBoard, 'first-task')?.unmet.map((r) => r.id)).toEqual(['service'])
|
|
220
234
|
})
|
|
221
235
|
|
|
222
236
|
it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
|
|
223
237
|
// The state the launch prompt actually auto-opens in. Orientation alone would leave a
|
|
224
238
|
// new workspace with a tour of an empty canvas and no route to a first service, which
|
|
225
239
|
// is what `add-service` exists to fix — so it must survive exactly this gate set.
|
|
226
|
-
|
|
227
|
-
expect(filtered.tutorialTours.map((t) => t.id)).toEqual(['board-basics', 'add-service'])
|
|
240
|
+
expect(ready(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
|
|
228
241
|
})
|
|
229
242
|
|
|
230
|
-
it('
|
|
243
|
+
it('names the missing connection when no source control can list repositories', () => {
|
|
231
244
|
const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
|
|
232
|
-
expect(
|
|
233
|
-
|
|
234
|
-
])
|
|
245
|
+
expect(ready(noSource)).toEqual(['board-basics'])
|
|
246
|
+
expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
|
|
235
247
|
})
|
|
236
248
|
|
|
237
249
|
it('offers the run tour once a task exists, and the review tour once a run finished', () => {
|
|
238
250
|
const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
|
|
239
|
-
expect(
|
|
240
|
-
|
|
241
|
-
)
|
|
242
|
-
expect(
|
|
243
|
-
navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id),
|
|
244
|
-
).not.toContain('review-merge')
|
|
251
|
+
expect(ready(withTask)).toContain('run-task')
|
|
252
|
+
expect(ready(withTask)).not.toContain('review-merge')
|
|
253
|
+
expect(entry(withTask, 'review-merge')?.unmet.map((r) => r.id)).toEqual(['finished-run'])
|
|
245
254
|
|
|
246
255
|
const finished: NavGates = { ...withTask, boardHasRun: true, boardHasFinishedRun: true }
|
|
247
|
-
expect(
|
|
248
|
-
|
|
249
|
-
|
|
256
|
+
expect(ready(finished)).toContain('review-merge')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('names every unmet requirement, not just the first', () => {
|
|
260
|
+
// The reader has to do all of them; reporting one at a time turns unblocking a tour into
|
|
261
|
+
// a guessing game with a fresh answer after each attempt.
|
|
262
|
+
const bare: NavGates = { ...FRESH_BOARD, canWriteBoard: false, githubAvailable: false }
|
|
263
|
+
expect(entry(bare, 'add-service')?.unmet.map((r) => r.id)).toEqual([
|
|
264
|
+
'board-write',
|
|
265
|
+
'source-control',
|
|
266
|
+
])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('resolves every requirement copy key against the en catalog', () => {
|
|
270
|
+
// Same tier-2 i18n guard as the tour copy above: a requirement's label is looked up from
|
|
271
|
+
// data, so a renamed key would reach the user as a raw path in the "available once" list.
|
|
272
|
+
for (const requirement of Object.values(TUTORIAL_REQUIREMENTS)) {
|
|
273
|
+
expect(typeof lookupKey(requirement.labelKey), requirement.labelKey).toBe('string')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('gives every tour a step that always applies, so none can be listed then not run', () => {
|
|
278
|
+
// The authoring rule `resolveTourCatalogue` documents. `blocked` outranks `not-applicable`,
|
|
279
|
+
// which is right — but it means a tour COULD be named in the catalogue as unlockable and
|
|
280
|
+
// then, once the reader has gone and done the thing it asked for, resolve to no applicable
|
|
281
|
+
// steps and still refuse to start. An unconditional step (every built-in has an intro and a
|
|
282
|
+
// finish card) makes `steps` non-empty under any gates, so that outcome is unreachable.
|
|
283
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
284
|
+
const unconditional = tour.steps.filter((s) => !s.when).map((s) => s.id)
|
|
285
|
+
expect(unconditional, tour.id).not.toEqual([])
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('declares its requirements from the shared set', () => {
|
|
290
|
+
// A tour with an inline requirement object is not wrong, but a duplicate of a shared one
|
|
291
|
+
// is: two copies of "a service on the board" drift into two different sentences about the
|
|
292
|
+
// same gate. Pinning the built-ins to the table keeps that a deliberate act.
|
|
293
|
+
const shared = new Set(Object.values(TUTORIAL_REQUIREMENTS).map((r) => r.id))
|
|
294
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
295
|
+
for (const requirement of tour.requires ?? []) {
|
|
296
|
+
expect(shared, `${tour.id}: ${requirement.id}`).toContain(requirement.id)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
250
299
|
})
|
|
251
300
|
|
|
252
301
|
it('passes tours through untouched when no gates service is wired', () => {
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
expect(resolveTours(TUTORIAL_TOURS, null).map((t) => t.id)).toEqual(
|
|
303
|
+
TUTORIAL_TOURS.map((t) => t.id),
|
|
304
|
+
)
|
|
255
305
|
})
|
|
256
306
|
})
|
|
257
307
|
|
|
258
308
|
describe('the parked-run tour branches', () => {
|
|
259
309
|
const stepIds = (gates: NavGates, tourId: string) =>
|
|
260
|
-
|
|
261
|
-
.
|
|
310
|
+
resolveTours(TUTORIAL_TOURS, gates)
|
|
311
|
+
.find((t) => t.id === tourId)
|
|
262
312
|
?.steps.map((s) => s.id)
|
|
263
313
|
|
|
264
314
|
it('is not offered while nothing is waiting for a human', () => {
|
|
265
|
-
|
|
266
|
-
expect(ids).not.toContain('answer-park')
|
|
315
|
+
expect(resolveTours(TUTORIAL_TOURS, FRESH_BOARD).map((t) => t.id)).not.toContain('answer-park')
|
|
267
316
|
})
|
|
268
317
|
|
|
269
318
|
it('shows the decision branch only, for a run parked on a decision', () => {
|
|
@@ -297,7 +346,7 @@ describe('the parked-run tour branches', () => {
|
|
|
297
346
|
it('keeps every branch when no gates service is wired', () => {
|
|
298
347
|
// Same dev-open parity as `nav`: with nothing to gate against, nothing is withheld —
|
|
299
348
|
// including the per-step branches, which a bare install must not silently thin out.
|
|
300
|
-
const answerPark =
|
|
349
|
+
const answerPark = resolveTours(TUTORIAL_TOURS, null).find((t) => t.id === 'answer-park')
|
|
301
350
|
expect(answerPark?.steps.map((s) => s.id)).toEqual([
|
|
302
351
|
'intro',
|
|
303
352
|
'resolve',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
-
import type { TutorialTour } from '~/utils/tutorial'
|
|
2
|
+
import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The first-party tutorial-tour catalog, contributed to the `tutorialTours` slot the same
|
|
@@ -27,11 +27,15 @@ import type { TutorialTour } from '~/utils/tutorial'
|
|
|
27
27
|
* - A step whose branch of the flow this board simply isn't on declares `when`, so it is
|
|
28
28
|
* DROPPED rather than skipped: a skip is reported as an abridged tour, and a parked run
|
|
29
29
|
* that has a decision and no approval gate is not an abridged anything.
|
|
30
|
+
* - A tour's own preconditions are DECLARED ({@link TUTORIAL_REQUIREMENTS}), never an
|
|
31
|
+
* anonymous predicate: the catalogue lists every tour this deployment ships and has to say
|
|
32
|
+
* what a user must do before one it is holding back becomes available.
|
|
30
33
|
*
|
|
31
34
|
* Together the tours below walk the delivery loop end to end — get a repo onto the board,
|
|
32
35
|
* put a task on it, run it, answer it when it asks, read the result and merge it — with each
|
|
33
|
-
* later tour
|
|
34
|
-
* offers what this board can actually demonstrate
|
|
36
|
+
* later tour requiring the state the previous one produces, so the launch prompt only ever
|
|
37
|
+
* offers what this board can actually demonstrate, and the catalogue turns the rest into a
|
|
38
|
+
* to-do list rather than an absence.
|
|
35
39
|
*/
|
|
36
40
|
|
|
37
41
|
/**
|
|
@@ -43,6 +47,51 @@ import type { TutorialTour } from '~/utils/tutorial'
|
|
|
43
47
|
*/
|
|
44
48
|
export const SAMPLE_REPO = 'kibertoad/cat-factory-sample-repository'
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The preconditions the built-in tours declare, each pairing the gate that decides it with
|
|
52
|
+
* the copy that NAMES it — so a tour the board can't run yet is listed with the one thing
|
|
53
|
+
* still missing instead of being silently absent from the catalogue.
|
|
54
|
+
*
|
|
55
|
+
* Shared constants rather than a literal per tour because several tours need the same fact
|
|
56
|
+
* (`service` gates two of them), and a second copy of a requirement is a second reason string
|
|
57
|
+
* to keep in step with the gate it describes.
|
|
58
|
+
*/
|
|
59
|
+
export const TUTORIAL_REQUIREMENTS = {
|
|
60
|
+
boardWrite: {
|
|
61
|
+
id: 'board-write',
|
|
62
|
+
labelKey: 'tutorial.requirements.boardWrite',
|
|
63
|
+
met: (gates) => gates.canWriteBoard,
|
|
64
|
+
},
|
|
65
|
+
sourceControl: {
|
|
66
|
+
id: 'source-control',
|
|
67
|
+
labelKey: 'tutorial.requirements.sourceControl',
|
|
68
|
+
met: (gates) => gates.githubAvailable,
|
|
69
|
+
},
|
|
70
|
+
service: {
|
|
71
|
+
id: 'service',
|
|
72
|
+
labelKey: 'tutorial.requirements.service',
|
|
73
|
+
met: (gates) => gates.boardHasService,
|
|
74
|
+
},
|
|
75
|
+
task: {
|
|
76
|
+
id: 'task',
|
|
77
|
+
labelKey: 'tutorial.requirements.task',
|
|
78
|
+
met: (gates) => gates.boardHasTask,
|
|
79
|
+
},
|
|
80
|
+
// One requirement over both kinds of park, mirroring the tour's single `task-resolve`
|
|
81
|
+
// anchor: the card offers ONE attention action whichever way a run is waiting, so splitting
|
|
82
|
+
// this would list two things to go and do where either one alone unlocks the tour.
|
|
83
|
+
waitingAnswer: {
|
|
84
|
+
id: 'waiting-answer',
|
|
85
|
+
labelKey: 'tutorial.requirements.waitingAnswer',
|
|
86
|
+
met: (gates) => gates.boardHasOpenDecision || gates.boardHasPendingApproval,
|
|
87
|
+
},
|
|
88
|
+
finishedRun: {
|
|
89
|
+
id: 'finished-run',
|
|
90
|
+
labelKey: 'tutorial.requirements.finishedRun',
|
|
91
|
+
met: (gates) => gates.boardHasFinishedRun,
|
|
92
|
+
},
|
|
93
|
+
} as const satisfies Record<string, TutorialRequirement>
|
|
94
|
+
|
|
46
95
|
export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
47
96
|
{
|
|
48
97
|
id: 'board-basics',
|
|
@@ -116,7 +165,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
116
165
|
// repo is a board write against a connected source, and in basic interface mode
|
|
117
166
|
// add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
|
|
118
167
|
// worth a tour rather than a hint.
|
|
119
|
-
|
|
168
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.sourceControl],
|
|
120
169
|
steps: [
|
|
121
170
|
{
|
|
122
171
|
id: 'intro',
|
|
@@ -172,7 +221,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
172
221
|
// hunting for controls and then claim to have taught the core loop. Offering it only
|
|
173
222
|
// once a service exists is the honest version — and the launch prompt still lists
|
|
174
223
|
// `board-basics`, which is the tour an empty board can actually deliver.
|
|
175
|
-
|
|
224
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.service],
|
|
176
225
|
steps: [
|
|
177
226
|
{
|
|
178
227
|
id: 'intro',
|
|
@@ -236,7 +285,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
236
285
|
// pipeline it will run, the start control, the live step list. `first-task` stops at the
|
|
237
286
|
// card, so without this tour a user who finished the shipped walkthrough has never seen
|
|
238
287
|
// the inspector. Needs a task to open, not merely a service to hold one.
|
|
239
|
-
|
|
288
|
+
requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.task],
|
|
240
289
|
steps: [
|
|
241
290
|
{
|
|
242
291
|
id: 'intro',
|
|
@@ -306,7 +355,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
306
355
|
// realise a run is asking them something has a run that never finishes and a workspace
|
|
307
356
|
// in-flight slot held open. Offered only while something is actually waiting, because
|
|
308
357
|
// the whole tour anchors on controls that exist only then.
|
|
309
|
-
|
|
358
|
+
requires: [TUTORIAL_REQUIREMENTS.waitingAnswer],
|
|
310
359
|
steps: [
|
|
311
360
|
{
|
|
312
361
|
id: 'intro',
|
|
@@ -361,7 +410,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
361
410
|
// The last mile: a task is only DONE when its PR actually merged, so a user who never
|
|
362
411
|
// finds the result and the merge control has a board full of finished-looking work that
|
|
363
412
|
// shipped nothing. Its subject is a run's output, so it needs a run that produced one.
|
|
364
|
-
|
|
413
|
+
requires: [TUTORIAL_REQUIREMENTS.finishedRun],
|
|
365
414
|
steps: [
|
|
366
415
|
{
|
|
367
416
|
id: 'intro',
|
package/app/pages/index.vue
CHANGED
|
@@ -142,11 +142,15 @@ const AiPresetMismatchDialog = defineAsyncComponent(
|
|
|
142
142
|
() => import('~/components/providers/AiPresetMismatchDialog.vue'),
|
|
143
143
|
)
|
|
144
144
|
// The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
|
|
145
|
-
// it)
|
|
146
|
-
//
|
|
145
|
+
// it), the catalogue of every tour the deployment ships (opened from the sidebar's Help
|
|
146
|
+
// section or the palette, at any time), and the coach-mark overlay that runs a tour. All
|
|
147
|
+
// mount only while their store flag is set, so they cost the initial bundle nothing.
|
|
147
148
|
const TutorialPrompt = defineAsyncComponent(
|
|
148
149
|
() => import('~/components/tutorial/TutorialPrompt.vue'),
|
|
149
150
|
)
|
|
151
|
+
const TutorialCatalogue = defineAsyncComponent(
|
|
152
|
+
() => import('~/components/tutorial/TutorialCatalogue.vue'),
|
|
153
|
+
)
|
|
150
154
|
const TutorialOverlay = defineAsyncComponent(
|
|
151
155
|
() => import('~/components/tutorial/TutorialOverlay.vue'),
|
|
152
156
|
)
|
|
@@ -512,6 +516,7 @@ watch(
|
|
|
512
516
|
<AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
|
|
513
517
|
<AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
|
|
514
518
|
<TutorialPrompt v-if="tutorial.promptOpen" />
|
|
519
|
+
<TutorialCatalogue v-if="tutorial.catalogueOpen" />
|
|
515
520
|
<TutorialOverlay v-if="tutorial.touring" />
|
|
516
521
|
</template>
|
|
517
522
|
|
package/app/stores/agents.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
SYSTEM_AGENT_META,
|
|
11
11
|
uid,
|
|
12
12
|
} from '~/utils/catalog'
|
|
13
|
+
import type { RegisteredBinaryGenerator } from '@cat-factory/contracts'
|
|
13
14
|
import type { AgentArchetype, AgentKind, AgentKindVariant, CustomAgentKind } from '~/types/domain'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -45,6 +46,13 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
45
46
|
// there — so folding it into the kind catalog would make it placeable, which is exactly what
|
|
46
47
|
// the backend model says it is not. A straight replace, like the skills catalog it mirrors.
|
|
47
48
|
const variants = ref<AgentKindVariant[]>([])
|
|
49
|
+
/**
|
|
50
|
+
* The deployment's GENERATIVE BINARY INTEGRATIONS, from the workspace snapshot. Static
|
|
51
|
+
* deployment-registered composition data like {@link variants}, and it rides the same store for
|
|
52
|
+
* the same reason: it is a fact ABOUT the agent catalog that the pipeline builder branches on,
|
|
53
|
+
* with no workspace state behind it. Empty on the stock product — the platform ships none.
|
|
54
|
+
*/
|
|
55
|
+
const binaryGenerators = ref<RegisteredBinaryGenerator[]>([])
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
58
|
* The merged CUSTOM catalog (consumer-slot → backend-manifest → runtime), each
|
|
@@ -140,6 +148,16 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
140
148
|
capabilitiesManifest.value = manifest
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Hydrate the deployment's registered generative binary integrations from the snapshot (a
|
|
153
|
+
* straight replace, like {@link hydrateVariants}). The builder's binary-output picker offers
|
|
154
|
+
* exactly these ids, so they are the same set run admission resolves a step's `generatorIds`
|
|
155
|
+
* against — an id offered from anywhere else would save clean and be refused at run START.
|
|
156
|
+
*/
|
|
157
|
+
function hydrateBinaryGenerators(list: readonly RegisteredBinaryGenerator[]) {
|
|
158
|
+
binaryGenerators.value = [...list]
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
/** Hydrate the deployment's registered agent-kind variants from the snapshot (straight replace). */
|
|
144
162
|
function hydrateVariants(list: readonly AgentKindVariant[]) {
|
|
145
163
|
variants.value = [...list]
|
|
@@ -172,6 +190,8 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
172
190
|
variants,
|
|
173
191
|
hydrateVariants,
|
|
174
192
|
variantsForKind,
|
|
193
|
+
binaryGenerators,
|
|
194
|
+
hydrateBinaryGenerators,
|
|
175
195
|
variantLabel,
|
|
176
196
|
}
|
|
177
197
|
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The pipeline-builder draft's binary-output selection (`stepOptions.binaryOutput`), the store
|
|
6
|
+
* half of the picker (docs/initiatives/binary-output-foundational-storage.md). Same contract as
|
|
7
|
+
* the skill and variant helpers beside it — merge into the options bag, normalize an emptied bag
|
|
8
|
+
* back to null — plus the one rule specific to this field: an empty `contextServiceIds` is
|
|
9
|
+
* DROPPED rather than persisted, because `[]` and absence are different claims to the agent.
|
|
10
|
+
*/
|
|
11
|
+
describe('pipelines store — per-step binary-output selection', () => {
|
|
12
|
+
it('sets, reads and clears the selection', () => {
|
|
13
|
+
const pipelines = usePipelinesStore()
|
|
14
|
+
pipelines.addToDraft('coder')
|
|
15
|
+
expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
|
|
16
|
+
|
|
17
|
+
pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
|
|
18
|
+
expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
|
|
19
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
20
|
+
binaryOutput: { storageServiceId: 'file-storage' },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
|
|
24
|
+
// so a step that never used it persists exactly the shape it always did.
|
|
25
|
+
pipelines.setDraftBinaryOutput(0, undefined)
|
|
26
|
+
expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
|
|
27
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// `[]` reads as "context was considered and rejected", which the brief would then repeat to
|
|
31
|
+
// the agent; absence reads as "no scope service was selected". Only one of those is true.
|
|
32
|
+
it('drops an emptied context list rather than persisting an empty array', () => {
|
|
33
|
+
const pipelines = usePipelinesStore()
|
|
34
|
+
pipelines.addToDraft('coder')
|
|
35
|
+
|
|
36
|
+
pipelines.setDraftBinaryOutput(0, {
|
|
37
|
+
storageServiceId: 'file-storage',
|
|
38
|
+
contextServiceIds: ['asset-management'],
|
|
39
|
+
})
|
|
40
|
+
expect(pipelines.draftBinaryOutput(0)?.contextServiceIds).toEqual(['asset-management'])
|
|
41
|
+
|
|
42
|
+
pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage', contextServiceIds: [] })
|
|
43
|
+
expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// A blank storage id is not a selection: the backend refuses the step either way, so the
|
|
47
|
+
// draft must not carry a half-filled shape that saves and then fails.
|
|
48
|
+
it('treats a blank storage id as no selection at all', () => {
|
|
49
|
+
const pipelines = usePipelinesStore()
|
|
50
|
+
pipelines.addToDraft('coder')
|
|
51
|
+
pipelines.setDraftBinaryOutput(0, { storageServiceId: '', contextServiceIds: ['inventory'] })
|
|
52
|
+
expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
|
|
53
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('merges into the options bag rather than clobbering other per-step options', () => {
|
|
57
|
+
const pipelines = usePipelinesStore()
|
|
58
|
+
pipelines.addToDraft('coder')
|
|
59
|
+
pipelines.draftStepOptions[0] = { agentVariantId: 'acme:fast' }
|
|
60
|
+
|
|
61
|
+
pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
|
|
62
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
63
|
+
agentVariantId: 'acme:fast',
|
|
64
|
+
binaryOutput: { storageServiceId: 'file-storage' },
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
pipelines.setDraftBinaryOutput(0, undefined)
|
|
68
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ agentVariantId: 'acme:fast' })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StepOptions } from '@cat-factory/contracts'
|
|
1
|
+
import type { BinaryOutputConfig, StepOptions } from '@cat-factory/contracts'
|
|
2
2
|
import type { ConsensusStepConfig } from '~/types/consensus'
|
|
3
3
|
import { defaultConsensusConfig, type PipelinesContext } from './context'
|
|
4
4
|
|
|
@@ -6,7 +6,8 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
|
|
|
6
6
|
* The pipeline-builder draft's PER-STEP CONFIG toggles: consensus (inline panel and the workspace
|
|
7
7
|
* consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
|
|
8
8
|
* follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
|
|
9
|
-
* (requirements auto-recommendation, the picked skill, the picked agent-kind variant
|
|
9
|
+
* (requirements auto-recommendation, the picked skill, the picked agent-kind variant, the
|
|
10
|
+
* per-step output-token ceiling, the binary-output storage/context selection).
|
|
10
11
|
*
|
|
11
12
|
* Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
|
|
12
13
|
* units). Every function here reads and writes one of the parallel per-step arrays at an index and
|
|
@@ -160,6 +161,45 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
160
161
|
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The binary-output SELECTION on the draft step at `index` (its `stepOptions.binaryOutput`) —
|
|
166
|
+
* the foundational storage service a generator kind's artifacts are stored through, plus any
|
|
167
|
+
* services consulted for the generation's scope. Undefined on every step of every stock
|
|
168
|
+
* pipeline; required on a step whose kind carries the `binary-output` trait.
|
|
169
|
+
*/
|
|
170
|
+
function draftBinaryOutput(index: number): BinaryOutputConfig | undefined {
|
|
171
|
+
return draftStepOptions.value[index]?.binaryOutput
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Set (or clear) the binary-output selection on the draft step at `index`. Merges into the
|
|
176
|
+
* step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
|
|
177
|
+
* bag empties, the whole entry — exactly like the other options here, so a step that never
|
|
178
|
+
* used it persists the shape it always did.
|
|
179
|
+
*
|
|
180
|
+
* An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
|
|
181
|
+
* tier set drops its own empty array: the field's absence means "no scope service was
|
|
182
|
+
* selected", while `[]` reads as "context was considered and rejected" — a different claim,
|
|
183
|
+
* and one the brief renderer would repeat to the agent. `generatorIds` and `modalities` take
|
|
184
|
+
* the same treatment for the same reason: an absent `generatorIds` means the step generates
|
|
185
|
+
* through whatever its agent already has, and an absent `modalities` imposes no delivery
|
|
186
|
+
* requirement — both of which the brief STATES, so persisting `[]` would have it state the
|
|
187
|
+
* wrong thing.
|
|
188
|
+
*/
|
|
189
|
+
function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
|
|
190
|
+
const next: StepOptions = { ...draftStepOptions.value[index] }
|
|
191
|
+
if (config?.storageServiceId) {
|
|
192
|
+
const { storageServiceId, contextServiceIds, generatorIds, modalities } = config
|
|
193
|
+
next.binaryOutput = {
|
|
194
|
+
storageServiceId,
|
|
195
|
+
...(contextServiceIds?.length ? { contextServiceIds } : {}),
|
|
196
|
+
...(generatorIds?.length ? { generatorIds } : {}),
|
|
197
|
+
...(modalities?.length ? { modalities } : {}),
|
|
198
|
+
}
|
|
199
|
+
} else delete next.binaryOutput
|
|
200
|
+
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
201
|
+
}
|
|
202
|
+
|
|
163
203
|
/**
|
|
164
204
|
* The output-token ceiling pinned on the draft step at `index`, or undefined when the step
|
|
165
205
|
* inherits (the workspace's per-kind setting, else the deployment default).
|
|
@@ -197,6 +237,8 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
197
237
|
setDraftSkillId,
|
|
198
238
|
draftAgentVariantId,
|
|
199
239
|
setDraftAgentVariantId,
|
|
240
|
+
draftBinaryOutput,
|
|
241
|
+
setDraftBinaryOutput,
|
|
200
242
|
draftMaxOutputTokens,
|
|
201
243
|
setDraftMaxOutputTokens,
|
|
202
244
|
}
|
|
@@ -64,6 +64,81 @@ describe('useTutorialStore launch prompt', () => {
|
|
|
64
64
|
})
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
describe('useTutorialStore catalogue', () => {
|
|
68
|
+
it('opens over the launch prompt without answering it', () => {
|
|
69
|
+
// Browsing the full list is not "no thanks" — it is the opposite — so the offer must
|
|
70
|
+
// return next launch if the user browses and starts nothing. And the two are modals:
|
|
71
|
+
// leaving the prompt open would stack them.
|
|
72
|
+
const tutorial = useTutorialStore()
|
|
73
|
+
tutorial.maybeOfferOnLaunch()
|
|
74
|
+
tutorial.openCatalogue()
|
|
75
|
+
expect(tutorial.catalogueOpen).toBe(true)
|
|
76
|
+
expect(tutorial.promptOpen).toBe(false)
|
|
77
|
+
expect(tutorial.decision).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('closes when a tour starts or resumes from it', () => {
|
|
81
|
+
const tutorial = useTutorialStore()
|
|
82
|
+
tutorial.openCatalogue()
|
|
83
|
+
tutorial.startTour('board-basics')
|
|
84
|
+
expect(tutorial.catalogueOpen).toBe(false)
|
|
85
|
+
|
|
86
|
+
tutorial.setStepIndex(2)
|
|
87
|
+
tutorial.stopTour()
|
|
88
|
+
tutorial.openCatalogue()
|
|
89
|
+
tutorial.resumeTour('board-basics')
|
|
90
|
+
expect(tutorial.catalogueOpen).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('reports its own window as open, so the coach marks stand down', () => {
|
|
94
|
+
// The overlay renders at z-[70] to sit ABOVE the app's modals, since a step legitimately
|
|
95
|
+
// points into one. The catalogue is openable mid-tour (that is what the `continue` action
|
|
96
|
+
// is for), and there the same z-index would float a ring and a tooltip over the window the
|
|
97
|
+
// user just opened. The tour itself is untouched — only the marks go.
|
|
98
|
+
const tutorial = useTutorialStore()
|
|
99
|
+
tutorial.startTour('board-basics')
|
|
100
|
+
expect(tutorial.ownWindowOpen).toBe(false)
|
|
101
|
+
|
|
102
|
+
tutorial.openCatalogue()
|
|
103
|
+
expect(tutorial.ownWindowOpen).toBe(true)
|
|
104
|
+
expect(tutorial.activeTourId).toBe('board-basics')
|
|
105
|
+
|
|
106
|
+
tutorial.closeCatalogue()
|
|
107
|
+
expect(tutorial.ownWindowOpen).toBe(false)
|
|
108
|
+
|
|
109
|
+
tutorial.openPrompt()
|
|
110
|
+
expect(tutorial.ownWindowOpen).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('resets every record of progress, including the answered offer', () => {
|
|
114
|
+
// What someone handing the app to a colleague is asking for: the first-launch experience
|
|
115
|
+
// back. Clearing only the completion list would leave the offer answered, so the prompt
|
|
116
|
+
// they are trying to demo would never appear.
|
|
117
|
+
const tutorial = useTutorialStore()
|
|
118
|
+
tutorial.startTour('board-basics')
|
|
119
|
+
tutorial.completeTour()
|
|
120
|
+
tutorial.startTour('run-task')
|
|
121
|
+
tutorial.setStepIndex(2)
|
|
122
|
+
tutorial.stopTour()
|
|
123
|
+
|
|
124
|
+
tutorial.resetProgress()
|
|
125
|
+
expect(tutorial.completedTourIds).toEqual([])
|
|
126
|
+
expect(tutorial.interruptedAt('run-task')).toBeNull()
|
|
127
|
+
expect(tutorial.decision).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('leaves a running tour alone when progress is reset', () => {
|
|
131
|
+
// A click about history must not end the walkthrough the user is in the middle of —
|
|
132
|
+
// which would also leave it unrecorded, since nothing marks a stopped tour complete.
|
|
133
|
+
const tutorial = useTutorialStore()
|
|
134
|
+
tutorial.startTour('board-basics')
|
|
135
|
+
tutorial.setStepIndex(2)
|
|
136
|
+
tutorial.resetProgress()
|
|
137
|
+
expect(tutorial.activeTourId).toBe('board-basics')
|
|
138
|
+
expect(tutorial.stepIndex).toBe(2)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
67
142
|
describe('useTutorialStore tours', () => {
|
|
68
143
|
it('starting a tour records acceptance, closes the prompt, and resets the cursor', () => {
|
|
69
144
|
const tutorial = useTutorialStore()
|