@cat-factory/app 0.201.1 → 0.204.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 +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
- import type { TutorialTour } from '~/utils/tutorial'
2
+ import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
3
3
 
4
4
  /**
5
5
  * The first-party tutorial-tour catalog, contributed to the `tutorialTours` slot the same
@@ -27,11 +27,15 @@ import type { TutorialTour } from '~/utils/tutorial'
27
27
  * - A step whose branch of the flow this board simply isn't on declares `when`, so it is
28
28
  * DROPPED rather than skipped: a skip is reported as an abridged tour, and a parked run
29
29
  * that has a decision and no approval gate is not an abridged anything.
30
+ * - A tour's own preconditions are DECLARED ({@link TUTORIAL_REQUIREMENTS}), never an
31
+ * anonymous predicate: the catalogue lists every tour this deployment ships and has to say
32
+ * what a user must do before one it is holding back becomes available.
30
33
  *
31
34
  * Together the tours below walk the delivery loop end to end — get a repo onto the board,
32
35
  * put a task on it, run it, answer it when it asks, read the result and merge it — with each
33
- * later tour gated on the state the previous one produces, so the launch prompt only ever
34
- * offers what this board can actually demonstrate.
36
+ * later tour requiring the state the previous one produces, so the launch prompt only ever
37
+ * offers what this board can actually demonstrate, and the catalogue turns the rest into a
38
+ * to-do list rather than an absence.
35
39
  */
36
40
 
37
41
  /**
@@ -43,6 +47,51 @@ import type { TutorialTour } from '~/utils/tutorial'
43
47
  */
44
48
  export const SAMPLE_REPO = 'kibertoad/cat-factory-sample-repository'
45
49
 
