@cat-factory/app 0.202.0 → 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.
- package/README.md +62 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +40 -33
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +92 -43
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +38 -2
- package/app/stores/tutorial.spec.ts +75 -0
- package/app/stores/tutorial.ts +66 -1
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +307 -0
- package/app/utils/binaryOutput.ts +343 -0
- package/app/utils/tutorial.spec.ts +120 -8
- package/app/utils/tutorial.ts +166 -21
- package/i18n/locales/de.json +88 -8
- package/i18n/locales/en.json +94 -8
- package/i18n/locales/es.json +88 -8
- package/i18n/locales/fr.json +88 -8
- package/i18n/locales/he.json +88 -8
- package/i18n/locales/it.json +88 -8
- package/i18n/locales/ja.json +88 -8
- package/i18n/locales/pl.json +88 -8
- package/i18n/locales/tr.json +88 -8
- package/i18n/locales/uk.json +88 -8
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@ import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
|
7
7
|
import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
|
|
8
8
|
import EstimateThresholdFields from '~/components/pipeline/EstimateThresholdFields.vue'
|
|
9
9
|
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
10
|
+
import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker.vue'
|
|
10
11
|
import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
|
|
11
12
|
import { showOverrideField } from '~/utils/uiMode'
|
|
12
13
|
import {
|
|
@@ -202,6 +203,36 @@ const stepsDisallowedByPurpose = computed(() =>
|
|
|
202
203
|
}),
|
|
203
204
|
)
|
|
204
205
|
|
|
206
|
+
// The workspace's foundational-services catalog, for the binary-output storage/context picker.
|
|
207
|
+
const foundational = useFoundationalServicesStore()
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether this step's kind is a BINARY-OUTPUT generator, and therefore needs the storage +
|
|
211
|
+
* context picker. Read off the kind's projected `binaryOutput` flag rather than a kind-id list,
|
|
212
|
+
* so a deployment's generator opts in by carrying the trait exactly as the engine's own checks
|
|
213
|
+
* key on it.
|
|
214
|
+
*
|
|
215
|
+
* Deliberately NOT behind `showOverrideField` / `isAdvanced` the way the variant picker is: a
|
|
216
|
+
* variant OVERRIDES what the kind ships, while this selection is REQUIRED. A basic-mode user
|
|
217
|
+
* who cannot see it has a step that cannot be saved and no way to find out why.
|
|
218
|
+
*/
|
|
219
|
+
function showBinaryOutputPicker(kind: AgentKind): boolean {
|
|
220
|
+
return agentKindMeta(kind).binaryOutput === true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// An enabled generator step with no storage selection — mirrors the backend save/start
|
|
224
|
+
// rejection (`assertValidBinaryOutputSteps`), surfaced as an inline hint so the user fixes it
|
|
225
|
+
// before the round trip. Same disposition as `skillStepNeedsPick`, for the same reason: both
|
|
226
|
+
// are a step parametrized by a selection it cannot run without.
|
|
227
|
+
const binaryOutputStepNeedsPick = computed(() =>
|
|
228
|
+
pipelines.draft.some(
|
|
229
|
+
(kind, i) =>
|
|
230
|
+
showBinaryOutputPicker(kind) &&
|
|
231
|
+
pipelines.draftEnabled[i] !== false &&
|
|
232
|
+
!pipelines.draftBinaryOutput(i)?.storageServiceId,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
205
236
|
// A step's picked skill id is no longer in the account catalog (the source dir was renamed or
|
|
206
237
|
// unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
|
|
207
238
|
function skillMissing(index: number): boolean {
|
|
@@ -226,6 +257,11 @@ watch(open, (isOpen) => {
|
|
|
226
257
|
// The workspace's per-kind output ceilings, which the per-step field shows as its inherited
|
|
227
258
|
// placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
|
|
228
259
|
if (isOpen) agentSettings.load().catch(() => {})
|
|
260
|
+
// The resolved foundational-services catalog, which the binary-output picker offers from.
|
|
261
|
+
// Single-flighted per workspace, so this shares the panel's load rather than adding one. A
|
|
262
|
+
// failure is not swallowed into an empty picker: the store records `available: false`, and
|
|
263
|
+
// the picker says the catalog is unreachable rather than "no services exist".
|
|
264
|
+
if (isOpen) void foundational.ensureProbed()
|
|
229
265
|
})
|
|
230
266
|
|
|
231
267
|
function add(kind: AgentKind) {
|
|
@@ -531,6 +567,15 @@ async function clone(p: Pipeline) {
|
|
|
531
567
|
{{ t('pipeline.builder.skillNeedsPick') }}
|
|
532
568
|
</p>
|
|
533
569
|
|
|
570
|
+
<p
|
|
571
|
+
v-if="binaryOutputStepNeedsPick"
|
|
572
|
+
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
573
|
+
data-testid="binary-output-needs-pick"
|
|
574
|
+
>
|
|
575
|
+
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
576
|
+
{{ t('pipeline.builder.binaryOutputNeedsPick') }}
|
|
577
|
+
</p>
|
|
578
|
+
|
|
534
579
|
<p
|
|
535
580
|
v-if="stepsDisallowedByPurpose.length"
|
|
536
581
|
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
@@ -792,6 +837,15 @@ async function clone(p: Pipeline) {
|
|
|
792
837
|
/>
|
|
793
838
|
</div>
|
|
794
839
|
|
|
840
|
+
<!-- Binary-output picker: a generator kind's step is parametrized by the
|
|
841
|
+
foundational STORAGE service its artifacts go through (`stepOptions.binaryOutput`)
|
|
842
|
+
plus any services consulted for the generation's scope. Required, not an
|
|
843
|
+
override — so it shows in both interface tiers. -->
|
|
844
|
+
<BinaryOutputStepPicker
|
|
845
|
+
v-if="showBinaryOutputPicker(unit.kind)"
|
|
846
|
+
:index="unit.index"
|
|
847
|
+
/>
|
|
848
|
+
|
|
795
849
|
<!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
|
|
796
850
|
setting (itself an override of the deployment routing default), so it is
|
|
797
851
|
advanced-only until a value is pinned; empty inherits. -->
|
|
@@ -32,10 +32,13 @@ const back = useIntegrationBack(open)
|
|
|
32
32
|
const RECOMMENDED_SLUGS = [
|
|
33
33
|
'anthropic/claude-fable-5',
|
|
34
34
|
'anthropic/claude-opus-5',
|
|
35
|
-
'openai/gpt-5.
|
|
36
|
-
'
|
|
37
|
-
'
|
|
35
|
+
'openai/gpt-5.6-sol',
|
|
36
|
+
'openai/gpt-5.6-terra',
|
|
37
|
+
'google/gemini-3.1-pro-preview',
|
|
38
|
+
'google/gemini-3.6-flash',
|
|
39
|
+
'deepseek/deepseek-v4-flash',
|
|
38
40
|
'moonshotai/kimi-k2.7-code',
|
|
41
|
+
'z-ai/glm-5.2',
|
|
39
42
|
]
|
|
40
43
|
|
|
41
44
|
// Whether the workspace/user has an OpenRouter key connected at any reachable scope.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic'
|
|
3
|
+
import type { TutorialCatalogueEntry, TutorialTourState } from '~/utils/tutorial'
|
|
4
|
+
|
|
5
|
+
const entry = (
|
|
6
|
+
id: string,
|
|
7
|
+
availability: TutorialCatalogueEntry['availability'],
|
|
8
|
+
stepCount = 3,
|
|
9
|
+
): TutorialCatalogueEntry => ({
|
|
10
|
+
tour: {
|
|
11
|
+
id,
|
|
12
|
+
order: 10,
|
|
13
|
+
titleKey: `tutorial.tours.${id}.title`,
|
|
14
|
+
descriptionKey: `tutorial.tours.${id}.description`,
|
|
15
|
+
steps: Array.from({ length: stepCount }, (_, i) => ({
|
|
16
|
+
id: `s${i}`,
|
|
17
|
+
titleKey: 't',
|
|
18
|
+
bodyKey: 'b',
|
|
19
|
+
})),
|
|
20
|
+
},
|
|
21
|
+
availability,
|
|
22
|
+
unmet:
|
|
23
|
+
availability === 'blocked'
|
|
24
|
+
? [{ id: 'service', labelKey: 'tutorial.requirements.service', met: () => false }]
|
|
25
|
+
: [],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const states = (map: Record<string, TutorialTourState>) => (id: string) => map[id] ?? 'notStarted'
|
|
29
|
+
|
|
30
|
+
describe('buildCatalogueRows', () => {
|
|
31
|
+
it('carries every tour through, ready or not', () => {
|
|
32
|
+
const rows = buildCatalogueRows(
|
|
33
|
+
[entry('a', 'ready'), entry('b', 'blocked'), entry('c', 'not-applicable')],
|
|
34
|
+
states({}),
|
|
35
|
+
)
|
|
36
|
+
expect(rows.map((r) => r.tour.id)).toEqual(['a', 'b', 'c'])
|
|
37
|
+
expect(rows.map((r) => r.startable)).toEqual([true, false, false])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('counts the steps of a runnable tour and withholds a count for the rest', () => {
|
|
41
|
+
// A blocked tour's resolved script is not what the user gets once they unblock it, and a
|
|
42
|
+
// number that quietly changes under them is worse than no number.
|
|
43
|
+
const rows = buildCatalogueRows([entry('a', 'ready', 4), entry('b', 'blocked', 4)], states({}))
|
|
44
|
+
expect(rows[0]?.stepCount).toBe(4)
|
|
45
|
+
expect(rows[1]?.stepCount).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('labels each row from the user`s own progress', () => {
|
|
49
|
+
const rows = buildCatalogueRows(
|
|
50
|
+
[entry('a', 'ready'), entry('b', 'ready'), entry('c', 'ready')],
|
|
51
|
+
states({ a: 'completed', b: 'paused', c: 'inProgress' }),
|
|
52
|
+
)
|
|
53
|
+
expect(rows.map((r) => r.action)).toEqual(['restart', 'resume', 'continue'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps a blocked tour`s unmet requirements for the reason list', () => {
|
|
57
|
+
const [row] = buildCatalogueRows([entry('a', 'blocked')], states({}))
|
|
58
|
+
expect(row?.unmet.map((r) => r.id)).toEqual(['service'])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('summarizeProgress', () => {
|
|
63
|
+
/** The launch offer is still unanswered, so only the rows can make anything resettable. */
|
|
64
|
+
const unanswered = { launchOfferAnswered: false }
|
|
65
|
+
const rows = (map: Record<string, TutorialTourState>, ids: string[]) =>
|
|
66
|
+
buildCatalogueRows(
|
|
67
|
+
ids.map((id) => entry(id, 'ready')),
|
|
68
|
+
states(map),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
it('counts completions against the WHOLE catalog, not the runnable part', () => {
|
|
72
|
+
// Counting only what this board can offer today would move the denominator every time a
|
|
73
|
+
// repo was linked or a run finished — and "2 of 2" on a board with four walkthroughs
|
|
74
|
+
// still waiting reads as a finished tutorial, which is what this surface disproves.
|
|
75
|
+
const all = buildCatalogueRows(
|
|
76
|
+
[entry('a', 'ready'), entry('b', 'blocked'), entry('c', 'not-applicable')],
|
|
77
|
+
states({ a: 'completed' }),
|
|
78
|
+
)
|
|
79
|
+
expect(summarizeProgress(all, unanswered)).toMatchObject({ completed: 1, total: 3 })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('offers a reset for a paused tour, not only for completed ones', () => {
|
|
83
|
+
expect(summarizeProgress(rows({}, ['a', 'b']), unanswered).resettable).toBe(false)
|
|
84
|
+
expect(summarizeProgress(rows({ a: 'paused' }, ['a', 'b']), unanswered).resettable).toBe(true)
|
|
85
|
+
expect(summarizeProgress(rows({ a: 'completed' }, ['a', 'b']), unanswered).resettable).toBe(
|
|
86
|
+
true,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('offers a reset to a user who only ever answered the launch offer', () => {
|
|
91
|
+
// The case keying Reset off the rows alone got wrong, and the one that matters most: someone
|
|
92
|
+
// who clicked "No thanks" and took no tour has nothing completed and nothing paused, yet the
|
|
93
|
+
// saved answer is exactly what stops the prompt returning. Hiding the control left them no
|
|
94
|
+
// route back to the first-launch experience Reset promises.
|
|
95
|
+
expect(summarizeProgress(rows({}, ['a', 'b']), { launchOfferAnswered: true }).resettable).toBe(
|
|
96
|
+
true,
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('offers no reset on a genuinely untouched install', () => {
|
|
101
|
+
expect(summarizeProgress([], unanswered).resettable).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { launchActionFor } from '~/utils/tutorial'
|
|
2
|
+
import type {
|
|
3
|
+
TutorialAvailability,
|
|
4
|
+
TutorialCatalogueEntry,
|
|
5
|
+
TutorialLaunchAction,
|
|
6
|
+
TutorialRequirement,
|
|
7
|
+
TutorialTour,
|
|
8
|
+
TutorialTourState,
|
|
9
|
+
} from '~/utils/tutorial'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the catalogue renders per tour, and the progress line above the list.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from `TutorialCatalogue.vue` for the same reason the overlay's decisions are
|
|
15
|
+
* (`TutorialOverlay.logic.ts`): the vitest setup has no SFC transform, so anything that
|
|
16
|
+
* DECIDES has to live outside the component to be tested. Here that is the whole of what the
|
|
17
|
+
* surface claims — which tours can be started, which are held back and by what, and how much
|
|
18
|
+
* of the catalog this user has been through.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One rendered row: an entry, plus everything derived from it and the user's progress. */
|
|
22
|
+
export interface TutorialCatalogueRow {
|
|
23
|
+
tour: TutorialTour
|
|
24
|
+
availability: TutorialAvailability
|
|
25
|
+
/** What is standing in the way, when {@link availability} is `blocked`. */
|
|
26
|
+
unmet: readonly TutorialRequirement[]
|
|
27
|
+
state: TutorialTourState
|
|
28
|
+
action: TutorialLaunchAction
|
|
29
|
+
/**
|
|
30
|
+
* How many steps a start would walk this board through — the RESOLVED count, not the
|
|
31
|
+
* declared one, since branch steps that don't apply here are already gone.
|
|
32
|
+
*
|
|
33
|
+
* Null for a tour that cannot run: its resolved script is not what the user would get once
|
|
34
|
+
* the missing requirement is met, and a number that quietly changes when they unblock it is
|
|
35
|
+
* worse than no number.
|
|
36
|
+
*/
|
|
37
|
+
stepCount: number | null
|
|
38
|
+
/** Whether the row's button does anything (a blocked tour's is inert, not hidden). */
|
|
39
|
+
startable: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The list, in catalog order — the entries arrive sorted by `resolveTourCatalogue`. */
|
|
43
|
+
export function buildCatalogueRows(
|
|
44
|
+
entries: readonly TutorialCatalogueEntry[],
|
|
45
|
+
stateOf: (tourId: string) => TutorialTourState,
|
|
46
|
+
): TutorialCatalogueRow[] {
|
|
47
|
+
return entries.map((entry) => {
|
|
48
|
+
const ready = entry.availability === 'ready'
|
|
49
|
+
const state = stateOf(entry.tour.id)
|
|
50
|
+
return {
|
|
51
|
+
tour: entry.tour,
|
|
52
|
+
availability: entry.availability,
|
|
53
|
+
unmet: entry.unmet,
|
|
54
|
+
state,
|
|
55
|
+
action: launchActionFor(state),
|
|
56
|
+
stepCount: ready ? entry.tour.steps.length : null,
|
|
57
|
+
startable: ready,
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The headline count. */
|
|
63
|
+
export interface TutorialProgressSummary {
|
|
64
|
+
completed: number
|
|
65
|
+
/** Every tour this deployment ships, available or not — the honest denominator. */
|
|
66
|
+
total: number
|
|
67
|
+
/**
|
|
68
|
+
* Whether there is anything for Reset to clear — which is everything `resetProgress` writes,
|
|
69
|
+
* not only what this list shows. See {@link summarizeProgress}.
|
|
70
|
+
*/
|
|
71
|
+
resettable: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Progress across the WHOLE catalog, not just the runnable part.
|
|
76
|
+
*
|
|
77
|
+
* Counting only what this board can offer today would move the denominator under the user
|
|
78
|
+
* every time they linked a repo or finished a run — "2 of 2 completed" on a board with four
|
|
79
|
+
* more walkthroughs waiting behind requirements reads as a finished tutorial, which is the
|
|
80
|
+
* one thing this surface exists to disprove.
|
|
81
|
+
*
|
|
82
|
+
* `launchOfferAnswered` is the store's `decision`, and it is here rather than derived from the
|
|
83
|
+
* rows because `resetProgress` clears it too — Reset restores the FIRST-LAUNCH experience, and
|
|
84
|
+
* the saved answer to "would you like a tour?" is most of that. Keying the control on the rows
|
|
85
|
+
* alone hid it from the one user who most needs it: someone who clicked "No thanks" and took no
|
|
86
|
+
* tour has nothing completed and nothing paused, so the only route back to the offer was the
|
|
87
|
+
* control that was not being drawn.
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeProgress(
|
|
90
|
+
rows: readonly TutorialCatalogueRow[],
|
|
91
|
+
input: { launchOfferAnswered: boolean },
|
|
92
|
+
): TutorialProgressSummary {
|
|
93
|
+
const completed = rows.filter((row) => row.state === 'completed').length
|
|
94
|
+
return {
|
|
95
|
+
completed,
|
|
96
|
+
total: rows.length,
|
|
97
|
+
// A paused tour is progress too: clearing it is exactly what someone handing this to a
|
|
98
|
+
// colleague wants, and offering Reset only for completions would leave it behind.
|
|
99
|
+
resettable:
|
|
100
|
+
completed > 0 || rows.some((row) => row.state === 'paused') || input.launchOfferAnswered,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tutorial catalogue: every guided walkthrough this deployment ships, startable (or
|
|
3
|
+
// re-startable) at any time from the sidebar's Help section and the command palette.
|
|
4
|
+
//
|
|
5
|
+
// It is deliberately NOT the launch prompt with a second entry point. The prompt asks a
|
|
6
|
+
// question once, offers what this board can run, and goes away; this answers "what else is
|
|
7
|
+
// there, and why can't I take that one yet?" — so it lists the tours that are HELD BACK too,
|
|
8
|
+
// each with the requirement still missing. Omitting them (which is all a slot filter could do)
|
|
9
|
+
// makes a deployment shipping six walkthroughs look like one shipping two.
|
|
10
|
+
//
|
|
11
|
+
// Everything that decides lives in `TutorialCatalogue.logic.ts` (rows, progress) and
|
|
12
|
+
// `utils/tutorial.ts` (availability, state, action), so the SFC only renders.
|
|
13
|
+
import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic'
|
|
14
|
+
import type { TutorialCatalogueRow } from './TutorialCatalogue.logic'
|
|
15
|
+
import { TUTORIAL_ACTION_KEYS, TUTORIAL_STATUS_KEYS } from '~/utils/tutorial'
|
|
16
|
+
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const tutorial = useTutorialStore()
|
|
19
|
+
const { catalogue } = useTutorialTours()
|
|
20
|
+
const { stateOf, launch } = useTutorialLaunch()
|
|
21
|
+
|
|
22
|
+
const open = computed({
|
|
23
|
+
get: () => tutorial.catalogueOpen,
|
|
24
|
+
set: (v: boolean) => (v ? tutorial.openCatalogue() : tutorial.closeCatalogue()),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const rows = computed(() => buildCatalogueRows(catalogue.value, stateOf))
|
|
28
|
+
const progress = computed(() =>
|
|
29
|
+
summarizeProgress(rows.value, { launchOfferAnswered: tutorial.decision !== null }),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
/** A badge only where there is something to say: "not started" is the unremarkable default. */
|
|
33
|
+
const showsStatus = (row: TutorialCatalogueRow) => row.state !== 'notStarted'
|
|
34
|
+
|
|
35
|
+
const statusColor = (row: TutorialCatalogueRow) =>
|
|
36
|
+
row.state === 'completed' ? 'success' : row.state === 'inProgress' ? 'primary' : 'neutral'
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<UModal
|
|
41
|
+
v-model:open="open"
|
|
42
|
+
:title="t('tutorial.catalogue.title')"
|
|
43
|
+
:description="t('tutorial.catalogue.intro')"
|
|
44
|
+
:ui="{ content: 'max-w-2xl' }"
|
|
45
|
+
>
|
|
46
|
+
<template #body>
|
|
47
|
+
<div class="space-y-4" data-testid="tutorial-catalogue">
|
|
48
|
+
<p v-if="rows.length > 0" class="text-xs text-slate-400" data-testid="tutorial-progress">
|
|
49
|
+
{{
|
|
50
|
+
t('tutorial.catalogue.progress', {
|
|
51
|
+
completed: progress.completed,
|
|
52
|
+
total: progress.total,
|
|
53
|
+
})
|
|
54
|
+
}}
|
|
55
|
+
</p>
|
|
56
|
+
<ul class="space-y-2">
|
|
57
|
+
<li
|
|
58
|
+
v-for="row in rows"
|
|
59
|
+
:key="row.tour.id"
|
|
60
|
+
class="flex items-start gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
61
|
+
:class="row.startable ? '' : 'opacity-75'"
|
|
62
|
+
:data-testid="`tutorial-catalogue-entry-${row.tour.id}`"
|
|
63
|
+
>
|
|
64
|
+
<UIcon
|
|
65
|
+
:name="row.tour.icon ?? 'i-lucide-compass'"
|
|
66
|
+
class="mt-0.5 h-5 w-5 shrink-0 text-primary-400"
|
|
67
|
+
/>
|
|
68
|
+
<div class="min-w-0 flex-1 space-y-1">
|
|
69
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
70
|
+
<span class="text-sm font-medium text-slate-100">{{ t(row.tour.titleKey) }}</span>
|
|
71
|
+
<UBadge
|
|
72
|
+
v-if="showsStatus(row)"
|
|
73
|
+
:color="statusColor(row)"
|
|
74
|
+
variant="subtle"
|
|
75
|
+
size="sm"
|
|
76
|
+
:data-testid="`tutorial-catalogue-status-${row.tour.id}`"
|
|
77
|
+
>
|
|
78
|
+
{{ t(TUTORIAL_STATUS_KEYS[row.state]) }}
|
|
79
|
+
</UBadge>
|
|
80
|
+
</div>
|
|
81
|
+
<p class="text-xs text-slate-400">{{ t(row.tour.descriptionKey) }}</p>
|
|
82
|
+
<p v-if="row.stepCount !== null" class="text-xs text-slate-500">
|
|
83
|
+
{{ t('tutorial.catalogue.steps', { count: row.stepCount }, row.stepCount) }}
|
|
84
|
+
</p>
|
|
85
|
+
<!-- A held-back tour says what would unlock it, rather than vanishing from the
|
|
86
|
+
list: these are things the reader can go and do. -->
|
|
87
|
+
<div
|
|
88
|
+
v-else-if="row.availability === 'blocked'"
|
|
89
|
+
class="text-xs text-slate-500"
|
|
90
|
+
:data-testid="`tutorial-catalogue-requirements-${row.tour.id}`"
|
|
91
|
+
>
|
|
92
|
+
<span>{{ t('tutorial.catalogue.blocked') }}</span>
|
|
93
|
+
<ul class="mt-1 space-y-0.5">
|
|
94
|
+
<li v-for="req in row.unmet" :key="req.id" class="flex items-center gap-1.5">
|
|
95
|
+
<UIcon name="i-lucide-lock" class="h-3 w-3 shrink-0" />
|
|
96
|
+
<span>{{ t(req.labelKey) }}</span>
|
|
97
|
+
</li>
|
|
98
|
+
</ul>
|
|
99
|
+
</div>
|
|
100
|
+
<!-- Requirements met, but every step is about a branch this board isn't on:
|
|
101
|
+
nothing to go and fix, so it must not read like the case above. -->
|
|
102
|
+
<p v-else class="text-xs text-slate-500">
|
|
103
|
+
{{ t('tutorial.catalogue.notApplicable') }}
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
<UButton
|
|
107
|
+
size="sm"
|
|
108
|
+
color="primary"
|
|
109
|
+
:variant="row.state === 'completed' ? 'soft' : 'solid'"
|
|
110
|
+
:disabled="!row.startable"
|
|
111
|
+
:data-testid="`tutorial-catalogue-start-${row.tour.id}`"
|
|
112
|
+
@click="launch(row.tour.id)"
|
|
113
|
+
>
|
|
114
|
+
{{ t(TUTORIAL_ACTION_KEYS[row.action]) }}
|
|
115
|
+
</UButton>
|
|
116
|
+
</li>
|
|
117
|
+
</ul>
|
|
118
|
+
<!-- No tours at all is a real state (a deployment may register none of its own and
|
|
119
|
+
strip the built-ins), and it is not the same as one whose tours are all blocked. -->
|
|
120
|
+
<p v-if="rows.length === 0" class="text-sm text-slate-400">
|
|
121
|
+
{{ t('tutorial.catalogue.empty') }}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
125
|
+
<template #footer>
|
|
126
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
127
|
+
<UButton
|
|
128
|
+
v-if="progress.resettable"
|
|
129
|
+
color="neutral"
|
|
130
|
+
variant="ghost"
|
|
131
|
+
icon="i-lucide-rotate-ccw"
|
|
132
|
+
:title="t('tutorial.catalogue.resetHint')"
|
|
133
|
+
data-testid="tutorial-catalogue-reset"
|
|
134
|
+
@click="tutorial.resetProgress()"
|
|
135
|
+
>
|
|
136
|
+
{{ t('tutorial.catalogue.reset') }}
|
|
137
|
+
</UButton>
|
|
138
|
+
<span v-else />
|
|
139
|
+
<UButton
|
|
140
|
+
color="neutral"
|
|
141
|
+
variant="soft"
|
|
142
|
+
data-testid="tutorial-catalogue-close"
|
|
143
|
+
@click="tutorial.closeCatalogue()"
|
|
144
|
+
>
|
|
145
|
+
{{ t('common.close') }}
|
|
146
|
+
</UButton>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</UModal>
|
|
150
|
+
</template>
|
|
@@ -428,7 +428,9 @@ onUnmounted(() => {
|
|
|
428
428
|
|
|
429
429
|
<template>
|
|
430
430
|
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
431
|
-
the app's modals (z-50s), since steps legitimately point INTO an open modal
|
|
431
|
+
the app's modals (z-50s), since steps legitimately point INTO an open modal — with the
|
|
432
|
+
one exception of the tutorial's OWN windows (`ownWindowOpen`), which no step points into
|
|
433
|
+
and over which the same z-index would float a ring and a tooltip the user cannot use. -->
|
|
432
434
|
<Teleport to="body">
|
|
433
435
|
<!-- The step-change announcement. Visually hidden, and outside BOTH the dialog and the
|
|
434
436
|
`v-if` below, so the live region is a stable node whose TEXT changes for the whole
|
|
@@ -438,7 +440,12 @@ onUnmounted(() => {
|
|
|
438
440
|
<div class="sr-only" role="status" aria-live="polite" data-testid="tutorial-announcement">
|
|
439
441
|
{{ announcement }}
|
|
440
442
|
</div>
|
|
441
|
-
|
|
443
|
+
<!-- SUPPRESSED, not unmounted, while a tutorial-owned window is open: this component holds
|
|
444
|
+
the running tour's resolved script (see `tour` above), and a remount would re-resolve it
|
|
445
|
+
against gates that may have flipped since the tour started — which is the very failure
|
|
446
|
+
that holding it fixed. The cursor, the tracking and the script all survive; only the
|
|
447
|
+
marks go, and they come back the moment the window closes. -->
|
|
448
|
+
<div v-if="step && !tutorial.ownWindowOpen" data-testid="tutorial-overlay">
|
|
442
449
|
<!-- `motion-safe:` on the ring's transition: it slides between controls on every step,
|
|
443
450
|
which is exactly the involuntary movement `prefers-reduced-motion` is about. -->
|
|
444
451
|
<div
|
|
@@ -1,39 +1,33 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// The tutorial launch prompt: asks once on first launch whether the user wants a guided
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// tours
|
|
2
|
+
// The tutorial launch prompt: asks once on first launch whether the user wants a guided tour,
|
|
3
|
+
// listing the tours this board can actually run right now (first-party + consumer, resolved
|
|
4
|
+
// against the same gates the nav uses), so it grows with the catalog rather than hard-coding
|
|
5
|
+
// tours.
|
|
6
|
+
//
|
|
7
|
+
// It is the OFFER, not the library: the full list — including the walkthroughs this board
|
|
8
|
+
// can't run yet and what would unlock them — is `TutorialCatalogue.vue`, one button away in
|
|
9
|
+
// the footer and permanently reachable from the sidebar's Help section. That split is why
|
|
10
|
+
// this stays a short, answerable question instead of growing into a browsing surface.
|
|
6
11
|
//
|
|
7
12
|
// The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
|
|
8
13
|
// prompt never auto-opens again), while closing without answering defers to next launch.
|
|
14
|
+
import { TUTORIAL_ACTION_KEYS } from '~/utils/tutorial'
|
|
15
|
+
|
|
9
16
|
const { t } = useI18n()
|
|
10
17
|
const tutorial = useTutorialStore()
|
|
11
18
|
const { tours } = useTutorialTours()
|
|
19
|
+
// Start / Resume / Repeat is decided in one place for both surfaces — see `useTutorialLaunch`.
|
|
20
|
+
const { actionFor, launch } = useTutorialLaunch()
|
|
12
21
|
|
|
13
22
|
const open = computed({
|
|
14
23
|
get: () => tutorial.promptOpen,
|
|
15
24
|
set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
|
|
16
25
|
})
|
|
17
26
|
|
|
18
|
-
// Only an unanswered prompt offers the persistent "No thanks"
|
|
19
|
-
//
|
|
27
|
+
// Only an unanswered prompt offers the persistent "No thanks": there is a decision to save.
|
|
28
|
+
// Once one exists — this window can still be opened by the store — the only dismissal left is
|
|
29
|
+
// a plain close, since declining something already answered would write nothing new.
|
|
20
30
|
const undecided = computed(() => tutorial.decision === null)
|
|
21
|
-
|
|
22
|
-
// A tour broken off mid-way (Esc, or Skip to get the overlay out of the way) offers to RESUME
|
|
23
|
-
// where it stopped rather than only to start over. Session-scoped, so this is only ever offered
|
|
24
|
-
// while the board is still in the state the tour left it in — see the store.
|
|
25
|
-
const isResumable = (tourId: string) => tutorial.interruptedAt(tourId) !== null
|
|
26
|
-
|
|
27
|
-
function launch(tourId: string) {
|
|
28
|
-
if (isResumable(tourId)) tutorial.resumeTour(tourId)
|
|
29
|
-
else tutorial.startTour(tourId)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Resume beats Completed: a tour taken again and broken off is offered where it stopped. */
|
|
33
|
-
function launchLabel(tourId: string): string {
|
|
34
|
-
if (isResumable(tourId)) return t('tutorial.prompt.resume')
|
|
35
|
-
return tutorial.isCompleted(tourId) ? t('tutorial.prompt.restart') : t('tutorial.prompt.start')
|
|
36
|
-
}
|
|
37
31
|
</script>
|
|
38
32
|
|
|
39
33
|
<template>
|
|
@@ -65,7 +59,7 @@ function launchLabel(tourId: string): string {
|
|
|
65
59
|
size="sm"
|
|
66
60
|
data-testid="tutorial-tour-completed"
|
|
67
61
|
>
|
|
68
|
-
{{ t('tutorial.
|
|
62
|
+
{{ t('tutorial.status.completed') }}
|
|
69
63
|
</UBadge>
|
|
70
64
|
</div>
|
|
71
65
|
<p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
|
|
@@ -73,11 +67,11 @@ function launchLabel(tourId: string): string {
|
|
|
73
67
|
<UButton
|
|
74
68
|
size="sm"
|
|
75
69
|
color="primary"
|
|
76
|
-
:variant="
|
|
70
|
+
:variant="actionFor(tour.id) === 'restart' ? 'soft' : 'solid'"
|
|
77
71
|
:data-testid="`tutorial-start-${tour.id}`"
|
|
78
72
|
@click="launch(tour.id)"
|
|
79
73
|
>
|
|
80
|
-
{{
|
|
74
|
+
{{ t(TUTORIAL_ACTION_KEYS[actionFor(tour.id)]) }}
|
|
81
75
|
</UButton>
|
|
82
76
|
</li>
|
|
83
77
|
</ul>
|
|
@@ -100,14 +94,27 @@ function launchLabel(tourId: string): string {
|
|
|
100
94
|
{{ t('tutorial.prompt.decline') }}
|
|
101
95
|
</UButton>
|
|
102
96
|
<span v-else />
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
97
|
+
<div class="flex items-center gap-2">
|
|
98
|
+
<!-- The way to the tours this board can't run yet, and to the ones already taken.
|
|
99
|
+
Browsing answers nothing, so it neither declines the offer nor accepts it. -->
|
|
100
|
+
<UButton
|
|
101
|
+
color="neutral"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
icon="i-lucide-graduation-cap"
|
|
104
|
+
data-testid="tutorial-browse"
|
|
105
|
+
@click="tutorial.openCatalogue()"
|
|
106
|
+
>
|
|
107
|
+
{{ t('tutorial.prompt.browse') }}
|
|
108
|
+
</UButton>
|
|
109
|
+
<UButton
|
|
110
|
+
color="neutral"
|
|
111
|
+
variant="soft"
|
|
112
|
+
data-testid="tutorial-close"
|
|
113
|
+
@click="tutorial.closePrompt()"
|
|
114
|
+
>
|
|
115
|
+
{{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
|
|
116
|
+
</UButton>
|
|
117
|
+
</div>
|
|
111
118
|
</div>
|
|
112
119
|
</template>
|
|
113
120
|
</UModal>
|
|
@@ -59,7 +59,10 @@ export function useNavContributions() {
|
|
|
59
59
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
60
60
|
reports: () => ui.openReports(),
|
|
61
61
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
62
|
-
|
|
62
|
+
// The CATALOGUE, not the launch prompt: reaching this from the sidebar or the palette is
|
|
63
|
+
// "show me the walkthroughs", and the prompt answers a narrower question (would you like
|
|
64
|
+
// one now?) that a returning user has already answered once.
|
|
65
|
+
tutorial: () => useTutorialStore().openCatalogue(),
|
|
63
66
|
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
64
67
|
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
65
68
|
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { launchActionFor, tourState } from '~/utils/tutorial'
|
|
2
|
+
import type { TutorialLaunchAction, TutorialTourState } from '~/utils/tutorial'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Starting a tour, from whichever surface offers it.
|
|
6
|
+
*
|
|
7
|
+
* Both the launch prompt and the catalogue answer the same question per tour — start it,
|
|
8
|
+
* resume where it was broken off, take it again, or step back into the one already running —
|
|
9
|
+
* and getting that precedence subtly different between the two surfaces would show up as the
|
|
10
|
+
* same button doing different things on two screens. So the decision is made once here, over
|
|
11
|
+
* the pure {@link tourState} / {@link launchActionFor} pair, and each surface renders it.
|
|
12
|
+
*/
|
|
13
|
+
export function useTutorialLaunch() {
|
|
14
|
+
const tutorial = useTutorialStore()
|
|
15
|
+
|
|
16
|
+
/** Where this tour stands for this user right now. */
|
|
17
|
+
function stateOf(tourId: string): TutorialTourState {
|
|
18
|
+
return tourState({
|
|
19
|
+
active: tutorial.activeTourId === tourId,
|
|
20
|
+
resumable: tutorial.interruptedAt(tourId) !== null,
|
|
21
|
+
completed: tutorial.isCompleted(tourId),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What this tour's button will do — also what labels it. */
|
|
26
|
+
function actionFor(tourId: string): TutorialLaunchAction {
|
|
27
|
+
return launchActionFor(stateOf(tourId))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Act on that. `continue` only steps out of the way: the overlay for that tour is already
|
|
32
|
+
* on screen, so restarting it from step one — which is what a plain `startTour` would do —
|
|
33
|
+
* would throw away the position of the walkthrough the user was pointing at.
|
|
34
|
+
*/
|
|
35
|
+
function launch(tourId: string): void {
|
|
36
|
+
switch (actionFor(tourId)) {
|
|
37
|
+
case 'continue':
|
|
38
|
+
tutorial.closeCatalogue()
|
|
39
|
+
tutorial.closePrompt()
|
|
40
|
+
return
|
|
41
|
+
case 'resume':
|
|
42
|
+
tutorial.resumeTour(tourId)
|
|
43
|
+
return
|
|
44
|
+
default:
|
|
45
|
+
tutorial.startTour(tourId)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { stateOf, actionFor, launch }
|
|
50
|
+
}
|