@cat-factory/app 0.202.0 → 0.205.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 +220 -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 +274 -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/usePipelineErrorToast.ts +4 -0
- 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/agents.ts +20 -0
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +44 -2
- package/app/stores/tutorial.spec.ts +75 -0
- package/app/stores/tutorial.ts +66 -1
- package/app/stores/workspace/hydrate.ts +3 -0
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +421 -0
- package/app/utils/binaryOutput.ts +444 -0
- package/app/utils/tutorial.spec.ts +120 -8
- package/app/utils/tutorial.ts +166 -21
- package/i18n/locales/de.json +105 -8
- package/i18n/locales/en.json +111 -8
- package/i18n/locales/es.json +105 -8
- package/i18n/locales/fr.json +105 -8
- package/i18n/locales/he.json +105 -8
- package/i18n/locales/it.json +105 -8
- package/i18n/locales/ja.json +105 -8
- package/i18n/locales/pl.json +105 -8
- package/i18n/locales/tr.json +105 -8
- package/i18n/locales/uk.json +105 -8
- package/package.json +2 -2
|
@@ -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(),
|
|
@@ -228,6 +228,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
228
228
|
titleKey: 'errors.conflict.title.binary_output_service_invalid',
|
|
229
229
|
descriptionKey: 'errors.conflict.description.binary_output_service_invalid',
|
|
230
230
|
},
|
|
231
|
+
binary_output_generator_invalid: {
|
|
232
|
+
titleKey: 'errors.conflict.title.binary_output_generator_invalid',
|
|
233
|
+
descriptionKey: 'errors.conflict.description.binary_output_generator_invalid',
|
|
234
|
+
},
|
|
231
235
|
foundational_service_not_inherited: {
|
|
232
236
|
titleKey: 'errors.conflict.title.foundational_service_not_inherited',
|
|
233
237
|
descriptionKey: 'errors.conflict.description.foundational_service_not_inherited',
|
|
@@ -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
|
+
}
|
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
2
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { createSharedComposables } from '@modular-vue/vue'
|
|
4
|
+
import { resolveTourCatalogue } from '~/utils/tutorial'
|
|
5
|
+
import type { TutorialCatalogueEntry, TutorialTour } from '~/utils/tutorial'
|
|
6
|
+
import type { AppDeps } from '~/modular/registry'
|
|
5
7
|
import type { AppSlots } from '~/modular/nav-contributions'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The registered `gates` service — the SAME reactive object `navSlotFilter` reads, resolved
|
|
11
|
+
* from the modular app's shared dependencies rather than rebuilt here, so the tutorial
|
|
12
|
+
* surfaces and the nav can never disagree about what this board offers. Reading its getters
|
|
13
|
+
* inside a `computed` tracks the underlying stores, so availability re-resolves the instant a
|
|
14
|
+
* permission flips or a task lands on the board.
|
|
15
|
+
*
|
|
16
|
+
* `useOptional` (not `useService`) covers the bare-install case the nav filter also allows: a
|
|
17
|
+
* host that registered no gates withholds nothing rather than throwing.
|
|
18
|
+
*/
|
|
19
|
+
const { useOptional } = createSharedComposables<AppDeps>()
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The tutorial catalog as this board sees it, resolved ONCE for every surface that reads it.
|
|
23
|
+
*
|
|
24
|
+
* Two views over one resolution, and the difference between them is the point:
|
|
25
|
+
*
|
|
26
|
+
* - `tours` — what can be started right now. The launch prompt offers these, and the overlay
|
|
27
|
+
* resolves a running tour from them, exactly as when this gating lived in `navSlotFilter`.
|
|
28
|
+
* - `catalogue` — EVERY tour this deployment ships, each carrying why it is or isn't
|
|
29
|
+
* available. The catalogue surface needs the unavailable ones: a list that quietly omits
|
|
30
|
+
* four of six tours is indistinguishable from a deployment that ships two, and the user it
|
|
31
|
+
* fails is the one who came looking for the walkthrough they were told about.
|
|
32
|
+
*
|
|
33
|
+
* Gating cannot live in the slot filter for that reason — a `SlotFilter` maps slots to slots,
|
|
34
|
+
* so it can only drop, never annotate. `resolveTourCatalogue` is pure and does both.
|
|
13
35
|
*/
|
|
14
36
|
export function useTutorialTours() {
|
|
15
37
|
const slots = useReactiveSlots<AppSlots>()
|
|
16
|
-
const
|
|
17
|
-
|
|
38
|
+
const gates = useOptional('gates')
|
|
39
|
+
const catalogue = computed<TutorialCatalogueEntry[]>(() =>
|
|
40
|
+
resolveTourCatalogue(slots.value.tutorialTours ?? [], gates.value),
|
|
41
|
+
)
|
|
42
|
+
const tours = computed<TutorialTour[]>(() =>
|
|
43
|
+
catalogue.value.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour),
|
|
44
|
+
)
|
|
45
|
+
return { tours, catalogue }
|
|
18
46
|
}
|
|
@@ -236,17 +236,17 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
|
|
|
236
236
|
**components** must be named through the `#components` virtual module (see the boxed note
|
|
237
237
|
below). Compose these:
|
|
238
238
|
|
|
239
|
-
| Building block | Reference it as | What it gives you
|
|
240
|
-
| ----------------------------- | ----------------------------------------- |
|
|
241
|
-
| `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control.
|
|
242
|
-
| `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata.
|
|
243
|
-
| `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown.
|
|
244
|
-
| `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance.
|
|
245
|
-
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one.
|
|
246
|
-
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here.
|
|
247
|
-
| `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead.
|
|
248
|
-
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`.
|
|
249
|
-
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays").
|
|
239
|
+
| Building block | Reference it as | What it gives you |
|
|
240
|
+
| ----------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
241
|
+
| `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. It also renders the universal per-step trailing sections (agent effort, pre-PR validation, binary outputs) off the ACTIVE step, so your window inherits them and must not re-render them itself. |
|
|
242
|
+
| `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
|
|
243
|
+
| `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
|
|
244
|
+
| `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
|
|
245
|
+
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
|
|
246
|
+
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
|
|
247
|
+
| `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
|
|
248
|
+
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
|
|
249
|
+
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
|
|
250
250
|
|
|
251
251
|
> **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
|
|
252
252
|
> layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
|
|
@@ -270,6 +270,19 @@ below). Compose these:
|
|
|
270
270
|
> Hardening these building blocks into an explicitly exported, location-independent public
|
|
271
271
|
> surface is slice G of the initiative.
|
|
272
272
|
|
|
273
|
+
### Don't render step state the shell already owns
|
|
274
|
+
|
|
275
|
+
Some state the engine records on a step is deliberately NOT a result view's job, because the
|
|
276
|
+
record's scope is wider than any one kind: the agent's effort self-assessment, the pre-PR
|
|
277
|
+
validation report, and — for a `binary-output` generator — the artifacts it declared it stored
|
|
278
|
+
(`step.binaryOutputs`). `ResultWindowShell` resolves the active step itself and renders each as a
|
|
279
|
+
collapsible trailing section, and the generic step-detail panel renders the same components for a
|
|
280
|
+
step whose kind declares no window at all.
|
|
281
|
+
|
|
282
|
+
So a generator kind should declare a result view for its OWN output (or none), and leave the
|
|
283
|
+
artifact list alone — you get it either way, on every entry point, with no id to register. A
|
|
284
|
+
window that renders it again just shows it twice.
|
|
285
|
+
|
|
273
286
|
The example `AcmeSecurityReport.vue` window is a full demonstration: it imports
|
|
274
287
|
`ResultWindowShell` + `StepRunMeta` + `MarkdownProse` from `#components`, composes them with
|
|
275
288
|
the auto-imported `useResultView`, adds only its own bespoke body (the security findings), and
|
|
@@ -32,5 +32,11 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
|
|
|
32
32
|
// projection would fork the rule the moment the default changes.
|
|
33
33
|
...(p.tier ? { tier: p.tier } : {}),
|
|
34
34
|
...(p.resultView ? { resultView: p.resultView } : {}),
|
|
35
|
+
// Not part of `presentation` on the wire — it is a fact about how the kind RUNS, projected
|
|
36
|
+
// beside `container` — so it is lifted from the entry itself. Carried onto the archetype
|
|
37
|
+
// because the pipeline builder resolves a step's meta through `agentKindMeta`, not through
|
|
38
|
+
// the snapshot, and a required step option cannot be gated on something the read model
|
|
39
|
+
// does not carry.
|
|
40
|
+
...(kind.binaryOutput ? { binaryOutput: true } : {}),
|
|
35
41
|
}
|
|
36
42
|
}
|
|
@@ -310,6 +310,7 @@ describe('nav grouping helpers', () => {
|
|
|
310
310
|
'infrastructure',
|
|
311
311
|
'workspaceContext',
|
|
312
312
|
'configuration',
|
|
313
|
+
'help',
|
|
313
314
|
])
|
|
314
315
|
// The model layer is its own section, ahead of the optional integrations: the engines,
|
|
315
316
|
// the per-agent model choice (beside the providers it picks from, rather than under
|
|
@@ -332,6 +333,12 @@ describe('nav grouping helpers', () => {
|
|
|
332
333
|
'operator-dashboard',
|
|
333
334
|
'reports',
|
|
334
335
|
])
|
|
336
|
+
// The tail section: what teaches the product, rather than what configures it. It is on
|
|
337
|
+
// the sidebar at all because the launch prompt is answered once and the palette was then
|
|
338
|
+
// the only route back to the walkthroughs — asking the user least likely to have found
|
|
339
|
+
// the palette to find it.
|
|
340
|
+
const help = groups.find((g) => g.group === 'help')
|
|
341
|
+
expect(help?.items.map((i) => i.id)).toEqual(['tutorial'])
|
|
335
342
|
})
|
|
336
343
|
|
|
337
344
|
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
-
import { resolveTours } from '~/utils/tutorial'
|
|
3
2
|
import { filterExternalTools } from './external-tools'
|
|
4
3
|
import type { AppSlots } from './slots'
|
|
5
4
|
|
|
@@ -47,6 +46,11 @@ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
|
|
|
47
46
|
* dropped entirely where none are registered. It is separate from `integrations` for the same
|
|
48
47
|
* reason `models` is — an integration is a system cat-factory READS FROM or WRITES TO on your
|
|
49
48
|
* behalf, while these are places a person GOES.
|
|
49
|
+
*
|
|
50
|
+
* `help` is the tail section: surfaces that teach the product rather than configure it. It is
|
|
51
|
+
* separate from `configuration` because nothing in it changes what a run does — and it is a
|
|
52
|
+
* SIDEBAR section rather than a palette-only entry because the people it serves are the ones
|
|
53
|
+
* least likely to know the palette exists.
|
|
50
54
|
*/
|
|
51
55
|
export type NavSidebarGroup =
|
|
52
56
|
| 'create'
|
|
@@ -57,6 +61,7 @@ export type NavSidebarGroup =
|
|
|
57
61
|
| 'workspaceContext'
|
|
58
62
|
| 'externalTools'
|
|
59
63
|
| 'configuration'
|
|
64
|
+
| 'help'
|
|
60
65
|
|
|
61
66
|
/** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
|
|
62
67
|
export type NavCommandGroup =
|
|
@@ -539,21 +544,27 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
539
544
|
sidebar: { group: 'configuration', order: 45 },
|
|
540
545
|
},
|
|
541
546
|
{
|
|
542
|
-
// Deliberately NOT `advanced`: the
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
547
|
+
// Deliberately NOT `advanced`: the tutorials exist for exactly the users basic mode
|
|
548
|
+
// serves. It is on the SIDEBAR as well as in the palette because the launch prompt is
|
|
549
|
+
// shown once and answered once — after that, a palette entry was the only route back to
|
|
550
|
+
// the walkthroughs, which asks the user least likely to have found the palette to find
|
|
551
|
+
// it. Ungated: every tour answers for itself (an unavailable one is listed with what
|
|
552
|
+
// would unlock it), so the catalogue is worth reaching even when little of it can run.
|
|
546
553
|
id: 'tutorial',
|
|
547
|
-
labelKey: '
|
|
554
|
+
labelKey: 'nav.tutorials',
|
|
548
555
|
icon: 'i-lucide-graduation-cap',
|
|
549
|
-
surfaces: S('command'),
|
|
556
|
+
surfaces: S('sidebar', 'command'),
|
|
550
557
|
action: 'tutorial',
|
|
551
558
|
testId: 'nav-tutorial',
|
|
559
|
+
sidebar: { group: 'help', order: 10 },
|
|
552
560
|
command: {
|
|
553
561
|
// After the pre-slice-1 tail (the workspace group pins that order): genuinely new
|
|
554
562
|
// entries append rather than interleave.
|
|
555
563
|
group: 'workspace',
|
|
556
564
|
order: 100,
|
|
565
|
+
// No `labelKey` override: the palette shows "Tutorials" too, so an override would be a
|
|
566
|
+
// second key holding the same word in ten locales — two places to keep in step and one
|
|
567
|
+
// of them free to drift. `layout.commandBar.cmd.tutorial` was deleted with it.
|
|
557
568
|
keywordsKey: 'layout.commandBar.keywords.tutorial',
|
|
558
569
|
},
|
|
559
570
|
},
|
|
@@ -615,7 +626,6 @@ export const navigationModule = defineModule({
|
|
|
615
626
|
export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
|
|
616
627
|
const gates = deps.gates
|
|
617
628
|
const nav = slots.nav ?? []
|
|
618
|
-
const tutorialTours = slots.tutorialTours ?? []
|
|
619
629
|
const externalTools = slots.externalTools ?? []
|
|
620
630
|
return {
|
|
621
631
|
...slots,
|
|
@@ -626,11 +636,12 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
626
636
|
(i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
|
|
627
637
|
)
|
|
628
638
|
: nav,
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
639
|
+
// `tutorialTours` is deliberately NOT filtered here, unlike every other gated slot. A
|
|
640
|
+
// `SlotFilter` can only DROP, and the tutorial catalogue's whole job is to explain what
|
|
641
|
+
// was dropped — which tour this board can't run yet, and what would unlock it. That is a
|
|
642
|
+
// richer value than a thinned list, so tour resolution lives in `resolveTourCatalogue`
|
|
643
|
+
// (pure, gates-nullable) and runs once in `useTutorialTours`, whose `tours` is the same
|
|
644
|
+
// gated set the launch prompt and the overlay always saw.
|
|
634
645
|
// External tools gate on the same two axes as `nav` — they become nav items downstream
|
|
635
646
|
// (`useNavContributions` projects them), so gating them anywhere else would let a tool the
|
|
636
647
|
// caller can't use reach the palette while its sidebar twin was correctly hidden.
|
|
@@ -650,6 +661,7 @@ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
|
|
|
650
661
|
// configuration tail: they are destinations someone reaches mid-work, not settings.
|
|
651
662
|
'externalTools',
|
|
652
663
|
'configuration',
|
|
664
|
+
'help',
|
|
653
665
|
]
|
|
654
666
|
|
|
655
667
|
/** Command-palette groups, in render order; each label is `layout.commandBar.groups.<group>`. */
|
package/app/modular/slots.ts
CHANGED
|
@@ -37,8 +37,11 @@ import type { WorkspaceMetadataFieldDefinition } from './workspace-metadata'
|
|
|
37
37
|
* - `tutorialTours` — the in-app tutorial catalog ({@link TutorialTour}: data-only
|
|
38
38
|
* guided tours anchored to `data-testid`s, no components). First-party tours come
|
|
39
39
|
* from `modular/tutorial-tours.ts`; a consumer contributes its own to the same slot
|
|
40
|
-
* and they appear in the launch prompt beside the built-ins
|
|
41
|
-
* `
|
|
40
|
+
* and they appear in the launch prompt and the tutorial catalogue beside the built-ins.
|
|
41
|
+
* The one gated slot `navSlotFilter` does NOT filter: a tour's `requires` is resolved by
|
|
42
|
+
* `resolveTourCatalogue` in `useTutorialTours` instead, because the catalogue must list
|
|
43
|
+
* the tours this board can't run yet WITH what would unlock them — an annotation a
|
|
44
|
+
* slots-to-slots filter cannot carry.
|
|
42
45
|
* - `externalTools` — the deployment's OWN web applications, listed in their own
|
|
43
46
|
* "External tools" sidebar section ({@link ExternalToolContribution}). Each entry resolves
|
|
44
47
|
* its URL from the invocation context (user, workspace, the custom metadata below), so the
|