@cat-factory/app 0.181.0 → 0.182.1
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 +11 -4
- package/app/components/board/nodes/BlockNode.vue +14 -4
- package/app/components/board/nodes/DraggableTask.vue +3 -2
- package/app/components/board/nodes/InitiativeCard.vue +27 -2
- package/app/components/board/nodes/TaskPipelineMini.vue +9 -12
- package/app/components/docs/DocInterviewWindow.vue +24 -3
- package/app/components/initiative/InitiativePlanningWindow.vue +27 -7
- package/app/components/initiative/InitiativeTrackerWindow.vue +149 -9
- package/app/components/panels/inspector/InitiativeInspector.vue +23 -2
- package/app/composables/useInitiativePlanning.ts +96 -8
- package/app/composables/useTaskExpansion.ts +30 -16
- package/app/modular/nav-contributions.spec.ts +13 -3
- package/app/modular/nav-contributions.ts +42 -32
- package/app/modular/panels/inspector.logic.ts +1 -1
- package/app/stores/taskExpansion.spec.ts +88 -0
- package/app/stores/taskExpansion.ts +34 -14
- package/app/utils/catalog.ts +5 -4
- package/app/utils/initiative.spec.ts +51 -1
- package/app/utils/initiative.ts +44 -0
- package/app/utils/interviewGate.spec.ts +87 -12
- package/app/utils/interviewGate.ts +57 -5
- package/i18n/locales/de.json +15 -1
- package/i18n/locales/en.json +15 -1
- package/i18n/locales/es.json +15 -1
- package/i18n/locales/fr.json +15 -1
- package/i18n/locales/he.json +15 -1
- package/i18n/locales/it.json +15 -1
- package/i18n/locales/ja.json +15 -1
- package/i18n/locales/pl.json +15 -1
- package/i18n/locales/tr.json +15 -1
- package/i18n/locales/uk.json +15 -1
- package/package.json +1 -1
|
@@ -4,7 +4,24 @@ import { useExecutionStore } from '~/stores/execution'
|
|
|
4
4
|
import { useInitiativesStore } from '~/stores/initiative'
|
|
5
5
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
6
|
import { useUiStore } from '~/stores/ui'
|
|
7
|
-
import {
|
|
7
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
8
|
+
import { selectPlanApproval, type InitiativeAttentionKind } from '~/utils/initiative'
|
|
9
|
+
import {
|
|
10
|
+
INITIATIVE_INTERVIEWER_KIND,
|
|
11
|
+
interviewGatePhase,
|
|
12
|
+
interviewStepReached,
|
|
13
|
+
} from '~/utils/interviewGate'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What an initiative's planning run needs from a human right now: which kind of park it is (the
|
|
17
|
+
* card + inspector resolve its icon/label from the shared `INITIATIVE_ATTENTION_*` maps, so the
|
|
18
|
+
* two surfaces word one park identically), and the action that opens the surface which can
|
|
19
|
+
* RESOLVE it — the step's own dedicated window, via `dispatchStepView`.
|
|
20
|
+
*/
|
|
21
|
+
export interface InitiativeAttention {
|
|
22
|
+
kind: InitiativeAttentionKind
|
|
23
|
+
open: () => void
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
/**
|
|
10
27
|
* Shared planning affordances for an `initiative`-level block, used by BOTH the board card
|
|
@@ -41,12 +58,14 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
41
58
|
* The live interview phase, derived from the entity AND the planning run (see
|
|
42
59
|
* {@link interviewGatePhase} for why the run status is load-bearing).
|
|
43
60
|
*/
|
|
44
|
-
const interviewPhase = computed(() =>
|
|
45
|
-
|
|
61
|
+
const interviewPhase = computed(() => {
|
|
62
|
+
const run = execution.getByBlock(toValue(blockId))
|
|
63
|
+
return interviewGatePhase(
|
|
46
64
|
initiative.value?.interview?.status,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
run?.status,
|
|
66
|
+
interviewStepReached(run, INITIATIVE_INTERVIEWER_KIND),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
50
69
|
|
|
51
70
|
/**
|
|
52
71
|
* The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
|
|
@@ -62,8 +81,74 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
62
81
|
*/
|
|
63
82
|
const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
|
|
64
83
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The planning run is mid-flight with nothing for the human to answer — either an interviewer
|
|
86
|
+
* pass is running (`working`) or the run has not reached the interview yet (`preparing`, the
|
|
87
|
+
* codebase analysis that now leads). BOTH phases, deliberately: this drives the card's and
|
|
88
|
+
* inspector's "Planning in progress" button, which is the only route into the planning window
|
|
89
|
+
* while a run owns the block. Narrowing it to `working` would drop that route for the whole of
|
|
90
|
+
* the analysis and fall through to a "Run planning" button for a run already running.
|
|
91
|
+
*
|
|
92
|
+
* The two phases are distinguished INSIDE the window, where there is room to say which is which;
|
|
93
|
+
* the affordance is the same either way.
|
|
94
|
+
*/
|
|
95
|
+
const interviewing = computed(
|
|
96
|
+
() => interviewPhase.value === 'working' || interviewPhase.value === 'preparing',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The planner's parked plan-approval gate, or undefined. `pl_initiative` gates the planner step
|
|
101
|
+
* (`{ kind: 'initiative-planner', gate: true }`), so a finished planning pass PARKS the run on a
|
|
102
|
+
* pending `step.approval` until a human accepts the plan — the state this composable's other
|
|
103
|
+
* flags deliberately do NOT cover (the interview has converged, and the run is `blocked`, so
|
|
104
|
+
* `awaitingAnswers` and `interviewing` are both false and the card would otherwise sit on a
|
|
105
|
+
* disabled, spinning "Run planning").
|
|
106
|
+
*
|
|
107
|
+
* The interviewer's own park is excluded by the window its step routes to (see
|
|
108
|
+
* {@link selectPlanApproval}), not by the interview phase, so the two affordances can never both
|
|
109
|
+
* claim one park.
|
|
110
|
+
*/
|
|
111
|
+
const planApproval = computed(() =>
|
|
112
|
+
selectPlanApproval(
|
|
113
|
+
execution.approvalsByBlock.get(toValue(blockId)) ?? [],
|
|
114
|
+
(kind) => agentKindMeta(kind).resultView,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** An agent-raised decision on the planning run (the analyst is an ordinary agent step). */
|
|
119
|
+
const pendingDecision = computed(() => execution.decisionsByBlock.get(toValue(blockId))?.[0])
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The single thing a human has to act on, or null — the initiative dual of a task card's
|
|
123
|
+
* `attention`. A decision outranks an approval (a step never holds both; this is just a stable
|
|
124
|
+
* order). Opening always goes through the step-view dispatch, so the park lands in the window
|
|
125
|
+
* that can RESOLVE it (the plan gate → the tracker window's plan-review rail) rather than a
|
|
126
|
+
* generic panel that would refuse it.
|
|
127
|
+
*/
|
|
128
|
+
const attention = computed<InitiativeAttention | null>(() => {
|
|
129
|
+
const id = toValue(blockId)
|
|
130
|
+
const decision = pendingDecision.value
|
|
131
|
+
if (decision) {
|
|
132
|
+
return {
|
|
133
|
+
kind: 'decision',
|
|
134
|
+
open: () => {
|
|
135
|
+
ui.select(id)
|
|
136
|
+
ui.openDecision(decision.instanceId, decision.decision.id)
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const approval = planApproval.value
|
|
141
|
+
if (approval) {
|
|
142
|
+
return {
|
|
143
|
+
kind: 'approval',
|
|
144
|
+
open: () => {
|
|
145
|
+
ui.select(id)
|
|
146
|
+
ui.openApprovalDetail(approval.instanceId, approval.approval.id)
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
})
|
|
67
152
|
|
|
68
153
|
/**
|
|
69
154
|
* Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
|
|
@@ -105,6 +190,9 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
105
190
|
interviewPhase,
|
|
106
191
|
awaitingAnswers,
|
|
107
192
|
interviewing,
|
|
193
|
+
planApproval,
|
|
194
|
+
pendingDecision,
|
|
195
|
+
attention,
|
|
108
196
|
starting,
|
|
109
197
|
runPlanning,
|
|
110
198
|
openPlanning,
|
|
@@ -15,22 +15,24 @@ function sameSet(a: Set<string>, b: Set<string>) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Board-level driver deciding which task cards
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Board-level driver deciding which task cards expand their full pipeline list.
|
|
19
|
+
* Recomputed every frame against live DOM rects so it follows pan / zoom / drag / resize,
|
|
20
|
+
* and writes two independent grants into the `taskExpansion` store (which combines them —
|
|
21
|
+
* see the store for how they resolve):
|
|
21
22
|
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
* task expands only if its footprint doesn't collide with one already granted, so the
|
|
25
|
-
* card you're looking at wins an overlap and the rest stay compact.
|
|
26
|
-
* - hover: the task directly under the pointer is granted first, so hovering any card
|
|
27
|
-
* expands its pipeline regardless of its position on screen. "Under the pointer" is
|
|
28
|
-
* the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
|
|
23
|
+
* - hover: the task directly under the pointer, at ANY zoom level. "Under the pointer"
|
|
24
|
+
* is the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
|
|
29
25
|
* already covered by another open pipeline keeps that pipeline, not the card beneath.
|
|
26
|
+
* - zoom: at the deep `steps`/`subtasks` bands, every on-screen card, minus overlaps —
|
|
27
|
+
* two sub-gates:
|
|
28
|
+
* - visibility: a task expands only while its card overlaps the board viewport.
|
|
29
|
+
* - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
|
|
30
|
+
* task expands only if its footprint doesn't collide with one already granted, so
|
|
31
|
+
* the card you're looking at wins an overlap and the rest stay compact. The hovered
|
|
32
|
+
* card is granted first, so it wins every overlap it's part of.
|
|
30
33
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* wouldn't expand never blocks a neighbour.
|
|
34
|
+
* Only tasks with a running pipeline (steps to show) are candidates for either grant — a
|
|
35
|
+
* task that wouldn't expand never blocks a neighbour and never lifts an empty card.
|
|
34
36
|
*/
|
|
35
37
|
export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
36
38
|
const board = useBoardStore()
|
|
@@ -65,14 +67,27 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
65
67
|
// The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
|
|
66
68
|
// rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
|
|
67
69
|
// a region obscured by another pipeline doesn't switch to the card hidden beneath it.
|
|
70
|
+
//
|
|
71
|
+
// Blocks with no pipeline to show are filtered out here rather than left to the card:
|
|
72
|
+
// a frame, a module, or a task with no run expands to nothing, and granting it would
|
|
73
|
+
// still lift an empty card over its neighbours (see DraggableTask's z-index).
|
|
68
74
|
function hoveredTaskId(): string | null {
|
|
69
75
|
if (!pointer) return null
|
|
70
76
|
const hit = document.elementFromPoint(pointer.x, pointer.y)
|
|
71
|
-
|
|
77
|
+
const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
|
|
78
|
+
if (!id || !execution.getByBlock(id)?.steps.length) return null
|
|
79
|
+
return id
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
function recompute() {
|
|
75
|
-
//
|
|
83
|
+
// Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
|
|
84
|
+
// zoom gate below — resolving it after would collapse the hovered card the moment the
|
|
85
|
+
// user zoomed back out past the `steps` band.
|
|
86
|
+
const hovered = hoveredTaskId()
|
|
87
|
+
if (store.hoveredId !== hovered) store.setHovered(hovered)
|
|
88
|
+
|
|
89
|
+
// The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
|
|
90
|
+
// only; clear its grants otherwise. The hover grant above stands on its own.
|
|
76
91
|
if (!lodAtLeast(ui.lod, 'steps')) {
|
|
77
92
|
if (store.allowed.size) store.setAllowed(new Set())
|
|
78
93
|
return
|
|
@@ -117,7 +132,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
117
132
|
// clears every footprint already granted, so the centre-most card wins any overlap.
|
|
118
133
|
// The hovered card is granted FIRST, so hovering a card expands it regardless of its
|
|
119
134
|
// distance from the centre (and a centre-most neighbour it overlaps yields to it).
|
|
120
|
-
const hovered = hoveredTaskId()
|
|
121
135
|
const claimed: Rect[] = []
|
|
122
136
|
const next = new Set<string>()
|
|
123
137
|
const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
|
|
@@ -285,10 +285,20 @@ describe('nav grouping helpers', () => {
|
|
|
285
285
|
'workspaceContext',
|
|
286
286
|
'configuration',
|
|
287
287
|
])
|
|
288
|
-
// The
|
|
289
|
-
//
|
|
288
|
+
// The model layer is its own section, ahead of the optional integrations: the engines,
|
|
289
|
+
// the per-agent model choice (beside the providers it picks from, rather than under
|
|
290
|
+
// `configuration`), and the two surfaces that evaluate a prompt+agent+model. Sandbox and
|
|
291
|
+
// Kaizen used to sit under `integrations`, which read as a claim they connect to an
|
|
292
|
+
// external system; they don't — they exercise and grade what this section configures.
|
|
290
293
|
const models = groups.find((g) => g.group === 'models')
|
|
291
|
-
expect(models?.items.map((i) => i.id)).toEqual([
|
|
294
|
+
expect(models?.items.map((i) => i.id)).toEqual([
|
|
295
|
+
'model-providers',
|
|
296
|
+
'model-config',
|
|
297
|
+
'sandbox',
|
|
298
|
+
'kaizen',
|
|
299
|
+
])
|
|
300
|
+
const integrations = groups.find((g) => g.group === 'integrations')
|
|
301
|
+
expect(integrations?.items.map((i) => i.id)).toEqual(['integrations-hub'])
|
|
292
302
|
const configuration = groups.find((g) => g.group === 'configuration')
|
|
293
303
|
expect(configuration?.items.map((i) => i.id)).toEqual([
|
|
294
304
|
'workspace-settings',
|
|
@@ -30,11 +30,15 @@ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
|
|
|
30
30
|
*
|
|
31
31
|
* `models` and `integrations` are deliberately SEPARATE sections even though a model
|
|
32
32
|
* provider is technically also an external system we connect to. They answer different
|
|
33
|
-
* questions: `models` is the
|
|
34
|
-
* all),
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* questions: `models` is the MODEL LAYER — which engine the harnesses run on (no provider
|
|
34
|
+
* ⇒ nothing runs at all), which model each agent kind uses, and how well a given
|
|
35
|
+
* prompt+agent+model actually performs (`sandbox`, `kaizen`); `integrations` is the
|
|
36
|
+
* optional EXTERNAL SYSTEMS that feed a run context or receive its output (source control,
|
|
37
|
+
* trackers, documents, chat, observability) — each of which a deployment can live without.
|
|
38
|
+
* Folding the providers in among them buried the one connection every deployment must make
|
|
39
|
+
* in a list of ones most never touch; parking the two model-quality surfaces there was the
|
|
40
|
+
* same mistake from the other end — neither Sandbox nor Kaizen connects to anything, they
|
|
41
|
+
* evaluate what the `models` section configures.
|
|
38
42
|
*/
|
|
39
43
|
export type NavSidebarGroup =
|
|
40
44
|
| 'create'
|
|
@@ -268,33 +272,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
268
272
|
testId: 'nav-integrations',
|
|
269
273
|
sidebar: { group: 'integrations', order: 10 },
|
|
270
274
|
},
|
|
271
|
-
{
|
|
272
|
-
id: 'sandbox',
|
|
273
|
-
labelKey: 'nav.sandbox',
|
|
274
|
-
icon: 'i-lucide-flask-conical',
|
|
275
|
-
surfaces: S('sidebar', 'command'),
|
|
276
|
-
advanced: true,
|
|
277
|
-
gate: (g) => g.canManageIntegrations,
|
|
278
|
-
action: 'sandbox',
|
|
279
|
-
testId: 'nav-sandbox',
|
|
280
|
-
sidebar: { group: 'integrations', order: 20 },
|
|
281
|
-
command: {
|
|
282
|
-
group: 'workspace',
|
|
283
|
-
order: 70,
|
|
284
|
-
labelKey: 'layout.commandBar.cmd.sandbox',
|
|
285
|
-
keywordsKey: 'layout.commandBar.keywords.sandbox',
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
{
|
|
289
|
-
id: 'kaizen',
|
|
290
|
-
labelKey: 'nav.kaizen',
|
|
291
|
-
icon: 'i-lucide-sparkles',
|
|
292
|
-
surfaces: S('sidebar'),
|
|
293
|
-
advanced: true,
|
|
294
|
-
action: 'kaizen',
|
|
295
|
-
testId: 'nav-kaizen',
|
|
296
|
-
sidebar: { group: 'integrations', order: 30 },
|
|
297
|
-
},
|
|
298
275
|
{
|
|
299
276
|
id: 'infrastructure',
|
|
300
277
|
labelKey: 'nav.infrastructure',
|
|
@@ -370,6 +347,39 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
370
347
|
keywordsKey: 'layout.commandBar.keywords.modelConfiguration',
|
|
371
348
|
},
|
|
372
349
|
},
|
|
350
|
+
{
|
|
351
|
+
// Trying prompt versions and models against graded fixtures — a model-layer surface,
|
|
352
|
+
// not an integration: it connects to no external system, it exercises the providers
|
|
353
|
+
// and per-agent models the two entries above configure.
|
|
354
|
+
id: 'sandbox',
|
|
355
|
+
labelKey: 'nav.sandbox',
|
|
356
|
+
icon: 'i-lucide-flask-conical',
|
|
357
|
+
surfaces: S('sidebar', 'command'),
|
|
358
|
+
advanced: true,
|
|
359
|
+
gate: (g) => g.canManageIntegrations,
|
|
360
|
+
action: 'sandbox',
|
|
361
|
+
testId: 'nav-sandbox',
|
|
362
|
+
sidebar: { group: 'models', order: 30 },
|
|
363
|
+
command: {
|
|
364
|
+
group: 'workspace',
|
|
365
|
+
order: 70,
|
|
366
|
+
labelKey: 'layout.commandBar.cmd.sandbox',
|
|
367
|
+
keywordsKey: 'layout.commandBar.keywords.sandbox',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
// The same axis after the fact: grading history and verified prompt+agent+model combos.
|
|
372
|
+
// Sandbox asks "which combination should we use", Kaizen answers "how is the one we
|
|
373
|
+
// shipped doing" — both read the model layer, so they sit with it.
|
|
374
|
+
id: 'kaizen',
|
|
375
|
+
labelKey: 'nav.kaizen',
|
|
376
|
+
icon: 'i-lucide-sparkles',
|
|
377
|
+
surfaces: S('sidebar'),
|
|
378
|
+
advanced: true,
|
|
379
|
+
action: 'kaizen',
|
|
380
|
+
testId: 'nav-kaizen',
|
|
381
|
+
sidebar: { group: 'models', order: 40 },
|
|
382
|
+
},
|
|
373
383
|
{
|
|
374
384
|
id: 'service-fragment-defaults',
|
|
375
385
|
labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
|
|
@@ -76,7 +76,7 @@ const isTask = (b: Block) => b.level === 'task'
|
|
|
76
76
|
const isFrame = (b: Block) => b.level === 'frame'
|
|
77
77
|
/**
|
|
78
78
|
* A block whose inspector carries a pipeline RUN. An initiative's planning pipeline is an
|
|
79
|
-
* ordinary run of ordinary agent steps (
|
|
79
|
+
* ordinary run of ordinary agent steps (analyst → interviewer → planner → committer), so it
|
|
80
80
|
* gets the same execution panel a task does — step list, live phases, step-detail drill-down,
|
|
81
81
|
* and the Stop / Discard-run controls that are the only way to unwedge a stalled planning run.
|
|
82
82
|
* Before this it had no run surface at all, which is why a stuck plan was a dead end.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { useTaskExpansionStore } from '~/stores/taskExpansion'
|
|
3
|
+
import { useUiStore } from '~/stores/ui'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The expansion gate combines two independent grants (hover at any zoom, the deep zoom
|
|
7
|
+
* bands otherwise). Both `TaskPipelineMini` (what renders) and `DraggableTask` (what
|
|
8
|
+
* stacks on top) read `isExpanded`, so these cases pin the rule they share.
|
|
9
|
+
*
|
|
10
|
+
* Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
|
|
11
|
+
* `lod` derives from it (< 1.8 is shallower than the `steps` band).
|
|
12
|
+
*/
|
|
13
|
+
function setup(zoom: number) {
|
|
14
|
+
const ui = useUiStore()
|
|
15
|
+
ui.zoom = zoom
|
|
16
|
+
const store = useTaskExpansionStore()
|
|
17
|
+
store.setDriverActive(true)
|
|
18
|
+
return store
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('taskExpansion — hover expands at any zoom level', () => {
|
|
22
|
+
it('expands the hovered card while zoomed out past the steps band', () => {
|
|
23
|
+
const store = setup(0.5) // `far`
|
|
24
|
+
store.setHovered('task-a')
|
|
25
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
26
|
+
// Hover is a grant of ONE card: its neighbours stay compact at this zoom.
|
|
27
|
+
expect(store.isExpanded('task-b')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('expands the hovered card at every band, not just the shallow ones', () => {
|
|
31
|
+
const store = setup(1) // `mid`
|
|
32
|
+
store.setHovered('task-a')
|
|
33
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
34
|
+
useUiStore().zoom = 1.5 // `close`
|
|
35
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
36
|
+
useUiStore().zoom = 3 // `subtasks`
|
|
37
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('collapses again once the pointer leaves the card', () => {
|
|
41
|
+
const store = setup(0.5)
|
|
42
|
+
store.setHovered('task-a')
|
|
43
|
+
store.setHovered(null)
|
|
44
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('expands a hovered card the zoom gate denied for overlapping a neighbour', () => {
|
|
48
|
+
const store = setup(2) // `steps`
|
|
49
|
+
store.setAllowed(new Set(['task-b']))
|
|
50
|
+
store.setHovered('task-a')
|
|
51
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
52
|
+
expect(store.isExpanded('task-b')).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('taskExpansion — the zoom grant is unchanged', () => {
|
|
57
|
+
it('honours the driver grant only from the steps band up', () => {
|
|
58
|
+
const store = setup(1.5) // `close` — one band shallower than `steps`
|
|
59
|
+
store.setAllowed(new Set(['task-a']))
|
|
60
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
61
|
+
useUiStore().zoom = 2 // `steps`
|
|
62
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('denies a card the driver left out of the permitted set', () => {
|
|
66
|
+
const store = setup(2)
|
|
67
|
+
store.setAllowed(new Set(['task-b']))
|
|
68
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('falls back to allowed with no driver mounted, still only at the deep bands', () => {
|
|
72
|
+
const ui = useUiStore()
|
|
73
|
+
ui.zoom = 2
|
|
74
|
+
const store = useTaskExpansionStore() // driverActive stays false
|
|
75
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
76
|
+
ui.zoom = 1
|
|
77
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('drops both grants when the driver unmounts', () => {
|
|
81
|
+
const store = setup(2)
|
|
82
|
+
store.setAllowed(new Set(['task-a']))
|
|
83
|
+
store.setHovered('task-b')
|
|
84
|
+
store.setDriverActive(false)
|
|
85
|
+
expect(store.allowed.size).toBe(0)
|
|
86
|
+
expect(store.hoveredId).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -1,37 +1,57 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
+
import { lodAtLeast } from '~/composables/useSemanticZoom'
|
|
4
|
+
import { useUiStore } from '~/stores/ui'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Which task cards
|
|
7
|
+
* Which task cards render their full build-pipeline list.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* recomputes a permitted set every frame — only on-screen cards, the one closest to
|
|
11
|
-
* the screen centre when two would overlap, plus whichever card the pointer is hovering
|
|
12
|
-
* (hover expands a card regardless of position) — and writes it here.
|
|
13
|
-
* `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
|
|
9
|
+
* Two independent grants, both written every frame by the board driver
|
|
10
|
+
* (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
|
|
11
|
+
* stacking (`DraggableTask`) can never disagree about which cards are expanded:
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* - HOVER — the card under the pointer expands at ANY zoom level. Pointing at a task
|
|
14
|
+
* is asking what it is doing right now, and that answer used to be reachable only
|
|
15
|
+
* by first zooming past the `steps` band.
|
|
16
|
+
* - ZOOM — past the `steps` band, every on-screen card expands. Deep-zoom grows a card
|
|
17
|
+
* downward and cards are absolutely positioned in their frame, so several expanded
|
|
18
|
+
* cards stacked vertically pile on top of each other; the driver denies whichever
|
|
19
|
+
* would collide with a card nearer the screen centre and writes the survivors here.
|
|
20
|
+
*
|
|
21
|
+
* `driverActive` lets the zoom grant degrade gracefully: with no board driver mounted
|
|
22
|
+
* (e.g. a card rendered in isolation) it falls back to "allowed", so the plain zoom
|
|
23
|
+
* behaviour is unchanged. The hover grant needs no fallback — without the driver
|
|
24
|
+
* nothing is hovered.
|
|
18
25
|
*/
|
|
19
26
|
export const useTaskExpansionStore = defineStore('taskExpansion', () => {
|
|
27
|
+
const ui = useUiStore()
|
|
20
28
|
const allowed = ref<Set<string>>(new Set())
|
|
29
|
+
const hoveredId = ref<string | null>(null)
|
|
21
30
|
const driverActive = ref(false)
|
|
22
31
|
|
|
23
32
|
function setAllowed(ids: Set<string>) {
|
|
24
33
|
allowed.value = ids
|
|
25
34
|
}
|
|
26
35
|
|
|
36
|
+
function setHovered(id: string | null) {
|
|
37
|
+
hoveredId.value = id
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function setDriverActive(active: boolean) {
|
|
28
41
|
driverActive.value = active
|
|
29
|
-
if (!active)
|
|
42
|
+
if (!active) {
|
|
43
|
+
allowed.value = new Set()
|
|
44
|
+
hoveredId.value = null
|
|
45
|
+
}
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
/** Whether this task card should render its pipeline: hovered at any zoom, else
|
|
49
|
+
* granted by the deep-zoom bands. */
|
|
50
|
+
function isExpanded(id: string) {
|
|
51
|
+
if (hoveredId.value === id) return true
|
|
52
|
+
if (!lodAtLeast(ui.lod, 'steps')) return false
|
|
33
53
|
return driverActive.value ? allowed.value.has(id) : true
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
return { allowed, driverActive, setAllowed, setDriverActive,
|
|
56
|
+
return { allowed, hoveredId, driverActive, setAllowed, setHovered, setDriverActive, isExpanded }
|
|
37
57
|
})
|
package/app/utils/catalog.ts
CHANGED
|
@@ -439,16 +439,17 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
|
|
|
439
439
|
description:
|
|
440
440
|
'Provisions the ephemeral environment the tester and human-test gate run against (kubernetes / custom services); a no-op for docker-compose / infraless.',
|
|
441
441
|
},
|
|
442
|
-
// The Initiative Planning pipeline's
|
|
442
|
+
// The Initiative Planning pipeline's steps. Only runnable on an initiative
|
|
443
443
|
// block (pl_initiative — enforced by the engine), so they are display-metadata
|
|
444
|
-
// system kinds, never palette archetypes.
|
|
444
|
+
// system kinds, never palette archetypes. The analyst runs FIRST, ahead of the
|
|
445
|
+
// interviewer, so the interview covers only what the code cannot answer.
|
|
445
446
|
'initiative-interviewer': {
|
|
446
447
|
kind: 'initiative-interviewer',
|
|
447
448
|
label: 'Initiative Interviewer',
|
|
448
449
|
icon: 'i-lucide-messages-square',
|
|
449
450
|
color: '#818cf8',
|
|
450
451
|
description:
|
|
451
|
-
'Interviews you on the goals, scope and constraints
|
|
452
|
+
'Interviews you on the goals, scope and constraints the codebase cannot answer, then synthesizes the agreed brief the planner builds on.',
|
|
452
453
|
// Opens the dedicated planning Q&A window (answer / continue / proceed) while parked.
|
|
453
454
|
resultView: 'initiative-planning',
|
|
454
455
|
},
|
|
@@ -458,7 +459,7 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
|
|
|
458
459
|
icon: 'i-lucide-microscope',
|
|
459
460
|
color: '#818cf8',
|
|
460
461
|
description:
|
|
461
|
-
'Explores the codebase and writes an analysis (architecture, touch points, risks) that grounds the plan. Makes no changes.',
|
|
462
|
+
'Explores the codebase first and writes an analysis (architecture, touch points, risks) that grounds both the interview and the plan. Makes no changes.',
|
|
462
463
|
resultView: 'initiative-tracker',
|
|
463
464
|
},
|
|
464
465
|
'initiative-planner': {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
|
|
2
2
|
import { describe, it, expect } from 'vitest'
|
|
3
3
|
import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isPendingQuestion,
|
|
6
|
+
orderInterviewQuestions,
|
|
7
|
+
pendingCheckpointPhase,
|
|
8
|
+
selectPlanApproval,
|
|
9
|
+
} from './initiative'
|
|
5
10
|
|
|
6
11
|
// `pendingCheckpointPhase` mirrors the backend `pendingCheckpoint` (orchestration
|
|
7
12
|
// `initiative.logic.ts`); these pin the same ordering/edge cases the loop pauses on, so the
|
|
@@ -150,3 +155,48 @@ describe('orderInterviewQuestions', () => {
|
|
|
150
155
|
expect(orderInterviewQuestions([])).toEqual([])
|
|
151
156
|
})
|
|
152
157
|
})
|
|
158
|
+
|
|
159
|
+
// The plan-review park: which of a block's pending approvals the board card / inspector offer as
|
|
160
|
+
// "Review plan", and which one they must leave alone. Both gates on the planning pipeline park on
|
|
161
|
+
// a `step.approval`, so the interviewer's park (owned by the planning window's "Answer planning
|
|
162
|
+
// questions") is the case this selector exists to keep out — offering it here would give one park
|
|
163
|
+
// two differently-worded buttons, and the tracker window it opened could not resolve it.
|
|
164
|
+
|
|
165
|
+
/** A pending approval as `execution.approvalsByBlock` carries it (only the fields read here). */
|
|
166
|
+
const parked = (agentKind: string, id: string) => ({ agentKind, approval: { id } })
|
|
167
|
+
|
|
168
|
+
/** The catalog's result-view resolver, as the composable passes it in. */
|
|
169
|
+
const resultViewOf = (kind: string): string | undefined =>
|
|
170
|
+
kind === 'initiative-interviewer'
|
|
171
|
+
? 'initiative-planning'
|
|
172
|
+
: kind.startsWith('initiative-')
|
|
173
|
+
? 'initiative-tracker'
|
|
174
|
+
: undefined
|
|
175
|
+
|
|
176
|
+
describe('selectPlanApproval', () => {
|
|
177
|
+
it('is undefined when nothing is parked', () => {
|
|
178
|
+
expect(selectPlanApproval([], resultViewOf)).toBeUndefined()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('picks the planner gate — the plan awaiting approval', () => {
|
|
182
|
+
const approvals = [parked('initiative-planner', 'ap_1')]
|
|
183
|
+
expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_1')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('leaves the interviewer park to the planning window', () => {
|
|
187
|
+
const approvals = [parked('initiative-interviewer', 'ap_interview')]
|
|
188
|
+
expect(selectPlanApproval(approvals, resultViewOf)).toBeUndefined()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('finds the plan gate past an interview park (a re-run interviewing again)', () => {
|
|
192
|
+
const approvals = [parked('initiative-interviewer', 'ap_interview'), parked('x', 'ap_plan')]
|
|
193
|
+
expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_plan')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('offers a gated step of a custom planning pipeline, whatever window it routes to', () => {
|
|
197
|
+
// A kind with no dedicated window at all still parks a human; the affordance opens whatever
|
|
198
|
+
// `dispatchStepView` routes it to (the generic panel), which is exactly what resolves it.
|
|
199
|
+
const approvals = [parked('some-custom-kind', 'ap_custom')]
|
|
200
|
+
expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_custom')
|
|
201
|
+
})
|
|
202
|
+
})
|
package/app/utils/initiative.ts
CHANGED
|
@@ -59,6 +59,50 @@ export const INITIATIVE_ITEM_STATUS_CHIPS: Record<InitiativeItemStatus, BadgeCol
|
|
|
59
59
|
skipped: 'neutral',
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The two ways an initiative's planning run parks for a human: an agent-raised `decision`, or a
|
|
64
|
+
* pending step `approval` (the plan-approval gate the `initiative-planner` step carries). Resolved
|
|
65
|
+
* per block by `useInitiativePlanning().attention`.
|
|
66
|
+
*/
|
|
67
|
+
export type InitiativeAttentionKind = 'decision' | 'approval'
|
|
68
|
+
|
|
69
|
+
/** Park kind → i18n label key for the card/inspector button that opens the resolving window. */
|
|
70
|
+
export const INITIATIVE_ATTENTION_LABEL_KEYS: Record<InitiativeAttentionKind, string> = {
|
|
71
|
+
decision: 'initiative.inspector.resolveDecision',
|
|
72
|
+
approval: 'initiative.inspector.reviewPlan',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Park kind → button icon, so the board card and the inspector can't diverge on one park. */
|
|
76
|
+
export const INITIATIVE_ATTENTION_ICONS: Record<InitiativeAttentionKind, string> = {
|
|
77
|
+
decision: 'i-lucide-circle-help',
|
|
78
|
+
approval: 'i-lucide-clipboard-check',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The result view the INTERVIEW gate owns. Its park rides the same `step.approval` mechanism as
|
|
83
|
+
* every other gate, so anything offering "there is a plan to review here" has to exclude it: the
|
|
84
|
+
* interview park is already owned by the planning window, behind the differently-worded "Answer
|
|
85
|
+
* planning questions".
|
|
86
|
+
*/
|
|
87
|
+
export const INTERVIEW_GATE_RESULT_VIEW = 'initiative-planning'
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The block's parked approval that is a PLAN REVIEW — the planner's human gate (`pl_initiative`
|
|
91
|
+
* declares `{ kind: 'initiative-planner', gate: true }`), or any other gated step of a custom
|
|
92
|
+
* planning pipeline — as opposed to the interviewer's park.
|
|
93
|
+
*
|
|
94
|
+
* Discriminated by the step's own result view, the seam `dispatchStepView` routes on, rather than
|
|
95
|
+
* by an agent-kind list or by the interview phase: the affordance's ACTION is that dispatch, so
|
|
96
|
+
* keying the offer on the same fact guarantees the button opens a window that can resolve what it
|
|
97
|
+
* offered — and that the interview and plan affordances can never both claim one park.
|
|
98
|
+
*/
|
|
99
|
+
export function selectPlanApproval<A extends { agentKind: string }>(
|
|
100
|
+
approvals: readonly A[],
|
|
101
|
+
resultViewOf: (agentKind: string) => string | undefined,
|
|
102
|
+
): A | undefined {
|
|
103
|
+
return approvals.find((a) => resultViewOf(a.agentKind) !== INTERVIEW_GATE_RESULT_VIEW)
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
/** Follow-up triage status → i18n label key. Exhaustive so a new status fails the build. */
|
|
63
107
|
export const INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS: Record<InitiativeFollowUp['status'], string> = {
|
|
64
108
|
open: 'initiative.followUpStatus.open',
|