@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.
Files changed (50) hide show
  1. package/README.md +107 -13
  2. package/app/components/observability/StepMetricsBar.vue +11 -0
  3. package/app/components/panels/AgentStepDetail.vue +14 -0
  4. package/app/components/panels/MergerResultView.vue +20 -2
  5. package/app/components/panels/ObservabilityPanel.vue +57 -0
  6. package/app/components/panels/ResultWindowShell.vue +77 -0
  7. package/app/components/panels/StepReproductionReport.vue +167 -0
  8. package/app/components/pipeline/BinaryOutputStepPicker.vue +25 -0
  9. package/app/components/tutorial/TutorialCatalogue.vue +14 -1
  10. package/app/components/tutorial/TutorialNudge.vue +107 -0
  11. package/app/components/tutorial/TutorialOverlay.vue +92 -11
  12. package/app/composables/api/execution.ts +5 -2
  13. package/app/composables/api/tutorial.ts +25 -0
  14. package/app/composables/useApi.ts +2 -0
  15. package/app/composables/usePipelineErrorToast.ts +4 -0
  16. package/app/composables/useTutorialNudge.ts +77 -0
  17. package/app/composables/useTutorialSync.ts +141 -0
  18. package/app/modular/external-tools.spec.ts +1 -0
  19. package/app/modular/nav-contributions.spec.ts +2 -0
  20. package/app/modular/nav-contributions.ts +11 -0
  21. package/app/modular/nav-gates.ts +10 -0
  22. package/app/modular/registry.spec.ts +1 -0
  23. package/app/modular/tutorial-tours.spec.ts +55 -4
  24. package/app/modular/tutorial-tours.ts +231 -9
  25. package/app/pages/index.vue +20 -1
  26. package/app/stores/tutorial.prompt.ts +59 -0
  27. package/app/stores/tutorial.record.ts +191 -0
  28. package/app/stores/tutorial.spec.ts +207 -0
  29. package/app/stores/tutorial.ts +78 -91
  30. package/app/stores/workspace/hydrate.ts +5 -0
  31. package/app/types/domain.ts +3 -0
  32. package/app/types/reproduction.ts +11 -0
  33. package/app/utils/binaryOutput.spec.ts +56 -2
  34. package/app/utils/binaryOutput.ts +55 -32
  35. package/app/utils/observability.spec.ts +44 -1
  36. package/app/utils/observability.ts +50 -0
  37. package/app/utils/reproduction.ts +51 -0
  38. package/app/utils/tutorial.spec.ts +255 -0
  39. package/app/utils/tutorial.ts +173 -0
  40. package/i18n/locales/de.json +148 -6
  41. package/i18n/locales/en.json +153 -6
  42. package/i18n/locales/es.json +148 -6
  43. package/i18n/locales/fr.json +148 -6
  44. package/i18n/locales/he.json +148 -6
  45. package/i18n/locales/it.json +148 -6
  46. package/i18n/locales/ja.json +148 -6
  47. package/i18n/locales/pl.json +148 -6
  48. package/i18n/locales/tr.json +148 -6
  49. package/i18n/locales/uk.json +148 -6
  50. 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. */
@@ -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
  }
@@ -19,6 +19,7 @@ const NO_GATES: NavGates = {
19
19
  boardHasOpenDecision: false,
20
20
  boardHasPendingApproval: false,
21
21
  boardHasFinishedRun: false,
22
+ boardHasFailedRun: false,
22
23
  }
23
24
 
24
25
  describe('app modular registry', () => {
@@ -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 the four platform
299
- // tours. And a gate field that is not a boolean would silently never vary across the
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(5)
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 = ['wire-models', 'design-pipeline', 'agent-standards', 'connect-systems']
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 requiring the state the previous
38
- * one produces, so the prompt only ever offers what this board can actually demonstrate and
39
- * the catalogue turns the rest into a to-do list rather than an absence.
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. Each is
42
- * gated on a PERMISSION rather than on board state, so every one is startable on a brand-new
43
- * board; offered at launch they would bury the two tours a first-time user can act on. They
44
- * are reference material someone goes and gets from the catalogue when the question comes up,
45
- * which is why each covers ONE surface and ends there rather than touring the sidebar: these
46
- * surfaces open as modals, so a step after one cannot reach another sidebar entry anyway.
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`. */
@@ -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), and the coach-mark overlay that runs a tour. All
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
+ }