@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.
Files changed (41) hide show
  1. package/README.md +39 -0
  2. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
  3. package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
  4. package/app/components/tutorial/TutorialOverlay.vue +273 -0
  5. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  6. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  7. package/app/composables/useNavContributions.ts +1 -0
  8. package/app/composables/usePipelineErrorToast.ts +6 -164
  9. package/app/composables/useTutorialTours.ts +18 -0
  10. package/app/modular/nav-contributions.spec.ts +4 -0
  11. package/app/modular/nav-contributions.ts +35 -0
  12. package/app/modular/nav-gates.ts +7 -0
  13. package/app/modular/registry.spec.ts +1 -0
  14. package/app/modular/registry.ts +3 -1
  15. package/app/modular/slots.ts +7 -0
  16. package/app/modular/tutorial-tours.spec.ts +107 -0
  17. package/app/modular/tutorial-tours.ts +147 -0
  18. package/app/pages/index.vue +61 -0
  19. package/app/stores/board/dependencies.ts +52 -0
  20. package/app/stores/board/placement.ts +4 -37
  21. package/app/stores/execution/pendingGates.ts +109 -0
  22. package/app/stores/execution.ts +7 -94
  23. package/app/stores/requirements/recommendations.ts +77 -0
  24. package/app/stores/requirements.ts +17 -43
  25. package/app/stores/tutorial.spec.ts +135 -0
  26. package/app/stores/tutorial.ts +145 -0
  27. package/app/stores/workspace/commands.ts +77 -0
  28. package/app/stores/workspace.ts +11 -50
  29. package/app/utils/tutorial.spec.ts +68 -0
  30. package/app/utils/tutorial.ts +192 -0
  31. package/i18n/locales/de.json +89 -2
  32. package/i18n/locales/en.json +92 -2
  33. package/i18n/locales/es.json +89 -2
  34. package/i18n/locales/fr.json +89 -2
  35. package/i18n/locales/he.json +89 -2
  36. package/i18n/locales/it.json +89 -2
  37. package/i18n/locales/ja.json +89 -2
  38. package/i18n/locales/pl.json +89 -2
  39. package/i18n/locales/tr.json +89 -2
  40. package/i18n/locales/uk.json +89 -2
  41. 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
+ })
@@ -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
- * Toggle a dependency edge target -> source (target dependsOn source). The backend
220
- * rejects an edge that would close a cycle (422) — surface that as a toast rather than
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
+ }
@@ -1,7 +1,8 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
- import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
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
- /** How many decisions anywhere are awaiting a human. */
192
- const pendingDecisionCount = computed(() =>
193
- instances.value.reduce(
194
- (n, e) => n + e.steps.filter((s) => s.decision && !s.decision.chosen).length,
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
- pendingDecisionCount,
294
- openDecisions,
295
- openApprovals,
296
- decisionsByBlock,
297
- approvalsByBlock,
298
- pendingApprovalCount,
211
+ ...pendingGates,
299
212
  ...commands,
300
213
  }
301
214
  })