@cat-factory/app 0.121.0 → 0.121.2

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.
@@ -0,0 +1,63 @@
1
+ import { computed, ref } from 'vue'
2
+ import type { LodLevel } from '~/types/domain'
3
+ import { zoomToLod } from '~/composables/useSemanticZoom'
4
+
5
+ /**
6
+ * The board-navigation slice of the UI store: selection / focus, canvas zoom + the derived
7
+ * level-of-detail, and the (retained) expanded-frame set. Hot paths (zoom/pan/select) live here,
8
+ * isolated from the modal + result-view state, per refactoring candidate #4. Composed into
9
+ * {@link useUiStore}; the returned refs/actions keep their names, so consumers are unchanged.
10
+ */
11
+ export function createUiNavigation() {
12
+ const selectedBlockId = ref<string | null>(null)
13
+ const focusBlockId = ref<string | null>(null)
14
+
15
+ /** Current canvas zoom (driven by Vue Flow viewport). */
16
+ const zoom = ref(1)
17
+
18
+ const lod = computed<LodLevel>(() => zoomToLod(zoom.value))
19
+
20
+ /** Frames the user has manually expanded to reveal their tasks. */
21
+ const expandedFrames = ref<Set<string>>(new Set())
22
+
23
+ function toggleFrame(id: string) {
24
+ const next = new Set(expandedFrames.value)
25
+ if (next.has(id)) next.delete(id)
26
+ else next.add(id)
27
+ expandedFrames.value = next
28
+ }
29
+
30
+ function expandFrame(id: string) {
31
+ if (expandedFrames.value.has(id)) return
32
+ expandedFrames.value = new Set(expandedFrames.value).add(id)
33
+ }
34
+
35
+ /** Services are always expanded to their task canvas, at every zoom level, so the
36
+ * board layout is fixed: panning never changes it and zooming has no expand/collapse
37
+ * transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
38
+ * no longer gate rendering.) */
39
+ function isFrameExpanded(_id: string) {
40
+ return true
41
+ }
42
+
43
+ function select(id: string | null) {
44
+ selectedBlockId.value = id
45
+ }
46
+
47
+ function focus(id: string | null) {
48
+ focusBlockId.value = id
49
+ }
50
+
51
+ return {
52
+ selectedBlockId,
53
+ focusBlockId,
54
+ zoom,
55
+ lod,
56
+ expandedFrames,
57
+ toggleFrame,
58
+ expandFrame,
59
+ isFrameExpanded,
60
+ select,
61
+ focus,
62
+ }
63
+ }
@@ -0,0 +1,246 @@
1
+ import { ref } from 'vue'
2
+ import { useExecutionStore } from '~/stores/execution'
3
+ import { agentKindMeta } from '~/utils/catalog'
4
+
5
+ /**
6
+ * The step-inspection / result-view slice of the UI store: the dedicated result-view overlay
7
+ * (`resultView`, driven by the universal `dispatchStepView` seam), the generic step-detail
8
+ * panel (`stepDetail`), the LLM per-call observability panel, and the Kaizen screen — plus the
9
+ * open/close actions every board + inspector entry point uses. Split out of the modal + nav
10
+ * state per refactoring candidate #4; the `dispatchStepView`/`ui.resultView` seam is preserved
11
+ * intact, so adding a bespoke window for a new agent is still just declaring `resultView` +
12
+ * registering a component. Composed into {@link useUiStore} with the same public names.
13
+ */
14
+ export function createUiResultViews() {
15
+ // Dedicated result-view overlay: a step whose agent kind declares a bespoke
16
+ // visualization (via the archetype's `resultView`) opens here instead of the generic
17
+ // prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
18
+ // `blockId` is always set; `instanceId`/`stepIndex` are present on the pipeline path and
19
+ // null for an off-path open (e.g. the inspector's pre-start requirements review).
20
+ const resultView = ref<{
21
+ view: string
22
+ blockId: string
23
+ instanceId: string | null
24
+ stepIndex: number | null
25
+ // The brainstorm dialogue stage, set only when `view === 'brainstorm'` (its two agent
26
+ // kinds share one window). Derived from the step's agent kind on the pipeline path, or
27
+ // passed explicitly on an off-path open.
28
+ stage?: 'requirements' | 'architecture'
29
+ } | null>(null)
30
+
31
+ // Agent step-detail overlay: which pipeline step (a run instance + step index)
32
+ // a human is inspecting, or null when closed. The overlay resolves the step
33
+ // from the execution store so it stays live; it shows the step's metadata
34
+ // (model, state, progress, subtasks, …) and — when the agent produced prose —
35
+ // a reader for it (ToC + collapsible sections).
36
+ const stepDetail = ref<{ instanceId: string; stepIndex: number } | null>(null)
37
+
38
+ // LLM observability panel: which run (execution instance) a human is inspecting
39
+ // the per-call model activity for, or null when closed. The panel loads the full
40
+ // per-call detail from the observability store on open.
41
+ const observabilityInstanceId = ref<string | null>(null)
42
+
43
+ // The Kaizen screen (grading history + verified-combo library), a full-panel overlay
44
+ // opened from the sidebar. Distinct from the per-run grading status shown in run details.
45
+ const kaizenScreenOpen = ref(false)
46
+
47
+ /**
48
+ * Open a pending approval gate in the conclusions reader (approval mode). Resolves
49
+ * the step index from the gate id so every board/inspector entry point can keep
50
+ * passing the approval id it already has.
51
+ */
52
+ function openApprovalDetail(instanceId: string, approvalId: string) {
53
+ const execution = useExecutionStore()
54
+ const instance = execution.getInstance(instanceId)
55
+ const idx = instance?.steps.findIndex((s) => s.approval?.id === approvalId) ?? -1
56
+ if (idx >= 0) dispatchStepView(instanceId, idx)
57
+ }
58
+
59
+ /**
60
+ * Open a pipeline step: route it to its agent kind's DEDICATED result window when the
61
+ * archetype declares one (the universal `resultView` seam), else the generic prose
62
+ * step-detail panel. This is the single dispatch every board/inspector entry point uses,
63
+ * so adding a bespoke window for a new agent is just declaring `resultView` + registering
64
+ * a component — no caller changes.
65
+ */
66
+ function dispatchStepView(instanceId: string, stepIndex: number) {
67
+ const execution = useExecutionStore()
68
+ const instance = execution.getInstance(instanceId)
69
+ const step = instance?.steps[stepIndex]
70
+ // A step that actually ran the consensus mechanism opens the dedicated Consensus
71
+ // Session window, regardless of its kind's normal result view — consensus is an
72
+ // execution MODE on a kind, not a kind, so it can't be a static archetype `resultView`.
73
+ const view = step?.consensus?.enabled
74
+ ? 'consensus-session'
75
+ : step
76
+ ? agentKindMeta(step.agentKind).resultView
77
+ : undefined
78
+ if (view && instance) {
79
+ // The brainstorm window is shared by both stages; carry which one from the step's kind.
80
+ const stage =
81
+ view === 'brainstorm'
82
+ ? step?.agentKind === 'architecture-brainstorm'
83
+ ? 'architecture'
84
+ : 'requirements'
85
+ : undefined
86
+ resultView.value = {
87
+ view,
88
+ blockId: instance.blockId,
89
+ instanceId,
90
+ stepIndex,
91
+ ...(stage ? { stage } : {}),
92
+ }
93
+ return
94
+ }
95
+ stepDetail.value = { instanceId, stepIndex }
96
+ }
97
+
98
+ function openRequirementReview(blockId: string) {
99
+ resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
100
+ }
101
+ function openClarityReview(blockId: string) {
102
+ resultView.value = { view: 'clarity-review', blockId, instanceId: null, stepIndex: null }
103
+ }
104
+ function openBrainstorm(blockId: string, stage: 'requirements' | 'architecture') {
105
+ resultView.value = { view: 'brainstorm', blockId, instanceId: null, stepIndex: null, stage }
106
+ }
107
+ // Open the service-spec window for a service frame (the inspector's "View Requirements").
108
+ function openServiceSpec(blockId: string) {
109
+ resultView.value = { view: 'service-spec', blockId, instanceId: null, stepIndex: null }
110
+ }
111
+ // Open the initiative tracker window for an initiative block (board card / inspector).
112
+ function openInitiativeTracker(blockId: string) {
113
+ resultView.value = { view: 'initiative-tracker', blockId, instanceId: null, stepIndex: null }
114
+ }
115
+ // Open the interactive-planning Q&A window for an initiative block (inspector / card,
116
+ // when the interviewer has parked the planning run with pending questions).
117
+ function openInitiativePlanning(blockId: string) {
118
+ resultView.value = { view: 'initiative-planning', blockId, instanceId: null, stepIndex: null }
119
+ }
120
+ // Open the Follow-up companion window for a run's Coder step (the blinking chip + the
121
+ // `followup_pending` notification). Resolves the Coder step index from the run when not
122
+ // given, so callers that only know the run can still open it.
123
+ function openFollowUps(instanceId: string, stepIndex: number | null = null) {
124
+ const execution = useExecutionStore()
125
+ const instance = execution.getInstance(instanceId)
126
+ if (!instance) return
127
+ // A pipeline may carry more than one follow-up-enabled Coder step, so don't blindly pick
128
+ // the first when no index is given: prefer the step that still has undecided items (the
129
+ // one the run is parked on), else the current step, else the first enabled one.
130
+ const resolveIdx = () => {
131
+ const pending = instance.steps.findIndex(
132
+ (s) => s.followUps?.enabled && s.followUps.items.some((i) => i.status === 'pending'),
133
+ )
134
+ if (pending >= 0) return pending
135
+ const current = instance.steps[instance.currentStep]
136
+ if (current?.followUps?.enabled) return instance.currentStep
137
+ return instance.steps.findIndex((s) => s.followUps?.enabled)
138
+ }
139
+ const idx = stepIndex ?? resolveIdx()
140
+ if (idx < 0) return
141
+ resultView.value = {
142
+ view: 'follow-ups',
143
+ blockId: instance.blockId,
144
+ instanceId,
145
+ stepIndex: idx,
146
+ }
147
+ }
148
+ // Open the implementation-fork decision window for a run's coder step (from the inspector /
149
+ // pipeline chip / `fork_decision_pending` notification). Resolves the coder step index from
150
+ // the run when not given, preferring the step parked awaiting a choice.
151
+ function openForkDecision(instanceId: string, stepIndex: number | null = null) {
152
+ const execution = useExecutionStore()
153
+ const instance = execution.getInstance(instanceId)
154
+ if (!instance) return
155
+ const resolveIdx = () => {
156
+ const awaiting = instance.steps.findIndex(
157
+ (s) => s.agentKind === 'coder' && s.forkDecision?.status === 'awaiting_choice',
158
+ )
159
+ if (awaiting >= 0) return awaiting
160
+ const current = instance.steps[instance.currentStep]
161
+ if (current?.agentKind === 'coder' && current.forkDecision) return instance.currentStep
162
+ return instance.steps.findIndex((s) => s.agentKind === 'coder' && s.forkDecision)
163
+ }
164
+ const idx = stepIndex ?? resolveIdx()
165
+ if (idx < 0) return
166
+ resultView.value = {
167
+ view: 'fork-decision',
168
+ blockId: instance.blockId,
169
+ instanceId,
170
+ stepIndex: idx,
171
+ }
172
+ }
173
+ // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
174
+ // notification / the step). Resolves the step index from the run when not given, preferring
175
+ // the step parked awaiting a finding selection.
176
+ function openPrReview(instanceId: string, stepIndex: number | null = null) {
177
+ const execution = useExecutionStore()
178
+ const instance = execution.getInstance(instanceId)
179
+ if (!instance) return
180
+ const resolveIdx = () => {
181
+ const awaiting = instance.steps.findIndex(
182
+ (s) => s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection',
183
+ )
184
+ if (awaiting >= 0) return awaiting
185
+ const current = instance.steps[instance.currentStep]
186
+ if (current?.agentKind === 'pr-reviewer' && current.prReview) return instance.currentStep
187
+ return instance.steps.findIndex((s) => s.agentKind === 'pr-reviewer' && s.prReview)
188
+ }
189
+ const idx = stepIndex ?? resolveIdx()
190
+ if (idx < 0) return
191
+ resultView.value = {
192
+ view: 'pr-review',
193
+ blockId: instance.blockId,
194
+ instanceId,
195
+ stepIndex: idx,
196
+ }
197
+ }
198
+ function closeResultView() {
199
+ resultView.value = null
200
+ }
201
+ // Kept name for the requirements window's close handler.
202
+ const closeRequirementReview = closeResultView
203
+ function openStepDetail(instanceId: string, stepIndex: number) {
204
+ dispatchStepView(instanceId, stepIndex)
205
+ }
206
+ function closeStepDetail() {
207
+ stepDetail.value = null
208
+ }
209
+ function openObservability(instanceId: string) {
210
+ observabilityInstanceId.value = instanceId
211
+ }
212
+ function closeObservability() {
213
+ observabilityInstanceId.value = null
214
+ }
215
+ function openKaizen() {
216
+ kaizenScreenOpen.value = true
217
+ }
218
+ function closeKaizen() {
219
+ kaizenScreenOpen.value = false
220
+ }
221
+
222
+ return {
223
+ resultView,
224
+ stepDetail,
225
+ observabilityInstanceId,
226
+ kaizenScreenOpen,
227
+ openApprovalDetail,
228
+ openRequirementReview,
229
+ openClarityReview,
230
+ openBrainstorm,
231
+ openServiceSpec,
232
+ openInitiativeTracker,
233
+ openInitiativePlanning,
234
+ openFollowUps,
235
+ openForkDecision,
236
+ openPrReview,
237
+ closeResultView,
238
+ closeRequirementReview,
239
+ openStepDetail,
240
+ closeStepDetail,
241
+ openObservability,
242
+ closeObservability,
243
+ openKaizen,
244
+ closeKaizen,
245
+ }
246
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { useUiStore } from '~/stores/ui'
3
+ import { createUiNavigation } from '~/stores/ui/navigation'
4
+ import { createUiResultViews } from '~/stores/ui/resultViews'
5
+ import { createUiModals } from '~/stores/ui/modals'
6
+
7
+ /**
8
+ * `ui.ts` is a thin facade composing three slices (`navigation` / `resultViews` / `modals`)
9
+ * behind ONE unchanged public surface (refactoring candidate #4). These tests pin that
10
+ * invariant so a future slice edit can't silently drop, shadow, or duplicate a key that a
11
+ * `useUiStore()` consumer depends on — the split must stay purely internal.
12
+ */
13
+ describe('ui store — facade composes the slices with no surface drift', () => {
14
+ it('exposes exactly the union of the three slices, with no cross-slice key collisions', () => {
15
+ const nav = Object.keys(createUiNavigation())
16
+ const results = Object.keys(createUiResultViews())
17
+ const modals = Object.keys(createUiModals())
18
+ const sliceKeys = [...nav, ...results, ...modals]
19
+
20
+ // No two slices declare the same key — a collision would silently drop one on spread.
21
+ expect(new Set(sliceKeys).size).toBe(sliceKeys.length)
22
+
23
+ // The store surface is precisely the union of the slices — nothing added, nothing lost.
24
+ // (Filter out Pinia's own `$`/`_`-prefixed API, which no slice key uses.)
25
+ const storeKeys = Object.keys(useUiStore()).filter(
26
+ (k) => !k.startsWith('$') && !k.startsWith('_'),
27
+ )
28
+ expect(storeKeys.sort()).toEqual([...new Set(sliceKeys)].sort())
29
+ })
30
+ })