@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.
Files changed (47) hide show
  1. package/README.md +107 -13
  2. package/app/components/observability/StepMetricsBar.vue +11 -0
  3. package/app/components/panels/AgentStepDetail.vue +14 -0
  4. package/app/components/panels/MergerResultView.vue +20 -2
  5. package/app/components/panels/ObservabilityPanel.vue +57 -0
  6. package/app/components/panels/ResultWindowShell.vue +77 -0
  7. package/app/components/panels/StepReproductionReport.vue +167 -0
  8. package/app/components/tutorial/TutorialCatalogue.vue +14 -1
  9. package/app/components/tutorial/TutorialNudge.vue +107 -0
  10. package/app/components/tutorial/TutorialOverlay.vue +92 -11
  11. package/app/composables/api/execution.ts +5 -2
  12. package/app/composables/api/tutorial.ts +25 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +4 -0
  15. package/app/composables/useTutorialNudge.ts +77 -0
  16. package/app/composables/useTutorialSync.ts +141 -0
  17. package/app/modular/external-tools.spec.ts +1 -0
  18. package/app/modular/nav-contributions.spec.ts +2 -0
  19. package/app/modular/nav-contributions.ts +11 -0
  20. package/app/modular/nav-gates.ts +10 -0
  21. package/app/modular/registry.spec.ts +1 -0
  22. package/app/modular/tutorial-tours.spec.ts +55 -4
  23. package/app/modular/tutorial-tours.ts +231 -9
  24. package/app/pages/index.vue +20 -1
  25. package/app/stores/tutorial.prompt.ts +59 -0
  26. package/app/stores/tutorial.record.ts +191 -0
  27. package/app/stores/tutorial.spec.ts +207 -0
  28. package/app/stores/tutorial.ts +78 -91
  29. package/app/stores/workspace/hydrate.ts +5 -0
  30. package/app/types/domain.ts +3 -0
  31. package/app/types/reproduction.ts +11 -0
  32. package/app/utils/observability.spec.ts +44 -1
  33. package/app/utils/observability.ts +50 -0
  34. package/app/utils/reproduction.ts +51 -0
  35. package/app/utils/tutorial.spec.ts +255 -0
  36. package/app/utils/tutorial.ts +173 -0
  37. package/i18n/locales/de.json +147 -6
  38. package/i18n/locales/en.json +151 -6
  39. package/i18n/locales/es.json +147 -6
  40. package/i18n/locales/fr.json +147 -6
  41. package/i18n/locales/he.json +147 -6
  42. package/i18n/locales/it.json +147 -6
  43. package/i18n/locales/ja.json +147 -6
  44. package/i18n/locales/pl.json +147 -6
  45. package/i18n/locales/tr.json +147 -6
  46. package/i18n/locales/uk.json +147 -6
  47. 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
+ })
@@ -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
- /** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
5
- export type TutorialDecision = 'accepted' | 'declined'
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 user's explicit answer to "would you
14
- * like a tour?" and which tours they have finished. Browser-persisted like the interface
15
- * tier (`uiMode`): it is a per-person, per-browser preference, and `null` (never asked)
16
- * is a real state distinct from `declined` only an explicit answer stops the launch
17
- * prompt from returning, while closing it without answering merely defers it to the
18
- * next launch.
19
- * - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`) a tour is anchored to live
20
- * DOM, so replaying progress across a reload would point step N at a board that hasn't
21
- * reached that state; a reloaded tour restarts from its beginning instead.
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
- * party and consumer-contributed alike — without this store changing. It tracks ids and a
26
- * step cursor; the overlay resolves definitions and drives the cursor.
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
- /** The saved launch-prompt answer. Written only by an explicit accept/decline/start. */
32
- const decision = ref<TutorialDecision | null>(null)
33
- /** Ids of tours the user finished (reached the last step's Done), persisted. */
34
- const completedTourIds = ref<string[]>([])
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
- decision.value = 'declined'
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
- decision.value = 'accepted'
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
- decision.value = 'accepted'
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 && !completedTourIds.value.includes(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
- function isCompleted(tourId: string): boolean {
229
- return completedTourIds.value.includes(tourId)
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: which tours were finished,
234
- * where one was broken off, and the answer to the launch offer.
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
- * A running tour is left alone: this clears a record, it does not interrupt a walkthrough
244
- * the user is in the middle of (which would end it, unrecorded, on a click about history).
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
- completedTourIds.value = []
238
+ record.reset()
248
239
  interrupted.value = null
249
- decision.value = null
240
+ pendingNudgeId.value = null
250
241
  }
251
242
 
252
243
  return {
253
- decision,
254
- completedTourIds,
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(
@@ -86,6 +86,9 @@ export type {
86
86
  UpdateWorkspaceSettingsInput,
87
87
  UserSettings,
88
88
  UpdateUserSettingsInput,
89
+ TutorialProgress,
90
+ UpdateTutorialProgressInput,
91
+ RecordTutorialEventInput,
89
92
  InfraSetup,
90
93
  InfraSetupStatus,
91
94
  InfraSetupArea,
@@ -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
  }