@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
|
@@ -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="
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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 => ({
|
|
@@ -144,6 +144,17 @@ export interface NavGates {
|
|
|
144
144
|
* failed run never renders.
|
|
145
145
|
*/
|
|
146
146
|
boardHasFinishedRun: boolean
|
|
147
|
+
/**
|
|
148
|
+
* Some run on a task block has FAILED, so the card is rendering the failure banner.
|
|
149
|
+
*
|
|
150
|
+
* The other half of {@link boardHasFinishedRun}, and the reason it is a separate gate rather
|
|
151
|
+
* than a looser "a run settled": the two states render disjoint surfaces (a result view and
|
|
152
|
+
* a merge control; a failure banner and a retry), so one tour cannot cover both. It is also
|
|
153
|
+
* the state a new user is MOST likely to be in and the one the catalog had nothing for: with
|
|
154
|
+
* only the success gate, a board where every run failed reports the delivery loop as
|
|
155
|
+
* permanently half-finished and explains none of it.
|
|
156
|
+
*/
|
|
157
|
+
boardHasFailedRun: boolean
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -49,6 +49,13 @@ export function createNavGates(): NavGates {
|
|
|
49
49
|
const hasFinishedRun = computed(() =>
|
|
50
50
|
execution.instances.some((e) => e.status === 'done' && isTaskBlock(e.blockId)),
|
|
51
51
|
)
|
|
52
|
+
// Mirrors what the CARD renders, exactly as the park gates do: `TaskCard` shows the shared
|
|
53
|
+
// failure banner on `agentRun.status === 'failed'`, which is the anchor the diagnose tour
|
|
54
|
+
// points at. Kept apart from `hasFinishedRun` because the two states render disjoint
|
|
55
|
+
// controls — see `NavGates.boardHasFailedRun`.
|
|
56
|
+
const hasFailedRun = computed(() =>
|
|
57
|
+
execution.instances.some((e) => e.status === 'failed' && isTaskBlock(e.blockId)),
|
|
58
|
+
)
|
|
52
59
|
// Not the store's raw pending counts: those answer "is anything parked", while the tour
|
|
53
60
|
// these gate anchors on the card affordance a park RENDERS. See `hasActionablePark`.
|
|
54
61
|
const hasOpenDecision = computed(() =>
|
|
@@ -119,5 +126,8 @@ export function createNavGates(): NavGates {
|
|
|
119
126
|
get boardHasFinishedRun() {
|
|
120
127
|
return hasFinishedRun.value
|
|
121
128
|
},
|
|
129
|
+
get boardHasFailedRun() {
|
|
130
|
+
return hasFailedRun.value
|
|
131
|
+
},
|
|
122
132
|
}
|
|
123
133
|
}
|