@cat-factory/app 0.213.1 → 0.215.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 +107 -13
- package/app/components/observability/StepMetricsBar.vue +11 -0
- package/app/components/panels/AgentStepDetail.vue +14 -0
- package/app/components/panels/MergerResultView.vue +20 -2
- package/app/components/panels/ObservabilityPanel.vue +57 -0
- package/app/components/panels/ResultWindowShell.vue +77 -0
- package/app/components/panels/StepReproductionReport.vue +167 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +25 -0
- package/app/components/tutorial/TutorialCatalogue.vue +14 -1
- package/app/components/tutorial/TutorialNudge.vue +107 -0
- package/app/components/tutorial/TutorialOverlay.vue +92 -11
- package/app/composables/api/execution.ts +5 -2
- package/app/composables/api/tutorial.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTutorialNudge.ts +77 -0
- package/app/composables/useTutorialSync.ts +141 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +11 -0
- package/app/modular/nav-gates.ts +10 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +55 -4
- package/app/modular/tutorial-tours.ts +231 -9
- package/app/pages/index.vue +20 -1
- package/app/stores/tutorial.prompt.ts +59 -0
- package/app/stores/tutorial.record.ts +191 -0
- package/app/stores/tutorial.spec.ts +207 -0
- package/app/stores/tutorial.ts +78 -91
- package/app/stores/workspace/hydrate.ts +5 -0
- package/app/types/domain.ts +3 -0
- package/app/types/reproduction.ts +11 -0
- package/app/utils/binaryOutput.spec.ts +56 -2
- package/app/utils/binaryOutput.ts +55 -32
- package/app/utils/observability.spec.ts +44 -1
- package/app/utils/observability.ts +50 -0
- package/app/utils/reproduction.ts +51 -0
- package/app/utils/tutorial.spec.ts +255 -0
- package/app/utils/tutorial.ts +173 -0
- package/i18n/locales/de.json +148 -6
- package/i18n/locales/en.json +153 -6
- package/i18n/locales/es.json +148 -6
- package/i18n/locales/fr.json +148 -6
- package/i18n/locales/he.json +148 -6
- package/i18n/locales/it.json +148 -6
- package/i18n/locales/ja.json +148 -6
- package/i18n/locales/pl.json +148 -6
- package/i18n/locales/tr.json +148 -6
- package/i18n/locales/uk.json +148 -6
- package/package.json +2 -2
|
@@ -144,6 +144,17 @@ export interface NavGates {
|
|
|
144
144
|
* failed run never renders.
|
|
145
145
|
*/
|
|
146
146
|
boardHasFinishedRun: boolean
|
|
147
|
+
/**
|
|
148
|
+
* Some run on a task block has FAILED, so the card is rendering the failure banner.
|
|
149
|
+
*
|
|
150
|
+
* The other half of {@link boardHasFinishedRun}, and the reason it is a separate gate rather
|
|
151
|
+
* than a looser "a run settled": the two states render disjoint surfaces (a result view and
|
|
152
|
+
* a merge control; a failure banner and a retry), so one tour cannot cover both. It is also
|
|
153
|
+
* the state a new user is MOST likely to be in and the one the catalog had nothing for: with
|
|
154
|
+
* only the success gate, a board where every run failed reports the delivery loop as
|
|
155
|
+
* permanently half-finished and explains none of it.
|
|
156
|
+
*/
|
|
157
|
+
boardHasFailedRun: boolean
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -49,6 +49,13 @@ export function createNavGates(): NavGates {
|
|
|
49
49
|
const hasFinishedRun = computed(() =>
|
|
50
50
|
execution.instances.some((e) => e.status === 'done' && isTaskBlock(e.blockId)),
|
|
51
51
|
)
|
|
52
|
+
// Mirrors what the CARD renders, exactly as the park gates do: `TaskCard` shows the shared
|
|
53
|
+
// failure banner on `agentRun.status === 'failed'`, which is the anchor the diagnose tour
|
|
54
|
+
// points at. Kept apart from `hasFinishedRun` because the two states render disjoint
|
|
55
|
+
// controls — see `NavGates.boardHasFailedRun`.
|
|
56
|
+
const hasFailedRun = computed(() =>
|
|
57
|
+
execution.instances.some((e) => e.status === 'failed' && isTaskBlock(e.blockId)),
|
|
58
|
+
)
|
|
52
59
|
// Not the store's raw pending counts: those answer "is anything parked", while the tour
|
|
53
60
|
// these gate anchors on the card affordance a park RENDERS. See `hasActionablePark`.
|
|
54
61
|
const hasOpenDecision = computed(() =>
|
|
@@ -119,5 +126,8 @@ export function createNavGates(): NavGates {
|
|
|
119
126
|
get boardHasFinishedRun() {
|
|
120
127
|
return hasFinishedRun.value
|
|
121
128
|
},
|
|
129
|
+
get boardHasFailedRun() {
|
|
130
|
+
return hasFailedRun.value
|
|
131
|
+
},
|
|
122
132
|
}
|
|
123
133
|
}
|
|
@@ -29,6 +29,7 @@ const ALL_GATES: NavGates = {
|
|
|
29
29
|
boardHasOpenDecision: true,
|
|
30
30
|
boardHasPendingApproval: true,
|
|
31
31
|
boardHasFinishedRun: true,
|
|
32
|
+
boardHasFailedRun: true,
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -44,6 +45,7 @@ const FRESH_BOARD: NavGates = {
|
|
|
44
45
|
boardHasOpenDecision: false,
|
|
45
46
|
boardHasPendingApproval: false,
|
|
46
47
|
boardHasFinishedRun: false,
|
|
48
|
+
boardHasFailedRun: false,
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
|
|
@@ -295,10 +297,10 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
295
297
|
// rather than spelled out a second time here.
|
|
296
298
|
const pairs = navAnchoredSteps()
|
|
297
299
|
// Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
|
|
298
|
-
// there is one per tour that opens a sidebar surface: `add-service` plus
|
|
299
|
-
//
|
|
300
|
+
// there is one per tour that opens a sidebar surface: `add-service` plus every platform
|
|
301
|
+
// tour. And a gate field that is not a boolean would silently never vary across the
|
|
300
302
|
// matrix, leaving whatever it gates unexercised.
|
|
301
|
-
expect(pairs.length).toBeGreaterThanOrEqual(
|
|
303
|
+
expect(pairs.length).toBeGreaterThanOrEqual(8)
|
|
302
304
|
expect(Object.values(ALL_GATES).every((value) => typeof value === 'boolean')).toBe(true)
|
|
303
305
|
|
|
304
306
|
const drifted = pairs.flatMap((pair) => {
|
|
@@ -344,6 +346,9 @@ describe('tour availability across the catalog', () => {
|
|
|
344
346
|
'design-pipeline',
|
|
345
347
|
'agent-standards',
|
|
346
348
|
'connect-systems',
|
|
349
|
+
'prepare-infrastructure',
|
|
350
|
+
'panel-reviews',
|
|
351
|
+
'share-services',
|
|
347
352
|
])
|
|
348
353
|
})
|
|
349
354
|
|
|
@@ -383,9 +388,20 @@ describe('tour availability across the catalog', () => {
|
|
|
383
388
|
'first-task',
|
|
384
389
|
'run-task',
|
|
385
390
|
'answer-park',
|
|
391
|
+
// Work going WRONG is part of the arc, not an appendix to it: a first run fails often, and
|
|
392
|
+
// this is the tour the contextual offer raises when one does.
|
|
393
|
+
'diagnose-failure',
|
|
386
394
|
'review-merge',
|
|
387
395
|
]
|
|
388
|
-
const CATALOGUE_ONLY = [
|
|
396
|
+
const CATALOGUE_ONLY = [
|
|
397
|
+
'wire-models',
|
|
398
|
+
'design-pipeline',
|
|
399
|
+
'agent-standards',
|
|
400
|
+
'connect-systems',
|
|
401
|
+
'prepare-infrastructure',
|
|
402
|
+
'panel-reviews',
|
|
403
|
+
'share-services',
|
|
404
|
+
]
|
|
389
405
|
expect(TUTORIAL_TOURS.filter(isLaunchOffer).map((t) => t.id)).toEqual(LAUNCH_ARC)
|
|
390
406
|
expect(TUTORIAL_TOURS.filter((t) => !isLaunchOffer(t)).map((t) => t.id)).toEqual(CATALOGUE_ONLY)
|
|
391
407
|
// Un-offered is not un-runnable: it thins the offer, never the library.
|
|
@@ -435,6 +451,41 @@ describe('tour availability across the catalog', () => {
|
|
|
435
451
|
expect(ready(finished)).toContain('review-merge')
|
|
436
452
|
})
|
|
437
453
|
|
|
454
|
+
it('offers the failure tour on the state the success tour refuses', () => {
|
|
455
|
+
// The two are disjoint on purpose, and this is the pairing that used to have no second half:
|
|
456
|
+
// a board whose only run FAILED was offered a walkthrough of reading a successful result
|
|
457
|
+
// (blocked, so invisible in the prompt) and nothing whatsoever about the banner on screen.
|
|
458
|
+
const failed: NavGates = { ...FRESH_BOARD, boardHasRun: true, boardHasFailedRun: true }
|
|
459
|
+
expect(ready(failed)).toContain('diagnose-failure')
|
|
460
|
+
expect(ready(failed)).not.toContain('review-merge')
|
|
461
|
+
expect(entry(failed, 'review-merge')?.unmet.map((r) => r.id)).toEqual(['finished-run'])
|
|
462
|
+
|
|
463
|
+
const succeeded: NavGates = { ...FRESH_BOARD, boardHasRun: true, boardHasFinishedRun: true }
|
|
464
|
+
expect(ready(succeeded)).toContain('review-merge')
|
|
465
|
+
expect(entry(succeeded, 'diagnose-failure')?.unmet.map((r) => r.id)).toEqual(['failed-run'])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('holds the interface tier as a requirement of the tours whose surface hides in basic', () => {
|
|
469
|
+
// Two different reasons, one requirement. `share-services` clicks a nav entry marked
|
|
470
|
+
// `advanced: true` (which `navRequirementDrift` also enforces); `panel-reviews` clicks a
|
|
471
|
+
// BASIC entry whose consensus section renders only in advanced mode or once a group exists,
|
|
472
|
+
// one level below anything the nav guard can see. Basic is the shipped default, so without
|
|
473
|
+
// this both would be offered to nearly every user and find nothing.
|
|
474
|
+
const basic: NavGates = { ...ALL_GATES, advancedMode: false }
|
|
475
|
+
expect(ready(basic)).not.toContain('share-services')
|
|
476
|
+
expect(ready(basic)).not.toContain('panel-reviews')
|
|
477
|
+
expect(entry(basic, 'share-services')?.unmet.map((r) => r.id)).toEqual(['advanced-tier'])
|
|
478
|
+
expect(entry(basic, 'panel-reviews')?.unmet.map((r) => r.id)).toEqual(['advanced-tier'])
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('names the missing execution backend for the infrastructure tour', () => {
|
|
482
|
+
const noInfra: NavGates = { ...ALL_GATES, infrastructureAvailable: false }
|
|
483
|
+
expect(ready(noInfra)).not.toContain('prepare-infrastructure')
|
|
484
|
+
expect(entry(noInfra, 'prepare-infrastructure')?.unmet.map((r) => r.id)).toEqual([
|
|
485
|
+
'infrastructure',
|
|
486
|
+
])
|
|
487
|
+
})
|
|
488
|
+
|
|
438
489
|
it('names every unmet requirement, not just the first', () => {
|
|
439
490
|
// The reader has to do all of them; reporting one at a time turns unblocking a tour into
|
|
440
491
|
// a guessing game with a fresh answer after each attempt.
|
|
@@ -34,16 +34,26 @@ import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
|
|
|
34
34
|
* The catalog is in two halves, and the split is what keeps the launch prompt answerable:
|
|
35
35
|
*
|
|
36
36
|
* - The DELIVERY LOOP, end to end — get a repo onto the board, put a task on it, run it, answer
|
|
37
|
-
* it when it asks, read the result and merge it — each tour
|
|
38
|
-
* one produces, so the prompt only ever offers what this
|
|
39
|
-
* the catalogue turns the rest into a to-do list rather
|
|
37
|
+
* it when it asks, read a failure when it comes, read the result and merge it — each tour
|
|
38
|
+
* requiring the state the previous one produces, so the prompt only ever offers what this
|
|
39
|
+
* board can actually demonstrate and the catalogue turns the rest into a to-do list rather
|
|
40
|
+
* than an absence. The loop deliberately covers work going WRONG as well as right
|
|
41
|
+
* (`diagnose-failure`): a first run fails often, and that was the one state on the whole arc
|
|
42
|
+
* with no walkthrough.
|
|
40
43
|
* - The PLATFORM behind it (`offeredAtLaunch: false`) — the engine the agents run on, the
|
|
41
|
-
* pipelines that sequence them, the standards they read, the systems they talk to
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* pipelines that sequence them, the standards they read, the systems they talk to, where
|
|
45
|
+
* their containers and environments come from, and the two libraries that change what a
|
|
46
|
+
* review or a design is made of. Each is gated on a PERMISSION or a deployment CAPABILITY
|
|
47
|
+
* rather than on board state, so every one is startable on a brand-new board; offered at
|
|
48
|
+
* launch they would bury the two tours a first-time user can act on. They are reference
|
|
49
|
+
* material someone goes and gets from the catalogue when the question comes up, which is why
|
|
50
|
+
* each covers ONE surface and ends there rather than touring the sidebar: these surfaces open
|
|
51
|
+
* as modals, so a step after one cannot reach another sidebar entry anyway.
|
|
52
|
+
*
|
|
53
|
+
* The two halves also differ in what they are FOR now that the finish card hands off
|
|
54
|
+
* (`nextTourAfter`): the loop is a course, taken in order, each tour unlocked by the last one's
|
|
55
|
+
* own outcome; the platform half is a shelf. That is why the handoff prefers a launch-offer tour
|
|
56
|
+
* whatever its `order` — a deployment's own reference tour must not cut into the arc.
|
|
47
57
|
*/
|
|
48
58
|
|
|
49
59
|
/**
|
|
@@ -98,6 +108,28 @@ export const TUTORIAL_REQUIREMENTS = {
|
|
|
98
108
|
labelKey: 'tutorial.requirements.finishedRun',
|
|
99
109
|
met: (gates) => gates.boardHasFinishedRun,
|
|
100
110
|
},
|
|
111
|
+
failedRun: {
|
|
112
|
+
id: 'failed-run',
|
|
113
|
+
labelKey: 'tutorial.requirements.failedRun',
|
|
114
|
+
met: (gates) => gates.boardHasFailedRun,
|
|
115
|
+
},
|
|
116
|
+
// Two deployment-capability requirements the newer surfaces need. `infrastructure` is the
|
|
117
|
+
// gate of the sidebar entry its tour clicks (`nav-infrastructure`), which already folds in
|
|
118
|
+
// `integrations.manage`; `advancedTier` is the INTERFACE MODE, and it is a requirement in its
|
|
119
|
+
// own right for the two reasons the README's drift-guard section names: an entry marked
|
|
120
|
+
// `advanced: true` is absent from BASIC mode, which is the shipped default, and a SECTION
|
|
121
|
+
// inside a basic-mode surface can hide itself the same way. Either leaves a tour hunting for
|
|
122
|
+
// an anchor that nearly every user's screen does not render.
|
|
123
|
+
infrastructure: {
|
|
124
|
+
id: 'infrastructure',
|
|
125
|
+
labelKey: 'tutorial.requirements.infrastructure',
|
|
126
|
+
met: (gates) => gates.infrastructureAvailable,
|
|
127
|
+
},
|
|
128
|
+
advancedTier: {
|
|
129
|
+
id: 'advanced-tier',
|
|
130
|
+
labelKey: 'tutorial.requirements.advancedTier',
|
|
131
|
+
met: (gates) => gates.advancedMode,
|
|
132
|
+
},
|
|
101
133
|
// The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
|
|
102
134
|
// tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
|
|
103
135
|
// WEAKER than the gate of the control a step points at offers the tour to a user who has no
|
|
@@ -431,6 +463,59 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
431
463
|
},
|
|
432
464
|
],
|
|
433
465
|
},
|
|
466
|
+
{
|
|
467
|
+
id: 'diagnose-failure',
|
|
468
|
+
order: 45,
|
|
469
|
+
icon: 'i-lucide-triangle-alert',
|
|
470
|
+
titleKey: 'tutorial.tours.diagnoseFailure.title',
|
|
471
|
+
descriptionKey: 'tutorial.tours.diagnoseFailure.description',
|
|
472
|
+
// The state a first run reaches most often, and the one the catalog had nothing for. Every
|
|
473
|
+
// other delivery-loop tour describes work going right: `review-merge` requires a run that
|
|
474
|
+
// finished SUCCESSFULLY, so a user whose runs all failed was offered a walkthrough of the
|
|
475
|
+
// happy path they cannot reach and no account of the screen they are actually looking at.
|
|
476
|
+
// That is the point at which people conclude the product does not work.
|
|
477
|
+
requires: [TUTORIAL_REQUIREMENTS.failedRun],
|
|
478
|
+
steps: [
|
|
479
|
+
{
|
|
480
|
+
id: 'intro',
|
|
481
|
+
titleKey: 'tutorial.tours.diagnoseFailure.steps.intro.title',
|
|
482
|
+
bodyKey: 'tutorial.tours.diagnoseFailure.steps.intro.body',
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
// The banner, not the card: `TaskCard` swaps its progress bar for the shared failure
|
|
486
|
+
// banner on a failed run, and that banner is the whole subject of this tour.
|
|
487
|
+
id: 'banner',
|
|
488
|
+
target: 'agent-failure-banner',
|
|
489
|
+
placement: 'right',
|
|
490
|
+
titleKey: 'tutorial.tours.diagnoseFailure.steps.banner.title',
|
|
491
|
+
bodyKey: 'tutorial.tours.diagnoseFailure.steps.banner.body',
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
// NOT `target-click`, for `design-pipeline`'s reason rather than `run-task`'s: Retry is
|
|
495
|
+
// rendered but DISABLED without `runs.execute`, and a click-to-advance step whose
|
|
496
|
+
// control cannot be clicked drops its Next button and strands the tour. The copy
|
|
497
|
+
// therefore describes retrying rather than instructing it. (A retry also spends model
|
|
498
|
+
// budget, so handing the decision back is right on both counts.)
|
|
499
|
+
id: 'retry',
|
|
500
|
+
target: 'agent-failure-retry',
|
|
501
|
+
placement: 'right',
|
|
502
|
+
titleKey: 'tutorial.tours.diagnoseFailure.steps.retry.title',
|
|
503
|
+
bodyKey: 'tutorial.tours.diagnoseFailure.steps.retry.body',
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
id: 'finish',
|
|
507
|
+
titleKey: 'tutorial.tours.diagnoseFailure.steps.finish.title',
|
|
508
|
+
bodyKey: 'tutorial.tours.diagnoseFailure.steps.finish.body',
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
// Deliberately NOT anchored on `agent-failure-history` or
|
|
512
|
+
// `agent-failure-configure-environment`, both of which the finish card mentions in prose
|
|
513
|
+
// instead. Each renders only in a narrower state than this tour's own requirement (a trail
|
|
514
|
+
// of PRIOR attempts; a failure whose kind is `environment`), so a step on either would need
|
|
515
|
+
// its own `when` and therefore its own gate, added for one step apiece — and left without
|
|
516
|
+
// one it would count as an unexpected skip and tell a user who saw exactly the right
|
|
517
|
+
// walkthrough that they missed part of it.
|
|
518
|
+
},
|
|
434
519
|
{
|
|
435
520
|
id: 'review-merge',
|
|
436
521
|
order: 50,
|
|
@@ -681,6 +766,143 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
|
681
766
|
},
|
|
682
767
|
],
|
|
683
768
|
},
|
|
769
|
+
{
|
|
770
|
+
id: 'prepare-infrastructure',
|
|
771
|
+
order: 100,
|
|
772
|
+
icon: 'i-lucide-server-cog',
|
|
773
|
+
titleKey: 'tutorial.tours.prepareInfrastructure.title',
|
|
774
|
+
descriptionKey: 'tutorial.tours.prepareInfrastructure.description',
|
|
775
|
+
// Where agent containers run, what a test environment is brought up from, and which
|
|
776
|
+
// credentials the capabilities an agent uses are allowed to resolve. None of it announces
|
|
777
|
+
// itself, and the cost of not finding it is a run that fails on provisioning with a banner
|
|
778
|
+
// pointing at a window the user has never opened.
|
|
779
|
+
offeredAtLaunch: false,
|
|
780
|
+
requires: [TUTORIAL_REQUIREMENTS.infrastructure],
|
|
781
|
+
steps: [
|
|
782
|
+
{
|
|
783
|
+
id: 'intro',
|
|
784
|
+
titleKey: 'tutorial.tours.prepareInfrastructure.steps.intro.title',
|
|
785
|
+
bodyKey: 'tutorial.tours.prepareInfrastructure.steps.intro.body',
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
id: 'open',
|
|
789
|
+
target: 'nav-infrastructure',
|
|
790
|
+
advanceOn: 'target-click',
|
|
791
|
+
placement: 'right',
|
|
792
|
+
titleKey: 'tutorial.tours.prepareInfrastructure.steps.open.title',
|
|
793
|
+
bodyKey: 'tutorial.tours.prepareInfrastructure.steps.open.body',
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
// The tab STRIP, not any one tab's panel. Which tabs exist is itself resolved from three
|
|
797
|
+
// independent availability probes, so a step anchored on a panel inside one of them
|
|
798
|
+
// (`capability-credentials-panel`, `compose-env-setup-section`) points at a control that
|
|
799
|
+
// exists only while that tab is both offered AND selected — and the tour cannot select
|
|
800
|
+
// it, since the click that would is the user's. The strip is what the window always
|
|
801
|
+
// renders, and naming the tabs in the copy is what this tour is for.
|
|
802
|
+
id: 'tabs',
|
|
803
|
+
target: 'infrastructure-tabs',
|
|
804
|
+
waitForTargetMs: 8000,
|
|
805
|
+
placement: 'bottom',
|
|
806
|
+
titleKey: 'tutorial.tours.prepareInfrastructure.steps.tabs.title',
|
|
807
|
+
bodyKey: 'tutorial.tours.prepareInfrastructure.steps.tabs.body',
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: 'finish',
|
|
811
|
+
titleKey: 'tutorial.tours.prepareInfrastructure.steps.finish.title',
|
|
812
|
+
bodyKey: 'tutorial.tours.prepareInfrastructure.steps.finish.body',
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
id: 'panel-reviews',
|
|
818
|
+
order: 110,
|
|
819
|
+
icon: 'i-lucide-users',
|
|
820
|
+
titleKey: 'tutorial.tours.panelReviews.title',
|
|
821
|
+
descriptionKey: 'tutorial.tours.panelReviews.description',
|
|
822
|
+
// A review step can run as a multi-model PANEL instead of one agent, chosen per estimate
|
|
823
|
+
// tier from a workspace group library. Nobody guesses that from a pipeline: the step looks
|
|
824
|
+
// like every other review step until a group exists for it to select.
|
|
825
|
+
offeredAtLaunch: false,
|
|
826
|
+
// `advancedTier` is NOT redundant beside `settingsManage`, and the nav drift guard cannot
|
|
827
|
+
// see why: `nav-model-config` is a basic-mode entry, but the consensus SECTION inside that
|
|
828
|
+
// panel renders on `uiMode.isAdvanced || groups.hasGroups`, so on the shipped default tier a
|
|
829
|
+
// workspace that has never made a group renders nothing for the anchored step to find. The
|
|
830
|
+
// guard only pairs a tour against the visibility of a NAV entry, so a section hiding itself
|
|
831
|
+
// one level in is exactly the case that has to be declared by hand.
|
|
832
|
+
requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
|
|
833
|
+
steps: [
|
|
834
|
+
{
|
|
835
|
+
id: 'intro',
|
|
836
|
+
titleKey: 'tutorial.tours.panelReviews.steps.intro.title',
|
|
837
|
+
bodyKey: 'tutorial.tours.panelReviews.steps.intro.body',
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
id: 'open',
|
|
841
|
+
target: 'nav-model-config',
|
|
842
|
+
advanceOn: 'target-click',
|
|
843
|
+
placement: 'right',
|
|
844
|
+
titleKey: 'tutorial.tours.panelReviews.steps.open.title',
|
|
845
|
+
bodyKey: 'tutorial.tours.panelReviews.steps.open.body',
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
id: 'groups',
|
|
849
|
+
target: 'consensus-group-new',
|
|
850
|
+
waitForTargetMs: 8000,
|
|
851
|
+
placement: 'top',
|
|
852
|
+
titleKey: 'tutorial.tours.panelReviews.steps.groups.title',
|
|
853
|
+
bodyKey: 'tutorial.tours.panelReviews.steps.groups.body',
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
id: 'finish',
|
|
857
|
+
titleKey: 'tutorial.tours.panelReviews.steps.finish.title',
|
|
858
|
+
bodyKey: 'tutorial.tours.panelReviews.steps.finish.body',
|
|
859
|
+
},
|
|
860
|
+
],
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
id: 'share-services',
|
|
864
|
+
order: 120,
|
|
865
|
+
icon: 'i-lucide-library-big',
|
|
866
|
+
titleKey: 'tutorial.tours.shareServices.title',
|
|
867
|
+
descriptionKey: 'tutorial.tours.shareServices.description',
|
|
868
|
+
// The catalog of shared capabilities an org already runs, with the API contracts a design
|
|
869
|
+
// is expected to build ON rather than reinvent. Without it an architect agent designs every
|
|
870
|
+
// service as if the estate were empty.
|
|
871
|
+
offeredAtLaunch: false,
|
|
872
|
+
// `nav-foundational-services` is marked `advanced: true`, so the tier is part of what
|
|
873
|
+
// renders the entry this tour clicks. Declared rather than assumed: `navRequirementDrift`
|
|
874
|
+
// enumerates the whole gate matrix against that entry's own visibility rule and fails
|
|
875
|
+
// without it.
|
|
876
|
+
requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
|
|
877
|
+
steps: [
|
|
878
|
+
{
|
|
879
|
+
id: 'intro',
|
|
880
|
+
titleKey: 'tutorial.tours.shareServices.steps.intro.title',
|
|
881
|
+
bodyKey: 'tutorial.tours.shareServices.steps.intro.body',
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
id: 'open',
|
|
885
|
+
target: 'nav-foundational-services',
|
|
886
|
+
advanceOn: 'target-click',
|
|
887
|
+
placement: 'right',
|
|
888
|
+
titleKey: 'tutorial.tours.shareServices.steps.open.title',
|
|
889
|
+
bodyKey: 'tutorial.tours.shareServices.steps.open.body',
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
id: 'catalog',
|
|
893
|
+
target: 'foundational-manager',
|
|
894
|
+
waitForTargetMs: 8000,
|
|
895
|
+
placement: 'bottom',
|
|
896
|
+
titleKey: 'tutorial.tours.shareServices.steps.catalog.title',
|
|
897
|
+
bodyKey: 'tutorial.tours.shareServices.steps.catalog.body',
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
id: 'finish',
|
|
901
|
+
titleKey: 'tutorial.tours.shareServices.steps.finish.title',
|
|
902
|
+
bodyKey: 'tutorial.tours.shareServices.steps.finish.body',
|
|
903
|
+
},
|
|
904
|
+
],
|
|
905
|
+
},
|
|
684
906
|
]
|
|
685
907
|
|
|
686
908
|
/** The module that contributes the catalog; registered by `createAppRegistry`. */
|
package/app/pages/index.vue
CHANGED
|
@@ -143,7 +143,8 @@ const AiPresetMismatchDialog = defineAsyncComponent(
|
|
|
143
143
|
)
|
|
144
144
|
// The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
|
|
145
145
|
// it), the catalogue of every tour the deployment ships (opened from the sidebar's Help
|
|
146
|
-
// section or the palette, at any time),
|
|
146
|
+
// section or the palette, at any time), the coach-mark overlay that runs a tour, and the
|
|
147
|
+
// contextual offer that raises the ONE walkthrough this board just made takeable. All
|
|
147
148
|
// mount only while their store flag is set, so they cost the initial bundle nothing.
|
|
148
149
|
const TutorialPrompt = defineAsyncComponent(
|
|
149
150
|
() => import('~/components/tutorial/TutorialPrompt.vue'),
|
|
@@ -154,6 +155,7 @@ const TutorialCatalogue = defineAsyncComponent(
|
|
|
154
155
|
const TutorialOverlay = defineAsyncComponent(
|
|
155
156
|
() => import('~/components/tutorial/TutorialOverlay.vue'),
|
|
156
157
|
)
|
|
158
|
+
const TutorialNudge = defineAsyncComponent(() => import('~/components/tutorial/TutorialNudge.vue'))
|
|
157
159
|
|
|
158
160
|
const workspace = useWorkspaceStore()
|
|
159
161
|
const github = useGitHubStore()
|
|
@@ -332,6 +334,18 @@ if (!tutorialOfferSettled()) {
|
|
|
332
334
|
)
|
|
333
335
|
if (tutorialOfferSettled()) stopTutorialOffer()
|
|
334
336
|
}
|
|
337
|
+
// The CONTEXTUAL offer: a different mechanism from the launch one above, so it sits outside that
|
|
338
|
+
// watcher's `settled` guard. It has no decision to settle and nothing to defer, because it fires
|
|
339
|
+
// on a tour becoming TAKEABLE rather than on the app starting. It reads `workspace.ready` itself
|
|
340
|
+
// rather than being gated here, because readiness is not a precondition for CALLING it but the
|
|
341
|
+
// definition of its baseline: the gates it watches are board state, so a baseline taken before
|
|
342
|
+
// the snapshot lands makes the board's own hydration look like a transition (see `resolveNudge`).
|
|
343
|
+
// It honours an explicit decline itself, too (see `newlyAvailableTour`).
|
|
344
|
+
useTutorialNudge()
|
|
345
|
+
// The mirror to the signed-in user's server row (so progress follows the PERSON, not the browser)
|
|
346
|
+
// plus the funnel counters. Unconditional and best-effort: with no accounts or no store wired it
|
|
347
|
+
// simply has nothing to talk to, and the browser-persisted store carries on alone.
|
|
348
|
+
useTutorialSync()
|
|
335
349
|
|
|
336
350
|
// Probe the GitHub integration as soon as a board is active (re-probe per board —
|
|
337
351
|
// connections are per workspace). The result drives the onboarding gate below
|
|
@@ -518,6 +532,11 @@ watch(
|
|
|
518
532
|
<TutorialPrompt v-if="tutorial.promptOpen" />
|
|
519
533
|
<TutorialCatalogue v-if="tutorial.catalogueOpen" />
|
|
520
534
|
<TutorialOverlay v-if="tutorial.touring" />
|
|
535
|
+
<!-- Mounted off the PENDING id rather than off whether it may currently be SHOWN: the
|
|
536
|
+
component holds a suppressed offer (a tour is running, a tutorial window is open) and
|
|
537
|
+
renders it once the way is clear, which is the whole reason the offer survives the
|
|
538
|
+
moment it was raised in. -->
|
|
539
|
+
<TutorialNudge v-if="tutorial.pendingNudgeId" />
|
|
521
540
|
</template>
|
|
522
541
|
|
|
523
542
|
<!-- Backend unreachable / bootstrap failed -->
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The LAUNCH PROMPT's own state machine, extracted from `stores/tutorial.ts`.
|
|
5
|
+
*
|
|
6
|
+
* It is a small machine with four distinguishable exits and no other consumer, which is what makes
|
|
7
|
+
* it a seam worth having rather than four refs among twenty: closing without answering, an explicit
|
|
8
|
+
* decline, a DEFERRAL (something the user must actually answer opened on top), and the
|
|
9
|
+
* once-per-session auto-open are four different things, and only two of them write anything down.
|
|
10
|
+
* The subtlety they share is the ONE-OFFER-PER-SESSION guard, which is why they belong together:
|
|
11
|
+
* `promptAutoOpened` must be spent by an offer the user saw and NOT by one that was withdrawn.
|
|
12
|
+
*
|
|
13
|
+
* `hasDecision` is a bound getter over the persisted record rather than the record itself, so this
|
|
14
|
+
* module never learns what a decision IS — only whether one exists, which is the whole of what the
|
|
15
|
+
* offer needs.
|
|
16
|
+
*/
|
|
17
|
+
export function createTutorialPrompt(deps: { hasDecision: () => boolean }) {
|
|
18
|
+
const promptOpen = ref(false)
|
|
19
|
+
/** Once-per-session guard for the launch auto-open; later opens are user-driven. */
|
|
20
|
+
const promptAutoOpened = ref(false)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Auto-open the launch prompt, at most once per session and only while the user has never
|
|
24
|
+
* answered it. Callers gate on the rest of the launch context (board ready, no other startup
|
|
25
|
+
* advisory open) — see `pages/index.vue`.
|
|
26
|
+
*/
|
|
27
|
+
function maybeOfferOnLaunch() {
|
|
28
|
+
if (deps.hasDecision() || promptAutoOpened.value) return
|
|
29
|
+
promptAutoOpened.value = true
|
|
30
|
+
promptOpen.value = true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** User-driven open (command palette), regardless of any saved decision. */
|
|
34
|
+
function openPrompt() {
|
|
35
|
+
promptOpen.value = true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Withdraw an offer this store made, because something the user actually has to answer (a startup
|
|
40
|
+
* advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so the offer returns
|
|
41
|
+
* once that surface is gone. Distinct from {@link closePrompt}: no decision is written EITHER way,
|
|
42
|
+
* but a deferral was not the user's doing, so it must not consume this session's one offer.
|
|
43
|
+
*
|
|
44
|
+
* Only ever withdraws the AUTO-opened prompt; a prompt the user opened themselves from the palette
|
|
45
|
+
* is theirs to close.
|
|
46
|
+
*/
|
|
47
|
+
function deferPrompt() {
|
|
48
|
+
if (!promptAutoOpened.value) return
|
|
49
|
+
promptOpen.value = false
|
|
50
|
+
promptAutoOpened.value = false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Close without answering: no decision is written, so the next launch asks again. */
|
|
54
|
+
function closePrompt() {
|
|
55
|
+
promptOpen.value = false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { promptOpen, promptAutoOpened, maybeOfferOnLaunch, openPrompt, deferPrompt, closePrompt }
|
|
59
|
+
}
|