@cat-factory/app 0.196.0 → 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 +49 -3
- package/app/components/brainstorm/BrainstormWindow.vue +11 -4
- package/app/components/clarity/ClarityReviewWindow.vue +11 -4
- package/app/components/initiative/InitiativePlanReview.vue +44 -37
- package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
- package/app/components/layout/SideBar.vue +13 -2
- package/app/components/layout/UiModeSwitcher.vue +66 -39
- package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
- package/app/components/panels/ResultWindowShell.logic.ts +31 -0
- package/app/components/panels/ResultWindowShell.vue +37 -8
- package/app/components/prReview/PrReviewWindow.vue +15 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
- package/app/components/spec/ServiceSpecWindow.vue +7 -4
- package/app/components/testing/TestReportWindow.vue +11 -6
- 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 +38 -3
- 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 +90 -2
- package/i18n/locales/en.json +96 -2
- package/i18n/locales/es.json +90 -2
- package/i18n/locales/fr.json +90 -2
- package/i18n/locales/he.json +90 -2
- package/i18n/locales/it.json +90 -2
- package/i18n/locales/ja.json +90 -2
- package/i18n/locales/pl.json +90 -2
- package/i18n/locales/tr.json +90 -2
- package/i18n/locales/uk.json +90 -2
- package/package.json +1 -1
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
|
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { RequestRecommendationItem, RequirementReview } from '~/types/requirements'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared state + injected dependencies the recommendation slice closes over. Created once in the
|
|
6
|
+
* `requirements` store setup and threaded into {@link createRecommendationCommands} so the split
|
|
7
|
+
* operations stay behaviourally identical to the original single-closure store — a size-only
|
|
8
|
+
* extraction, not a new seam.
|
|
9
|
+
*/
|
|
10
|
+
export interface RecommendationCommandContext {
|
|
11
|
+
api: ReturnType<typeof useApi>
|
|
12
|
+
workspace: ReturnType<typeof useWorkspaceStore>
|
|
13
|
+
/** Block ids whose Requirement Writer is currently producing recommendations. */
|
|
14
|
+
recommending: Ref<Set<string>>
|
|
15
|
+
/** Toggle a block/review id in one of the store's flag sets. */
|
|
16
|
+
withFlag: (set: Ref<Set<string>>, key: string, on: boolean) => void
|
|
17
|
+
/** Commit a server-returned review into the cache. */
|
|
18
|
+
store: (review: RequirementReview) => void
|
|
19
|
+
/** Whether a `pending` recommendation placeholder still exists for the block (server-derived). */
|
|
20
|
+
hasPendingRecommendations: (blockId: string) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
|
|
24
|
+
export function createRecommendationCommands(ctx: RecommendationCommandContext) {
|
|
25
|
+
const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
|
|
26
|
+
|
|
27
|
+
function isRecommending(blockId: string): boolean {
|
|
28
|
+
return recommending.value.has(blockId) || hasPendingRecommendations(blockId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ask the Requirement Writer to recommend answers for a batch of findings. Each item carries
|
|
33
|
+
* its finding id plus optional per-finding guidance (the note the human typed before choosing
|
|
34
|
+
* "recommend something"). ASYNCHRONOUS: returns at once with `pending` placeholder
|
|
35
|
+
* recommendations (the Writer runs per finding in the durable driver), which fill in (`ready`)
|
|
36
|
+
* via live `requirements` stream events; a notification calls the user back when the batch is
|
|
37
|
+
* ready. The board shows the `recommending` background stage while any placeholder is pending.
|
|
38
|
+
*/
|
|
39
|
+
async function requestRecommendations(blockId: string, items: RequestRecommendationItem[]) {
|
|
40
|
+
withFlag(recommending, blockId, true)
|
|
41
|
+
try {
|
|
42
|
+
const updated = await api.requestRecommendations(workspace.requireId(), blockId, items)
|
|
43
|
+
if (updated) store(updated)
|
|
44
|
+
return updated
|
|
45
|
+
} finally {
|
|
46
|
+
withFlag(recommending, blockId, false)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
|
|
51
|
+
async function acceptRecommendation(review: RequirementReview, recId: string) {
|
|
52
|
+
store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
|
|
56
|
+
async function rejectRecommendation(review: RequirementReview, recId: string) {
|
|
57
|
+
store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-request a recommendation with a "do it differently" note. */
|
|
61
|
+
async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
|
|
62
|
+
withFlag(recommending, review.blockId, true)
|
|
63
|
+
try {
|
|
64
|
+
store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
|
|
65
|
+
} finally {
|
|
66
|
+
withFlag(recommending, review.blockId, false)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
isRecommending,
|
|
72
|
+
requestRecommendations,
|
|
73
|
+
acceptRecommendation,
|
|
74
|
+
rejectRecommendation,
|
|
75
|
+
reRequestRecommendation,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
|
-
RequestRecommendationItem,
|
|
5
4
|
RequirementReview,
|
|
6
5
|
ResolveRequirementsExceededChoice,
|
|
7
6
|
ReviewItemStatus,
|
|
8
7
|
} from '~/types/requirements'
|
|
9
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
9
|
+
import { createRecommendationCommands } from '~/stores/requirements/recommendations'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Requirements-review state. On the pipeline path the reviewer runs as the first gate
|
|
@@ -204,48 +204,22 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
204
204
|
return updated
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (updated) store(updated)
|
|
224
|
-
return updated
|
|
225
|
-
} finally {
|
|
226
|
-
withFlag(recommending, blockId, false)
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Accept a recommendation (becomes the finding's answer, folded into the next incorporation). */
|
|
231
|
-
async function acceptRecommendation(review: RequirementReview, recId: string) {
|
|
232
|
-
store(await api.acceptRecommendation(workspace.requireId(), review.id, recId))
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Reject a recommendation (the human then dismisses / answers manually / re-requests). */
|
|
236
|
-
async function rejectRecommendation(review: RequirementReview, recId: string) {
|
|
237
|
-
store(await api.rejectRecommendation(workspace.requireId(), review.id, recId))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Re-request a recommendation with a "do it differently" note. */
|
|
241
|
-
async function reRequestRecommendation(review: RequirementReview, recId: string, note: string) {
|
|
242
|
-
withFlag(recommending, review.blockId, true)
|
|
243
|
-
try {
|
|
244
|
-
store(await api.reRequestRecommendation(workspace.requireId(), review.id, recId, note))
|
|
245
|
-
} finally {
|
|
246
|
-
withFlag(recommending, review.blockId, false)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
207
|
+
// The Requirement Writer recommendation slice (request / accept / reject / re-request),
|
|
208
|
+
// extracted into a cohesive factory over the state above — a size-only split.
|
|
209
|
+
const {
|
|
210
|
+
isRecommending,
|
|
211
|
+
requestRecommendations,
|
|
212
|
+
acceptRecommendation,
|
|
213
|
+
rejectRecommendation,
|
|
214
|
+
reRequestRecommendation,
|
|
215
|
+
} = createRecommendationCommands({
|
|
216
|
+
api,
|
|
217
|
+
workspace,
|
|
218
|
+
recommending,
|
|
219
|
+
withFlag,
|
|
220
|
+
store,
|
|
221
|
+
hasPendingRecommendations,
|
|
222
|
+
})
|
|
249
223
|
|
|
250
224
|
/** Resolve a capped review: extra-round / proceed / stop-reset. */
|
|
251
225
|
async function resolveExceeded(
|