@cat-factory/app 0.196.1 → 0.197.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 +39 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
- package/app/components/tutorial/TutorialOverlay.vue +273 -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.ts +6 -164
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +35 -0
- package/app/modular/nav-gates.ts +7 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +107 -0
- package/app/modular/tutorial-tours.ts +147 -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 +68 -0
- package/app/utils/tutorial.ts +192 -0
- package/i18n/locales/de.json +89 -2
- package/i18n/locales/en.json +92 -2
- package/i18n/locales/es.json +89 -2
- package/i18n/locales/fr.json +89 -2
- package/i18n/locales/he.json +89 -2
- package/i18n/locales/it.json +89 -2
- package/i18n/locales/ja.json +89 -2
- package/i18n/locales/pl.json +89 -2
- package/i18n/locales/tr.json +89 -2
- package/i18n/locales/uk.json +89 -2
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import en from '../../i18n/locales/en.json'
|
|
3
|
+
import { TUTORIAL_TOURS, tutorialToursModule } from '~/modular/tutorial-tours'
|
|
4
|
+
import { NAV_CONTRIBUTIONS, navSlotFilter } from '~/modular/nav-contributions'
|
|
5
|
+
import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
|
|
6
|
+
import type { AppSlots, NavGates } from '~/modular/nav-contributions'
|
|
7
|
+
|
|
8
|
+
const ALL_GATES: NavGates = {
|
|
9
|
+
canWriteBoard: true,
|
|
10
|
+
canManageIntegrations: true,
|
|
11
|
+
canManageSettings: true,
|
|
12
|
+
githubAvailable: true,
|
|
13
|
+
libraryAvailable: true,
|
|
14
|
+
infrastructureAvailable: true,
|
|
15
|
+
accountsEnabled: true,
|
|
16
|
+
isAccountAdmin: true,
|
|
17
|
+
advancedMode: true,
|
|
18
|
+
boardHasService: true,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const slots = (): AppSlots =>
|
|
22
|
+
({
|
|
23
|
+
nav: [...NAV_CONTRIBUTIONS],
|
|
24
|
+
tutorialTours: [...TUTORIAL_TOURS],
|
|
25
|
+
}) as unknown as AppSlots
|
|
26
|
+
|
|
27
|
+
/** Resolve a dot-path against the en catalog; undefined when any hop is missing. */
|
|
28
|
+
function lookupKey(key: string): unknown {
|
|
29
|
+
return key
|
|
30
|
+
.split('.')
|
|
31
|
+
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('the built-in tutorial tour catalog', () => {
|
|
35
|
+
it('has unique tour ids and unique step ids within each tour', () => {
|
|
36
|
+
const tourIds = TUTORIAL_TOURS.map((t) => t.id)
|
|
37
|
+
expect(new Set(tourIds).size).toBe(tourIds.length)
|
|
38
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
39
|
+
const stepIds = tour.steps.map((s) => s.id)
|
|
40
|
+
expect(new Set(stepIds).size).toBe(stepIds.length)
|
|
41
|
+
expect(tour.steps.length).toBeGreaterThan(0)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resolves every i18n key it names against the en catalog', () => {
|
|
46
|
+
// Tour copy is looked up with runtime-assembled keys, which the typed-key check
|
|
47
|
+
// cannot cover (i18n drift-guard tier 2): pin the catalog here instead, so a renamed
|
|
48
|
+
// key or a new step without copy fails a test rather than rendering a raw key path.
|
|
49
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
50
|
+
for (const key of [tour.titleKey, tour.descriptionKey]) {
|
|
51
|
+
expect(typeof lookupKey(key), key).toBe('string')
|
|
52
|
+
}
|
|
53
|
+
for (const s of tour.steps) {
|
|
54
|
+
for (const key of [s.titleKey, s.bodyKey]) {
|
|
55
|
+
expect(typeof lookupKey(key), key).toBe('string')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('names plain data-testid values as targets, never selectors', () => {
|
|
62
|
+
for (const tour of TUTORIAL_TOURS) {
|
|
63
|
+
for (const s of tour.steps) {
|
|
64
|
+
for (const target of [s.target, ...(s.altTargets ?? [])]) {
|
|
65
|
+
if (target === undefined) continue
|
|
66
|
+
// Asserted through the runtime's OWN guard, not a copy of its regex: the overlay
|
|
67
|
+
// drops an id this rejects, so a built-in tour that tripped it would silently
|
|
68
|
+
// lose the step rather than fail here.
|
|
69
|
+
expect(isSafeTargetId(target), `${tour.id}/${s.id}: ${target}`).toBe(true)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('is contributed to the tutorialTours slot by the module', () => {
|
|
76
|
+
expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('navSlotFilter over tutorialTours', () => {
|
|
81
|
+
it('keeps every tour for a fully-gated user', () => {
|
|
82
|
+
const filtered = navSlotFilter(slots(), { gates: ALL_GATES })
|
|
83
|
+
expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('drops the task-creating tour for a read-only viewer', () => {
|
|
87
|
+
const viewer: NavGates = { ...ALL_GATES, canWriteBoard: false }
|
|
88
|
+
const filtered = navSlotFilter(slots(), { gates: viewer })
|
|
89
|
+
const ids = filtered.tutorialTours.map((t) => t.id)
|
|
90
|
+
expect(ids).toContain('board-basics')
|
|
91
|
+
expect(ids).not.toContain('first-task')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('drops the task-creating tour on a board with no service to add a task to', () => {
|
|
95
|
+
// Every targeted step of that tour would time out in turn and it would then claim to
|
|
96
|
+
// have taught the core loop; `board-basics` is what an empty board can deliver.
|
|
97
|
+
const emptyBoard: NavGates = { ...ALL_GATES, boardHasService: false }
|
|
98
|
+
const filtered = navSlotFilter(slots(), { gates: emptyBoard })
|
|
99
|
+
const ids = filtered.tutorialTours.map((t) => t.id)
|
|
100
|
+
expect(ids).toEqual(['board-basics'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('passes tours through untouched when no gates service is wired', () => {
|
|
104
|
+
const filtered = navSlotFilter(slots(), {})
|
|
105
|
+
expect(filtered.tutorialTours.map((t) => t.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineModule } from '@modular-vue/core'
|
|
2
|
+
import type { TutorialTour } from '~/utils/tutorial'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The first-party tutorial-tour catalog, contributed to the `tutorialTours` slot the same
|
|
6
|
+
* way the nav catalog fills `nav`: declared ONCE as data, rendered by one shared runtime
|
|
7
|
+
* (`TutorialOverlay`), and open to consumer deployments — `registerAppModule` a module
|
|
8
|
+
* with its own `tutorialTours` entries and they appear in the launch prompt beside these.
|
|
9
|
+
*
|
|
10
|
+
* Authoring rules (what keeps a tour evolvable as the app changes):
|
|
11
|
+
*
|
|
12
|
+
* - A step points at a control by its `data-testid` — the same stable anchor vocabulary
|
|
13
|
+
* the e2e suite owns. Covering a control that has none means adding the test id first
|
|
14
|
+
* (a behaviour-neutral change), never inventing a parallel attribute.
|
|
15
|
+
* - A missing anchor SKIPS the step rather than stranding the tour: controls come and go
|
|
16
|
+
* with RBAC, interface tier, and deployment wiring, so a tour must be a set of
|
|
17
|
+
* opportunities, not a fixed script. Gate a whole tour on `when` only when its SUBJECT
|
|
18
|
+
* requires it (e.g. board-write for a tour that creates a task).
|
|
19
|
+
* - "Now click this" steps use `advanceOn: 'target-click'` so the user drives the real
|
|
20
|
+
* control and the app's real response (the actual modal, the actual task) is what the
|
|
21
|
+
* next step anchors to. Steps whose anchor only exists after that response give it a
|
|
22
|
+
* longer `waitForTargetMs`.
|
|
23
|
+
* - Copy lives under `tutorial.tours.<tourCamelId>.steps.<stepId>` in the i18n catalogs;
|
|
24
|
+
* tours never carry display strings.
|
|
25
|
+
*/
|
|
26
|
+
export const TUTORIAL_TOURS: readonly TutorialTour[] = [
|
|
27
|
+
{
|
|
28
|
+
id: 'board-basics',
|
|
29
|
+
order: 10,
|
|
30
|
+
icon: 'i-lucide-map',
|
|
31
|
+
titleKey: 'tutorial.tours.boardBasics.title',
|
|
32
|
+
descriptionKey: 'tutorial.tours.boardBasics.description',
|
|
33
|
+
steps: [
|
|
34
|
+
{
|
|
35
|
+
id: 'welcome',
|
|
36
|
+
titleKey: 'tutorial.tours.boardBasics.steps.welcome.title',
|
|
37
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.welcome.body',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'canvas',
|
|
41
|
+
target: 'board-canvas',
|
|
42
|
+
placement: 'right',
|
|
43
|
+
titleKey: 'tutorial.tours.boardBasics.steps.canvas.title',
|
|
44
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.canvas.body',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'sidebar',
|
|
48
|
+
target: 'sidebar',
|
|
49
|
+
placement: 'right',
|
|
50
|
+
titleKey: 'tutorial.tours.boardBasics.steps.sidebar.title',
|
|
51
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.sidebar.body',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'commandBar',
|
|
55
|
+
target: 'command-bar-launcher',
|
|
56
|
+
placement: 'right',
|
|
57
|
+
titleKey: 'tutorial.tours.boardBasics.steps.commandBar.title',
|
|
58
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.commandBar.body',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'toolbar',
|
|
62
|
+
target: 'board-fit-view',
|
|
63
|
+
placement: 'bottom',
|
|
64
|
+
titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
|
|
65
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'finish',
|
|
69
|
+
titleKey: 'tutorial.tours.boardBasics.steps.finish.title',
|
|
70
|
+
bodyKey: 'tutorial.tours.boardBasics.steps.finish.body',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'first-task',
|
|
76
|
+
order: 20,
|
|
77
|
+
icon: 'i-lucide-list-plus',
|
|
78
|
+
titleKey: 'tutorial.tours.firstTask.title',
|
|
79
|
+
descriptionKey: 'tutorial.tours.firstTask.description',
|
|
80
|
+
// Creating a task is a board WRITE; a viewer has no add-task button to point at.
|
|
81
|
+
// It also needs somewhere to PUT the task: on a board with no service frame every
|
|
82
|
+
// targeted step below would time out in turn, so the tour would spend half a minute
|
|
83
|
+
// hunting for controls and then claim to have taught the core loop. Offering it only
|
|
84
|
+
// once a service exists is the honest version — and the launch prompt still lists
|
|
85
|
+
// `board-basics`, which is the tour an empty board can actually deliver.
|
|
86
|
+
when: (gates) => gates.canWriteBoard && gates.boardHasService,
|
|
87
|
+
steps: [
|
|
88
|
+
{
|
|
89
|
+
id: 'intro',
|
|
90
|
+
titleKey: 'tutorial.tours.firstTask.steps.intro.title',
|
|
91
|
+
bodyKey: 'tutorial.tours.firstTask.steps.intro.body',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'addTask',
|
|
95
|
+
target: 'frame-add-task',
|
|
96
|
+
// An empty frame renders its add-task affordance as a full-width button instead.
|
|
97
|
+
altTargets: ['frame-add-task-empty'],
|
|
98
|
+
advanceOn: 'target-click',
|
|
99
|
+
placement: 'bottom',
|
|
100
|
+
titleKey: 'tutorial.tours.firstTask.steps.addTask.title',
|
|
101
|
+
bodyKey: 'tutorial.tours.firstTask.steps.addTask.body',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'describe',
|
|
105
|
+
target: 'add-task-title',
|
|
106
|
+
// Deliberately NOT `target-click`: clicking a text field is how you START typing,
|
|
107
|
+
// so click-to-advance would move the tooltip off the instruction the moment the
|
|
108
|
+
// user acted on it. The user reads, types, and presses Next when they are ready;
|
|
109
|
+
// `target-click` is for buttons, where the click IS the completed action.
|
|
110
|
+
placement: 'right',
|
|
111
|
+
// The anchor lives inside the modal the previous click opens; allow it to mount.
|
|
112
|
+
waitForTargetMs: 8000,
|
|
113
|
+
titleKey: 'tutorial.tours.firstTask.steps.describe.title',
|
|
114
|
+
bodyKey: 'tutorial.tours.firstTask.steps.describe.body',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'create',
|
|
118
|
+
target: 'add-task-submit',
|
|
119
|
+
advanceOn: 'target-click',
|
|
120
|
+
placement: 'top',
|
|
121
|
+
titleKey: 'tutorial.tours.firstTask.steps.create.title',
|
|
122
|
+
bodyKey: 'tutorial.tours.firstTask.steps.create.body',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'card',
|
|
126
|
+
target: 'task-card',
|
|
127
|
+
placement: 'bottom',
|
|
128
|
+
// The card arrives over the live event stream after the create round-trips.
|
|
129
|
+
waitForTargetMs: 10000,
|
|
130
|
+
titleKey: 'tutorial.tours.firstTask.steps.card.title',
|
|
131
|
+
bodyKey: 'tutorial.tours.firstTask.steps.card.body',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'finish',
|
|
135
|
+
titleKey: 'tutorial.tours.firstTask.steps.finish.title',
|
|
136
|
+
bodyKey: 'tutorial.tours.firstTask.steps.finish.body',
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
/** The module that contributes the catalog; registered by `createAppRegistry`. */
|
|
143
|
+
export const tutorialToursModule = defineModule({
|
|
144
|
+
id: 'cat-factory:tutorial-tours',
|
|
145
|
+
version: '1.0.0',
|
|
146
|
+
slots: { tutorialTours: [...TUTORIAL_TOURS] },
|
|
147
|
+
})
|
package/app/pages/index.vue
CHANGED
|
@@ -138,6 +138,15 @@ const AiProviderOnboardingModal = defineAsyncComponent(
|
|
|
138
138
|
const AiPresetMismatchDialog = defineAsyncComponent(
|
|
139
139
|
() => import('~/components/providers/AiPresetMismatchDialog.vue'),
|
|
140
140
|
)
|
|
141
|
+
// The in-app tutorial: the launch prompt (auto-opened once for a user who never answered
|
|
142
|
+
// it) and the coach-mark overlay that runs a tour. Both mount only while their store flag
|
|
143
|
+
// is set, so they cost the initial bundle nothing.
|
|
144
|
+
const TutorialPrompt = defineAsyncComponent(
|
|
145
|
+
() => import('~/components/tutorial/TutorialPrompt.vue'),
|
|
146
|
+
)
|
|
147
|
+
const TutorialOverlay = defineAsyncComponent(
|
|
148
|
+
() => import('~/components/tutorial/TutorialOverlay.vue'),
|
|
149
|
+
)
|
|
141
150
|
|
|
142
151
|
const workspace = useWorkspaceStore()
|
|
143
152
|
const github = useGitHubStore()
|
|
@@ -267,6 +276,56 @@ watch(
|
|
|
267
276
|
{ immediate: true },
|
|
268
277
|
)
|
|
269
278
|
|
|
279
|
+
// Offer the tutorial on launch, once the board is up. Yields to every other startup
|
|
280
|
+
// surface — the GitHub onboarding gate and the advisory/onboarding modals above — so a
|
|
281
|
+
// first launch never stacks the tour prompt on top of a dialog that needs answering
|
|
282
|
+
// first; when one of those is open, the flip of its flag re-fires this watcher and the
|
|
283
|
+
// prompt appears then. The store guards the rest: only a user who never answered is
|
|
284
|
+
// asked, at most once per session.
|
|
285
|
+
//
|
|
286
|
+
// Yielding runs in BOTH directions: an advisory that opens LATER (a health probe that
|
|
287
|
+
// resolves a beat after the board) would otherwise land on top of an open tour prompt,
|
|
288
|
+
// which is the stacking this ordering exists to prevent. So the same watcher withdraws
|
|
289
|
+
// an unanswered prompt and re-arms the offer, and the user is asked once the advisory
|
|
290
|
+
// they actually have to answer is gone.
|
|
291
|
+
//
|
|
292
|
+
// The watcher exists only while it can still do something. A saved decision (hydrated
|
|
293
|
+
// synchronously from the persisted store) means it never registers, and once a decision
|
|
294
|
+
// lands — or the offer has been made and left standing — it stops itself, so the steady
|
|
295
|
+
// state pays nothing for the launch offer: no watcher, no mounted component (the v-ifs
|
|
296
|
+
// below), no store reads.
|
|
297
|
+
const tutorial = useTutorialStore()
|
|
298
|
+
const startupAdvisoryOpen = computed(
|
|
299
|
+
() =>
|
|
300
|
+
needsGitHubInstall.value ||
|
|
301
|
+
githubProbePending.value ||
|
|
302
|
+
ui.pipelineHealthOpen ||
|
|
303
|
+
ui.riskPolicyHealthOpen ||
|
|
304
|
+
ui.modelPresetHealthOpen ||
|
|
305
|
+
ui.aiProviderSetupOpen ||
|
|
306
|
+
ui.aiPresetMismatchOpen,
|
|
307
|
+
)
|
|
308
|
+
// Settled = the offer can never need to act again: a decision exists, or the prompt was
|
|
309
|
+
// auto-opened and is still standing (a deferral clears `promptAutoOpened`, which is
|
|
310
|
+
// exactly what keeps the watcher alive to re-offer).
|
|
311
|
+
const tutorialOfferSettled = () =>
|
|
312
|
+
tutorial.decision !== null || (tutorial.promptAutoOpened && !tutorial.promptOpen)
|
|
313
|
+
if (!tutorialOfferSettled()) {
|
|
314
|
+
// `let` + optional call: with `immediate: true` the first run happens synchronously
|
|
315
|
+
// inside `watch(...)`, before the handle is assigned — the trailing check covers it.
|
|
316
|
+
let stopTutorialOffer: (() => void) | undefined
|
|
317
|
+
stopTutorialOffer = watch(
|
|
318
|
+
() => [workspace.ready, startupAdvisoryOpen.value, tutorial.promptOpen],
|
|
319
|
+
() => {
|
|
320
|
+
if (startupAdvisoryOpen.value) tutorial.deferPrompt()
|
|
321
|
+
else if (workspace.ready) tutorial.maybeOfferOnLaunch()
|
|
322
|
+
if (tutorialOfferSettled()) stopTutorialOffer?.()
|
|
323
|
+
},
|
|
324
|
+
{ immediate: true },
|
|
325
|
+
)
|
|
326
|
+
if (tutorialOfferSettled()) stopTutorialOffer()
|
|
327
|
+
}
|
|
328
|
+
|
|
270
329
|
// Probe the GitHub integration as soon as a board is active (re-probe per board —
|
|
271
330
|
// connections are per workspace). The result drives the onboarding gate below
|
|
272
331
|
// before the board mounts, so an unconnected user can't slip past it. `ensureProbed`
|
|
@@ -448,6 +507,8 @@ watch(
|
|
|
448
507
|
<VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
|
|
449
508
|
<AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
|
|
450
509
|
<AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
|
|
510
|
+
<TutorialPrompt v-if="tutorial.promptOpen" />
|
|
511
|
+
<TutorialOverlay v-if="tutorial.touring" />
|
|
451
512
|
</template>
|
|
452
513
|
|
|
453
514
|
<!-- Backend unreachable / bootstrap failed -->
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
2
|
+
import type { BoardWriteContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The board's dependency-edge writes. Split out of {@link createBoardPlacement} along the same
|
|
6
|
+
* seam it was split from {@link createBoardMutations}: it closes over the shared
|
|
7
|
+
* {@link BoardWriteContext} so behaviour is identical to the original in-closure functions, and
|
|
8
|
+
* the split is purely to keep every function within the size budget.
|
|
9
|
+
*/
|
|
10
|
+
export function createBoardDependencies(ctx: BoardWriteContext) {
|
|
11
|
+
const { getBlock, upsert, api, toast, tr } = ctx
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Toggle a dependency edge target -> source (target dependsOn source). The backend
|
|
15
|
+
* rejects an edge that would close a cycle (422) — surface that as a toast rather than
|
|
16
|
+
* letting it throw unhandled out of a board gesture.
|
|
17
|
+
*/
|
|
18
|
+
async function toggleDependency(targetId: string, sourceId: string) {
|
|
19
|
+
if (targetId === sourceId || !getBlock(targetId)) return
|
|
20
|
+
try {
|
|
21
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
22
|
+
} catch (e) {
|
|
23
|
+
toast.add({
|
|
24
|
+
title: tr('board.toast.linkFailed'),
|
|
25
|
+
description: e instanceof Error ? e.message : String(e),
|
|
26
|
+
icon: 'i-lucide-triangle-alert',
|
|
27
|
+
color: 'error',
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Remove a dependency edge target -> source if it exists. */
|
|
33
|
+
async function removeDependency(targetId: string, sourceId: string) {
|
|
34
|
+
const t = getBlock(targetId)
|
|
35
|
+
if (!t || !t.dependsOn.includes(sourceId)) return
|
|
36
|
+
// the backend exposes a single toggle; the edge exists, so toggling removes it
|
|
37
|
+
try {
|
|
38
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
|
|
41
|
+
// than rejecting unhandled with no feedback.
|
|
42
|
+
toast.add({
|
|
43
|
+
title: tr('board.toast.unlinkFailed'),
|
|
44
|
+
description: e instanceof Error ? e.message : String(e),
|
|
45
|
+
icon: 'i-lucide-triangle-alert',
|
|
46
|
+
color: 'error',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { toggleDependency, removeDependency }
|
|
52
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { UpdateBlockInput } from '@cat-factory/contracts'
|
|
2
2
|
import { useServicesStore } from '~/stores/services'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import { createBoardDependencies } from './dependencies'
|
|
4
5
|
import type { BoardWriteContext } from './context'
|
|
5
6
|
import { UNDO_WINDOW_MS } from './context'
|
|
6
7
|
|
|
@@ -215,43 +216,9 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
* letting it throw unhandled out of a board gesture.
|
|
222
|
-
*/
|
|
223
|
-
async function toggleDependency(targetId: string, sourceId: string) {
|
|
224
|
-
if (targetId === sourceId || !getBlock(targetId)) return
|
|
225
|
-
try {
|
|
226
|
-
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
227
|
-
} catch (e) {
|
|
228
|
-
toast.add({
|
|
229
|
-
title: tr('board.toast.linkFailed'),
|
|
230
|
-
description: e instanceof Error ? e.message : String(e),
|
|
231
|
-
icon: 'i-lucide-triangle-alert',
|
|
232
|
-
color: 'error',
|
|
233
|
-
})
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Remove a dependency edge target -> source if it exists. */
|
|
238
|
-
async function removeDependency(targetId: string, sourceId: string) {
|
|
239
|
-
const t = getBlock(targetId)
|
|
240
|
-
if (!t || !t.dependsOn.includes(sourceId)) return
|
|
241
|
-
// the backend exposes a single toggle; the edge exists, so toggling removes it
|
|
242
|
-
try {
|
|
243
|
-
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
244
|
-
} catch (e) {
|
|
245
|
-
// Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
|
|
246
|
-
// than rejecting unhandled with no feedback.
|
|
247
|
-
toast.add({
|
|
248
|
-
title: tr('board.toast.unlinkFailed'),
|
|
249
|
-
description: e instanceof Error ? e.message : String(e),
|
|
250
|
-
icon: 'i-lucide-triangle-alert',
|
|
251
|
-
color: 'error',
|
|
252
|
-
})
|
|
253
|
-
}
|
|
254
|
-
}
|
|
219
|
+
// The dependency-edge writes, split along the same seam into a sibling factory over the same
|
|
220
|
+
// context; re-exposed here so every existing caller is unchanged.
|
|
221
|
+
const { toggleDependency, removeDependency } = createBoardDependencies(ctx)
|
|
255
222
|
|
|
256
223
|
return {
|
|
257
224
|
reparentBlock,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { computed, type Ref } from 'vue'
|
|
2
|
+
import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The read-only projections of what across every cached run is awaiting a human: the open
|
|
6
|
+
* decisions and approval gates, their per-block indexes, and the two badge counts.
|
|
7
|
+
*
|
|
8
|
+
* Created once in the `execution` store setup over its `instances` ref, so the derivations stay
|
|
9
|
+
* behaviourally identical to the former in-closure computeds — a size-only extraction mirroring
|
|
10
|
+
* {@link createExecutionCommands}, not a new seam.
|
|
11
|
+
*/
|
|
12
|
+
export function createPendingGateSelectors(instances: Ref<ExecutionInstance[]>) {
|
|
13
|
+
/** How many decisions anywhere are awaiting a human. */
|
|
14
|
+
const pendingDecisionCount = computed(() =>
|
|
15
|
+
instances.value.reduce(
|
|
16
|
+
(n, e) => n + e.steps.filter((s) => s.decision && !s.decision.chosen).length,
|
|
17
|
+
0,
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
/** All currently-unresolved decisions across all runs (for the toolbar/queue). */
|
|
22
|
+
const openDecisions = computed(() => {
|
|
23
|
+
const out: {
|
|
24
|
+
instanceId: string
|
|
25
|
+
blockId: string
|
|
26
|
+
decision: Decision
|
|
27
|
+
agentKind: PipelineStep['agentKind']
|
|
28
|
+
}[] = []
|
|
29
|
+
for (const e of instances.value) {
|
|
30
|
+
for (const s of e.steps) {
|
|
31
|
+
if (s.decision && !s.decision.chosen) {
|
|
32
|
+
out.push({
|
|
33
|
+
instanceId: e.id,
|
|
34
|
+
blockId: e.blockId,
|
|
35
|
+
decision: s.decision,
|
|
36
|
+
agentKind: s.agentKind,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** All currently-pending approval gates across all runs (board badges/queue). */
|
|
45
|
+
const openApprovals = computed(() => {
|
|
46
|
+
const out: {
|
|
47
|
+
instanceId: string
|
|
48
|
+
blockId: string
|
|
49
|
+
approval: StepApproval
|
|
50
|
+
agentKind: PipelineStep['agentKind']
|
|
51
|
+
/**
|
|
52
|
+
* Whether the gate's proposal is a RENDERING of an artifact the step already committed
|
|
53
|
+
* (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
|
|
54
|
+
* WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
|
|
55
|
+
* no way to tell a rendered document from the agent's raw transcript summary, and would
|
|
56
|
+
* present a one-line summary as though it were the artifact.
|
|
57
|
+
*/
|
|
58
|
+
outputIsRendered: boolean
|
|
59
|
+
}[] = []
|
|
60
|
+
for (const e of instances.value) {
|
|
61
|
+
for (const s of e.steps) {
|
|
62
|
+
if (s.approval?.status === 'pending') {
|
|
63
|
+
out.push({
|
|
64
|
+
instanceId: e.id,
|
|
65
|
+
blockId: e.blockId,
|
|
66
|
+
approval: s.approval,
|
|
67
|
+
agentKind: s.agentKind,
|
|
68
|
+
outputIsRendered: s.outputIsRendered === true,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Open decisions/approvals grouped by the block they belong to, so a board card
|
|
78
|
+
* resolves its own + its tasks' pending gates with O(1) lookups instead of
|
|
79
|
+
* re-filtering the global lists once per frame on every execution event.
|
|
80
|
+
*/
|
|
81
|
+
function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
|
|
82
|
+
const map = new Map<string, T[]>()
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
const list = map.get(item.blockId)
|
|
85
|
+
if (list) list.push(item)
|
|
86
|
+
else map.set(item.blockId, [item])
|
|
87
|
+
}
|
|
88
|
+
return map
|
|
89
|
+
}
|
|
90
|
+
const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
|
|
91
|
+
const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
|
|
92
|
+
|
|
93
|
+
/** How many approval gates anywhere are awaiting a human. */
|
|
94
|
+
const pendingApprovalCount = computed(() =>
|
|
95
|
+
instances.value.reduce(
|
|
96
|
+
(n, e) => n + e.steps.filter((s) => s.approval?.status === 'pending').length,
|
|
97
|
+
0,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
pendingDecisionCount,
|
|
103
|
+
openDecisions,
|
|
104
|
+
openApprovals,
|
|
105
|
+
decisionsByBlock,
|
|
106
|
+
approvalsByBlock,
|
|
107
|
+
pendingApprovalCount,
|
|
108
|
+
}
|
|
109
|
+
}
|
package/app/stores/execution.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import type {
|
|
3
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
4
4
|
import { createExecutionCommands } from '~/stores/execution/commands'
|
|
5
|
+
import { createPendingGateSelectors } from '~/stores/execution/pendingGates'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Running pipeline instances. The simulation engine lives on the backend: this
|
|
@@ -188,93 +189,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
188
189
|
return runs.find((e) => !isTerminal(e.status)) ?? runs.at(-1)
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
0,
|
|
196
|
-
),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
/** All currently-unresolved decisions across all runs (for the toolbar/queue). */
|
|
200
|
-
const openDecisions = computed(() => {
|
|
201
|
-
const out: {
|
|
202
|
-
instanceId: string
|
|
203
|
-
blockId: string
|
|
204
|
-
decision: Decision
|
|
205
|
-
agentKind: PipelineStep['agentKind']
|
|
206
|
-
}[] = []
|
|
207
|
-
for (const e of instances.value) {
|
|
208
|
-
for (const s of e.steps) {
|
|
209
|
-
if (s.decision && !s.decision.chosen) {
|
|
210
|
-
out.push({
|
|
211
|
-
instanceId: e.id,
|
|
212
|
-
blockId: e.blockId,
|
|
213
|
-
decision: s.decision,
|
|
214
|
-
agentKind: s.agentKind,
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return out
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
/** All currently-pending approval gates across all runs (board badges/queue). */
|
|
223
|
-
const openApprovals = computed(() => {
|
|
224
|
-
const out: {
|
|
225
|
-
instanceId: string
|
|
226
|
-
blockId: string
|
|
227
|
-
approval: StepApproval
|
|
228
|
-
agentKind: PipelineStep['agentKind']
|
|
229
|
-
/**
|
|
230
|
-
* Whether the gate's proposal is a RENDERING of an artifact the step already committed
|
|
231
|
-
* (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
|
|
232
|
-
* WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
|
|
233
|
-
* no way to tell a rendered document from the agent's raw transcript summary, and would
|
|
234
|
-
* present a one-line summary as though it were the artifact.
|
|
235
|
-
*/
|
|
236
|
-
outputIsRendered: boolean
|
|
237
|
-
}[] = []
|
|
238
|
-
for (const e of instances.value) {
|
|
239
|
-
for (const s of e.steps) {
|
|
240
|
-
if (s.approval?.status === 'pending') {
|
|
241
|
-
out.push({
|
|
242
|
-
instanceId: e.id,
|
|
243
|
-
blockId: e.blockId,
|
|
244
|
-
approval: s.approval,
|
|
245
|
-
agentKind: s.agentKind,
|
|
246
|
-
outputIsRendered: s.outputIsRendered === true,
|
|
247
|
-
})
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return out
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Open decisions/approvals grouped by the block they belong to, so a board card
|
|
256
|
-
* resolves its own + its tasks' pending gates with O(1) lookups instead of
|
|
257
|
-
* re-filtering the global lists once per frame on every execution event.
|
|
258
|
-
*/
|
|
259
|
-
function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
|
|
260
|
-
const map = new Map<string, T[]>()
|
|
261
|
-
for (const item of items) {
|
|
262
|
-
const list = map.get(item.blockId)
|
|
263
|
-
if (list) list.push(item)
|
|
264
|
-
else map.set(item.blockId, [item])
|
|
265
|
-
}
|
|
266
|
-
return map
|
|
267
|
-
}
|
|
268
|
-
const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
|
|
269
|
-
const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
|
|
270
|
-
|
|
271
|
-
/** How many approval gates anywhere are awaiting a human. */
|
|
272
|
-
const pendingApprovalCount = computed(() =>
|
|
273
|
-
instances.value.reduce(
|
|
274
|
-
(n, e) => n + e.steps.filter((s) => s.approval?.status === 'pending').length,
|
|
275
|
-
0,
|
|
276
|
-
),
|
|
277
|
-
)
|
|
192
|
+
// What across every cached run is awaiting a human (open decisions + approval gates, their
|
|
193
|
+
// per-block indexes and the two badge counts), extracted into a cohesive factory over the same
|
|
194
|
+
// `instances` ref — a size-only split mirroring `createExecutionCommands` below.
|
|
195
|
+
const pendingGates = createPendingGateSelectors(instances)
|
|
278
196
|
|
|
279
197
|
// The run-control commands (start / decide / approve / merge / restart / cancel / stop),
|
|
280
198
|
// extracted into a cohesive factory sharing the state above (a size-only split mirroring
|
|
@@ -290,12 +208,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
290
208
|
byId,
|
|
291
209
|
getInstance,
|
|
292
210
|
getByBlock,
|
|
293
|
-
|
|
294
|
-
openDecisions,
|
|
295
|
-
openApprovals,
|
|
296
|
-
decisionsByBlock,
|
|
297
|
-
approvalsByBlock,
|
|
298
|
-
pendingApprovalCount,
|
|
211
|
+
...pendingGates,
|
|
299
212
|
...commands,
|
|
300
213
|
}
|
|
301
214
|
})
|