50
+ /**
51
+ * The preconditions the built-in tours declare, each pairing the gate that decides it with
52
+ * the copy that NAMES it — so a tour the board can't run yet is listed with the one thing
53
+ * still missing instead of being silently absent from the catalogue.
54
+ *
55
+ * Shared constants rather than a literal per tour because several tours need the same fact
56
+ * (`service` gates two of them), and a second copy of a requirement is a second reason string
57
+ * to keep in step with the gate it describes.
58
+ */
59
+ export const TUTORIAL_REQUIREMENTS = {
60
+ boardWrite: {
61
+ id: 'board-write',
62
+ labelKey: 'tutorial.requirements.boardWrite',
63
+ met: (gates) => gates.canWriteBoard,
64
+ },
65
+ sourceControl: {
66
+ id: 'source-control',
67
+ labelKey: 'tutorial.requirements.sourceControl',
68
+ met: (gates) => gates.githubAvailable,
69
+ },
70
+ service: {
71
+ id: 'service',
72
+ labelKey: 'tutorial.requirements.service',
73
+ met: (gates) => gates.boardHasService,
74
+ },
75
+ task: {
76
+ id: 'task',
77
+ labelKey: 'tutorial.requirements.task',
78
+ met: (gates) => gates.boardHasTask,
79
+ },
80
+ // One requirement over both kinds of park, mirroring the tour's single `task-resolve`
81
+ // anchor: the card offers ONE attention action whichever way a run is waiting, so splitting
82
+ // this would list two things to go and do where either one alone unlocks the tour.
83
+ waitingAnswer: {
84
+ id: 'waiting-answer',
85
+ labelKey: 'tutorial.requirements.waitingAnswer',
86
+ met: (gates) => gates.boardHasOpenDecision || gates.boardHasPendingApproval,
87
+ },
88
+ finishedRun: {
89
+ id: 'finished-run',
90
+ labelKey: 'tutorial.requirements.finishedRun',
91
+ met: (gates) => gates.boardHasFinishedRun,
92
+ },
93
+ } as const satisfies Record<string, TutorialRequirement>
94
+
46
95
  export const TUTORIAL_TOURS: readonly TutorialTour[] = [
47
96
  {
48
97
  id: 'board-basics',
@@ -116,7 +165,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
116
165
  // repo is a board write against a connected source, and in basic interface mode
117
166
  // add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
118
167
  // worth a tour rather than a hint.
119
- when: (gates) => gates.canWriteBoard && gates.githubAvailable,
168
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.sourceControl],
120
169
  steps: [
121
170
  {
122
171
  id: 'intro',
@@ -172,7 +221,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
172
221
  // hunting for controls and then claim to have taught the core loop. Offering it only
173
222
  // once a service exists is the honest version — and the launch prompt still lists
174
223
  // `board-basics`, which is the tour an empty board can actually deliver.
175
- when: (gates) => gates.canWriteBoard && gates.boardHasService,
224
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.service],
176
225
  steps: [
177
226
  {
178
227
  id: 'intro',
@@ -236,7 +285,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
236
285
  // pipeline it will run, the start control, the live step list. `first-task` stops at the
237
286
  // card, so without this tour a user who finished the shipped walkthrough has never seen
238
287
  // the inspector. Needs a task to open, not merely a service to hold one.
239
- when: (gates) => gates.canWriteBoard && gates.boardHasTask,
288
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.task],
240
289
  steps: [
241
290
  {
242
291
  id: 'intro',
@@ -306,7 +355,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
306
355
  // realise a run is asking them something has a run that never finishes and a workspace
307
356
  // in-flight slot held open. Offered only while something is actually waiting, because
308
357
  // the whole tour anchors on controls that exist only then.
309
- when: (gates) => gates.boardHasOpenDecision || gates.boardHasPendingApproval,
358
+ requires: [TUTORIAL_REQUIREMENTS.waitingAnswer],
310
359
  steps: [
311
360
  {
312
361
  id: 'intro',
@@ -361,7 +410,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
361
410
  // The last mile: a task is only DONE when its PR actually merged, so a user who never
362
411
  // finds the result and the merge control has a board full of finished-looking work that
363
412
  // shipped nothing. Its subject is a run's output, so it needs a run that produced one.
364
- when: (gates) => gates.boardHasFinishedRun,
413
+ requires: [TUTORIAL_REQUIREMENTS.finishedRun],
365
414
  steps: [
366
415
  {
367
416
  id: 'intro',
@@ -142,11 +142,15 @@ const AiPresetMismatchDialog = defineAsyncComponent(
142
142
  () => import('~/components/providers/AiPresetMismatchDialog.vue'),
143
143
  )
144
144
  // The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
145
- // it) and the coach-mark overlay that runs a tour. Both mount only while their store flag
146
- // is set, so they cost the initial bundle nothing.
145
+ // it), the catalogue of every tour the deployment ships (opened from the sidebar's Help
146
+ // section or the palette, at any time), and the coach-mark overlay that runs a tour. All
147
+ // mount only while their store flag is set, so they cost the initial bundle nothing.
147
148
  const TutorialPrompt = defineAsyncComponent(
148
149
  () => import('~/components/tutorial/TutorialPrompt.vue'),
149
150
  )
151
+ const TutorialCatalogue = defineAsyncComponent(
152
+ () => import('~/components/tutorial/TutorialCatalogue.vue'),
153
+ )
150
154
  const TutorialOverlay = defineAsyncComponent(
151
155
  () => import('~/components/tutorial/TutorialOverlay.vue'),
152
156
  )
@@ -512,6 +516,7 @@ watch(
512
516
  <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
513
517
  <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
514
518
  <TutorialPrompt v-if="tutorial.promptOpen" />
519
+ <TutorialCatalogue v-if="tutorial.catalogueOpen" />
515
520
  <TutorialOverlay v-if="tutorial.touring" />
516
521
  </template>
517
522
 
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { usePipelinesStore } from '~/stores/pipelines'
3
+
4
+ /**
5
+ * The pipeline-builder draft's binary-output selection (`stepOptions.binaryOutput`), the store
6
+ * half of the picker (docs/initiatives/binary-output-foundational-storage.md). Same contract as
7
+ * the skill and variant helpers beside it — merge into the options bag, normalize an emptied bag
8
+ * back to null — plus the one rule specific to this field: an empty `contextServiceIds` is
9
+ * DROPPED rather than persisted, because `[]` and absence are different claims to the agent.
10
+ */
11
+ describe('pipelines store — per-step binary-output selection', () => {
12
+ it('sets, reads and clears the selection', () => {
13
+ const pipelines = usePipelinesStore()
14
+ pipelines.addToDraft('coder')
15
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
16
+
17
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
18
+ expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
19
+ expect(pipelines.draftStepOptions[0]).toEqual({
20
+ binaryOutput: { storageServiceId: 'file-storage' },
21
+ })
22
+
23
+ // Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
24
+ // so a step that never used it persists exactly the shape it always did.
25
+ pipelines.setDraftBinaryOutput(0, undefined)
26
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
27
+ expect(pipelines.draftStepOptions[0]).toBeNull()
28
+ })
29
+
30
+ // `[]` reads as "context was considered and rejected", which the brief would then repeat to
31
+ // the agent; absence reads as "no scope service was selected". Only one of those is true.
32
+ it('drops an emptied context list rather than persisting an empty array', () => {
33
+ const pipelines = usePipelinesStore()
34
+ pipelines.addToDraft('coder')
35
+
36
+ pipelines.setDraftBinaryOutput(0, {
37
+ storageServiceId: 'file-storage',
38
+ contextServiceIds: ['asset-management'],
39
+ })
40
+ expect(pipelines.draftBinaryOutput(0)?.contextServiceIds).toEqual(['asset-management'])
41
+
42
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage', contextServiceIds: [] })
43
+ expect(pipelines.draftBinaryOutput(0)).toEqual({ storageServiceId: 'file-storage' })
44
+ })
45
+
46
+ // A blank storage id is not a selection: the backend refuses the step either way, so the
47
+ // draft must not carry a half-filled shape that saves and then fails.
48
+ it('treats a blank storage id as no selection at all', () => {
49
+ const pipelines = usePipelinesStore()
50
+ pipelines.addToDraft('coder')
51
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: '', contextServiceIds: ['inventory'] })
52
+ expect(pipelines.draftBinaryOutput(0)).toBeUndefined()
53
+ expect(pipelines.draftStepOptions[0]).toBeNull()
54
+ })
55
+
56
+ it('merges into the options bag rather than clobbering other per-step options', () => {
57
+ const pipelines = usePipelinesStore()
58
+ pipelines.addToDraft('coder')
59
+ pipelines.draftStepOptions[0] = { agentVariantId: 'acme:fast' }
60
+
61
+ pipelines.setDraftBinaryOutput(0, { storageServiceId: 'file-storage' })
62
+ expect(pipelines.draftStepOptions[0]).toEqual({
63
+ agentVariantId: 'acme:fast',
64
+ binaryOutput: { storageServiceId: 'file-storage' },
65
+ })
66
+
67
+ pipelines.setDraftBinaryOutput(0, undefined)
68
+ expect(pipelines.draftStepOptions[0]).toEqual({ agentVariantId: 'acme:fast' })
69
+ })
70
+ })
@@ -1,4 +1,4 @@
1
- import type { StepOptions } from '@cat-factory/contracts'
1
+ import type { BinaryOutputConfig, StepOptions } from '@cat-factory/contracts'
2
2
  import type { ConsensusStepConfig } from '~/types/consensus'
3
3
  import { defaultConsensusConfig, type PipelinesContext } from './context'
4
4
 
@@ -6,7 +6,8 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
6
6
  * The pipeline-builder draft's PER-STEP CONFIG toggles: consensus (inline panel and the workspace
7
7
  * consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
8
8
  * follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
9
- * (requirements auto-recommendation, the picked skill, the picked agent-kind variant).
9
+ * (requirements auto-recommendation, the picked skill, the picked agent-kind variant, the
10
+ * per-step output-token ceiling, the binary-output storage/context selection).
10
11
  *
11
12
  * Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
12
13
  * units). Every function here reads and writes one of the parallel per-step arrays at an index and
@@ -160,6 +161,39 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
160
161
  draftStepOptions.value[index] = Object.keys(next).length ? next : null
161
162
  }
162
163
 
164
+ /**
165
+ * The binary-output SELECTION on the draft step at `index` (its `stepOptions.binaryOutput`) —
166
+ * the foundational storage service a generator kind's artifacts are stored through, plus any
167
+ * services consulted for the generation's scope. Undefined on every step of every stock
168
+ * pipeline; required on a step whose kind carries the `binary-output` trait.
169
+ */
170
+ function draftBinaryOutput(index: number): BinaryOutputConfig | undefined {
171
+ return draftStepOptions.value[index]?.binaryOutput
172
+ }
173
+
174
+ /**
175
+ * Set (or clear) the binary-output selection on the draft step at `index`. Merges into the
176
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
177
+ * bag empties, the whole entry — exactly like the other options here, so a step that never
178
+ * used it persists the shape it always did.
179
+ *
180
+ * An EMPTY `contextServiceIds` is dropped rather than stored, for the reason the consensus
181
+ * tier set drops its own empty array: the field's absence means "no scope service was
182
+ * selected", while `[]` reads as "context was considered and rejected" — a different claim,
183
+ * and one the brief renderer would repeat to the agent.
184
+ */
185
+ function setDraftBinaryOutput(index: number, config: BinaryOutputConfig | undefined) {
186
+ const next: StepOptions = { ...draftStepOptions.value[index] }
187
+ if (config?.storageServiceId) {
188
+ const { storageServiceId, contextServiceIds } = config
189
+ next.binaryOutput = {
190
+ storageServiceId,
191
+ ...(contextServiceIds?.length ? { contextServiceIds } : {}),
192
+ }
193
+ } else delete next.binaryOutput
194
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
195
+ }
196
+
163
197
  /**
164
198
  * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
165
199
  * inherits (the workspace's per-kind setting, else the deployment default).
@@ -197,6 +231,8 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
197
231
  setDraftSkillId,
198
232
  draftAgentVariantId,
199
233
  setDraftAgentVariantId,
234
+ draftBinaryOutput,
235
+ setDraftBinaryOutput,
200
236
  draftMaxOutputTokens,
201
237
  setDraftMaxOutputTokens,
202
238
  }
@@ -64,6 +64,81 @@ describe('useTutorialStore launch prompt', () => {
64
64
  })
65
65
  })
66
66
 
67
+ describe('useTutorialStore catalogue', () => {
68
+ it('opens over the launch prompt without answering it', () => {
69
+ // Browsing the full list is not "no thanks" — it is the opposite — so the offer must
70
+ // return next launch if the user browses and starts nothing. And the two are modals:
71
+ // leaving the prompt open would stack them.
72
+ const tutorial = useTutorialStore()
73
+ tutorial.maybeOfferOnLaunch()
74
+ tutorial.openCatalogue()
75
+ expect(tutorial.catalogueOpen).toBe(true)
76
+ expect(tutorial.promptOpen).toBe(false)
77
+ expect(tutorial.decision).toBeNull()
78
+ })
79
+
80
+ it('closes when a tour starts or resumes from it', () => {
81
+ const tutorial = useTutorialStore()
82
+ tutorial.openCatalogue()
83
+ tutorial.startTour('board-basics')
84
+ expect(tutorial.catalogueOpen).toBe(false)
85
+
86
+ tutorial.setStepIndex(2)
87
+ tutorial.stopTour()
88
+ tutorial.openCatalogue()
89
+ tutorial.resumeTour('board-basics')
90
+ expect(tutorial.catalogueOpen).toBe(false)
91
+ })
92
+
93
+ it('reports its own window as open, so the coach marks stand down', () => {
94
+ // The overlay renders at z-[70] to sit ABOVE the app's modals, since a step legitimately
95
+ // points into one. The catalogue is openable mid-tour (that is what the `continue` action
96
+ // is for), and there the same z-index would float a ring and a tooltip over the window the
97
+ // user just opened. The tour itself is untouched — only the marks go.
98
+ const tutorial = useTutorialStore()
99
+ tutorial.startTour('board-basics')
100
+ expect(tutorial.ownWindowOpen).toBe(false)
101
+
102
+ tutorial.openCatalogue()
103
+ expect(tutorial.ownWindowOpen).toBe(true)
104
+ expect(tutorial.activeTourId).toBe('board-basics')
105
+
106
+ tutorial.closeCatalogue()
107
+ expect(tutorial.ownWindowOpen).toBe(false)
108
+
109
+ tutorial.openPrompt()
110
+ expect(tutorial.ownWindowOpen).toBe(true)
111
+ })
112
+
113
+ it('resets every record of progress, including the answered offer', () => {
114
+ // What someone handing the app to a colleague is asking for: the first-launch experience
115
+ // back. Clearing only the completion list would leave the offer answered, so the prompt
116
+ // they are trying to demo would never appear.
117
+ const tutorial = useTutorialStore()
118
+ tutorial.startTour('board-basics')
119
+ tutorial.completeTour()
120
+ tutorial.startTour('run-task')
121
+ tutorial.setStepIndex(2)
122
+ tutorial.stopTour()
123
+
124
+ tutorial.resetProgress()
125
+ expect(tutorial.completedTourIds).toEqual([])
126
+ expect(tutorial.interruptedAt('run-task')).toBeNull()
127
+ expect(tutorial.decision).toBeNull()
128
+ })
129
+
130
+ it('leaves a running tour alone when progress is reset', () => {
131
+ // A click about history must not end the walkthrough the user is in the middle of —
132
+ // which would also leave it unrecorded, since nothing marks a stopped tour complete.
133
+ const tutorial = useTutorialStore()
134
+ tutorial.startTour('board-basics')
135
+ tutorial.setStepIndex(2)
136
+ tutorial.resetProgress()
137
+ expect(tutorial.activeTourId).toBe('board-basics')
138
+ expect(tutorial.stepIndex).toBe(2)
139
+ })
140
+ })
141
+
67
142
  describe('useTutorialStore tours', () => {
68
143
  it('starting a tour records acceptance, closes the prompt, and resets the cursor', () => {
69
144
  const tutorial = useTutorialStore()
@@ -133,3 +208,95 @@ describe('useTutorialStore tours', () => {
133
208
  expect(tutorial.isCompleted('made-up')).toBe(false)
134
209
  })
135
210
  })
211
+
212
+ describe('useTutorialStore resuming a broken-off tour', () => {
213
+ it('offers to resume a tour abandoned part-way', () => {
214
+ // Esc and Skip are both cheap to hit — one by accident, one to get the overlay out of the
215
+ // way for a moment — and before this the position they discarded was the whole walkthrough.
216
+ const tutorial = useTutorialStore()
217
+ tutorial.startTour('board-basics')
218
+ tutorial.setStepIndex(3)
219
+ tutorial.stopTour()
220
+ expect(tutorial.interruptedAt('board-basics')).toBe(3)
221
+
222
+ tutorial.resumeTour('board-basics')
223
+ expect(tutorial.activeTourId).toBe('board-basics')
224
+ expect(tutorial.stepIndex).toBe(3)
225
+ // Consumed: the tour is running again, so there is no longer a position to go back to.
226
+ expect(tutorial.interruptedAt('board-basics')).toBeNull()
227
+ })
228
+
229
+ it('offers nothing for a tour abandoned on its very first step', () => {
230
+ // Resuming and starting are the same thing there, so a Resume label would be noise.
231
+ const tutorial = useTutorialStore()
232
+ tutorial.startTour('board-basics')
233
+ tutorial.stopTour()
234
+ expect(tutorial.interruptedAt('board-basics')).toBeNull()
235
+ })
236
+
237
+ it('keeps the resume point scoped to the tour it belongs to', () => {
238
+ const tutorial = useTutorialStore()
239
+ tutorial.startTour('run-task')
240
+ tutorial.setStepIndex(2)
241
+ tutorial.stopTour()
242
+ expect(tutorial.interruptedAt('first-task')).toBeNull()
243
+ // Resuming a DIFFERENT tour degrades to a plain start rather than resuming the wrong one.
244
+ tutorial.resumeTour('first-task')
245
+ expect(tutorial.activeTourId).toBe('first-task')
246
+ expect(tutorial.stepIndex).toBe(0)
247
+ })
248
+
249
+ it('discards the resume point when the same tour is started from the top', () => {
250
+ const tutorial = useTutorialStore()
251
+ tutorial.startTour('board-basics')
252
+ tutorial.setStepIndex(3)
253
+ tutorial.stopTour()
254
+ tutorial.startTour('board-basics')
255
+ expect(tutorial.stepIndex).toBe(0)
256
+ expect(tutorial.interruptedAt('board-basics')).toBeNull()
257
+ })
258
+
259
+ it('keeps another tour’s resume point when a different tour is started from the top', () => {
260
+ // Starting a tour discards ITS own stale position, not somebody else's. Glancing at
261
+ // another tour and pressing Esc at step 0 must not cost the position you were coming
262
+ // back to — that one loses the single slot only when this tour is broken off past step 0.
263
+ const tutorial = useTutorialStore()
264
+ tutorial.startTour('board-basics')
265
+ tutorial.setStepIndex(3)
266
+ tutorial.stopTour()
267
+
268
+ tutorial.startTour('run-task')
269
+ tutorial.stopTour()
270
+ expect(tutorial.interruptedAt('board-basics')).toBe(3)
271
+
272
+ // ...and it does lose it the moment the other tour is broken off past its first step.
273
+ tutorial.startTour('run-task')
274
+ tutorial.setStepIndex(1)
275
+ tutorial.stopTour()
276
+ expect(tutorial.interruptedAt('board-basics')).toBeNull()
277
+ expect(tutorial.interruptedAt('run-task')).toBe(1)
278
+ })
279
+
280
+ it('leaves no resume point behind a completed tour', () => {
281
+ // It would sit beside that tour's own Completed badge, offering to resume what just finished.
282
+ const tutorial = useTutorialStore()
283
+ tutorial.startTour('board-basics')
284
+ tutorial.setStepIndex(3)
285
+ tutorial.stopTour()
286
+ tutorial.resumeTour('board-basics')
287
+ tutorial.setStepIndex(4)
288
+ tutorial.completeTour()
289
+ expect(tutorial.interruptedAt('board-basics')).toBeNull()
290
+ expect(tutorial.isCompleted('board-basics')).toBe(true)
291
+ })
292
+
293
+ it('leaves no resume point when the runtime bails out on an unusable position', () => {
294
+ // The overlay could not resolve the tour at all; handing that position back would put the
295
+ // user straight into the same dead overlay.
296
+ const tutorial = useTutorialStore()
297
+ tutorial.startTour('gone-away')
298
+ tutorial.setStepIndex(2)
299
+ tutorial.stopTour({ resumable: false })
300
+ expect(tutorial.interruptedAt('gone-away')).toBeNull()
301
+ })
302
+ })