@cat-factory/app 0.196.1 → 0.198.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 +105 -0
- package/app/components/github/AddServiceFromRepoModal.vue +6 -1
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +164 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +125 -0
- package/app/components/tutorial/TutorialOverlay.vue +307 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.spec.ts +4 -1
- package/app/composables/usePipelineErrorToast.ts +24 -165
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +14 -0
- package/app/modular/nav-contributions.ts +70 -0
- package/app/modular/nav-gates.logic.spec.ts +41 -0
- package/app/modular/nav-gates.logic.ts +36 -0
- package/app/modular/nav-gates.ts +54 -0
- package/app/modular/registry.spec.ts +6 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +222 -0
- package/app/modular/tutorial-tours.ts +421 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +115 -0
- package/app/utils/tutorial.ts +246 -0
- package/i18n/locales/de.json +209 -2
- package/i18n/locales/en.json +215 -2
- package/i18n/locales/es.json +209 -2
- package/i18n/locales/fr.json +209 -2
- package/i18n/locales/he.json +209 -2
- package/i18n/locales/it.json +209 -2
- package/i18n/locales/ja.json +209 -2
- package/i18n/locales/pl.json +209 -2
- package/i18n/locales/tr.json +209 -2
- package/i18n/locales/uk.json +209 -2
- package/package.json +1 -1
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* added — which is exactly why the detail stays reachable rather than being dropped).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { createBespokeConflictToasts } from '~/composables/pipelineErrorToast/bespokeConflicts'
|
|
27
28
|
import type { ApiErrorCode, ConflictReason } from '@cat-factory/contracts'
|
|
28
29
|
import { apiErrorEnvelope, apiErrorStatus } from './api/errors'
|
|
29
30
|
|
|
@@ -244,7 +245,7 @@ export function parseConflict(
|
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
/** The non-null parsed shape of a backend conflict, as returned by {@link parseConflict}. */
|
|
247
|
-
type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
|
|
248
|
+
export type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
|
|
248
249
|
|
|
249
250
|
/**
|
|
250
251
|
* Generic translated description per STATUS CLASS, for a failure no `reason` code narrows.
|
|
@@ -330,171 +331,29 @@ export function describeGenericFailure(error: unknown): GenericFailure {
|
|
|
330
331
|
export function usePipelineErrorToast() {
|
|
331
332
|
const toast = useToast()
|
|
332
333
|
const ui = useUiStore()
|
|
333
|
-
|
|
334
|
+
// Resolved through the Nuxt app's global i18n instance rather than `useI18n()`, which
|
|
335
|
+
// requires an active component instance — the same pattern (and the same reason) as the
|
|
336
|
+
// board / recurring-pipelines stores.
|
|
337
|
+
//
|
|
338
|
+
// This composable is called from STORE SETUP (`stores/execution.ts`, `stores/agentRuns.ts`),
|
|
339
|
+
// and a Pinia setup store runs its body on the FIRST `useStore()` anywhere. That used to be
|
|
340
|
+
// a component, so `useI18n()` happened to be legal; the moment anything instantiated one of
|
|
341
|
+
// those stores earlier — `createNavGates()` does, from the `enforce: 'post'` modular plugin —
|
|
342
|
+
// vue-i18n threw `MUST_BE_CALL_SETUP_TOP`, the plugin threw, and Nuxt's error boundary
|
|
343
|
+
// replaced the entire app with its 500 page. Every single e2e spec failed on a blank board.
|
|
344
|
+
// A store must be instantiable outside a component, so the i18n handle it reaches for has
|
|
345
|
+
// to be too.
|
|
346
|
+
//
|
|
347
|
+
// Typed as `useI18n`'s own return (`$i18n` IS that global Composer in composition mode), so
|
|
348
|
+
// `t`/`te` keep their real signatures. No typed-message-key coverage is lost by the switch:
|
|
349
|
+
// tier 1 only sees literal keys written in a `<script setup>`, never in a `.ts` composable —
|
|
350
|
+
// the drift guard here is the exhaustive `CONFLICT_INFO` / `ApiErrorCode` records above.
|
|
351
|
+
const { t, te } = useNuxtApp().$i18n as ReturnType<typeof useI18n>
|
|
334
352
|
|
|
335
|
-
// The
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
340
|
-
const list = models.join(', ')
|
|
341
|
-
toast.add({
|
|
342
|
-
title: t('errors.conflict.providersUnconfigured.title'),
|
|
343
|
-
description: list
|
|
344
|
-
? t('errors.conflict.providersUnconfigured.body', { models: list })
|
|
345
|
-
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
346
|
-
color: 'error',
|
|
347
|
-
icon: 'i-lucide-cpu',
|
|
348
|
-
// Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
|
|
349
|
-
// auto-dismiss takes the one-click fix with it before the user can reach it.
|
|
350
|
-
duration: 0,
|
|
351
|
-
actions: [
|
|
352
|
-
{
|
|
353
|
-
label: t('errors.conflict.providersUnconfigured.action'),
|
|
354
|
-
icon: 'i-lucide-settings',
|
|
355
|
-
onClick: () => ui.openAiProviderSetup(),
|
|
356
|
-
},
|
|
357
|
-
],
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
|
|
362
|
-
// but the account has none configured. Explain it and offer the jump to the content-storage
|
|
363
|
-
// settings — the same shape as the providers-unconfigured case above. Prefer the localized
|
|
364
|
-
// body (it carries no runtime interpolation) so non-English users see translated copy; the
|
|
365
|
-
// raw backend prose is only the last-resort fallback when the locale lacks the key.
|
|
366
|
-
function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
|
|
367
|
-
toast.add({
|
|
368
|
-
title: t('errors.conflict.binaryStorageUnconfigured.title'),
|
|
369
|
-
description: te('errors.conflict.binaryStorageUnconfigured.body')
|
|
370
|
-
? t('errors.conflict.binaryStorageUnconfigured.body')
|
|
371
|
-
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
372
|
-
color: 'error',
|
|
373
|
-
icon: 'i-lucide-image',
|
|
374
|
-
// Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
|
|
375
|
-
// remedy reachable instead of letting it auto-dismiss.
|
|
376
|
-
duration: 0,
|
|
377
|
-
actions: [
|
|
378
|
-
{
|
|
379
|
-
label: t('errors.conflict.binaryStorageUnconfigured.action'),
|
|
380
|
-
icon: 'i-lucide-settings',
|
|
381
|
-
onClick: () => ui.openContentStorageSettings(),
|
|
382
|
-
},
|
|
383
|
-
],
|
|
384
|
-
})
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
|
|
388
|
-
// "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
|
|
389
|
-
// environment config — the compose wizard for docker-compose, the service inspector otherwise —
|
|
390
|
-
// falling back to the workspace infrastructure window if the frame id wasn't carried.
|
|
391
|
-
function presentDeployerServiceConfig(conflict: ParsedConflict): void {
|
|
392
|
-
const frameId =
|
|
393
|
-
typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
|
|
394
|
-
const provisionType =
|
|
395
|
-
typeof conflict.details.provisionType === 'string'
|
|
396
|
-
? conflict.details.provisionType
|
|
397
|
-
: undefined
|
|
398
|
-
const missing = Array.isArray(conflict.details.missing)
|
|
399
|
-
? conflict.details.missing.join(', ')
|
|
400
|
-
: ''
|
|
401
|
-
toast.add({
|
|
402
|
-
title: t('errors.conflict.deployerServiceConfig.title'),
|
|
403
|
-
description: missing
|
|
404
|
-
? t('errors.conflict.deployerServiceConfig.body', { missing })
|
|
405
|
-
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
406
|
-
color: 'error',
|
|
407
|
-
icon: 'i-lucide-server',
|
|
408
|
-
// Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
|
|
409
|
-
duration: 0,
|
|
410
|
-
actions: [
|
|
411
|
-
{
|
|
412
|
-
label: t('errors.conflict.deployerServiceConfig.action'),
|
|
413
|
-
icon: 'i-lucide-settings',
|
|
414
|
-
onClick: () => {
|
|
415
|
-
if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
|
|
416
|
-
else if (frameId) ui.select(frameId)
|
|
417
|
-
else ui.openProviderConnection('environment')
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
],
|
|
421
|
-
})
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
|
|
425
|
-
// resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
|
|
426
|
-
// window's Test-environments tab. (Also raised by the Tester start gate — same fix applies.)
|
|
427
|
-
function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
|
|
428
|
-
const type =
|
|
429
|
-
typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
|
|
430
|
-
toast.add({
|
|
431
|
-
title: t('errors.conflict.provisionTypeUnhandled.title'),
|
|
432
|
-
description: type
|
|
433
|
-
? t('errors.conflict.provisionTypeUnhandled.body', { type })
|
|
434
|
-
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
435
|
-
color: 'error',
|
|
436
|
-
icon: 'i-lucide-server-cog',
|
|
437
|
-
duration: 0,
|
|
438
|
-
actions: [
|
|
439
|
-
{
|
|
440
|
-
label: t('errors.conflict.provisionTypeUnhandled.action'),
|
|
441
|
-
icon: 'i-lucide-settings',
|
|
442
|
-
onClick: () => ui.openProviderConnection('environment'),
|
|
443
|
-
},
|
|
444
|
-
],
|
|
445
|
-
})
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// A pipeline includes a Deployer, the config is structurally complete, but the live connection
|
|
449
|
-
// probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
|
|
450
|
-
// token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
|
|
451
|
-
function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
|
|
452
|
-
const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
|
|
453
|
-
toast.add({
|
|
454
|
-
title: t('errors.conflict.deployerConnectionFailed.title'),
|
|
455
|
-
description: detail
|
|
456
|
-
? t('errors.conflict.deployerConnectionFailed.body', { detail })
|
|
457
|
-
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
458
|
-
color: 'error',
|
|
459
|
-
icon: 'i-lucide-plug',
|
|
460
|
-
duration: 0,
|
|
461
|
-
actions: [
|
|
462
|
-
{
|
|
463
|
-
label: t('errors.conflict.deployerConnectionFailed.action'),
|
|
464
|
-
icon: 'i-lucide-settings',
|
|
465
|
-
onClick: () => ui.openProviderConnection('environment'),
|
|
466
|
-
},
|
|
467
|
-
],
|
|
468
|
-
})
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
|
|
473
|
-
* each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
|
|
474
|
-
* the reason was one of them (and the toast was raised), `false` to fall through to the generic
|
|
475
|
-
* map. The reason values are mutually exclusive, so dispatch order is irrelevant.
|
|
476
|
-
*/
|
|
477
|
-
function presentBespokeConflict(conflict: ParsedConflict): boolean {
|
|
478
|
-
switch (conflict.reason) {
|
|
479
|
-
case 'providers_unconfigured':
|
|
480
|
-
presentProvidersUnconfigured(conflict)
|
|
481
|
-
return true
|
|
482
|
-
case 'binary_storage_unconfigured':
|
|
483
|
-
presentBinaryStorageUnconfigured(conflict)
|
|
484
|
-
return true
|
|
485
|
-
case 'deployer_service_provisioning_incomplete':
|
|
486
|
-
presentDeployerServiceConfig(conflict)
|
|
487
|
-
return true
|
|
488
|
-
case 'provision_type_unhandled':
|
|
489
|
-
presentProvisionTypeUnhandled(conflict)
|
|
490
|
-
return true
|
|
491
|
-
case 'deployer_connection_test_failed':
|
|
492
|
-
presentDeployerConnectionFailed(conflict)
|
|
493
|
-
return true
|
|
494
|
-
default:
|
|
495
|
-
return false
|
|
496
|
-
}
|
|
497
|
-
}
|
|
353
|
+
// The five bespoke conflict reasons (a runtime-interpolated body + a "configure X" jump each)
|
|
354
|
+
// live in a sibling factory over the same toast/ui/i18n handles, so this composable stays
|
|
355
|
+
// within the per-function line budget.
|
|
356
|
+
const presentBespokeConflict = createBespokeConflictToasts({ toast, ui, t, te })
|
|
498
357
|
|
|
499
358
|
/**
|
|
500
359
|
* Per-reason copy from the exhaustive map: a translated title + description, and a jump
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
|
+
import { sortTours } from '~/utils/tutorial'
|
|
4
|
+
import type { TutorialTour } from '~/utils/tutorial'
|
|
5
|
+
import type { AppSlots } from '~/modular/nav-contributions'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tours the current user may take: the merged `tutorialTours` slot (first-party +
|
|
9
|
+
* consumer-contributed), already gated per tour by `navSlotFilter` (each tour's `when`
|
|
10
|
+
* runs over the reactive gates service, so a permission flip shows/hides tours live),
|
|
11
|
+
* in deterministic catalog order. The single source both the launch prompt and the
|
|
12
|
+
* coach-mark overlay resolve tours from.
|
|
13
|
+
*/
|
|
14
|
+
export function useTutorialTours() {
|
|
15
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
16
|
+
const tours = computed<TutorialTour[]>(() => sortTours(slots.value.tutorialTours ?? []))
|
|
17
|
+
return { tours }
|
|
18
|
+
}
|
|
@@ -25,6 +25,12 @@ const NO_GATES: NavGates = {
|
|
|
25
25
|
// The permission axis is what these cases vary; keep the interface tier at `advanced`
|
|
26
26
|
// so a dropped item is unambiguously an RBAC/availability drop, not a tier drop.
|
|
27
27
|
advancedMode: true,
|
|
28
|
+
boardHasService: false,
|
|
29
|
+
boardHasTask: false,
|
|
30
|
+
boardHasRun: false,
|
|
31
|
+
boardHasOpenDecision: false,
|
|
32
|
+
boardHasPendingApproval: false,
|
|
33
|
+
boardHasFinishedRun: false,
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const ALL_GATES: NavGates = {
|
|
@@ -37,6 +43,12 @@ const ALL_GATES: NavGates = {
|
|
|
37
43
|
accountsEnabled: true,
|
|
38
44
|
isAccountAdmin: true,
|
|
39
45
|
advancedMode: true,
|
|
46
|
+
boardHasService: true,
|
|
47
|
+
boardHasTask: true,
|
|
48
|
+
boardHasRun: true,
|
|
49
|
+
boardHasOpenDecision: true,
|
|
50
|
+
boardHasPendingApproval: true,
|
|
51
|
+
boardHasFinishedRun: true,
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
const slots = (): AppSlots => ({
|
|
@@ -47,6 +59,7 @@ const slots = (): AppSlots => ({
|
|
|
47
59
|
taskTypes: [],
|
|
48
60
|
taskTypeFormPanels: [],
|
|
49
61
|
appOverlays: [],
|
|
62
|
+
tutorialTours: [],
|
|
50
63
|
})
|
|
51
64
|
const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
|
|
52
65
|
|
|
@@ -312,6 +325,7 @@ describe('nav grouping helpers', () => {
|
|
|
312
325
|
'sandbox',
|
|
313
326
|
'keyboard-shortcuts',
|
|
314
327
|
'ui-mode',
|
|
328
|
+
'tutorial',
|
|
315
329
|
])
|
|
316
330
|
})
|
|
317
331
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
import { resolveTours } from '~/utils/tutorial'
|
|
2
3
|
import type { AppSlots } from './slots'
|
|
3
4
|
|
|
4
5
|
// Re-exported for the slice-1 importers that reach `AppSlots` through this
|
|
@@ -81,6 +82,49 @@ export interface NavGates {
|
|
|
81
82
|
* available to a `gate` predicate that needs to combine it with something else.
|
|
82
83
|
*/
|
|
83
84
|
advancedMode: boolean
|
|
85
|
+
/**
|
|
86
|
+
* The open board has at least one service frame. Availability, not permission: a
|
|
87
|
+
* surface that operates ON a service (today the task-creation tutorial tour) has
|
|
88
|
+
* nothing to point at until one exists, and offering it anyway means a walkthrough
|
|
89
|
+
* that hunts for absent controls. Reactive like the rest, so it flips the moment a
|
|
90
|
+
* service lands on the board.
|
|
91
|
+
*/
|
|
92
|
+
boardHasService: boolean
|
|
93
|
+
/**
|
|
94
|
+
* The open board has at least one task block. The service frame that {@link boardHasService}
|
|
95
|
+
* reports is where a task GOES; this is whether one is actually there to run, which is what
|
|
96
|
+
* a tour about the run controls needs — they live in the inspector of a task, and a board of
|
|
97
|
+
* empty frames offers nothing to open.
|
|
98
|
+
*/
|
|
99
|
+
boardHasTask: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Some run on a TASK block is cached for this board, in any state. Availability for the
|
|
102
|
+
* steps that explain a run's ANATOMY (the step list, its live progress), which have nothing
|
|
103
|
+
* to anchor to until a run has been started at least once.
|
|
104
|
+
*
|
|
105
|
+
* Task-scoped, like the three below: every surface these gates open is reached through a
|
|
106
|
+
* task card and its inspector, and a frame-level run (a blueprint pass, an initiative plan)
|
|
107
|
+
* renders none of them.
|
|
108
|
+
*/
|
|
109
|
+
boardHasRun: boolean
|
|
110
|
+
/**
|
|
111
|
+
* Some task card is offering a human an unanswered DECISION to resolve.
|
|
112
|
+
*
|
|
113
|
+
* "Offering" rather than "exists": these two report what the board actually RENDERS, which
|
|
114
|
+
* is what a tour anchoring on the card's action can point at — see `hasActionablePark` for
|
|
115
|
+
* the two ways a park can exist with no control to show for it.
|
|
116
|
+
*/
|
|
117
|
+
boardHasOpenDecision: boolean
|
|
118
|
+
/** Some task card is offering a human an unanswered APPROVAL gate. See above. */
|
|
119
|
+
boardHasPendingApproval: boolean
|
|
120
|
+
/**
|
|
121
|
+
* Some run on a task block has finished successfully. Availability for the review/merge
|
|
122
|
+
* tour: its subject is the OUTPUT of a run, so there has to be one that produced output. A
|
|
123
|
+
* FAILED run deliberately does not count — the failure banner is its own surface, and a
|
|
124
|
+
* tour about reading a result and merging it would spend its steps pointing at controls a
|
|
125
|
+
* failed run never renders.
|
|
126
|
+
*/
|
|
127
|
+
boardHasFinishedRun: boolean
|
|
84
128
|
}
|
|
85
129
|
|
|
86
130
|
/** Command-palette placement + copy for a contribution that appears in the palette. */
|
|
@@ -119,6 +163,7 @@ export const NAV_ACTIONS = [
|
|
|
119
163
|
'operatorDashboard',
|
|
120
164
|
'reports',
|
|
121
165
|
'shortcuts',
|
|
166
|
+
'tutorial',
|
|
122
167
|
'toggleUiMode',
|
|
123
168
|
] as const
|
|
124
169
|
|
|
@@ -445,6 +490,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
445
490
|
testId: 'nav-reports',
|
|
446
491
|
sidebar: { group: 'configuration', order: 45 },
|
|
447
492
|
},
|
|
493
|
+
{
|
|
494
|
+
// Deliberately NOT `advanced`: the tours exist for exactly the users basic mode serves,
|
|
495
|
+
// and the palette entry is the way back to them after the launch prompt was declined
|
|
496
|
+
// or dismissed. Ungated: every tour gates itself via its own `when` predicate, and the
|
|
497
|
+
// prompt is worth reaching even when it can only list some tours.
|
|
498
|
+
id: 'tutorial',
|
|
499
|
+
labelKey: 'layout.commandBar.cmd.tutorial',
|
|
500
|
+
icon: 'i-lucide-graduation-cap',
|
|
501
|
+
surfaces: S('command'),
|
|
502
|
+
action: 'tutorial',
|
|
503
|
+
testId: 'nav-tutorial',
|
|
504
|
+
command: {
|
|
505
|
+
// After the pre-slice-1 tail (the workspace group pins that order): genuinely new
|
|
506
|
+
// entries append rather than interleave.
|
|
507
|
+
group: 'workspace',
|
|
508
|
+
order: 100,
|
|
509
|
+
keywordsKey: 'layout.commandBar.keywords.tutorial',
|
|
510
|
+
},
|
|
511
|
+
},
|
|
448
512
|
{
|
|
449
513
|
id: 'keyboard-shortcuts',
|
|
450
514
|
labelKey: 'layout.commandBar.cmd.shortcuts',
|
|
@@ -503,6 +567,7 @@ export const navigationModule = defineModule({
|
|
|
503
567
|
export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
|
|
504
568
|
const gates = deps.gates
|
|
505
569
|
const nav = slots.nav ?? []
|
|
570
|
+
const tutorialTours = slots.tutorialTours ?? []
|
|
506
571
|
return {
|
|
507
572
|
...slots,
|
|
508
573
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
@@ -512,6 +577,11 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
512
577
|
(i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
|
|
513
578
|
)
|
|
514
579
|
: nav,
|
|
580
|
+
// Tutorial tours gate over the same reactive service, so a tour about a surface the
|
|
581
|
+
// caller can't reach (e.g. creating tasks without board write) never shows, and a step
|
|
582
|
+
// about a branch this board isn't on is dropped rather than skipped (see `resolveTours`).
|
|
583
|
+
// Same gates-absent pass-through as `nav`.
|
|
584
|
+
tutorialTours: gates ? resolveTours(tutorialTours, gates) : tutorialTours,
|
|
515
585
|
}
|
|
516
586
|
}
|
|
517
587
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { hasActionablePark } from '~/modular/nav-gates.logic'
|
|
3
|
+
import type { ParkedGateRef } from '~/modular/nav-gates.logic'
|
|
4
|
+
|
|
5
|
+
const onlyTasks = (id: string) => id.startsWith('task_')
|
|
6
|
+
const nothingIsBackground = () => false
|
|
7
|
+
|
|
8
|
+
describe('hasActionablePark', () => {
|
|
9
|
+
it('reports a park a task card really shows an action for', () => {
|
|
10
|
+
const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'architect' }]
|
|
11
|
+
expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('ignores a park on a block that renders no task card', () => {
|
|
15
|
+
// A frame/module run parks too, but the affordance the tour anchors on is TaskCard's.
|
|
16
|
+
const parks: ParkedGateRef[] = [{ blockId: 'frame_billing', agentKind: 'blueprints' }]
|
|
17
|
+
expect(hasActionablePark(parks, onlyTasks, nothingIsBackground)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('ignores a reviewer gate the card suppresses as background work', () => {
|
|
21
|
+
// Mirrors `TaskCard.pendingApproval`: while the review is folding answers / re-reviewing
|
|
22
|
+
// it needs no human, so no Resolve button exists to point a tour at.
|
|
23
|
+
const parks: ParkedGateRef[] = [{ blockId: 'task_login', agentKind: 'requirements-review' }]
|
|
24
|
+
const isBackground = (kind: string | undefined) => kind === 'requirements-review'
|
|
25
|
+
expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports the actionable one when a board holds both kinds at once', () => {
|
|
29
|
+
const parks: ParkedGateRef[] = [
|
|
30
|
+
{ blockId: 'frame_billing', agentKind: 'architect' },
|
|
31
|
+
{ blockId: 'task_login', agentKind: 'requirements-review' },
|
|
32
|
+
{ blockId: 'task_signup', agentKind: 'coder' },
|
|
33
|
+
]
|
|
34
|
+
const isBackground = (kind: string | undefined) => kind === 'requirements-review'
|
|
35
|
+
expect(hasActionablePark(parks, onlyTasks, isBackground)).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('is false for a board with nothing parked at all', () => {
|
|
39
|
+
expect(hasActionablePark([], onlyTasks, nothingIsBackground)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions the `NavGates` service makes that are worth testing without a Pinia runtime.
|
|
3
|
+
* `nav-gates.ts` itself is pure store wiring (getters over computeds); everything that
|
|
4
|
+
* DECIDES something lives here, the same split as `TutorialOverlay.logic.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One park (an open decision or a pending approval gate) as the execution store projects it. */
|
|
8
|
+
export interface ParkedGateRef {
|
|
9
|
+
blockId: string
|
|
10
|
+
agentKind?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Is some park actually SHOWING a human an action to take?
|
|
15
|
+
*
|
|
16
|
+
* The gates this answers (`boardHasOpenDecision` / `boardHasPendingApproval`) exist to offer
|
|
17
|
+
* a tour that anchors on a task card's attention affordance (`task-resolve`), so they have to
|
|
18
|
+
* mean what makes that affordance RENDER — not merely "a park exists somewhere in the cached
|
|
19
|
+
* runs". Two facts the raw store counts miss, both of which would offer the tour onto a board
|
|
20
|
+
* with no control to point at (the tour then anchor-skips and reports itself abridged, which
|
|
21
|
+
* is exactly the noise per-step `when` gating exists to avoid):
|
|
22
|
+
*
|
|
23
|
+
* - a park on a frame or module block has no task card, so nothing renders the action;
|
|
24
|
+
* - a reviewer gate mid-cycle is deliberately SUPPRESSED by the card (`TaskCard.pendingApproval`
|
|
25
|
+
* → `useReviewStage().isBackground`): while the driver is folding answers or re-reviewing,
|
|
26
|
+
* the gate needs no human and the card shows a working indicator instead.
|
|
27
|
+
*
|
|
28
|
+
* Both predicates are injected rather than reached for, so the rule is checkable on plain data.
|
|
29
|
+
*/
|
|
30
|
+
export function hasActionablePark(
|
|
31
|
+
parks: readonly ParkedGateRef[],
|
|
32
|
+
isTaskBlock: (blockId: string) => boolean,
|
|
33
|
+
isBackground: (agentKind: string | undefined, blockId: string) => boolean,
|
|
34
|
+
): boolean {
|
|
35
|
+
return parks.some((p) => isTaskBlock(p.blockId) && !isBackground(p.agentKind, p.blockId))
|
|
36
|
+
}
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
|
+
import { hasActionablePark } from '~/modular/nav-gates.logic'
|
|
2
3
|
import type { NavGates } from '~/modular/nav-contributions'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -25,6 +26,37 @@ export function createNavGates(): NavGates {
|
|
|
25
26
|
const auth = useAuthStore()
|
|
26
27
|
const providerConnections = useProviderConnectionsStore()
|
|
27
28
|
const uiMode = useUiModeStore()
|
|
29
|
+
const board = useBoardStore()
|
|
30
|
+
const execution = useExecutionStore()
|
|
31
|
+
const reviews = useReviewStage()
|
|
32
|
+
|
|
33
|
+
// A top-level frame IS a service (see `app/types/domain.ts`); modules are sub-frames.
|
|
34
|
+
const hasService = computed(() => board.blocks.some((b) => b.level === 'frame' && !b.parentId))
|
|
35
|
+
const hasTask = computed(() => board.blocks.some((b) => b.level === 'task'))
|
|
36
|
+
|
|
37
|
+
// Every run gate below is scoped to runs on TASK blocks, because every surface they gate is
|
|
38
|
+
// reached through a task card and its inspector (the card's Resolve action, the inspector's
|
|
39
|
+
// step list and result views). A frame-level run — a blueprint pass, an initiative plan —
|
|
40
|
+
// renders none of those, so counting it would offer a tour onto controls that do not exist.
|
|
41
|
+
const taskBlockIds = computed(
|
|
42
|
+
() => new Set(board.blocks.filter((b) => b.level === 'task').map((b) => b.id)),
|
|
43
|
+
)
|
|
44
|
+
const isTaskBlock = (blockId: string) => taskBlockIds.value.has(blockId)
|
|
45
|
+
|
|
46
|
+
const hasRun = computed(() => execution.instances.some((e) => isTaskBlock(e.blockId)))
|
|
47
|
+
// A run that finished successfully. `done` only: see `NavGates.boardHasFinishedRun` for
|
|
48
|
+
// why a `failed` run is not a subject for the review/merge tour.
|
|
49
|
+
const hasFinishedRun = computed(() =>
|
|
50
|
+
execution.instances.some((e) => e.status === 'done' && isTaskBlock(e.blockId)),
|
|
51
|
+
)
|
|
52
|
+
// Not the store's raw pending counts: those answer "is anything parked", while the tour
|
|
53
|
+
// these gate anchors on the card affordance a park RENDERS. See `hasActionablePark`.
|
|
54
|
+
const hasOpenDecision = computed(() =>
|
|
55
|
+
hasActionablePark(execution.openDecisions, isTaskBlock, reviews.isBackground),
|
|
56
|
+
)
|
|
57
|
+
const hasPendingApproval = computed(() =>
|
|
58
|
+
hasActionablePark(execution.openApprovals, isTaskBlock, reviews.isBackground),
|
|
59
|
+
)
|
|
28
60
|
|
|
29
61
|
const infrastructureAvailable = computed(
|
|
30
62
|
() =>
|
|
@@ -65,5 +97,27 @@ export function createNavGates(): NavGates {
|
|
|
65
97
|
get advancedMode() {
|
|
66
98
|
return uiMode.isAdvanced
|
|
67
99
|
},
|
|
100
|
+
get boardHasService() {
|
|
101
|
+
return hasService.value
|
|
102
|
+
},
|
|
103
|
+
get boardHasTask() {
|
|
104
|
+
return hasTask.value
|
|
105
|
+
},
|
|
106
|
+
get boardHasRun() {
|
|
107
|
+
return hasRun.value
|
|
108
|
+
},
|
|
109
|
+
// The two park kinds read the execution store's existing open-decision / open-approval
|
|
110
|
+
// projections rather than re-scanning `instances` here, so a tour can never disagree with
|
|
111
|
+
// the queue that sent the user looking for it — then narrowed to the parks a task card
|
|
112
|
+
// actually offers an action for (`hasActionablePark`).
|
|
113
|
+
get boardHasOpenDecision() {
|
|
114
|
+
return hasOpenDecision.value
|
|
115
|
+
},
|
|
116
|
+
get boardHasPendingApproval() {
|
|
117
|
+
return hasPendingApproval.value
|
|
118
|
+
},
|
|
119
|
+
get boardHasFinishedRun() {
|
|
120
|
+
return hasFinishedRun.value
|
|
121
|
+
},
|
|
68
122
|
}
|
|
69
123
|
}
|
|
@@ -13,6 +13,12 @@ const NO_GATES: NavGates = {
|
|
|
13
13
|
accountsEnabled: false,
|
|
14
14
|
isAccountAdmin: false,
|
|
15
15
|
advancedMode: false,
|
|
16
|
+
boardHasService: false,
|
|
17
|
+
boardHasTask: false,
|
|
18
|
+
boardHasRun: false,
|
|
19
|
+
boardHasOpenDecision: false,
|
|
20
|
+
boardHasPendingApproval: false,
|
|
21
|
+
boardHasFinishedRun: false,
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
describe('app modular registry', () => {
|
package/app/modular/registry.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AnyModuleDescriptor } from '@modular-vue/core'
|
|
|
2
2
|
import { createRegistry } from '@modular-vue/runtime'
|
|
3
3
|
import { journeysPlugin } from '@modular-vue/journeys'
|
|
4
4
|
import { navigationModule } from '~/modular/nav-contributions'
|
|
5
|
+
import { tutorialToursModule } from '~/modular/tutorial-tours'
|
|
5
6
|
import type { NavGates } from '~/modular/nav-contributions'
|
|
6
7
|
import type { AppSlots } from '~/modular/slots'
|
|
7
8
|
|
|
@@ -37,7 +38,7 @@ export type AppDeps = {
|
|
|
37
38
|
* First-party modules the layer always registers. Real feature modules land
|
|
38
39
|
* here as each area is converted; slice 1 adds the navigation catalog.
|
|
39
40
|
*/
|
|
40
|
-
const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [navigationModule]
|
|
41
|
+
const FIRST_PARTY_MODULES: readonly AnyModuleDescriptor[] = [navigationModule, tutorialToursModule]
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Consumer-contributed modules, collected before the layer resolves its
|
|
@@ -109,6 +110,7 @@ export function createAppRegistry(
|
|
|
109
110
|
taskTypes: [],
|
|
110
111
|
taskTypeFormPanels: [],
|
|
111
112
|
appOverlays: [],
|
|
113
|
+
tutorialTours: [],
|
|
112
114
|
},
|
|
113
115
|
}).use(journeysPlugin())
|
|
114
116
|
for (const mod of [...FIRST_PARTY_MODULES, ...extraModules, ...consumerModules]) {
|
package/app/modular/slots.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Component } from 'vue'
|
|
2
2
|
import type { ComponentEntry, PanelEntry } from '@modular-vue/core'
|
|
3
3
|
import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
|
|
4
|
+
import type { TutorialTour } from '~/utils/tutorial'
|
|
4
5
|
import type { NavContribution } from './nav-contributions'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -31,6 +32,11 @@ import type { NavContribution } from './nav-contributions'
|
|
|
31
32
|
* per custom task type, addressed by the type's `formPanel` id and paired via
|
|
32
33
|
* `resolveComponentRegistry` (same shape as `resultViews`); shown INSTEAD of the
|
|
33
34
|
* descriptor-driven `fields`. An unpaired id degrades to the descriptor fields.
|
|
35
|
+
* - `tutorialTours` — the in-app tutorial catalog ({@link TutorialTour}: data-only
|
|
36
|
+
* guided tours anchored to `data-testid`s, no components). First-party tours come
|
|
37
|
+
* from `modular/tutorial-tours.ts`; a consumer contributes its own to the same slot
|
|
38
|
+
* and they appear in the launch prompt beside the built-ins, gated per tour by its
|
|
39
|
+
* `when(gates)` predicate in the same reactive `slotFilter` that gates `nav`.
|
|
34
40
|
* - `appOverlays` (extension slice D) — top-level modals/overlays a consumer module
|
|
35
41
|
* contributes ({@link OverlayContribution}, an id → component `ComponentEntry`),
|
|
36
42
|
* opened by `ui.openOverlay(id, subject?)` / `useAppOverlays().open(...)` and
|
|
@@ -52,6 +58,7 @@ export interface AppSlots {
|
|
|
52
58
|
taskTypes: CustomTaskType[]
|
|
53
59
|
taskTypeFormPanels: ResultViewContribution[]
|
|
54
60
|
appOverlays: OverlayContribution[]
|
|
61
|
+
tutorialTours: TutorialTour[]
|
|
55
62
|
[key: string]: unknown[]
|
|
56
63
|
}
|
|
57
64
|
|