@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
|
@@ -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
|
|
@@ -2,10 +2,14 @@ import { readdirSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
import en from '../../i18n/locales/en.json'
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
TUTORIAL_REQUIREMENTS,
|
|
7
|
+
TUTORIAL_TOURS,
|
|
8
|
+
tutorialToursModule,
|
|
9
|
+
} from '~/modular/tutorial-tours'
|
|
10
|
+
import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
7
11
|
import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
|
|
8
|
-
import type {
|
|
12
|
+
import type { NavGates } from '~/modular/nav-contributions'
|
|
9
13
|
|
|
10
14
|
const ALL_GATES: NavGates = {
|
|
11
15
|
canWriteBoard: true,
|
|
@@ -40,12 +44,6 @@ const FRESH_BOARD: NavGates = {
|
|
|
40
44
|
boardHasFinishedRun: false,
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
const slots = (): AppSlots =>
|
|
44
|
-
({
|
|
45
|
-
nav: [...NAV_CONTRIBUTIONS],
|
|
46
|
-
tutorialTours: [...TUTORIAL_TOURS],
|
|
47
|
-
}) as unknown as AppSlots
|
|
48
|
-
|
|
49
47
|
/** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
|
|
50
48
|
function lookupKey(key: string): unknown {
|
|
51
49
|
return key
|
|
@@ -197,73 +195,124 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
197
195
|
})
|
|
198
196
|
})
|
|
199
197
|
|
|
200
|
-
describe('
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
describe('tour availability across the catalog', () => {
|
|
199
|
+
/** The ids a board can START right now — what the launch prompt offers. */
|
|
200
|
+
const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
|
|
201
|
+
/** The catalogue's own view: every tour, with what is holding each one back. */
|
|
202
|
+
const entry = (gates: NavGates, tourId: string) =>
|
|
203
|
+
resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
|
|
204
|
+
|
|
205
|
+
it('offers every tour to a fully-gated user on a fully-populated board', () => {
|
|
206
|
+
expect(ready(ALL_GATES)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
204
207
|
})
|
|
205
208
|
|
|
206
|
-
it('
|
|
209
|
+
it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
|
|
210
|
+
// The catalogue surface's contract. A fresh board can run two of the six walkthroughs;
|
|
211
|
+
// dropping the other four (all a slot filter could do) would misrepresent the product as
|
|
212
|
+
// shipping two, to exactly the user who came looking for the rest.
|
|
213
|
+
const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
|
|
214
|
+
expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
215
|
+
expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
|
|
216
|
+
'board-basics',
|
|
217
|
+
'add-service',
|
|
218
|
+
])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('holds the task-creating tour back from a read-only viewer, and says why', () => {
|
|
207
222
|
const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
expect(
|
|
211
|
-
expect(ids).not.toContain('first-task')
|
|
223
|
+
expect(ready(viewer)).toContain('board-basics')
|
|
224
|
+
expect(ready(viewer)).not.toContain('first-task')
|
|
225
|
+
expect(entry(viewer, 'first-task')?.unmet.map((r) => r.id)).toEqual(['board-write'])
|
|
212
226
|
})
|
|
213
227
|
|
|
214
|
-
it('
|
|
228
|
+
it('holds the task-creating tour back on a board with no service to add a task to', () => {
|
|
215
229
|
// Every targeted step of that tour would time out in turn and it would then claim to
|
|
216
230
|
// have taught the core loop; `board-basics` is what an empty board can deliver.
|
|
217
231
|
const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
|
|
218
|
-
|
|
219
|
-
expect(
|
|
232
|
+
expect(ready(emptyBoard)).not.toContain('first-task')
|
|
233
|
+
expect(entry(emptyBoard, 'first-task')?.unmet.map((r) => r.id)).toEqual(['service'])
|
|
220
234
|
})
|
|
221
235
|
|
|
222
236
|
it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
|
|
223
237
|
// The state the launch prompt actually auto-opens in. Orientation alone would leave a
|
|
224
238
|
// new workspace with a tour of an empty canvas and no route to a first service, which
|
|
225
239
|
// is what `add-service` exists to fix — so it must survive exactly this gate set.
|
|
226
|
-
|
|
227
|
-
expect(filtered.tutorialTours.map((t) => t.id)).toEqual(['board-basics', 'add-service'])
|
|
240
|
+
expect(ready(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
|
|
228
241
|
})
|
|
229
242
|
|
|
230
|
-
it('
|
|
243
|
+
it('names the missing connection when no source control can list repositories', () => {
|
|
231
244
|
const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
|
|
232
|
-
expect(
|
|
233
|
-
|
|
234
|
-
])
|
|
245
|
+
expect(ready(noSource)).toEqual(['board-basics'])
|
|
246
|
+
expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
|
|
235
247
|
})
|
|
236
248
|
|
|
237
249
|
it('offers the run tour once a task exists, and the review tour once a run finished', () => {
|
|
238
250
|
const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
|
|
239
|
-
expect(
|
|
240
|
-
|
|
241
|
-
)
|
|
242
|
-
expect(
|
|
243
|
-
navSlotFilter(slots(), { gates: withTask }).tutorialTours.map((t) => t.id),
|
|
244
|
-
).not.toContain('review-merge')
|
|
251
|
+
expect(ready(withTask)).toContain('run-task')
|
|
252
|
+
expect(ready(withTask)).not.toContain('review-merge')
|
|
253
|
+
expect(entry(withTask, 'review-merge')?.unmet.map((r) => r.id)).toEqual(['finished-run'])
|
|
245
254
|
|
|
246
255
|
const finished: NavGates = { ...withTask, boardHasRun: true, boardHasFinishedRun: true }
|
|
247
|
-
expect(
|
|
248
|
-
|
|
249
|
-
|
|
256
|
+
expect(ready(finished)).toContain('review-merge')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('names every unmet requirement, not just the first', () => {
|
|
260
|
+
// The reader has to do all of them; reporting one at a time turns unblocking a tour into
|
|
261
|
+
// a guessing game with a fresh answer after each attempt.
|
|
262
|
+
const bare: NavGates = { ...FRESH_BOARD, canWriteBoard: false, githubAvailable: false }
|
|
263
|
+
expect(entry(bare, 'add-service')?.unmet.map((r) => r.id)).toEqual([
|
|
264
|
+
'board-write',
|
|
265
|
+
'source-control',
|
|
266
|
+
])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('resolves every requirement copy key against the en catalog', () => {
|
|
270
|
+
// Same tier-2 i18n guard as the tour copy above: a requirement's label is looked up from
|
|
271
|
+
// data, so a renamed key would reach the user as a raw path in the "available once" list.
|
|
272
|
+
for (const requirement of Object.values(TUTORIAL_REQUIREMENTS)) {
|
|
273
|
+
expect(typeof lookupKey(requirement.labelKey), requirement.labelKey).toBe('string')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('gives every tour a step that always applies, so none can be listed then not run', () => {
|
|
278
|
+
// The authoring rule `resolveTourCatalogue` documents. `blocked` outranks `not-applicable`,
|
|
279
|
+
// which is right — but it means a tour COULD be named in the catalogue as unlockable and
|
|
280
|
+
// then, once the reader has gone and done the thing it asked for, resolve to no applicable
|
|
281
|
+
// steps and still refuse to start. An unconditional step (every built-in has an intro and a
|
|
282
|
+
// finish card) makes `steps` non-empty under any gates, so that outcome is unreachable.
|
|
283
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
284
|
+
const unconditional = tour.steps.filter((s) => !s.when).map((s) => s.id)
|
|
285
|
+
expect(unconditional, tour.id).not.toEqual([])
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('declares its requirements from the shared set', () => {
|
|
290
|
+
// A tour with an inline requirement object is not wrong, but a duplicate of a shared one
|
|
291
|
+
// is: two copies of "a service on the board" drift into two different sentences about the
|
|
292
|
+
// same gate. Pinning the built-ins to the table keeps that a deliberate act.
|
|
293
|
+
const shared = new Set(Object.values(TUTORIAL_REQUIREMENTS).map((r) => r.id))
|
|
294
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
295
|
+
for (const requirement of tour.requires ?? []) {
|
|
296
|
+
expect(shared, `${tour.id}: ${requirement.id}`).toContain(requirement.id)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
250
299
|
})
|
|
251
300
|
|
|
252
301
|
it('passes tours through untouched when no gates service is wired', () => {
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
expect(resolveTours(TUTORIAL_TOURS, null).map((t) => t.id)).toEqual(
|
|
303
|
+
TUTORIAL_TOURS.map((t) => t.id),
|
|
304
|
+
)
|
|
255
305
|
})
|
|
256
306
|
})
|
|
257
307
|
|
|
258
308
|
describe('the parked-run tour branches', () => {
|
|
259
309
|
const stepIds = (gates: NavGates, tourId: string) =>
|
|
260
|
-
|
|
261
|
-
.
|
|
310
|
+
resolveTours(TUTORIAL_TOURS, gates)
|
|
311
|
+
.find((t) => t.id === tourId)
|
|
262
312
|
?.steps.map((s) => s.id)
|
|
263
313
|
|
|
264
314
|
it('is not offered while nothing is waiting for a human', () => {
|
|
265
|
-
|
|
266
|
-
expect(ids).not.toContain('answer-park')
|
|
315
|
+
expect(resolveTours(TUTORIAL_TOURS, FRESH_BOARD).map((t) => t.id)).not.toContain('answer-park')
|
|
267
316
|
})
|
|
268
317
|
|
|
269
318
|
it('shows the decision branch only, for a run parked on a decision', () => {
|
|
@@ -297,7 +346,7 @@ describe('the parked-run tour branches', () => {
|
|
|
297
346
|
it('keeps every branch when no gates service is wired', () => {
|
|
298
347
|
// Same dev-open parity as `nav`: with nothing to gate against, nothing is withheld —
|
|
299
348
|
// including the per-step branches, which a bare install must not silently thin out.
|
|
300
|
-
const answerPark =
|
|
349
|
+
const answerPark = resolveTours(TUTORIAL_TOURS, null).find((t) => t.id === 'answer-park')
|
|
301
350
|
expect(answerPark?.steps.map((s) => s.id)).toEqual([
|
|
302
351
|
'intro',
|
|
303
352
|
'resolve',
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
requires: [TUTORIAL_REQUIREMENTS.finishedRun],
|
|
365
414
|
steps: [
|
|
366
415
|
{
|
|
367
416
|
id: 'intro',
|
package/app/pages/index.vue
CHANGED
|
@@ -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)
|
|
146
|
-
//
|
|
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
|
|