@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
@@ -227,6 +227,20 @@ function setMediaTypes(text: string) {
227
227
  patch({ mediaTypes: lastWritten })
228
228
  }
229
229
 
230
+ /**
231
+ * The overlap, as `content type: id, id` per shared content type.
232
+ *
233
+ * Assembled here rather than in the read model because it needs `modalityLabel`, and it renders a
234
+ * RETIRED content type through the same guard every other line does: a step saved under an older
235
+ * vocabulary can hold one, and two integrations serving it would otherwise put a `TypeError` on
236
+ * the surface where the re-pick has to be made.
237
+ */
238
+ const overlapSummary = computed(() =>
239
+ pick.value.generatorOverlaps
240
+ .map((overlap) => `${modalityLabel(overlap.modality)}: ${overlap.generatorIds.join(', ')}`)
241
+ .join('; '),
242
+ )
243
+
230
244
  /** What the SELECTED integrations say they emit — the discoverable half of the free-text field. */
231
245
  const declaredFormats = computed(() => {
232
246
  const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
@@ -423,6 +437,17 @@ const declaredFormats = computed(() => {
423
437
  })
424
438
  }}
425
439
  </p>
440
+ <!-- ADVISORY, and grouped with the line above it rather than the refusals: selecting two
441
+ producers of one content type is the reason the selection is a list, and nothing about
442
+ the step is wrong. What it costs is a decision nobody wrote down, so the remedy this
443
+ line carries is the step's own prompt, which is the field right beside it. -->
444
+ <p
445
+ v-if="has('generator_overlap')"
446
+ class="text-[10px] text-slate-500"
447
+ data-testid="binary-output-generator-overlap"
448
+ >
449
+ {{ t('pipeline.builder.binaryOutputGeneratorOverlap', { overlaps: overlapSummary }) }}
450
+ </p>
426
451
  <p
427
452
  v-if="unusableMediaTypes.length"
428
453
  class="text-[10px] text-amber-400"
@@ -18,6 +18,19 @@ const { t } = useI18n()
18
18
  const tutorial = useTutorialStore()
19
19
  const { catalogue } = useTutorialTours()
20
20
  const { stateOf, launch } = useTutorialLaunch()
21
+ const { resetServerProgress } = useTutorialServer()
22
+
23
+ /**
24
+ * Forget everything, on BOTH sides. The local clear alone would be undone by the next board load:
25
+ * the snapshot brings the server row back and the store MERGES it, which is exactly right for every
26
+ * other reconciliation and exactly wrong for the one action whose whole point is to erase the
27
+ * record. `useTutorialServer` rather than `useTutorialSync` because this must not install a second
28
+ * set of mirror watchers each time the catalogue mounts.
29
+ */
30
+ function reset() {
31
+ tutorial.resetProgress()
32
+ resetServerProgress()
33
+ }
21
34
 
