@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
|
@@ -121,10 +121,16 @@ describe('useTutorialStore catalogue', () => {
|
|
|
121
121
|
tutorial.setStepIndex(2)
|
|
122
122
|
tutorial.stopTour()
|
|
123
123
|
|
|
124
|
+
tutorial.offerNudge('answer-park')
|
|
124
125
|
tutorial.resetProgress()
|
|
125
126
|
expect(tutorial.completedTourIds).toEqual([])
|
|
126
127
|
expect(tutorial.interruptedAt('run-task')).toBeNull()
|
|
127
128
|
expect(tutorial.decision).toBeNull()
|
|
129
|
+
// The spent contextual offers go too, or a board that has already run something would
|
|
130
|
+
// never make them again to the colleague the app was just handed to.
|
|
131
|
+
expect(tutorial.nudgedTourIds).toEqual([])
|
|
132
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
133
|
+
expect(tutorial.wasNudged('answer-park')).toBe(false)
|
|
128
134
|
})
|
|
129
135
|
|
|
130
136
|
it('leaves a running tour alone when progress is reset', () => {
|
|
@@ -300,3 +306,204 @@ describe('useTutorialStore resuming a broken-off tour', () => {
|
|
|
300
306
|
expect(tutorial.interruptedAt('gone-away')).toBeNull()
|
|
301
307
|
})
|
|
302
308
|
})
|
|
309
|
+
|
|
310
|
+
describe('useTutorialStore contextual offer', () => {
|
|
311
|
+
it('raises the offer and spends the id in one act', () => {
|
|
312
|
+
// The guard against re-offering is the PERSISTED list, not the visible state, so an offer
|
|
313
|
+
// cannot be made twice however many times the live gates flip.
|
|
314
|
+
const tutorial = useTutorialStore()
|
|
315
|
+
tutorial.offerNudge('answer-park')
|
|
316
|
+
expect(tutorial.pendingNudgeId).toBe('answer-park')
|
|
317
|
+
expect(tutorial.wasNudged('answer-park')).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('never re-raises an offer already spent, even once dismissed', () => {
|
|
321
|
+
const tutorial = useTutorialStore()
|
|
322
|
+
tutorial.offerNudge('answer-park')
|
|
323
|
+
tutorial.dismissNudge()
|
|
324
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
325
|
+
tutorial.offerNudge('answer-park')
|
|
326
|
+
expect(tutorial.pendingNudgeId).toBeNull()
|
|
327
|
+
expect(tutorial.nudgedTourIds).toEqual(['answer-park'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('holds one offer at a time, keeping the newer arrival', () => {
|
|
331
|
+
const tutorial = useTutorialStore()
|
|
332
|
+
tutorial.offerNudge('answer-park')
|
|
333
|
+
tutorial.offerNudge('diagnose-failure')
|
|
334
|
+
expect(tutorial.pendingNudgeId).toBe('diagnose-failure')
|
|
335
|
+
expect(tutorial.nudgedTourIds).toEqual(['answer-park', 'diagnose-failure'])
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('useTutorialStore server reconciliation', () => {
|
|
340
|
+
it('unions the server copy into the local one, keeping what only this browser knew', () => {
|
|
341
|
+
// Both lists are grow-only sets of things that HAPPENED, so neither side may un-say one. A
|
|
342
|
+
// replace here loses a tour finished while the mirror write was failing.
|
|
343
|
+
const tutorial = useTutorialStore()
|
|
344
|
+
tutorial.startTour('board-basics')
|
|
345
|
+
tutorial.completeTour()
|
|
346
|
+
const pushNeeded = tutorial.mergeServerProgress({
|
|
347
|
+
decision: 'accepted',
|
|
348
|
+
completedTourIds: ['first-task'],
|
|
349
|
+
nudgedTourIds: [],
|
|
350
|
+
})
|
|
351
|
+
expect(tutorial.completedTourIds).toEqual(['first-task', 'board-basics'])
|
|
352
|
+
// This browser held something the server did not, so the mirror owes it a write.
|
|
353
|
+
expect(pushNeeded).toBe(true)
|
|
354
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('asks for no push when the server copy is already a superset', () => {
|
|
358
|
+
// The ordinary reload. Writing anyway would mean every board load posted a mirror nobody reads.
|
|
359
|
+
const tutorial = useTutorialStore()
|
|
360
|
+
expect(
|
|
361
|
+
tutorial.mergeServerProgress({
|
|
362
|
+
decision: 'accepted',
|
|
363
|
+
completedTourIds: ['board-basics'],
|
|
364
|
+
nudgedTourIds: ['answer-park'],
|
|
365
|
+
}),
|
|
366
|
+
).toBe(false)
|
|
367
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
368
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('never duplicates an id both sides already hold', () => {
|
|
372
|
+
const tutorial = useTutorialStore()
|
|
373
|
+
tutorial.startTour('board-basics')
|
|
374
|
+
tutorial.completeTour()
|
|
375
|
+
tutorial.mergeServerProgress({
|
|
376
|
+
decision: null,
|
|
377
|
+
completedTourIds: ['board-basics'],
|
|
378
|
+
nudgedTourIds: [],
|
|
379
|
+
})
|
|
380
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('takes the server decision, but never clears a local one with an absent row', () => {
|
|
384
|
+
// The decision is a preference someone re-answers, so the shared record wins when it has an
|
|
385
|
+
// answer. A server row with no answer is not evidence that the local answer never happened.
|
|
386
|
+
const tutorial = useTutorialStore()
|
|
387
|
+
tutorial.decline()
|
|
388
|
+
tutorial.markServerPushed()
|
|
389
|
+
tutorial.mergeServerProgress({
|
|
390
|
+
decision: null,
|
|
391
|
+
completedTourIds: [],
|
|
392
|
+
nudgedTourIds: [],
|
|
393
|
+
})
|
|
394
|
+
expect(tutorial.decision).toBe('declined')
|
|
395
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
396
|
+
|
|
397
|
+
tutorial.markServerPushed()
|
|
398
|
+
tutorial.mergeServerProgress({
|
|
399
|
+
decision: 'accepted',
|
|
400
|
+
completedTourIds: [],
|
|
401
|
+
nudgedTourIds: [],
|
|
402
|
+
})
|
|
403
|
+
expect(tutorial.decision).toBe('accepted')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('holds an UN-MIRRORED local answer against the server, so a failed push cannot undo it', () => {
|
|
407
|
+
// The mirror is best-effort, so a decline can be sitting in this browser and nowhere else. If
|
|
408
|
+
// the snapshot re-adopted the older server answer, "No thanks" would silently come back as
|
|
409
|
+
// "accepted" and every contextual offer the user just declined would re-arm.
|
|
410
|
+
const tutorial = useTutorialStore()
|
|
411
|
+
tutorial.decline()
|
|
412
|
+
expect(
|
|
413
|
+
tutorial.mergeServerProgress({
|
|
414
|
+
decision: 'accepted',
|
|
415
|
+
completedTourIds: [],
|
|
416
|
+
nudgedTourIds: [],
|
|
417
|
+
}),
|
|
418
|
+
).toBe(true)
|
|
419
|
+
expect(tutorial.decision).toBe('declined')
|
|
420
|
+
|
|
421
|
+
// Once the answer HAS been mirrored, the shared record is authoritative again: another machine
|
|
422
|
+
// re-answering is a real change, and this browser has nothing newer to defend.
|
|
423
|
+
tutorial.markServerPushed()
|
|
424
|
+
tutorial.mergeServerProgress({ decision: 'accepted', completedTourIds: [], nudgedTourIds: [] })
|
|
425
|
+
expect(tutorial.decision).toBe('accepted')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('treats an absent server copy as nothing to reconcile, not as an empty one', () => {
|
|
429
|
+
// No accounts, no store wired, or a degraded read. Pushing here would write a mirror on a
|
|
430
|
+
// deployment that has nowhere to put it; clearing would be worse.
|
|
431
|
+
const tutorial = useTutorialStore()
|
|
432
|
+
tutorial.startTour('board-basics')
|
|
433
|
+
tutorial.completeTour()
|
|
434
|
+
expect(tutorial.mergeServerProgress(null)).toBe(false)
|
|
435
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics'])
|
|
436
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('clears the push request once the mirror has caught up', () => {
|
|
440
|
+
const tutorial = useTutorialStore()
|
|
441
|
+
tutorial.startTour('board-basics')
|
|
442
|
+
tutorial.completeTour()
|
|
443
|
+
tutorial.mergeServerProgress({ decision: null, completedTourIds: [], nudgedTourIds: [] })
|
|
444
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
445
|
+
tutorial.markServerPushed()
|
|
446
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('re-arms the push when a merge RESPONSE comes back missing something local', () => {
|
|
450
|
+
// What makes the server's un-guarded merge safe. Two concurrent merges can lose one writer's
|
|
451
|
+
// ids, and the loser finds out because the response it gets back is a row without them. The
|
|
452
|
+
// ordering is the load-bearing part: the push is marked done FIRST, so re-arming flips the flag
|
|
453
|
+
// and re-triggers the mirror rather than being swallowed by a later clear.
|
|
454
|
+
const tutorial = useTutorialStore()
|
|
455
|
+
tutorial.startTour('board-basics')
|
|
456
|
+
tutorial.completeTour()
|
|
457
|
+
tutorial.markServerPushed()
|
|
458
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
459
|
+
tutorial.mergeServerProgress({
|
|
460
|
+
decision: 'accepted',
|
|
461
|
+
completedTourIds: ['first-task'],
|
|
462
|
+
nudgedTourIds: [],
|
|
463
|
+
})
|
|
464
|
+
expect(tutorial.serverPushNeeded).toBe(true)
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
describe('useTutorialStore local revision (what the mirror watches)', () => {
|
|
469
|
+
it('counts only what the USER did, never what the server told us', () => {
|
|
470
|
+
// The mirror watches this instead of the state, because adopting the server's own ids is a
|
|
471
|
+
// state change too: watching the state posts the server's row straight back at it on every
|
|
472
|
+
// fresh-browser board load.
|
|
473
|
+
const tutorial = useTutorialStore()
|
|
474
|
+
const before = tutorial.localRev
|
|
475
|
+
tutorial.mergeServerProgress({
|
|
476
|
+
decision: 'accepted',
|
|
477
|
+
completedTourIds: ['board-basics', 'first-task'],
|
|
478
|
+
nudgedTourIds: ['answer-park'],
|
|
479
|
+
})
|
|
480
|
+
expect(tutorial.completedTourIds).toEqual(['board-basics', 'first-task'])
|
|
481
|
+
expect(tutorial.localRev).toBe(before)
|
|
482
|
+
|
|
483
|
+
tutorial.startTour('run-task')
|
|
484
|
+
tutorial.completeTour()
|
|
485
|
+
expect(tutorial.localRev).toBeGreaterThan(before)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('does not bump on a re-recorded completion, an unchanged answer, or a reset', () => {
|
|
489
|
+
const tutorial = useTutorialStore()
|
|
490
|
+
// Two real changes: the accept `startTour` writes, and the completion.
|
|
491
|
+
tutorial.startTour('board-basics')
|
|
492
|
+
tutorial.completeTour()
|
|
493
|
+
const settled = tutorial.localRev
|
|
494
|
+
expect(settled).toBe(2)
|
|
495
|
+
|
|
496
|
+
// Repeating the same tour changes nothing that is persisted: the answer is already 'accepted'
|
|
497
|
+
// and the completion is already recorded, so there is nothing for the mirror to carry.
|
|
498
|
+
tutorial.startTour('board-basics')
|
|
499
|
+
tutorial.completeTour()
|
|
500
|
+
expect(tutorial.localRev).toBe(settled)
|
|
501
|
+
|
|
502
|
+
// A reset's server side is a DELETE. A push of the freshly-emptied state racing it would
|
|
503
|
+
// re-create the row the DELETE removed, leaving "reset it" distinguishable from "never touched
|
|
504
|
+
// the tutorial" — the one thing the reset has to get right.
|
|
505
|
+
tutorial.resetProgress()
|
|
506
|
+
expect(tutorial.localRev).toBe(settled)
|
|
507
|
+
expect(tutorial.serverPushNeeded).toBe(false)
|
|
508
|
+
})
|
|
509
|
+
})
|
package/app/stores/tutorial.ts
CHANGED
|
@@ -1,41 +1,46 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { createTutorialPrompt } from '~/stores/tutorial.prompt'
|
|
4
|
+
import { createTutorialRecord } from '~/stores/tutorial.record'
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
// `TutorialDecision` is deliberately NOT re-exported from here even though it used to live here:
|
|
7
|
+
// both store modules are auto-import sources, so two exports of one name make Nuxt drop one of them
|
|
8
|
+
// with a build warning. It is reachable unqualified anywhere in the layer, from `tutorial.record.ts`
|
|
9
|
+
// where it is declared beside the state it describes.
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* The in-app tutorial state: the launch-prompt decision, per-tour completion, and the
|
|
9
13
|
* live progress of whichever tour is running.
|
|
10
14
|
*
|
|
11
|
-
* Two tiers of state, split on purpose:
|
|
15
|
+
* Two tiers of state, split on purpose — and the split is now a real seam rather than a comment:
|
|
12
16
|
*
|
|
13
|
-
* - PERSISTED (`decision`, `completedTourIds`) — the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* - PERSISTED (`decision`, `completedTourIds`, `nudgedTourIds`) — the standing record of what this
|
|
18
|
+
* PERSON has done, owned by `createTutorialRecord` (`stores/tutorial.record.ts`) along with the
|
|
19
|
+
* server-reconciliation rules that go with it. `null` (never asked) is a real state distinct from
|
|
20
|
+
* `declined`: only an explicit answer stops the launch prompt returning, while closing it without
|
|
21
|
+
* answering merely defers it to the next launch.
|
|
22
|
+
* - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`, `pendingNudgeId`) —
|
|
23
|
+
* owned here. A tour is anchored to live DOM, so replaying progress across a reload would point
|
|
24
|
+
* step N at a board that hasn't reached that state; a reloaded tour restarts from its beginning.
|
|
25
|
+
*
|
|
26
|
+
* The persisted half is a per-person fact rather than a per-browser one, so it is MIRRORED to the
|
|
27
|
+
* signed-in user's server row when the deployment has accounts (`useTutorialSync`), with this store
|
|
28
|
+
* staying the local cache the SPA reads. Persisting it here as well is not redundancy: it is what a
|
|
29
|
+
* deployment with auth disabled, and every load before the snapshot lands, runs on.
|
|
22
30
|
*
|
|
23
31
|
* The store deliberately knows nothing about WHICH tours exist: the catalog lives in the
|
|
24
|
-
* `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-party and
|
|
33
|
+
* consumer-contributed alike — without this store changing. It tracks ids and a step cursor; the
|
|
34
|
+
* overlay resolves definitions and drives the cursor.
|
|
27
35
|
*/
|
|
28
36
|
export const useTutorialStore = defineStore(
|
|
29
37
|
'tutorial',
|
|
30
38
|
() => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const promptOpen = ref(false)
|
|
37
|
-
/** Once-per-session guard for the launch auto-open; later opens are user-driven. */
|
|
38
|
-
const promptAutoOpened = ref(false)
|
|
39
|
+
const record = createTutorialRecord()
|
|
40
|
+
// The launch offer's own four-exit state machine (`stores/tutorial.prompt.ts`), which needs to
|
|
41
|
+
// know only WHETHER an answer exists.
|
|
42
|
+
const prompt = createTutorialPrompt({ hasDecision: () => record.decision.value !== null })
|
|
43
|
+
const { promptOpen } = prompt
|
|
39
44
|
/**
|
|
40
45
|
* The tutorial catalogue (every tour this deployment ships, startable at any time) is
|
|
41
46
|
* open. Always user-driven — nothing auto-opens it — which is why it carries none of the
|
|
@@ -58,6 +63,21 @@ export const useTutorialStore = defineStore(
|
|
|
58
63
|
*/
|
|
59
64
|
const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
|
|
60
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The tour the contextual offer is currently holding out, or null.
|
|
68
|
+
*
|
|
69
|
+
* Session state, and separate from `nudgedTourIds` on purpose: the id is marked as SPENT the
|
|
70
|
+
* moment the offer is made, while what is on SCREEN survives being suppressed. The two most
|
|
71
|
+
* valuable moments to offer a tour (a run parked, a run failed) routinely arrive while a
|
|
72
|
+
* tutorial window or another tour is up, and dropping the offer there would lose the one chance
|
|
73
|
+
* this mechanism gets. Holding it means it appears as soon as the way is clear.
|
|
74
|
+
*
|
|
75
|
+
* The trade is deliberate: a reload before it is ever shown burns the offer. That beats the
|
|
76
|
+
* alternative of re-arming it, which turns one missed moment into a prompt that keeps coming
|
|
77
|
+
* back on a board whose gates flip constantly.
|
|
78
|
+
*/
|
|
79
|
+
const pendingNudgeId = ref<string | null>(null)
|
|
80
|
+
|
|
61
81
|
/** A tour is currently running (the overlay mounts off this). */
|
|
62
82
|
const touring = computed(() => activeTourId.value !== null)
|
|
63
83
|
|
|
@@ -77,43 +97,6 @@ export const useTutorialStore = defineStore(
|
|
|
77
97
|
*/
|
|
78
98
|
const ownWindowOpen = computed(() => promptOpen.value || catalogueOpen.value)
|
|
79
99
|
|
|
80
|
-
/**
|
|
81
|
-
* Auto-open the launch prompt, at most once per session and only while the user has
|
|
82
|
-
* never answered it. Callers gate on the rest of the launch context (board ready, no
|
|
83
|
-
* other startup advisory open) — see `pages/index.vue`.
|
|
84
|
-
*/
|
|
85
|
-
function maybeOfferOnLaunch() {
|
|
86
|
-
if (decision.value !== null || promptAutoOpened.value) return
|
|
87
|
-
promptAutoOpened.value = true
|
|
88
|
-
promptOpen.value = true
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** User-driven open (command palette), regardless of any saved decision. */
|
|
92
|
-
function openPrompt() {
|
|
93
|
-
promptOpen.value = true
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Withdraw an offer this store made, because something the user actually has to answer
|
|
98
|
-
* (a startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so
|
|
99
|
-
* the offer returns once that surface is gone. Distinct from {@link closePrompt}: no
|
|
100
|
-
* decision is written EITHER way, but a deferral was not the user's doing, so it must
|
|
101
|
-
* not consume this session's one offer.
|
|
102
|
-
*
|
|
103
|
-
* Only ever withdraws the auto-opened prompt; a prompt the user opened themselves from
|
|
104
|
-
* the palette is theirs to close.
|
|
105
|
-
*/
|
|
106
|
-
function deferPrompt() {
|
|
107
|
-
if (!promptAutoOpened.value) return
|
|
108
|
-
promptOpen.value = false
|
|
109
|
-
promptAutoOpened.value = false
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Close without answering: no decision is written, so the next launch asks again. */
|
|
113
|
-
function closePrompt() {
|
|
114
|
-
promptOpen.value = false
|
|
115
|
-
}
|
|
116
|
-
|
|
117
100
|
/**
|
|
118
101
|
* Open the catalogue. Closes the launch prompt WITHOUT answering it: browsing the full
|
|
119
102
|
* list is not "no thanks" (it is the opposite), and the two are modals that would
|
|
@@ -131,7 +114,7 @@ export const useTutorialStore = defineStore(
|
|
|
131
114
|
|
|
132
115
|
/** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
|
|
133
116
|
function decline() {
|
|
134
|
-
|
|
117
|
+
record.declineOffer()
|
|
135
118
|
promptOpen.value = false
|
|
136
119
|
}
|
|
137
120
|
|
|
@@ -141,7 +124,7 @@ export const useTutorialStore = defineStore(
|
|
|
141
124
|
* leaving `declined` in place would misdescribe what happened).
|
|
142
125
|
*/
|
|
143
126
|
function startTour(tourId: string) {
|
|
144
|
-
|
|
127
|
+
record.acceptOffer()
|
|
145
128
|
promptOpen.value = false
|
|
146
129
|
catalogueOpen.value = false
|
|
147
130
|
activeTourId.value = tourId
|
|
@@ -171,7 +154,7 @@ export const useTutorialStore = defineStore(
|
|
|
171
154
|
startTour(tourId)
|
|
172
155
|
return
|
|
173
156
|
}
|
|
174
|
-
|
|
157
|
+
record.acceptOffer()
|
|
175
158
|
promptOpen.value = false
|
|
176
159
|
catalogueOpen.value = false
|
|
177
160
|
activeTourId.value = tourId
|
|
@@ -211,9 +194,7 @@ export const useTutorialStore = defineStore(
|
|
|
211
194
|
/** Finish the running tour: record completion (idempotent) and clear the cursor. */
|
|
212
195
|
function completeTour() {
|
|
213
196
|
const id = activeTourId.value
|
|
214
|
-
if (id
|
|
215
|
-
completedTourIds.value = [...completedTourIds.value, id]
|
|
216
|
-
}
|
|
197
|
+
if (id) record.markCompleted(id)
|
|
217
198
|
// A finished tour has no position left to resume, and an offer to resume the walkthrough
|
|
218
199
|
// the user just completed would sit beside its own Completed badge.
|
|
219
200
|
if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
|
|
@@ -225,45 +206,50 @@ export const useTutorialStore = defineStore(
|
|
|
225
206
|
return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
|
|
226
207
|
}
|
|
227
208
|
|
|
228
|
-
|
|
229
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Hold out the contextual offer for a tour that just became takeable.
|
|
211
|
+
*
|
|
212
|
+
* Spending the id and raising the offer are ONE action, so an offer can never be made twice
|
|
213
|
+
* however many times the gates flip: the guard is the persisted list, not the visible state.
|
|
214
|
+
* Idempotent, so a caller re-evaluating the catalogue needs no check of its own.
|
|
215
|
+
*/
|
|
216
|
+
function offerNudge(tourId: string) {
|
|
217
|
+
if (record.wasNudged(tourId)) return
|
|
218
|
+
record.markNudged(tourId)
|
|
219
|
+
pendingNudgeId.value = tourId
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Take the offer off screen. It is already spent, so it does not come back. */
|
|
223
|
+
function dismissNudge() {
|
|
224
|
+
pendingNudgeId.value = null
|
|
230
225
|
}
|
|
231
226
|
|
|
232
227
|
/**
|
|
233
|
-
* Forget everything this browser remembers about the tutorial
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* The decision goes with it deliberately. "Reset" is asked for by someone handing the app
|
|
237
|
-
* to a colleague, demoing it, or re-walking the product after it changed — and every one
|
|
238
|
-
* of those wants the first-launch experience back, which a cleared completion list alone
|
|
239
|
-
* does not restore. It does NOT re-open the prompt in this session: `promptAutoOpened` is
|
|
240
|
-
* session state and stays spent, so the offer returns at the next launch rather than
|
|
241
|
-
* appearing on top of the catalogue the user is still reading.
|
|
228
|
+
* Forget everything this browser remembers about the tutorial (see `record.reset`), plus the
|
|
229
|
+
* offer currently on screen.
|
|
242
230
|
*
|
|
243
|
-
*
|
|
244
|
-
* the
|
|
231
|
+
* It does NOT re-open the prompt in this session: `promptAutoOpened` is session state and stays
|
|
232
|
+
* spent, so the offer returns at the next launch rather than appearing on top of the catalogue
|
|
233
|
+
* the user is still reading. A running tour is left alone: this clears a record, it does not
|
|
234
|
+
* interrupt a walkthrough the user is in the middle of (which would end it, unrecorded, on a
|
|
235
|
+
* click about history).
|
|
245
236
|
*/
|
|
246
237
|
function resetProgress() {
|
|
247
|
-
|
|
238
|
+
record.reset()
|
|
248
239
|
interrupted.value = null
|
|
249
|
-
|
|
240
|
+
pendingNudgeId.value = null
|
|
250
241
|
}
|
|
251
242
|
|
|
252
243
|
return {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
promptOpen,
|
|
256
|
-
promptAutoOpened,
|
|
244
|
+
...record,
|
|
245
|
+
...prompt,
|
|
257
246
|
catalogueOpen,
|
|
258
247
|
activeTourId,
|
|
259
248
|
stepIndex,
|
|
260
249
|
interrupted,
|
|
250
|
+
pendingNudgeId,
|
|
261
251
|
touring,
|
|
262
252
|
ownWindowOpen,
|
|
263
|
-
maybeOfferOnLaunch,
|
|
264
|
-
openPrompt,
|
|
265
|
-
closePrompt,
|
|
266
|
-
deferPrompt,
|
|
267
253
|
openCatalogue,
|
|
268
254
|
closeCatalogue,
|
|
269
255
|
resetProgress,
|
|
@@ -273,9 +259,10 @@ export const useTutorialStore = defineStore(
|
|
|
273
259
|
setStepIndex,
|
|
274
260
|
stopTour,
|
|
275
261
|
completeTour,
|
|
276
|
-
isCompleted,
|
|
277
262
|
interruptedAt,
|
|
263
|
+
offerNudge,
|
|
264
|
+
dismissNudge,
|
|
278
265
|
}
|
|
279
266
|
},
|
|
280
|
-
{ persist: { pick: ['decision', 'completedTourIds'] } },
|
|
267
|
+
{ persist: { pick: ['decision', 'completedTourIds', 'nudgedTourIds'] } },
|
|
281
268
|
)
|
|
@@ -25,6 +25,7 @@ import { useSharedStacksStore } from '~/stores/sharedStacks'
|
|
|
25
25
|
import { useSkillsStore } from '~/stores/skills'
|
|
26
26
|
import { useTaskTypesStore } from '~/stores/taskTypes'
|
|
27
27
|
import { useTrackerStore } from '~/stores/tracker'
|
|
28
|
+
import { useTutorialStore } from '~/stores/tutorial'
|
|
28
29
|
import { useWorkspaceSettingsStore } from '~/stores/workspaceSettings'
|
|
29
30
|
import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
|
|
30
31
|
|
|
@@ -60,6 +61,10 @@ export function resetPerBoardCaches() {
|
|
|
60
61
|
*/
|
|
61
62
|
export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?: number) {
|
|
62
63
|
useUserSettingsStore().hydrate(snapshot.userSettings ?? null)
|
|
64
|
+
// The signed-in user's tutorial progress MERGES rather than replaces (see the store): both id
|
|
65
|
+
// lists are grow-only sets, so a snapshot must never un-say a walkthrough this browser finished
|
|
66
|
+
// while the mirror write was failing. Absent ⇒ no server copy, and the local one stands.
|
|
67
|
+
useTutorialStore().mergeServerProgress(snapshot.tutorialProgress ?? null)
|
|
63
68
|
useBoardStore().hydrate(snapshot.blocks, boardSince)
|
|
64
69
|
useBoardStore().hydrateArchived(snapshot.archivedServices ?? [])
|
|
65
70
|
usePipelinesStore().hydrate(
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Bugfix REPRODUCTION-PROOF shapes: the declared reproducing check as the executor-harness ran it
|
|
2
|
+
// against the pre-fix tree and the final tree, and the verdict it computed from the two exit codes.
|
|
3
|
+
//
|
|
4
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth).
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
ReproductionProofMode,
|
|
8
|
+
ReproductionPhaseOutcome,
|
|
9
|
+
ReproductionReport,
|
|
10
|
+
ReproductionStatus,
|
|
11
|
+
} from '@cat-factory/contracts'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import type { PipelineStep, StepPhaseMetrics } from '~/types/execution'
|
|
3
|
-
import { foldRunPhaseMetrics, totalInputTokens } from './observability'
|
|
3
|
+
import { foldRunPhaseMetrics, formatCost, sumCosts, totalInputTokens } from './observability'
|
|
4
4
|
|
|
5
5
|
describe('totalInputTokens', () => {
|
|
6
6
|
it('sums all three input classes, so the headline matches Claude Code’s context gauge', () => {
|
|
@@ -85,3 +85,46 @@ describe('foldRunPhaseMetrics', () => {
|
|
|
85
85
|
expect(row.calls).toBe(3)
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
describe('formatCost', () => {
|
|
90
|
+
it('omits the figure entirely when nothing priced it', () => {
|
|
91
|
+
// Null, never "0.00": a deployment that cannot price a model and a step that cost nothing
|
|
92
|
+
// are opposite facts, and rendering both as zero states the wrong one confidently.
|
|
93
|
+
expect(formatCost(null, 'EUR')).toBeNull()
|
|
94
|
+
expect(formatCost(undefined, 'EUR')).toBeNull()
|
|
95
|
+
// A genuine zero still renders — it is a real, priced answer.
|
|
96
|
+
expect(formatCost(0, 'EUR')).toBe('0.00 EUR')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps more decimals under a unit, where most steps land', () => {
|
|
100
|
+
expect(formatCost(0.0037, 'EUR')).toBe('0.0037 EUR')
|
|
101
|
+
expect(formatCost(12.5, 'EUR')).toBe('12.50 EUR')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows a threshold rather than rounding a real cost down to zero', () => {
|
|
105
|
+
// `0.0000` makes a priced-but-tiny step read as free — the same claim the null case is
|
|
106
|
+
// careful not to make. A cheap step is not a free one.
|
|
107
|
+
expect(formatCost(0.00001, 'EUR')).toBe('<0.0001 EUR')
|
|
108
|
+
expect(formatCost(0.0001, 'EUR')).toBe('0.0001 EUR')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('labels the amount with the currency it was priced in rather than assuming one', () => {
|
|
112
|
+
// The price table's currency is operator-configured; the built-in one is EUR, not USD.
|
|
113
|
+
expect(formatCost(1, 'USD')).toBe('1.00 USD')
|
|
114
|
+
expect(formatCost(1)).toBe('1.00')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('sumCosts', () => {
|
|
119
|
+
it('adds the parts it can price', () => {
|
|
120
|
+
expect(sumCosts([1, 2, 0.5])).toBe(3.5)
|
|
121
|
+
expect(sumCosts([])).toBe(0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('declines to answer when any part is unpriced, rather than under-reporting', () => {
|
|
125
|
+
// A total that silently dropped its unpriceable term is a smaller number that still reads
|
|
126
|
+
// as complete — strictly worse than no number.
|
|
127
|
+
expect(sumCosts([1, null, 2])).toBeNull()
|
|
128
|
+
expect(sumCosts([undefined])).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -36,6 +36,52 @@ export function totalInputTokens(m: {
|
|
|
36
36
|
return m.promptTokens + (m.cacheReadTokens ?? 0) + (m.cacheWriteTokens ?? 0)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Smallest amount {@link formatCost} will print as a figure. Below it, four decimals round to
|
|
41
|
+
* `0.0000`, which is the same "free" claim a null renders as `0.00` — so such an amount is
|
|
42
|
+
* shown as a threshold instead.
|
|
43
|
+
*/
|
|
44
|
+
const MIN_RENDERED_COST = 0.0001
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format an estimated cost for display, or null when there is nothing honest to show.
|
|
48
|
+
*
|
|
49
|
+
* Null in ⇒ null out, and the caller renders the tokens WITHOUT a money figure: a cost the
|
|
50
|
+
* deployment could not price and a cost of zero are different facts, and `0.00` claims the
|
|
51
|
+
* second one. Small amounts keep more decimals because most steps land well under a unit and
|
|
52
|
+
* rounding them all to `0.00` would make the whole column useless; an amount too small even
|
|
53
|
+
* for those decimals is rendered as `<0.0001` rather than rounded down to the zero this
|
|
54
|
+
* function exists to avoid printing.
|
|
55
|
+
*/
|
|
56
|
+
export function formatCost(amount: number | null | undefined, currency?: string): string | null {
|
|
57
|
+
if (amount == null) return null
|
|
58
|
+
const value = formatCostAmount(amount)
|
|
59
|
+
// The currency is a bare ISO code beside the number rather than a locale symbol: the amounts
|
|
60
|
+
// come from a deployment-configured table whose code is whatever an operator set, and a
|
|
61
|
+
// symbol we guessed for an unrecognised code would be a wrong label on a right number.
|
|
62
|
+
return currency ? `${value} ${currency}` : value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCostAmount(amount: number): string {
|
|
66
|
+
if (amount > 0 && amount < MIN_RENDERED_COST) return `<${MIN_RENDERED_COST}`
|
|
67
|
+
// Four decimals under a unit, where most steps land; two above it, where they read as money.
|
|
68
|
+
return amount.toFixed(amount > 0 && amount < 1 ? 4 : 2)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sum costs across rows the way the backend folds do: NULL contaminates rather than being
|
|
73
|
+
* skipped as zero, so a total that could not price one of its parts declines to answer instead
|
|
74
|
+
* of reporting a smaller number that reads as complete.
|
|
75
|
+
*/
|
|
76
|
+
export function sumCosts(values: readonly (number | null | undefined)[]): number | null {
|
|
77
|
+
let total = 0
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
if (value == null) return null
|
|
80
|
+
total += value
|
|
81
|
+
}
|
|
82
|
+
return total
|
|
83
|
+
}
|
|
84
|
+
|
|
39
85
|
/** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
|
|
40
86
|
export function formatMs(ms: number): string {
|
|
41
87
|
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
@@ -78,6 +124,7 @@ const EMPTY_PHASE: Omit<StepPhaseMetrics, 'phase'> = {
|
|
|
78
124
|
completionTokens: 0,
|
|
79
125
|
carryCostTokens: 0,
|
|
80
126
|
errors: 0,
|
|
127
|
+
costEstimate: 0,
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
/**
|
|
@@ -116,6 +163,9 @@ export function foldRunPhaseMetrics(steps: readonly PipelineStep[]): StepPhaseMe
|
|
|
116
163
|
completionTokens: prev.completionTokens + row.completionTokens,
|
|
117
164
|
carryCostTokens: prev.carryCostTokens + row.carryCostTokens,
|
|
118
165
|
errors: prev.errors + row.errors,
|
|
166
|
+
// Same contaminating sum the backend fold uses: one unpriced phase makes the run's
|
|
167
|
+
// figure unknown rather than quietly smaller.
|
|
168
|
+
costEstimate: sumCosts([prev.costEstimate, row.costEstimate]),
|
|
119
169
|
})
|
|
120
170
|
}
|
|
121
171
|
}
|