@cat-factory/app 0.213.1 → 0.214.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/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/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 +147 -6
- package/i18n/locales/en.json +151 -6
- package/i18n/locales/es.json +147 -6
- package/i18n/locales/fr.json +147 -6
- package/i18n/locales/he.json +147 -6
- package/i18n/locales/it.json +147 -6
- package/i18n/locales/ja.json +147 -6
- package/i18n/locales/pl.json +147 -6
- package/i18n/locales/tr.json +147 -6
- package/i18n/locales/uk.json +147 -6
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
|
|
4
|
+
export type TutorialDecision = 'accepted' | 'declined'
|
|
5
|
+
|
|
6
|
+
/** The server's copy of a user's record, as the snapshot delivers it. */
|
|
7
|
+
export interface RemoteTutorialRecord {
|
|
8
|
+
decision: TutorialDecision | null
|
|
9
|
+
completedTourIds: readonly string[]
|
|
10
|
+
nudgedTourIds: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The PERSISTED tier of the tutorial's state: the standing record of what this PERSON has done.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `stores/tutorial.ts` along the boundary that store's own docblock already draws.
|
|
17
|
+
* Everything here outlives a visit and is mirrored to the signed-in user's server row; everything
|
|
18
|
+
* left behind (the prompt, the catalogue, the step cursor, the resume point, which offer is on
|
|
19
|
+
* screen) is session state that a reload is right to discard. Splitting on that line also put every
|
|
20
|
+
* piece of the sync story in one module: the union rule, what counts as "this browser knows more",
|
|
21
|
+
* and the flag the mirror acts on.
|
|
22
|
+
*
|
|
23
|
+
* A factory rather than a second Pinia store, because the two tiers are one store's worth of state
|
|
24
|
+
* to every consumer: the returned refs go straight into the setup store's state, so
|
|
25
|
+
* `persist: { pick: [...] }` and every existing `tutorial.completedTourIds` read are unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export function createTutorialRecord() {
|
|
28
|
+
/** The saved launch-prompt answer. Written only by an explicit accept/decline/reset. */
|
|
29
|
+
const decision = ref<TutorialDecision | null>(null)
|
|
30
|
+
/** Ids of tours the user finished (reached the last step's Done). */
|
|
31
|
+
const completedTourIds = ref<string[]>([])
|
|
32
|
+
/**
|
|
33
|
+
* Ids the CONTEXTUAL offer has already been spent on (see `newlyAvailableTour`). Persisted beside
|
|
34
|
+
* the completions, because "we have mentioned this once" is a fact about the person, not about
|
|
35
|
+
* this visit: re-offering the parked-run walkthrough on every reload is a nag, and the gates it
|
|
36
|
+
* watches flip several times per run.
|
|
37
|
+
*/
|
|
38
|
+
const nudgedTourIds = ref<string[]>([])
|
|
39
|
+
/**
|
|
40
|
+
* This browser holds progress the server row does not, so the mirror owes it a write.
|
|
41
|
+
*
|
|
42
|
+
* Session-lived and a FLAG rather than a direct call, because the merge below runs inside the
|
|
43
|
+
* snapshot fan-out, which is synchronous and store-only. `useTutorialSync` watches it.
|
|
44
|
+
*/
|
|
45
|
+
const serverPushNeeded = ref(false)
|
|
46
|
+
/**
|
|
47
|
+
* Counts LOCAL changes to this record, and nothing else. Session-lived.
|
|
48
|
+
*
|
|
49
|
+
* The mirror watches this rather than the state itself, and the distinction is the whole reason
|
|
50
|
+
* it exists: adopting the server's ids in {@link mergeServerProgress} also changes the state, so
|
|
51
|
+
* a watcher over the state pushes the server's own row straight back at it on every fresh-browser
|
|
52
|
+
* board load. Counting only what the USER did means a write happens exactly when this browser has
|
|
53
|
+
* something to say.
|
|
54
|
+
*/
|
|
55
|
+
const localRev = ref(0)
|
|
56
|
+
/**
|
|
57
|
+
* The local `decision` has changed and has not been mirrored yet, so it must survive a snapshot.
|
|
58
|
+
*
|
|
59
|
+
* Without this, a failed mirror write silently UNDOES the user's answer: they click "No thanks",
|
|
60
|
+
* the push fails, and the next snapshot re-adopts the older `accepted` from the server row,
|
|
61
|
+
* re-arming every contextual offer they just declined. The server row wins on `decision` because
|
|
62
|
+
* it is the shared record of the latest answer, but only where this browser is not itself holding
|
|
63
|
+
* a newer one that the server has not seen.
|
|
64
|
+
*/
|
|
65
|
+
const decisionDirty = ref(false)
|
|
66
|
+
|
|
67
|
+
const isCompleted = (tourId: string) => completedTourIds.value.includes(tourId)
|
|
68
|
+
const wasNudged = (tourId: string) => nudgedTourIds.value.includes(tourId)
|
|
69
|
+
|
|
70
|
+
/** Record a finished walkthrough. Idempotent: the same tour taken twice is still one entry. */
|
|
71
|
+
function markCompleted(tourId: string) {
|
|
72
|
+
if (isCompleted(tourId)) return
|
|
73
|
+
completedTourIds.value = [...completedTourIds.value, tourId]
|
|
74
|
+
localRev.value += 1
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Spend the contextual offer for a tour. Idempotent, so an offer can never be made twice. */
|
|
78
|
+
function markNudged(tourId: string) {
|
|
79
|
+
if (wasNudged(tourId)) return
|
|
80
|
+
nudgedTourIds.value = [...nudgedTourIds.value, tourId]
|
|
81
|
+
localRev.value += 1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Record an answer to the launch prompt, and ask the mirror to carry it. */
|
|
85
|
+
function answer(next: TutorialDecision) {
|
|
86
|
+
if (decision.value === next) return
|
|
87
|
+
decision.value = next
|
|
88
|
+
decisionDirty.value = true
|
|
89
|
+
localRev.value += 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Starting a tour IS accepting the tutorial, however the user got there. */
|
|
93
|
+
function acceptOffer() {
|
|
94
|
+
answer('accepted')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
98
|
+
function declineOffer() {
|
|
99
|
+
answer('declined')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fold the signed-in user's SERVER copy into this browser's, and report whether the local copy
|
|
104
|
+
* held anything the server did not.
|
|
105
|
+
*
|
|
106
|
+
* A union rather than a replace, in the same direction and for the same reason the server merges:
|
|
107
|
+
* both lists are grow-only sets of things that HAPPENED, so neither side is ever right to un-say
|
|
108
|
+
* one. A replace here would lose a tour finished while the mirror write was failing; a replace on
|
|
109
|
+
* the server would lose a tour finished on another machine. `decision` is taken from the server
|
|
110
|
+
* when it HAS one and this browser is not holding an un-mirrored answer of its own
|
|
111
|
+
* ({@link decisionDirty}), because it is a preference someone re-answers rather than an
|
|
112
|
+
* accumulating fact — while a server row with no answer is not evidence that the local answer
|
|
113
|
+
* never happened.
|
|
114
|
+
*
|
|
115
|
+
* The return value closes the loop: `true` means this browser knows something the server does not,
|
|
116
|
+
* so the merged state is worth pushing back. Recomputing that comparison at the call site would be
|
|
117
|
+
* a second copy of the union rule. It is also what makes the server's un-guarded merge safe: the
|
|
118
|
+
* PUT's response goes through here too, so a merge that lost a concurrent writer's ids comes back
|
|
119
|
+
* missing something local, and the re-push is automatic rather than hoped for.
|
|
120
|
+
*/
|
|
121
|
+
function mergeServerProgress(remote: RemoteTutorialRecord | null): boolean {
|
|
122
|
+
// No server copy at all (no accounts, no store wired, or the read degraded) is NOT a reason to
|
|
123
|
+
// push: there is nothing to reconcile against, and treating an absent row as an empty one would
|
|
124
|
+
// make every such board load write a mirror nothing reads.
|
|
125
|
+
if (!remote) return false
|
|
126
|
+
const union = (mine: string[], theirs: readonly string[]) => [...new Set([...theirs, ...mine])]
|
|
127
|
+
const completed = union(completedTourIds.value, remote.completedTourIds)
|
|
128
|
+
const nudged = union(nudgedTourIds.value, remote.nudgedTourIds)
|
|
129
|
+
const keepLocalDecision = decisionDirty.value || remote.decision === null
|
|
130
|
+
const localOnly =
|
|
131
|
+
completed.length > remote.completedTourIds.length ||
|
|
132
|
+
nudged.length > remote.nudgedTourIds.length ||
|
|
133
|
+
(keepLocalDecision && decision.value !== remote.decision)
|
|
134
|
+
completedTourIds.value = completed
|
|
135
|
+
nudgedTourIds.value = nudged
|
|
136
|
+
if (!keepLocalDecision) decision.value = remote.decision
|
|
137
|
+
if (localOnly) serverPushNeeded.value = true
|
|
138
|
+
return localOnly
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The mirror has caught up: this browser's state is on the server, including its answer.
|
|
143
|
+
*
|
|
144
|
+
* Called BEFORE the response is reconciled, so that a response missing something local can flip
|
|
145
|
+
* {@link serverPushNeeded} back on and re-trigger the watcher. Clearing it after would look
|
|
146
|
+
* identical and would swallow exactly the retry that matters.
|
|
147
|
+
*/
|
|
148
|
+
function markServerPushed() {
|
|
149
|
+
serverPushNeeded.value = false
|
|
150
|
+
decisionDirty.value = false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Forget the whole record, including the answered offer.
|
|
155
|
+
*
|
|
156
|
+
* The decision goes with it deliberately: "Reset" is asked for by someone handing the app to a
|
|
157
|
+
* colleague, demoing it, or re-walking the product after it changed, and every one of those wants
|
|
158
|
+
* the first-launch experience back, which a cleared completion list alone does not restore. The
|
|
159
|
+
* spent contextual offers go too, or a board that has already run something would never make
|
|
160
|
+
* those offers again to the colleague the app was just handed to.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately does NOT bump {@link localRev}, and cancels any pending mirror write. The server
|
|
163
|
+
* side of a reset is a DELETE, and a PUT of the freshly-emptied state racing it would re-create
|
|
164
|
+
* the row the DELETE just removed — leaving "reset it" distinguishable from "never touched the
|
|
165
|
+
* tutorial", which is the one thing the reset has to get right.
|
|
166
|
+
*/
|
|
167
|
+
function reset() {
|
|
168
|
+
completedTourIds.value = []
|
|
169
|
+
nudgedTourIds.value = []
|
|
170
|
+
decision.value = null
|
|
171
|
+
serverPushNeeded.value = false
|
|
172
|
+
decisionDirty.value = false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
decision,
|
|
177
|
+
completedTourIds,
|
|
178
|
+
nudgedTourIds,
|
|
179
|
+
serverPushNeeded,
|
|
180
|
+
localRev,
|
|
181
|
+
isCompleted,
|
|
182
|
+
wasNudged,
|
|
183
|
+
markCompleted,
|
|
184
|
+
markNudged,
|
|
185
|
+
acceptOffer,
|
|
186
|
+
declineOffer,
|
|
187
|
+
mergeServerProgress,
|
|
188
|
+
markServerPushed,
|
|
189
|
+
reset,
|
|
190
|
+
}
|
|
191
|
+
}
|