22
35
  const open = computed({
23
36
  get: () => tutorial.catalogueOpen,
@@ -131,7 +144,7 @@ const statusColor = (row: TutorialCatalogueRow) =>
131
144
  icon="i-lucide-rotate-ccw"
132
145
  :title="t('tutorial.catalogue.resetHint')"
133
146
  data-testid="tutorial-catalogue-reset"
134
- @click="tutorial.resetProgress()"
147
+ @click="reset()"
135
148
  >
136
149
  {{ t('tutorial.catalogue.reset') }}
137
150
  </UButton>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ // The contextual offer's surface: one walkthrough, offered beside the work that just made it
5
+ // relevant. Deliberately NOT a modal — the whole point is the moment, and a modal would
6
+ // interrupt whatever the user was doing to reach it (answering a parked run, reading a failure).
7
+ // A corner card they can ignore is the strongest thing that is still honest about being an aside.
8
+ //
9
+ // Which tour, and whether one is on offer at all, is decided upstream (`useTutorialNudge` over
10
+ // the pure `newlyAvailableTour`). This component owns only whether now is a moment it may be on
11
+ // screen, and that is the reason the offer is HELD rather than dropped: the gates that raise it
12
+ // are live run state, so it routinely arrives while a tour or a tutorial window is already up.
13
+ const { t } = useI18n()
14
+ const tutorial = useTutorialStore()
15
+ const { catalogue } = useTutorialTours()
16
+ const { launch } = useTutorialLaunch()
17
+
18
+ /**
19
+ * The offered tour, resolved from the catalogue rather than held as an object.
20
+ *
21
+ * Resolving by id means a tour whose availability has since changed cannot be offered from a
22
+ * stale copy: if the gates dropped it while the offer sat suppressed (a run un-parked itself),
23
+ * the entry is no longer `ready` and the card goes away rather than starting a walkthrough whose
24
+ * every step would now anchor-skip.
25
+ */
26
+ const offered = computed(() => {
27
+ const id = tutorial.pendingNudgeId
28
+ if (id === null) return null
29
+ const entry = catalogue.value.find((e) => e.tour.id === id)
30
+ return entry?.availability === 'ready' ? entry.tour : null
31
+ })
32
+
33
+ /**
34
+ * A tour in progress or a tutorial window on screen suppresses the card.
35
+ *
36
+ * `ownWindowOpen` covers the prompt and the catalogue for the same reason the coach marks stand
37
+ * down for them: those are windows the user is answering, and an offer floating over the library
38
+ * they opened to browse offers is noise. A running tour suppresses it because the card would
39
+ * compete with the coach mark for the same attention, and because taking the offer mid-tour
40
+ * would end the walkthrough the user is in the middle of.
41
+ */
42
+ const suppressed = computed(() => tutorial.touring || tutorial.ownWindowOpen)
43
+
44
+ function take(tourId: string) {
45
+ tutorial.dismissNudge()
46
+ launch(tourId)
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <!-- `aria-live="polite"`: this appears without the user asking, so a screen-reader user is told
52
+ about it when they are between things rather than mid-sentence. The card is not a dialog —
53
+ nothing is trapped and nothing must be answered — so it takes no focus. -->
54
+ <Transition
55
+ enter-active-class="motion-safe:transition motion-safe:duration-200"
56
+ enter-from-class="opacity-0 translate-y-2"
57
+ leave-active-class="motion-safe:transition motion-safe:duration-150"
58
+ leave-to-class="opacity-0 translate-y-2"
59
+ >
60
+ <!-- `end-4`, not `right-4`: the layer ships a RTL locale, so a physical side would pin this to
61
+ the wrong corner in Hebrew. `bottom-20` rather than `bottom-4` because `UApp`'s toaster
62
+ defaults to `bottom-right` and this card is PERSISTENT where a toast is transient, so the
63
+ card yields the lane rather than sitting under it. A tall toast stack can still reach up
64
+ this far, which is acceptable in a way the standing overlap was not: it clears itself. -->
65
+ <div
66
+ v-if="offered && !suppressed"
67
+ class="fixed end-4 bottom-20 z-50 w-80 max-w-[calc(100vw-32px)] rounded-xl border border-slate-700 bg-slate-900/95 p-3 shadow-2xl backdrop-blur"
68
+ role="status"
69
+ aria-live="polite"
70
+ data-testid="tutorial-nudge"
71
+ >
72
+ <div class="flex items-start gap-2">
73
+ <UIcon
74
+ :name="offered.icon ?? 'i-lucide-graduation-cap'"
75
+ class="text-primary-300 mt-0.5 h-4 w-4 shrink-0"
76
+ />
77
+ <div class="min-w-0 flex-1">
78
+ <p class="text-[11px] tracking-wide text-slate-400 uppercase">
79
+ {{ t('tutorial.nudge.label') }}
80
+ </p>
81
+ <p class="mt-0.5 text-sm font-medium text-slate-100">{{ t(offered.titleKey) }}</p>
82
+ <p class="mt-0.5 text-xs text-slate-400">{{ t(offered.descriptionKey) }}</p>
83
+ </div>
84
+ <UButton
85
+ size="xs"
86
+ variant="ghost"
87
+ color="neutral"
88
+ icon="i-lucide-x"
89
+ :aria-label="t('tutorial.nudge.dismiss')"
90
+ data-testid="tutorial-nudge-dismiss"
91
+ @click="tutorial.dismissNudge()"
92
+ />
93
+ </div>
94
+ <div class="mt-2 flex justify-end">
95
+ <UButton
96
+ size="xs"
97
+ color="primary"
98
+ variant="soft"
99
+ data-testid="tutorial-nudge-start"
100
+ @click="take(offered.id)"
101
+ >
102
+ {{ t('tutorial.nudge.start') }}
103
+ </UButton>
104
+ </div>
105
+ </div>
106
+ </Transition>
107
+ </template>
@@ -4,6 +4,7 @@ import {
4
4
  computeCoachMarkLayout,
5
5
  DEFAULT_TARGET_WAIT_MS,
6
6
  needsReveal,
7
+ nextTourAfter,
7
8
  TARGET_IDLE_INTERVAL_MS,
8
9
  TARGET_TRACK_INTERVAL_MS,
9
10
  } from '~/utils/tutorial'
@@ -41,6 +42,7 @@ import type { TutorialAdvanceCause, TutorialDirection } from './TutorialOverlay.
41
42
  const { t } = useI18n()
42
43
  const tutorial = useTutorialStore()
43
44
  const { tours } = useTutorialTours()
45
+ const { launch } = useTutorialLaunch()
44
46
  const { fitView, viewport } = useBoardFlow()
45
47
  // Reduced motion is honoured in BOTH directions here: the CSS below drops the ring's transition
46
48
  // and the searching spinner behind `motion-safe:`, and this drives the JS half — an instant
@@ -49,6 +51,25 @@ const { fitView, viewport } = useBoardFlow()
49
51
  const reducedMotion = usePreferredReducedMotion()
50
52
  const motionMs = computed(() => (reducedMotion.value === 'reduce' ? 0 : 250))
51
53
 
54
+ // ---------------------------------------------------------------------------------------
55
+ // PER-RUN state: everything below belongs to ONE pass through ONE script, so it is declared
56
+ // here, ahead of the script itself, and reset by the same watcher that re-resolves it. It
57
+ // used to be declared further down and rebuilt by the component UNMOUNTING between tours,
58
+ // which the finish card's handoff broke: that completes one tour and starts the next within a
59
+ // single tick, so `touring` never goes false for a render and nothing unmounts.
60
+ // ---------------------------------------------------------------------------------------
61
+ /** Which way `skipMissingStep` travels — see `resolveSkip`. */
62
+ const direction = ref<TutorialDirection>('forward')
63
+ /** Steps this run gave up on, so the final card can be honest about an abridged tour. */
64
+ const skippedStepIds = ref<Set<string>>(new Set())
65
+ /**
66
+ * The step index whose anchor has already been brought into view. A reveal is attempted at
67
+ * most ONCE per step: `fitView` and `scrollIntoView` are animations that take longer than a
68
+ * tracking tick, so re-deciding each tick would re-issue the move against a viewport still
69
+ * mid-flight and fight the user the moment they panned away deliberately.
70
+ */
71
+ const revealedForStep = ref<number | null>(null)
72
+
52
73
  /**
53
74
  * The running tour's script, resolved ONCE from the slot when the tour starts and then HELD
54
75
  * for its duration. Gates decide what is OFFERED; they do not get to rewrite a walkthrough
@@ -70,6 +91,15 @@ watch(
70
91
  // Read untracked (a watch callback registers no dependencies), which is what pins the
71
92
  // script: only starting a DIFFERENT tour re-resolves it.
72
93
  tour.value = id ? (tours.value.find((x) => x.id === id) ?? null) : null
94
+ // Per-RUN state belongs to the script, so it resets with it. This used to be safe by
95
+ // accident: the overlay is mounted only while `tutorial.touring`, so it unmounted between
96
+ // tours and every ref below was rebuilt. The finish card's handoff completes one tour and
97
+ // starts the next in ONE tick, so `touring` never goes false for a render and nothing
98
+ // unmounts — leaving the finished tour's skips to be counted against the new one, which
99
+ // would open a fresh walkthrough already claiming the user had missed part of it.
100
+ skippedStepIds.value = new Set()
101
+ revealedForStep.value = null
102
+ direction.value = 'forward'
73
103
  },
74
104
  { immediate: true },
75
105
  )
@@ -105,13 +135,6 @@ const targetRect = ref<TutorialRect | null>(null)
105
135
  * document query several times a second for the whole length of the tour.
106
136
  */
107
137
  const anchorEl = ref<HTMLElement | null>(null)
108
- /**
109
- * The step index whose anchor has already been brought into view. A reveal is attempted at
110
- * most ONCE per step: `fitView` and `scrollIntoView` are animations that take longer than a
111
- * tracking tick, so re-deciding each tick would re-issue the move against a viewport still
112
- * mid-flight and fight the user the moment they panned away deliberately.
113
- */
114
- const revealedForStep = ref<number | null>(null)
115
138
  const cardEl = ref<HTMLElement | null>(null)
116
139
  const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
117
140
 
@@ -121,10 +144,6 @@ const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'cente
121
144
  * invocations let a resize drag burn a "4000 ms" budget in a fraction of that time.
122
145
  */
123
146
  const searchDeadline = ref(0)
124
- /** Which way `skipMissingStep` travels — see `resolveSkip`. */
125
- const direction = ref<TutorialDirection>('forward')
126
- /** Steps this run gave up on, so the final card can be honest about an abridged tour. */
127
- const skippedStepIds = ref<Set<string>>(new Set())
128
147
 
129
148
  /** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
130
149
  const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
@@ -134,6 +153,41 @@ const unexpectedSkips = computed(() =>
134
153
  )
135
154
  const abridged = computed(() => unexpectedSkips.value.length > 0)
136
155
 
156
+ /**
157
+ * The walkthrough to hand off to on the finish card, or null when there is nothing left.
158
+ *
159
+ * Read LIVE from the gated slot, which is the deliberate exception to the held-script rule
160
+ * above: the delivery loop is a chain in which each tour produces the state the next one
161
+ * requires, so the completion the user is about to record is itself what makes the next tour
162
+ * takeable. A candidate resolved when the tour STARTED would be empty exactly when this
163
+ * matters. Nothing about the running script is read from it, so the hazard the hold exists to
164
+ * prevent (a step swapped underneath a stationary cursor) cannot arise here.
165
+ *
166
+ * `justFinishedId` is the tour's OWN id rather than `tutorial.activeTourId`, so that the
167
+ * suggestion is stable across the completion that clears the cursor.
168
+ */
169
+ const nextTour = computed<TutorialTour | null>(() =>
170
+ tour.value
171
+ ? nextTourAfter(tours.value, {
172
+ justFinishedId: tour.value.id,
173
+ isCompleted: (id) => tutorial.isCompleted(id),
174
+ })
175
+ : null,
176
+ )
177
+
178
+ /**
179
+ * Finish this tour and go straight into the one offered beside Done.
180
+ *
181
+ * Two calls rather than `launch()` alone: the completion has to be recorded first, or the tour
182
+ * the user just finished keeps its "not started" badge in the catalogue. `launch` (not
183
+ * `startTour`) so a suggested tour the user had broken off earlier RESUMES, exactly as it would
184
+ * from the catalogue — the precedence lives in one place for every surface that offers a tour.
185
+ */
186
+ function takeNextTour(tourId: string) {
187
+ tutorial.completeTour()
188
+ launch(tourId)
189
+ }
190
+
137
191
  /**
138
192
  * The browser viewport. Named apart from Vue Flow's `viewport` (the board CAMERA) above —
139
193
  * and not `screen`, which would shadow the DOM global of that name for the whole component.
@@ -523,6 +577,33 @@ onUnmounted(() => {
523
577
  )
524
578
  }}
525
579
  </p>
580
+ <!-- The handoff. The catalog is a chain — each delivery-loop tour produces the state
581
+ the next one needs — and this is the last moment the product can say so: starting
582
+ any tour saves `decision: 'accepted'`, which stops the launch prompt returning, so
583
+ without this the walkthrough the user's own last action just unlocked is reachable
584
+ only by going and finding the catalogue. One tour, not a list; absent when there is
585
+ nothing ready, where the plain Done below is the honest ending. -->
586
+ <div
587
+ v-if="isLast && nextTour"
588
+ class="mt-3 rounded-lg border border-slate-700/70 bg-slate-800/40 p-2.5"
589
+ data-testid="tutorial-next-tour"
590
+ >
591
+ <p class="text-[11px] tracking-wide text-slate-400 uppercase">
592
+ {{ t('tutorial.overlay.nextUp') }}
593
+ </p>
594
+ <p class="mt-0.5 text-sm font-medium text-slate-100">{{ t(nextTour.titleKey) }}</p>
595
+ <p class="mt-0.5 text-xs text-slate-400">{{ t(nextTour.descriptionKey) }}</p>
596
+ <UButton
597
+ size="xs"
598
+ color="primary"
599
+ variant="soft"
600
+ class="mt-2"
601
+ data-testid="tutorial-next-tour-start"
602
+ @click="takeNextTour(nextTour.id)"
603
+ >
604
+ {{ t('tutorial.overlay.takeNext') }}
605
+ </UButton>
606
+ </div>
526
607
  <div class="mt-3 flex items-center justify-between gap-2">
527
608
  <UButton
528
609
  size="xs"
@@ -15,7 +15,7 @@ import {
15
15
  resumeSpendContract,
16
16
  startExecutionContract,
17
17
  } from '@cat-factory/contracts'
18
- import type { RequestStepChangesInput } from '@cat-factory/contracts'
18
+ import type { RequestStepChangesInput, RunMode } from '@cat-factory/contracts'
19
19
  import type { IterationCapChoice } from '~/types/execution'
20
20
  import type { ReviewEffort } from '~/types/merge'
21
21
  import type { ApiContext } from './context'
@@ -27,7 +27,10 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
27
27
  startExecution: (
28
28
  workspaceId: string,
29
29
  blockId: string,
30
- body: { pipelineId: string },
30
+ // `mode: 'dry_run'` REQUESTS a sandboxed run (the pipeline runs and opens its PR, but
31
+ // nothing merges). Omitted ⇒ live. The task's merge preset can force a sandbox regardless
32
+ // of what is asked here, so the response's `mode` is what the run actually got.
33
+ body: { pipelineId: string; mode?: RunMode },
31
34
  password?: string,
32
35
  ) =>
33
36
  sendWith(pwHeaders(password), startExecutionContract, {
@@ -0,0 +1,25 @@
1
+ import {
2
+ getTutorialProgressContract,
3
+ recordTutorialEventContract,
4
+ resetTutorialProgressContract,
5
+ updateTutorialProgressContract,
6
+ } from '@cat-factory/contracts'
7
+ import type { RecordTutorialEventInput, UpdateTutorialProgressInput } from '~/types/domain'
8
+ import type { ApiContext } from './context'
9
+
10
+ /**
11
+ * The in-app tutorial's server surface, scoped to the signed-in user: progress that follows the
12
+ * PERSON across browsers, plus the funnel events (which store nothing and answer only whether the
13
+ * tutorial is being found and finished).
14
+ */
15
+ export function tutorialApi({ send }: ApiContext) {
16
+ return {
17
+ getTutorialProgress: () => send(getTutorialProgressContract, {}),
18
+ /** MERGES: the two id sets are grow-only, so this never removes what another device recorded. */
19
+ updateTutorialProgress: (body: UpdateTutorialProgressInput) =>
20
+ send(updateTutorialProgressContract, { body }),
21
+ resetTutorialProgress: () => send(resetTutorialProgressContract, {}),
22
+ recordTutorialEvent: (body: RecordTutorialEventInput) =>
23
+ send(recordTutorialEventContract, { body }),
24
+ }
25
+ }
@@ -51,6 +51,7 @@ import { tasksApi } from './api/tasks'
51
51
  import { bugHuntApi } from './api/bugHunt'
52
52
  import { testSecretsApi } from './api/testSecrets'
53
53
  import { userSecretsApi } from './api/userSecrets'
54
+ import { tutorialApi } from './api/tutorial'
54
55
  import { userSettingsApi } from './api/userSettings'
55
56
  import { workspacesApi } from './api/workspaces'
56
57
 
@@ -163,6 +164,7 @@ export function useApi() {
163
164
  ...slackApi(ctx),
164
165
  ...bootstrapApi(ctx),
165
166
  ...userSecretsApi(ctx),
167
+ ...tutorialApi(ctx),
166
168
  ...userSettingsApi(ctx),
167
169
  }
168
170
  }
@@ -105,6 +105,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
105
105
  titleKey: 'errors.conflict.title.no_pr_to_merge',
106
106
  descriptionKey: 'errors.conflict.description.no_pr_to_merge',
107
107
  },
108
+ dry_run_not_mergeable: {
109
+ titleKey: 'errors.conflict.title.dry_run_not_mergeable',
110
+ descriptionKey: 'errors.conflict.description.dry_run_not_mergeable',
111
+ },
108
112
  github_not_connected: {
109
113
  titleKey: 'errors.conflict.title.github_not_connected',
110
114
  descriptionKey: 'errors.conflict.description.github_not_connected',
@@ -0,0 +1,77 @@
1
+ import { ref, watch } from 'vue'
2
+ import { boardStateFingerprint, resolveNudge } from '~/utils/tutorial'
3
+ import { createSharedComposables } from '@modular-vue/vue'
4
+ import type { AppDeps } from '~/modular/registry'
5
+
6
+ const { useOptional } = createSharedComposables<AppDeps>()
7
+
8
+ /**
9
+ * The contextual offer: watch the resolved tour catalogue and hold out the ONE walkthrough that
10
+ * just became takeable.
11
+ *
12
+ * This is the half of the tutorial the catalogue could not fix. The catalogue made every tour
13
+ * reachable, but nothing brings one UP: starting any tour saves `decision: 'accepted'`, which is
14
+ * what stops the launch prompt auto-opening again, so after a user's first tour the product never
15
+ * mentions the tutorial unless they go looking. The two walkthroughs that matter most are also
16
+ * the two that are only available inside a transient window — `answer-park` while something is
17
+ * actually waiting for a human, `review-merge` once a run has produced something to merge — so
18
+ * "go and look" and "be available" rarely coincide.
19
+ *
20
+ * The rule itself is pure and lives in `resolveNudge` (over `newlyAvailableTour`); this is the
21
+ * reactive half and nothing more. That split is deliberate: the one piece of state here that
22
+ * cannot be pure is the BASELINE, and a wrong baseline silently converts a moment-triggered offer
23
+ * into an every-board-load greeting, which is the failure this mechanism is supposed to be the
24
+ * cure for.
25
+ *
26
+ * Called once from `pages/index.vue`. Never suppresses anything itself — whether the offer may be
27
+ * on screen right now is the component's business (`TutorialNudge.vue`), because the offer is
28
+ * held rather than dropped while a tutorial window or a tour is up.
29
+ */
30
+ export function useTutorialNudge() {
31
+ const tutorial = useTutorialStore()
32
+ const workspace = useWorkspaceStore()
33
+ const { catalogue } = useTutorialTours()
34
+ // The same registered `gates` service the catalogue resolves against, so the fingerprint below
35
+ // can never describe a different board than the availability it is paired with. `useOptional`
36
+ // for the bare-install case the nav filter also allows: no gates means no board state to move,
37
+ // so the offer simply never fires.
38
+ const gates = useOptional('gates')
39
+
40
+ /**
41
+ * What the last look saw: which tours were takeable, and the board-state stamp they went with.
42
+ * `null` = no baseline yet (never seeded, or the board went away and the next one must seed its
43
+ * own).
44
+ *
45
+ * `resolveNudge` decides when this is seeded, advanced and discarded; the only reason it lives
46
+ * out here is that a pure function cannot hold it.
47
+ */
48
+ const previous = ref<{ ready: ReadonlySet<string>; boardState: string } | null>(null)
49
+
50
+ watch(
51
+ // Two inputs beyond the catalogue, and each closes a different half of the "the app starting up
52
+ // is not a moment" problem. `workspace.ready` gates taking a baseline at all on the snapshot
53
+ // having landed, and is re-set per board so a SWITCH re-seeds. The board-state fingerprint is
54
+ // what an offer requires to have moved, so the permissions and capability probes that resolve
55
+ // after `ready` widen availability without being mistaken for something the user did.
56
+ () =>
57
+ [catalogue.value, workspace.ready, gates.value ? boardStateFingerprint(gates.value) : ''] as [
58
+ typeof catalogue.value,
59
+ boolean,
60
+ string,
61
+ ],
62
+ ([entries, boardReady, boardState]) => {
63
+ const { baseline, offer } = resolveNudge({
64
+ boardReady,
65
+ catalogue: entries,
66
+ boardState,
67
+ previous: previous.value,
68
+ declined: tutorial.decision === 'declined',
69
+ isCompleted: (id) => tutorial.isCompleted(id),
70
+ wasNudged: (id) => tutorial.wasNudged(id),
71
+ })
72
+ previous.value = baseline
73
+ if (offer) tutorial.offerNudge(offer.id)
74
+ },
75
+ { immediate: true },
76
+ )
77
+ }
@@ -0,0 +1,141 @@
1
+ import { watch } from 'vue'
2
+
3
+ /**
4
+ * The tutorial's server calls, with no watchers attached — safe to call from anywhere, as many
5
+ * times as you like.
6
+ *
7
+ * Split from {@link useTutorialSync} for exactly that reason: the catalogue's "Reset progress"
8
+ * button needs the DELETE, and reaching it through the composable that installs the mirror watchers
9
+ * would install a second set of them every time the catalogue mounted.
10
+ *
11
+ * Everything here is BEST-EFFORT and nothing throws into a caller. That is the design rather than a
12
+ * shortcut: the browser-persisted store stays the source the SPA reads and stays fully functional on
13
+ * a deployment with no accounts, with no progress store wired, or offline. A failed mirror costs a
14
+ * re-offer on another machine; it must never cost the walkthrough the user is taking.
15
+ */
16
+ export function useTutorialServer() {
17
+ const tutorial = useTutorialStore()
18
+ const api = useApi()
19
+
20
+ /**
21
+ * Push the WHOLE local state to the server, and reconcile against what comes back.
22
+ *
23
+ * Whole state, never a delta, which is what makes fire-and-forget correct here rather than merely
24
+ * convenient: a retry, a racing tab and a stale copy are all the same well-formed write.
25
+ *
26
+ * The response is the merged row, and feeding it back through the store is what closes the one
27
+ * hole a merge with no revision guard leaves: two concurrent merges CAN lose a writer's ids (a
28
+ * union is idempotent under retry, not commutative under concurrency), and the answer then comes
29
+ * back missing something local, which flips `serverPushNeeded` on again and re-pushes. So the
30
+ * repair is automatic and bounded to one extra round, rather than waiting on a local change that
31
+ * may never come. `markServerPushed` runs FIRST for that reason: it has to be able to go back on.
32
+ */
33
+ function push() {
34
+ void api
35
+ .updateTutorialProgress({
36
+ decision: tutorial.decision,
37
+ completedTourIds: [...tutorial.completedTourIds],
38
+ nudgedTourIds: [...tutorial.nudgedTourIds],
39
+ })
40
+ .then((merged) => {
41
+ tutorial.markServerPushed()
42
+ tutorial.mergeServerProgress(merged)
43
+ })
44
+ .catch(() => {
45
+ // silent-catch-ok: the local store is authoritative for this session and the next board
46
+ // load re-runs the whole reconciliation, so there is nothing to report and nothing a user
47
+ // could act on. Deliberately NOT re-armed here either: a refusal (the merged row would
48
+ // exceed its cap) would otherwise retry forever. (The SPA has no logger seam — see
49
+ // CLAUDE.md's silent-catch scope.)
50
+ })
51
+ }
52
+
53
+ /** Count one funnel event. */
54
+ function recordEvent(event: 'started' | 'completed' | 'abandoned', tourId: string) {
55
+ void api.recordTutorialEvent({ event, tourId }).catch(() => {
56
+ // silent-catch-ok: a dropped metric is a dropped metric. Failing a walkthrough over one, or
57
+ // retrying it, would both be worse than the missing data point.
58
+ })
59
+ }
60
+
61
+ /** Clear the server row too, so "Reset progress" is not undone by the next snapshot. */
62
+ function resetServerProgress() {
63
+ void api.resetTutorialProgress().catch(() => {
64
+ // silent-catch-ok: the local reset already happened. The failure mode is that the next
65
+ // snapshot restores the server copy, which is the state the user just left.
66
+ })
67
+ }
68
+
69
+ return { push, recordEvent, resetServerProgress }
70
+ }
71
+
72
+ /**
73
+ * Mirror the tutorial's persisted state to the signed-in user's server row, and count the funnel.
74
+ *
75
+ * Two things a browser-only store could not do, both of which the in-app-tutorial tracker left
76
+ * open:
77
+ *
78
+ * - progress that follows the PERSON. Client-persisted only, a second machine re-asks the launch
79
+ * question and re-makes every contextual offer, because "which walkthroughs have I finished" was
80
+ * a fact about a browser profile.
81
+ * - MEASUREMENT. Whether the catalogue is found and whether a tour is FINISHED was unmeasured, so
82
+ * every further slice of this feature was chosen on a guess.
83
+ *
84
+ * Called once, from `pages/index.vue`.
85
+ */
86
+ export function useTutorialSync() {
87
+ const tutorial = useTutorialStore()
88
+ const { push, recordEvent } = useTutorialServer()
89
+
90
+ // The adoption half runs in the snapshot fan-out (`stores/workspace/hydrate.ts` →
91
+ // `mergeServerProgress`), which is where every other per-user slice is hydrated and, crucially,
92
+ // early enough that the launch prompt decides whether to appear against the merged state rather
93
+ // than against this browser's copy alone. All that is left here is the write-back it asks for.
94
+ watch(
95
+ () => tutorial.serverPushNeeded,
96
+ (needed) => {
97
+ if (needed) push()
98
+ },
99
+ { immediate: true },
100
+ )
101
+
102
+ // Every later local change is mirrored. Watching the store's LOCAL revision counter rather than
103
+ // wrapping each action keeps this a single seam as the store grows (a new action that records a
104
+ // completion is covered by being a local change, where a hand-wired call per action is one more
105
+ // place to forget) — and rather than watching the STATE, because adopting the server's own ids in
106
+ // `mergeServerProgress` is a state change too, so that would post the server's row back at it on
107
+ // every fresh-browser board load. A reset bumps nothing on purpose: its server side is a DELETE.
108
+ watch(
109
+ () => tutorial.localRev,
110
+ () => push(),
111
+ )
112
+
113
+ /**
114
+ * Derive the funnel events from the cursor rather than emitting them from each action.
115
+ *
116
+ * One watcher instead of calls in `startTour` / `resumeTour` / `stopTour` / `completeTour` /
117
+ * `takeNextTour`, because those are five sites and a sixth is one refactor away — and a missing
118
+ * `started` does not fail anything, it just quietly biases the number the rest of this feature
119
+ * will be planned against.
120
+ *
121
+ * Vue batches, which is what makes the derivation work across the finish card's handoff: that
122
+ * completes one tour and starts the next in a single tick, so the cursor goes `A → null → B` and
123
+ * this sees `A → B` with the completion list one longer. It reports "A completed, B started",
124
+ * which is what happened. A plain Skip is `A → null` with the list unchanged: abandoned.
125
+ *
126
+ * A RESUME counts as a start. That is a deliberate reading, not an oversight: from the funnel's
127
+ * point of view an attempt is an attempt, and the alternative (silently not counting re-entries)
128
+ * would make completions exceed starts for anyone who breaks off and comes back.
129
+ */
130
+ watch(
131
+ () => [tutorial.activeTourId, tutorial.completedTourIds.length] as const,
132
+ ([activeId, completedCount], [previousId, previousCount]) => {
133
+ if (activeId === previousId) return
134
+ if (previousId !== null) {
135
+ const finished = completedCount > previousCount
136
+ recordEvent(finished ? 'completed' : 'abandoned', previousId)
137
+ }
138
+ if (activeId !== null) recordEvent('started', activeId)
139
+ },
140
+ )
141
+ }
@@ -43,6 +43,7 @@ const GATES: NavGates = {
43
43
  boardHasOpenDecision: true,
44
44
  boardHasPendingApproval: true,
45
45
  boardHasFinishedRun: true,
46
+ boardHasFailedRun: true,
46
47
  }
47
48
 
48
49
  describe('resolveExternalToolUrl', () => {
@@ -33,6 +33,7 @@ const NO_GATES: NavGates = {
33
33
  boardHasOpenDecision: false,
34
34
  boardHasPendingApproval: false,
35
35
  boardHasFinishedRun: false,
36
+ boardHasFailedRun: false,
36
37
  }
37
38
 
38
39
  const ALL_GATES: NavGates = {
@@ -51,6 +52,7 @@ const ALL_GATES: NavGates = {
51
52
  boardHasOpenDecision: true,
52
53
  boardHasPendingApproval: true,
53
54
  boardHasFinishedRun: true,
55
+ boardHasFailedRun: true,
54
56
  }
55
57
 
56
58
  const slots = (): AppSlots => ({