@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReproductionStatus } from '~/types/reproduction'
|
|
2
|
+
|
|
3
|
+
// Presentation for the BUGFIX REPRODUCTION PROOF verdict.
|
|
4
|
+
//
|
|
5
|
+
// The lookup is by a wire value, so it lives here as an EXHAUSTIVE `Record` keyed off the
|
|
6
|
+
// contracts union rather than as a t() call over a key assembled at the call site. The typed-key
|
|
7
|
+
// check cannot see a runtime-assembled key, so a fourth verdict added to the union would ship as a
|
|
8
|
+
// blank chip on exactly the surface whose job is to say what was and was not proven; keyed this
|
|
9
|
+
// way it fails to compile until the copy exists.
|
|
10
|
+
|
|
11
|
+
export interface ReproductionStatusPresentation {
|
|
12
|
+
/** Short chip copy: the verdict as one or two words. */
|
|
13
|
+
chip: string
|
|
14
|
+
/** One sentence a reviewer decides on. */
|
|
15
|
+
verdict: string
|
|
16
|
+
icon: string
|
|
17
|
+
/** Whether this verdict IS proof — the only one a collapsed row states completely. */
|
|
18
|
+
proven: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const REPRODUCTION_STATUS_KEYS: Record<ReproductionStatus, ReproductionStatusPresentation> =
|
|
22
|
+
{
|
|
23
|
+
reproduced: {
|
|
24
|
+
chip: 'panels.stepDetail.reproduction.status.reproduced',
|
|
25
|
+
verdict: 'panels.stepDetail.reproduction.verdict.reproduced',
|
|
26
|
+
icon: 'i-lucide-bug-off',
|
|
27
|
+
proven: true,
|
|
28
|
+
},
|
|
29
|
+
inconclusive: {
|
|
30
|
+
chip: 'panels.stepDetail.reproduction.status.inconclusive',
|
|
31
|
+
verdict: 'panels.stepDetail.reproduction.verdict.inconclusive',
|
|
32
|
+
icon: 'i-lucide-bug',
|
|
33
|
+
proven: false,
|
|
34
|
+
},
|
|
35
|
+
declared_infeasible: {
|
|
36
|
+
chip: 'panels.stepDetail.reproduction.status.declared_infeasible',
|
|
37
|
+
verdict: 'panels.stepDetail.reproduction.verdict.declared_infeasible',
|
|
38
|
+
icon: 'i-lucide-clipboard-list',
|
|
39
|
+
proven: false,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two checkouts the same command was run against. A literal pair rather than a lookup: it is
|
|
45
|
+
* the feature's own vocabulary (the tree BEFORE the change and the tree after it), not a wire
|
|
46
|
+
* union that can grow.
|
|
47
|
+
*/
|
|
48
|
+
export const REPRODUCTION_TREE_KEYS = {
|
|
49
|
+
base: 'panels.stepDetail.reproduction.tree.base',
|
|
50
|
+
final: 'panels.stepDetail.reproduction.tree.final',
|
|
51
|
+
} as const
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import en from '../../i18n/locales/en.json'
|
|
3
3
|
import {
|
|
4
|
+
boardStateFingerprint,
|
|
4
5
|
computeCoachMarkLayout,
|
|
5
6
|
isLaunchOffer,
|
|
6
7
|
launchActionFor,
|
|
7
8
|
needsReveal,
|
|
9
|
+
newlyAvailableTour,
|
|
10
|
+
nextTourAfter,
|
|
11
|
+
readyTourIds,
|
|
12
|
+
resolveNudge,
|
|
8
13
|
resolveTourCatalogue,
|
|
9
14
|
resolveTours,
|
|
10
15
|
sortTours,
|
|
@@ -162,6 +167,256 @@ describe('resolveTourCatalogue', () => {
|
|
|
162
167
|
})
|
|
163
168
|
})
|
|
164
169
|
|
|
170
|
+
describe('nextTourAfter', () => {
|
|
171
|
+
const ready = (...ids: string[]) => ids.map((id, index) => tour(id, (index + 1) * 10))
|
|
172
|
+
const none = () => false
|
|
173
|
+
|
|
174
|
+
it('offers the next unfinished tour after the one just completed', () => {
|
|
175
|
+
const next = nextTourAfter(ready('a', 'b', 'c'), { justFinishedId: 'a', isCompleted: none })
|
|
176
|
+
expect(next?.id).toBe('b')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('never offers the tour just finished, even before it is recorded as complete', () => {
|
|
180
|
+
// The handoff renders on the finish card, BEFORE Done writes the completion, so
|
|
181
|
+
// `isCompleted` still says no about the tour the user is looking at.
|
|
182
|
+
const next = nextTourAfter(ready('a', 'b'), { justFinishedId: 'b', isCompleted: none })
|
|
183
|
+
expect(next?.id).toBe('a')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('skips tours already completed', () => {
|
|
187
|
+
const next = nextTourAfter(ready('a', 'b', 'c'), {
|
|
188
|
+
justFinishedId: 'a',
|
|
189
|
+
isCompleted: (id) => id === 'b',
|
|
190
|
+
})
|
|
191
|
+
expect(next?.id).toBe('c')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('prefers the launch-offer arc over a lower-ordered catalogue-only tour', () => {
|
|
195
|
+
// The rule ordering alone would get wrong. A deployment's reference tour at order 1 must
|
|
196
|
+
// not cut into the delivery loop, which is the chain the handoff exists to keep moving.
|
|
197
|
+
const shelf = { ...tour('shelf', 1), offeredAtLaunch: false as const }
|
|
198
|
+
const next = nextTourAfter([shelf, tour('loop', 50)], {
|
|
199
|
+
justFinishedId: 'x',
|
|
200
|
+
isCompleted: none,
|
|
201
|
+
})
|
|
202
|
+
expect(next?.id).toBe('loop')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('falls back to a catalogue-only tour once the arc is exhausted', () => {
|
|
206
|
+
// Finishing the delivery loop is exactly when the platform half becomes the right thing
|
|
207
|
+
// to point at, so the preference is an ordering, not a filter.
|
|
208
|
+
const shelf = { ...tour('shelf', 60), offeredAtLaunch: false as const }
|
|
209
|
+
const next = nextTourAfter([shelf, tour('loop', 10)], {
|
|
210
|
+
justFinishedId: 'loop',
|
|
211
|
+
isCompleted: none,
|
|
212
|
+
})
|
|
213
|
+
expect(next?.id).toBe('shelf')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('offers nothing when every other tour is done', () => {
|
|
217
|
+
// Absence is a legitimate answer here, unlike in the catalogue: this is an offer, and the
|
|
218
|
+
// finish card keeps its plain Done.
|
|
219
|
+
expect(nextTourAfter(ready('a', 'b'), { justFinishedId: 'a', isCompleted: () => true })).toBe(
|
|
220
|
+
null,
|
|
221
|
+
)
|
|
222
|
+
expect(nextTourAfter([], { justFinishedId: 'a', isCompleted: none })).toBe(null)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('newlyAvailableTour', () => {
|
|
227
|
+
const entries = (...tours: TutorialTour[]) => resolveTourCatalogue(tours, gates(true))
|
|
228
|
+
const open = { declined: false, isCompleted: () => false, wasNudged: () => false }
|
|
229
|
+
|
|
230
|
+
it('offers a tour that has just become ready', () => {
|
|
231
|
+
const catalogue = entries(withSteps('a', [step('one')]), withSteps('b', [step('one')]))
|
|
232
|
+
const offer = newlyAvailableTour({
|
|
233
|
+
catalogue,
|
|
234
|
+
previouslyReady: new Set(['a']),
|
|
235
|
+
...open,
|
|
236
|
+
})
|
|
237
|
+
expect(offer?.id).toBe('b')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('says nothing about a tour that was already ready', () => {
|
|
241
|
+
// The transition rule. Fired on the standing state this would greet every board load with
|
|
242
|
+
// an offer about a walkthrough that has been available for weeks.
|
|
243
|
+
const catalogue = entries(withSteps('a', [step('one')]))
|
|
244
|
+
expect(newlyAvailableTour({ catalogue, previouslyReady: new Set(['a']), ...open })).toBe(null)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('ignores a tour that is still blocked', () => {
|
|
248
|
+
const catalogue = resolveTourCatalogue(
|
|
249
|
+
[withSteps('a', [step('one')], [needsAdvanced])],
|
|
250
|
+
gates(false),
|
|
251
|
+
)
|
|
252
|
+
expect(newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open })).toBe(null)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('leaves the catalogue-only half alone', () => {
|
|
256
|
+
// `offeredAtLaunch: false` declares a tour as reference material someone comes and gets;
|
|
257
|
+
// interrupting them with it is the thing that declaration rules out.
|
|
258
|
+
const shelf = { ...withSteps('shelf', [step('one')]), offeredAtLaunch: false as const }
|
|
259
|
+
expect(
|
|
260
|
+
newlyAvailableTour({ catalogue: entries(shelf), previouslyReady: new Set(), ...open }),
|
|
261
|
+
).toBe(null)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('never re-offers a tour already offered or already completed', () => {
|
|
265
|
+
const catalogue = entries(withSteps('a', [step('one')]))
|
|
266
|
+
expect(
|
|
267
|
+
newlyAvailableTour({
|
|
268
|
+
catalogue,
|
|
269
|
+
previouslyReady: new Set(),
|
|
270
|
+
...open,
|
|
271
|
+
wasNudged: (id) => id === 'a',
|
|
272
|
+
}),
|
|
273
|
+
).toBe(null)
|
|
274
|
+
expect(
|
|
275
|
+
newlyAvailableTour({
|
|
276
|
+
catalogue,
|
|
277
|
+
previouslyReady: new Set(),
|
|
278
|
+
...open,
|
|
279
|
+
isCompleted: (id) => id === 'a',
|
|
280
|
+
}),
|
|
281
|
+
).toBe(null)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('says nothing at all to a user who declined', () => {
|
|
285
|
+
// "No thanks" answered the question about guided tours, not about when it was asked.
|
|
286
|
+
const catalogue = entries(withSteps('a', [step('one')]))
|
|
287
|
+
expect(
|
|
288
|
+
newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open, declined: true }),
|
|
289
|
+
).toBe(null)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('offers the lowest-ordered tour when several become ready at once', () => {
|
|
293
|
+
const catalogue = entries(
|
|
294
|
+
{ ...withSteps('later', [step('one')]), order: 50 },
|
|
295
|
+
{ ...withSteps('earlier', [step('one')]), order: 20 },
|
|
296
|
+
)
|
|
297
|
+
const offer = newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open })
|
|
298
|
+
expect(offer?.id).toBe('earlier')
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
describe('boardStateFingerprint', () => {
|
|
303
|
+
/** Only the fields the fingerprint reads, plus the two it must ignore. */
|
|
304
|
+
const worldGates = (over: Partial<NavGates> = {}) =>
|
|
305
|
+
({
|
|
306
|
+
boardHasService: true,
|
|
307
|
+
boardHasTask: true,
|
|
308
|
+
boardHasRun: true,
|
|
309
|
+
boardHasOpenDecision: false,
|
|
310
|
+
boardHasPendingApproval: false,
|
|
311
|
+
boardHasFinishedRun: false,
|
|
312
|
+
boardHasFailedRun: false,
|
|
313
|
+
githubAvailable: true,
|
|
314
|
+
canWriteBoard: true,
|
|
315
|
+
...over,
|
|
316
|
+
}) as NavGates
|
|
317
|
+
|
|
318
|
+
it('changes when the world moves', () => {
|
|
319
|
+
expect(boardStateFingerprint(worldGates({ boardHasOpenDecision: true }))).not.toBe(
|
|
320
|
+
boardStateFingerprint(worldGates()),
|
|
321
|
+
)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('does NOT change when a permission or a capability resolves', () => {
|
|
325
|
+
// The distinction the contextual offer turns on. A run parking is something that HAPPENED; a
|
|
326
|
+
// probe answering is the app finding out about itself, and only the first is a moment to
|
|
327
|
+
// interrupt someone about. Every gate here loads asynchronously, so without this the app's own
|
|
328
|
+
// startup is indistinguishable from the user having done something.
|
|
329
|
+
expect(
|
|
330
|
+
boardStateFingerprint(worldGates({ githubAvailable: false, canWriteBoard: false })),
|
|
331
|
+
).toBe(boardStateFingerprint(worldGates()))
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('resolveNudge', () => {
|
|
336
|
+
const entries = (...tours: TutorialTour[]) => resolveTourCatalogue(tours, gates(true))
|
|
337
|
+
const open = { declined: false, isCompleted: () => false, wasNudged: () => false }
|
|
338
|
+
const catalogue = entries(withSteps('a', [step('one')]), withSteps('b', [step('one')]))
|
|
339
|
+
const AFTER = 'world-moved'
|
|
340
|
+
const held = (...ids: string[]) => ({ ready: new Set(ids), boardState: 'world-before' })
|
|
341
|
+
|
|
342
|
+
it('seeds nothing and offers nothing before the board is up', () => {
|
|
343
|
+
// Every gate reads a store something fills asynchronously, so BEFORE the snapshot lands
|
|
344
|
+
// nothing is ready — and a baseline taken then makes the app's own startup a transition.
|
|
345
|
+
expect(
|
|
346
|
+
resolveNudge({ boardReady: false, catalogue, boardState: AFTER, previous: null, ...open }),
|
|
347
|
+
).toEqual({ baseline: null, offer: null })
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('seeds the standing state on the first resolution once the board is up, silently', () => {
|
|
351
|
+
const { baseline, offer } = resolveNudge({
|
|
352
|
+
boardReady: true,
|
|
353
|
+
catalogue,
|
|
354
|
+
boardState: AFTER,
|
|
355
|
+
previous: null,
|
|
356
|
+
...open,
|
|
357
|
+
})
|
|
358
|
+
expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
|
|
359
|
+
expect(baseline?.boardState).toBe(AFTER)
|
|
360
|
+
expect(offer).toBeNull()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('offers what crossed into ready when the world also moved', () => {
|
|
364
|
+
const { baseline, offer } = resolveNudge({
|
|
365
|
+
boardReady: true,
|
|
366
|
+
catalogue,
|
|
367
|
+
boardState: AFTER,
|
|
368
|
+
previous: held('a'),
|
|
369
|
+
...open,
|
|
370
|
+
})
|
|
371
|
+
expect(offer?.id).toBe('b')
|
|
372
|
+
// Advanced even though it produced an offer, so a tour flickering ready → blocked → ready
|
|
373
|
+
// (which live run gates do) cannot re-offer itself.
|
|
374
|
+
expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('says NOTHING when readiness widened but the world did not move', () => {
|
|
378
|
+
// The failure this guard exists for, and the general form of it: a permission resolving or a
|
|
379
|
+
// capability probe answering makes tours takeable that were only "blocked" because the app had
|
|
380
|
+
// not found out yet. Offered there, the mechanism greets every board load — which is the thing
|
|
381
|
+
// it was built to replace. The baseline still advances, so the widening is absorbed silently.
|
|
382
|
+
const { baseline, offer } = resolveNudge({
|
|
383
|
+
boardReady: true,
|
|
384
|
+
catalogue,
|
|
385
|
+
boardState: 'world-before',
|
|
386
|
+
previous: held('a'),
|
|
387
|
+
...open,
|
|
388
|
+
})
|
|
389
|
+
expect(offer).toBeNull()
|
|
390
|
+
expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('DISCARDS the baseline when the board goes away, so the next one re-seeds', () => {
|
|
394
|
+
// A board switch re-inits the workspace store, and the incoming board legitimately satisfies a
|
|
395
|
+
// different set of tours. Carrying the old baseline across would report every one of them as
|
|
396
|
+
// having just become takeable.
|
|
397
|
+
expect(
|
|
398
|
+
resolveNudge({
|
|
399
|
+
boardReady: false,
|
|
400
|
+
catalogue,
|
|
401
|
+
boardState: AFTER,
|
|
402
|
+
previous: held('a'),
|
|
403
|
+
...open,
|
|
404
|
+
}).baseline,
|
|
405
|
+
).toBeNull()
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
describe('readyTourIds', () => {
|
|
410
|
+
it('is exactly the ids resolveTours would return', () => {
|
|
411
|
+
const tours = [
|
|
412
|
+
withSteps('ready', [step('one')]),
|
|
413
|
+
withSteps('blocked', [step('one')], [needsAdvanced]),
|
|
414
|
+
]
|
|
415
|
+
const catalogue = resolveTourCatalogue(tours, gates(false))
|
|
416
|
+
expect([...readyTourIds(catalogue)]).toEqual(resolveTours(tours, gates(false)).map((t) => t.id))
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
165
420
|
describe('tourState / launchActionFor', () => {
|
|
166
421
|
const state = (over: Partial<Parameters<typeof tourState>[0]>) =>
|
|
167
422
|
tourState({ active: false, resumable: false, completed: false, ...over })
|
package/app/utils/tutorial.ts
CHANGED
|
@@ -286,6 +286,179 @@ export function isLaunchOffer(tour: TutorialTour): boolean {
|
|
|
286
286
|
return tour.offeredAtLaunch !== false
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/** The ids a catalogue resolution says can be started right now. */
|
|
290
|
+
export function readyTourIds(catalogue: readonly TutorialCatalogueEntry[]): Set<string> {
|
|
291
|
+
return new Set(
|
|
292
|
+
catalogue.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour.id),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The tour that just became takeable, for the contextual offer, or null when nothing did.
|
|
298
|
+
*
|
|
299
|
+
* The catalogue made every walkthrough reachable; this is what reaches the user who needs one
|
|
300
|
+
* WITHOUT going looking. The trigger is deliberately not a per-surface hook ("when a run parks,
|
|
301
|
+
* mention `answer-park`"): every tour already declares, as its `requires`, the exact predicate
|
|
302
|
+
* that means "you can take this now". So one rule over the resolved catalogue covers the whole
|
|
303
|
+
* catalog and inherits `navRequirementDrift` unchanged, where a hand-wired trigger per surface
|
|
304
|
+
* would be a second copy of each requirement to keep in step.
|
|
305
|
+
*
|
|
306
|
+
* Three rules, and the first is the one that is easy to get wrong:
|
|
307
|
+
*
|
|
308
|
+
* - It fires on a TRANSITION into `ready`, never on the standing state, which is why the caller
|
|
309
|
+
* must SEED `previouslyReady` from a resolution taken once the board is up AND may only offer
|
|
310
|
+
* where the world itself moved (see {@link resolveNudge}, which owns both halves). Fired on
|
|
311
|
+
* the standing state it would nudge about everything already available on every board load,
|
|
312
|
+
* which is the launch prompt with none of its manners. The transition rule also means the
|
|
313
|
+
* permission-gated platform tours (ready from the first render on any board) naturally never
|
|
314
|
+
* reach it, and only the board-state tours — a run parked, a run failed, a PR ready to
|
|
315
|
+
* merge — can.
|
|
316
|
+
* - Only the launch-offer arc, for the reason `offeredAtLaunch` exists: a tour declared as
|
|
317
|
+
* reference material someone comes and gets is not one to interrupt them with. Reusing that
|
|
318
|
+
* declaration rather than inventing a second opt-out keeps a consumer deployment's tour
|
|
319
|
+
* behaving here exactly as it does in the prompt.
|
|
320
|
+
* - Never twice for the same tour (`wasNudged`) and never one already completed. A contextual
|
|
321
|
+
* offer that returns is a nag, and this one is unusually well placed to become one: the gates
|
|
322
|
+
* it reads flip several times per run.
|
|
323
|
+
*
|
|
324
|
+
* And nothing at all for a user who DECLINED. "No thanks" was an answer about guided tours, not
|
|
325
|
+
* about the startup timing of the question, so a mechanism that goes on offering them anyway is
|
|
326
|
+
* overriding the one explicit preference this feature collects. They keep the catalogue, which is
|
|
327
|
+
* where someone who changed their mind goes; `resetProgress` is the way back to being asked.
|
|
328
|
+
*/
|
|
329
|
+
export function newlyAvailableTour(input: {
|
|
330
|
+
catalogue: readonly TutorialCatalogueEntry[]
|
|
331
|
+
previouslyReady: ReadonlySet<string>
|
|
332
|
+
declined: boolean
|
|
333
|
+
isCompleted: (tourId: string) => boolean
|
|
334
|
+
wasNudged: (tourId: string) => boolean
|
|
335
|
+
}): TutorialTour | null {
|
|
336
|
+
if (input.declined) return null
|
|
337
|
+
const candidate = input.catalogue.find(
|
|
338
|
+
(entry) =>
|
|
339
|
+
entry.availability === 'ready' &&
|
|
340
|
+
isLaunchOffer(entry.tour) &&
|
|
341
|
+
!input.previouslyReady.has(entry.tour.id) &&
|
|
342
|
+
!input.isCompleted(entry.tour.id) &&
|
|
343
|
+
!input.wasNudged(entry.tour.id),
|
|
344
|
+
)
|
|
345
|
+
return candidate?.tour ?? null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The gates that describe the WORLD: facts about this board that a person or a run changed.
|
|
350
|
+
*
|
|
351
|
+
* Everything else `NavGates` carries is a fact about the DEPLOYMENT or the VIEWER (a permission, a
|
|
352
|
+
* wired integration, the interface tier), and the difference is what {@link resolveNudge} turns on.
|
|
353
|
+
*/
|
|
354
|
+
const BOARD_STATE_GATES = [
|
|
355
|
+
'boardHasService',
|
|
356
|
+
'boardHasTask',
|
|
357
|
+
'boardHasRun',
|
|
358
|
+
'boardHasOpenDecision',
|
|
359
|
+
'boardHasPendingApproval',
|
|
360
|
+
'boardHasFinishedRun',
|
|
361
|
+
'boardHasFailedRun',
|
|
362
|
+
] as const satisfies readonly (keyof NavGates)[]
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A comparable stamp of the board-state gates, so "did the world move" is one `!==`.
|
|
366
|
+
*
|
|
367
|
+
* A string of bits rather than a Set of what is true: the question is only whether this differs
|
|
368
|
+
* from the last look, and a stamp makes that answerable without caring which bit moved.
|
|
369
|
+
*/
|
|
370
|
+
export function boardStateFingerprint(gates: NavGates): string {
|
|
371
|
+
return BOARD_STATE_GATES.map((gate) => (gates[gate] ? '1' : '0')).join('')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One step of the contextual offer's state machine: the baseline to carry forward, and the tour
|
|
376
|
+
* to offer (if any) from this resolution of the catalogue.
|
|
377
|
+
*
|
|
378
|
+
* Pure, and separate from {@link newlyAvailableTour}, because the BASELINE is the part that was
|
|
379
|
+
* wrong when this shipped and the part a reactive wrapper cannot be trusted with. A transition
|
|
380
|
+
* rule is only as good as what it is measured against, and this one was measured against an app
|
|
381
|
+
* that had not finished starting: every gate reads a store something fills asynchronously, so a
|
|
382
|
+
* baseline taken too early records "nothing is takeable" and the app's own startup then reads as a
|
|
383
|
+
* transition. That turns the mechanism into exactly the every-board-load greeting it exists to
|
|
384
|
+
* prevent ("here is a new walkthrough", about a run that finished a fortnight ago).
|
|
385
|
+
*
|
|
386
|
+
* Two guards, because the first alone was not enough and the second is the general form of why:
|
|
387
|
+
*
|
|
388
|
+
* - `boardReady` gates any baseline at all on the workspace snapshot having been fanned out, so
|
|
389
|
+
* board state is in place before anything is recorded as the standing state. It also DISCARDS
|
|
390
|
+
* the baseline when the board goes away, so switching boards re-seeds against the new one
|
|
391
|
+
* rather than reporting everything it happens to satisfy as having just changed.
|
|
392
|
+
* - `boardState` (a {@link boardStateFingerprint}) is what an offer actually requires to have
|
|
393
|
+
* MOVED. Readiness widening is not the same as the world changing: a permission resolving, or
|
|
394
|
+
* a capability probe answering, makes tours takeable that were "blocked" only because the app
|
|
395
|
+
* did not know yet, and the app finding out about itself is not a moment to interrupt anyone
|
|
396
|
+
* about. Those resolutions advance the baseline SILENTLY. This is the guard that generalises:
|
|
397
|
+
* it needs no list of which stores load late, because none of them describe the world.
|
|
398
|
+
*
|
|
399
|
+
* So an offer needs both halves — a tour that just became takeable, and a world that just moved —
|
|
400
|
+
* and the baseline advances on every resolution either way, so a tour flickering
|
|
401
|
+
* ready → blocked → ready (which live run gates do) cannot re-offer itself.
|
|
402
|
+
*/
|
|
403
|
+
export function resolveNudge(input: {
|
|
404
|
+
boardReady: boolean
|
|
405
|
+
catalogue: readonly TutorialCatalogueEntry[]
|
|
406
|
+
boardState: string
|
|
407
|
+
previous: { ready: ReadonlySet<string>; boardState: string } | null
|
|
408
|
+
declined: boolean
|
|
409
|
+
isCompleted: (tourId: string) => boolean
|
|
410
|
+
wasNudged: (tourId: string) => boolean
|
|
411
|
+
}): { baseline: { ready: Set<string>; boardState: string } | null; offer: TutorialTour | null } {
|
|
412
|
+
if (!input.boardReady) return { baseline: null, offer: null }
|
|
413
|
+
const baseline = { ready: readyTourIds(input.catalogue), boardState: input.boardState }
|
|
414
|
+
if (input.previous === null) return { baseline, offer: null }
|
|
415
|
+
if (input.previous.boardState === input.boardState) return { baseline, offer: null }
|
|
416
|
+
return {
|
|
417
|
+
baseline,
|
|
418
|
+
offer: newlyAvailableTour({ ...input, previouslyReady: input.previous.ready }),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The tour to hand the user off to when they finish `justFinishedId`, or null when there is
|
|
424
|
+
* nothing left to offer.
|
|
425
|
+
*
|
|
426
|
+
* The catalog is a COURSE, not a list: each delivery-loop tour produces the state the next
|
|
427
|
+
* one requires, so finishing one is the single most reliable moment at which another became
|
|
428
|
+
* takeable. Without a handoff the walkthrough that the user's own last action unlocked is
|
|
429
|
+
* reachable only from the catalogue, and the launch prompt cannot bring it up either: starting
|
|
430
|
+
* any tour writes `decision: 'accepted'`, which is what stops the prompt auto-opening for good.
|
|
431
|
+
* So the finish card is the ONLY place the product can still say "and now this one".
|
|
432
|
+
*
|
|
433
|
+
* Two rules make it an offer rather than a list:
|
|
434
|
+
*
|
|
435
|
+
* - Launch-offer tours come FIRST, whatever their `order` (see {@link TutorialTour.offeredAtLaunch}).
|
|
436
|
+
* The delivery loop is the arc someone taking a tour is on; a deployment's catalogue-only
|
|
437
|
+
* tour with a low `order` must not jump in front of it. Ordering alone is not that
|
|
438
|
+
* guarantee — it only happens to be true of the built-ins' numbering.
|
|
439
|
+
* - It offers exactly one, and only a READY one. A list is what the catalogue is for, and a
|
|
440
|
+
* blocked tour named here would ask the user to go and do something at the moment they
|
|
441
|
+
* finished doing something.
|
|
442
|
+
*
|
|
443
|
+
* Absence is a legitimate answer, unlike in the catalogue: this is an offer, so having nothing
|
|
444
|
+
* to suggest means the card keeps its plain Done. The caller still shows the way to the
|
|
445
|
+
* catalogue, so the card never dead-ends.
|
|
446
|
+
*
|
|
447
|
+
* `ready` is deliberately read LIVE by the caller rather than from the tour's held script,
|
|
448
|
+
* which is the one place the "resolve once and HOLD" rule must not apply: completing
|
|
449
|
+
* `first-task` is exactly what makes `run-task` ready, so a candidate list frozen at tour
|
|
450
|
+
* start would be empty precisely when this exists to be useful.
|
|
451
|
+
*/
|
|
452
|
+
export function nextTourAfter(
|
|
453
|
+
ready: readonly TutorialTour[],
|
|
454
|
+
input: { justFinishedId: string; isCompleted: (tourId: string) => boolean },
|
|
455
|
+
): TutorialTour | null {
|
|
456
|
+
const fresh = sortTours(ready).filter(
|
|
457
|
+
(tour) => tour.id !== input.justFinishedId && !input.isCompleted(tour.id),
|
|
458
|
+
)
|
|
459
|
+
return fresh.find(isLaunchOffer) ?? fresh[0] ?? null
|
|
460
|
+
}
|
|
461
|
+
|
|
289
462
|
/**
|
|
290
463
|
* Where a tour stands for this user: the state the catalogue badges and the action label
|
|
291
464
|
* derive from. Camel-cased because the values ARE the i18n leaf keys
|