@cat-factory/app 0.132.0 → 0.134.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/app/components/layout/BoardToolbar.vue +18 -0
- package/app/components/layout/CommandBar.vue +95 -182
- package/app/components/layout/SideBar.vue +18 -266
- package/app/components/panels/StepResultViewHost.vue +48 -86
- package/app/composables/useNavContributions.ts +75 -0
- package/app/modular/agent-kinds.spec.ts +80 -0
- package/app/modular/agent-kinds.ts +83 -0
- package/app/modular/nav-contributions.spec.ts +182 -0
- package/app/modular/nav-contributions.ts +479 -0
- package/app/modular/nav-gates.ts +63 -0
- package/app/modular/registry.spec.ts +90 -7
- package/app/modular/registry.ts +48 -19
- package/app/modular/result-views.ts +104 -0
- package/app/modular/slots.ts +40 -0
- package/app/plugins/modular.client.ts +37 -6
- package/app/stores/agents.spec.ts +100 -0
- package/app/stores/agents.ts +115 -34
- package/app/stores/workspace.ts +4 -3
- package/app/types/domain.ts +4 -3
- package/app/utils/catalog.ts +57 -16
- package/package.json +6 -6
|
@@ -1,98 +1,60 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Universal dedicated-result-view host
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
|
|
26
|
-
import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
|
|
27
|
-
import MergerResultView from '~/components/panels/MergerResultView.vue'
|
|
28
|
-
import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
|
|
29
|
-
import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
|
|
30
|
-
import DocInterviewWindow from '~/components/docs/DocInterviewWindow.vue'
|
|
31
|
-
import RalphLoopResultView from '~/components/ralph/RalphLoopResultView.vue'
|
|
2
|
+
// Universal dedicated-result-view host (slice 2 of the modular-vue adoption —
|
|
3
|
+
// docs/initiatives/modular-vue-adoption.md). An agent archetype can declare a
|
|
4
|
+
// `resultView` id (see `~/utils/catalog`); when a step of that kind is opened,
|
|
5
|
+
// `ui.resultView` is set and this host mounts the matching registered window
|
|
6
|
+
// instead of the generic `AgentStepDetail` prose panel.
|
|
7
|
+
//
|
|
8
|
+
// The registry is no longer a hardcoded `Record` here — every built-in window is
|
|
9
|
+
// contributed to the modular `resultViews` slot (`~/modular/result-views`), and
|
|
10
|
+
// a consumer deployment adds its OWN window to the SAME slot from a
|
|
11
|
+
// `registerAppModule` module. This host reads the merged slot reactively via
|
|
12
|
+
// `useReactiveSlots` and indexes it with `resolveComponentRegistry`; the kind's
|
|
13
|
+
// (or custom kind's) `resultView` id selects the component. Adding a bespoke
|
|
14
|
+
// visualization for a new agent is: declare `resultView: '<id>'` on its
|
|
15
|
+
// archetype + contribute `{ id: '<id>', component }` to the `resultViews` slot.
|
|
16
|
+
//
|
|
17
|
+
// Each registered window builds on `useResultView(viewId, { onOpen })`, which
|
|
18
|
+
// owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
|
|
19
|
+
// window can't reintroduce the route-dependent empty-state bug by forgetting to
|
|
20
|
+
// fetch on mount.
|
|
21
|
+
import { computed, watchEffect, type Component } from 'vue'
|
|
22
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
23
|
+
import { pairById, resolveComponentRegistry } from '@modular-vue/core'
|
|
24
|
+
import type { AppSlots } from '~/modular/slots'
|
|
32
25
|
|
|
33
26
|
const ui = useUiStore()
|
|
27
|
+
const agents = useAgentsStore()
|
|
28
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
tester: TestReportWindow,
|
|
41
|
-
// The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
|
|
42
|
-
'human-test': HumanTestWindow,
|
|
43
|
-
// The visual-confirmation gate: actual-vs-reference screenshot gallery + approve / request-fix.
|
|
44
|
-
'visual-confirm': VisualConfirmationWindow,
|
|
45
|
-
// Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
|
|
46
|
-
gate: GateResultView,
|
|
47
|
-
// Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).
|
|
48
|
-
'consensus-session': ConsensusSessionWindow,
|
|
49
|
-
// Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
|
|
50
|
-
// a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
|
|
51
|
-
'generic-structured': GenericStructuredResultView,
|
|
52
|
-
// The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
|
|
53
|
-
// Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
|
|
54
|
-
'service-spec': ServiceSpecWindow,
|
|
55
|
-
// The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
|
|
56
|
-
// Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
|
|
57
|
-
'follow-ups': FollowUpWindow,
|
|
58
|
-
// The implementation-fork decision: the proposer's materially different approaches + the
|
|
59
|
-
// human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
|
|
60
|
-
// chip, the inspector button, and the `fork_decision_pending` card).
|
|
61
|
-
'fork-decision': ForkDecisionWindow,
|
|
62
|
-
// The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
|
|
63
|
-
// Opened as the `pr-reviewer` step's result view and via the `pr_review_ready` inbox card.
|
|
64
|
-
'pr-review': PrReviewWindow,
|
|
65
|
-
// The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
|
|
66
|
-
// or awaiting-review decision (and why), instead of the agent's raw JSON.
|
|
67
|
-
merger: MergerResultView,
|
|
68
|
-
// The initiative tracker: phases, per-item status + PR links, decisions, deviations,
|
|
69
|
-
// caveats. Opened from the initiative card / inspector (`ui.openInitiativeTracker`) and
|
|
70
|
-
// as the planner step's result view.
|
|
71
|
-
'initiative-tracker': InitiativeTrackerWindow,
|
|
72
|
-
'initiative-planning': InitiativePlanningWindow,
|
|
73
|
-
// The interactive document-interview gate (WS5): the interviewer's clarifying questions +
|
|
74
|
-
// answer / continue / proceed, opened as the `doc-interviewer` step's result view.
|
|
75
|
-
'doc-interview': DocInterviewWindow,
|
|
76
|
-
// The Ralph loop: the persistent retry-until-done iteration history + the programmatic
|
|
77
|
-
// validation command + its latest exit code / output. Opened as the `ralph` step's view.
|
|
78
|
-
'ralph-loop': RalphLoopResultView,
|
|
79
|
-
}
|
|
30
|
+
// Index the merged `resultViews` slot into an id → component registry. Duplicate
|
|
31
|
+
// ids throw by default (`resolveComponentRegistry`) — two modules claiming the
|
|
32
|
+
// same view id is a wiring bug, not a silent last-wins. Recomputed if a consumer
|
|
33
|
+
// module's contributions change (they don't after boot, but the read is cheap).
|
|
34
|
+
const registry = computed(() => resolveComponentRegistry(slots.value.resultViews ?? []))
|
|
80
35
|
|
|
81
36
|
const active = computed<Component | null>(() => {
|
|
82
37
|
const view = ui.resultView?.view
|
|
83
38
|
if (!view) return null
|
|
84
|
-
|
|
85
|
-
// An archetype declared a `resultView` id with no registered component — this used to
|
|
86
|
-
// silently fall back to the prose panel. The backend now validates `resultView` against the
|
|
87
|
-
// canonical id set, so this should only fire mid-development of a new view; make it loud.
|
|
88
|
-
if (!component && import.meta.dev) {
|
|
89
|
-
console.warn(
|
|
90
|
-
`[StepResultViewHost] no component registered for resultView "${view}". ` +
|
|
91
|
-
`Add it to STEP_RESULT_VIEWS (and to RESULT_VIEW_IDS in @cat-factory/contracts).`,
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
return component
|
|
39
|
+
return registry.value.get(view) ?? null
|
|
95
40
|
})
|
|
41
|
+
|
|
42
|
+
// Dev guard: surface any CUSTOM kind whose declared `resultView` id resolves to
|
|
43
|
+
// no registered component (a dangling reference — e.g. a backend kind naming a
|
|
44
|
+
// consumer view this build doesn't ship). `pairById`'s `missing` bucket makes
|
|
45
|
+
// the degradation explicit instead of a silent fall-through to the prose panel.
|
|
46
|
+
if (import.meta.dev) {
|
|
47
|
+
watchEffect(() => {
|
|
48
|
+
const { missing } = pairById(agents.customArchetypes, registry.value, (a) => a.resultView)
|
|
49
|
+
if (missing.length) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[StepResultViewHost] custom agent kind(s) reference unregistered resultView id(s): ` +
|
|
52
|
+
missing.map((m) => `${m.item.kind}→${m.id}`).join(', ') +
|
|
53
|
+
`. Register a component for the id in a resultViews-slot module.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
96
58
|
</script>
|
|
97
59
|
|
|
98
60
|
<template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
3
|
+
import { groupCommands, groupSidebar, sortToolbar } from '~/modular/nav-contributions'
|
|
4
|
+
import type {
|
|
5
|
+
AppSlots,
|
|
6
|
+
CommandGroup,
|
|
7
|
+
NavActionId,
|
|
8
|
+
NavContribution,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
} from '~/modular/nav-contributions'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The single source the three nav shells render from (slice 1 of the modular-vue
|
|
14
|
+
* adoption). Reads the reactively-gated `nav` slot via `useReactiveSlots` — the
|
|
15
|
+
* `slotFilter` has already dropped items the caller lacks permission for, and
|
|
16
|
+
* the computed re-runs when a gate flips — then shapes the surviving items per
|
|
17
|
+
* surface (via the pure helpers in `nav-contributions.ts`) and resolves each
|
|
18
|
+
* item's action.
|
|
19
|
+
*
|
|
20
|
+
* First-party items carry an `action` id resolved here against the `ui` store; a
|
|
21
|
+
* consumer-contributed item may instead carry its own `run` closure, which wins.
|
|
22
|
+
*/
|
|
23
|
+
export function useNavContributions() {
|
|
24
|
+
const slots = useReactiveSlots<AppSlots>()
|
|
25
|
+
const ui = useUiStore()
|
|
26
|
+
|
|
27
|
+
// First-party action ids → host handlers. Typed as an exhaustive
|
|
28
|
+
// `Record<NavActionId, …>`, so a catalog `action` with no handler (or a handler
|
|
29
|
+
// with no catalog entry) is a compile error, not a silently dead button.
|
|
30
|
+
// Consumer items bypass this map entirely via their own `run` closure.
|
|
31
|
+
const actions: Record<NavActionId, () => void> = {
|
|
32
|
+
buildPipeline: () => ui.openBuilder(),
|
|
33
|
+
addFromRepo: () => ui.openAddService(),
|
|
34
|
+
bootstrapRepo: () => ui.openBootstrap(),
|
|
35
|
+
integrationsHub: () => ui.openIntegrations(),
|
|
36
|
+
sandbox: () => ui.openSandbox(),
|
|
37
|
+
kaizen: () => ui.openKaizen(),
|
|
38
|
+
infrastructure: () => ui.openInfrastructure(),
|
|
39
|
+
environmentSetup: () => ui.openEnvironmentSetup(),
|
|
40
|
+
fragmentLibrary: () => ui.openFragmentLibrary(),
|
|
41
|
+
mergeThresholds: () => ui.openWorkspaceSettings('merge'),
|
|
42
|
+
workspaceSettings: () => ui.openWorkspaceSettings(),
|
|
43
|
+
modelConfiguration: () => ui.openModelConfig(),
|
|
44
|
+
serviceFragmentDefaults: () => ui.openWorkspaceSettings('fragments'),
|
|
45
|
+
localModels: () => ui.openLocalModels(),
|
|
46
|
+
accountSettings: () => ui.openAccountSettings(),
|
|
47
|
+
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
|
+
shortcuts: () => ui.openShortcutsHelp(),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
52
|
+
function invoke(item: NavContribution): void {
|
|
53
|
+
if (item.run) {
|
|
54
|
+
item.run()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
// First-party `action` is a `NavActionId`, so the exhaustive map always has a
|
|
58
|
+
// handler; the optional call only guards a consumer item that mis-uses
|
|
59
|
+
// `action` (its contract is `run`) — it no-ops rather than throwing.
|
|
60
|
+
if (item.action) actions[item.action]?.()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const all = computed<NavContribution[]>(() => slots.value.nav ?? [])
|
|
64
|
+
|
|
65
|
+
/** Grouped + ordered sidebar sections, empty sections dropped. */
|
|
66
|
+
const sidebarGroups = computed<SidebarGroup[]>(() => groupSidebar(all.value))
|
|
67
|
+
|
|
68
|
+
/** Grouped + ordered command-palette entries, empty groups dropped. */
|
|
69
|
+
const commandGroups = computed<CommandGroup[]>(() => groupCommands(all.value))
|
|
70
|
+
|
|
71
|
+
/** Toolbar contributions (the consumer extension point on `BoardToolbar`). */
|
|
72
|
+
const toolbarItems = computed<NavContribution[]>(() => sortToolbar(all.value))
|
|
73
|
+
|
|
74
|
+
return { sidebarGroups, commandGroups, toolbarItems, invoke }
|
|
75
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildAgentCapabilitiesManifest,
|
|
4
|
+
capabilitiesManifestVersion,
|
|
5
|
+
customKindToArchetype,
|
|
6
|
+
WORKSPACE_CAPABILITIES_MANIFEST_ID,
|
|
7
|
+
} from './agent-kinds'
|
|
8
|
+
import type { AgentKind, CustomAgentKind } from '~/types/domain'
|
|
9
|
+
|
|
10
|
+
const kind = (
|
|
11
|
+
over: Partial<CustomAgentKind['presentation']> = {},
|
|
12
|
+
kindId = 'acme-audit',
|
|
13
|
+
): CustomAgentKind => ({
|
|
14
|
+
kind: kindId as AgentKind,
|
|
15
|
+
container: true,
|
|
16
|
+
presentation: {
|
|
17
|
+
label: 'Audit',
|
|
18
|
+
icon: 'i-lucide-shield',
|
|
19
|
+
color: '#fff',
|
|
20
|
+
description: 'd',
|
|
21
|
+
...over,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('buildAgentCapabilitiesManifest', () => {
|
|
26
|
+
it('models the snapshot kinds as one remote capability manifest carrying the agentKinds slot', () => {
|
|
27
|
+
const kinds = [kind(), kind()]
|
|
28
|
+
const manifest = buildAgentCapabilitiesManifest(kinds)
|
|
29
|
+
expect(manifest.id).toBe(WORKSPACE_CAPABILITIES_MANIFEST_ID)
|
|
30
|
+
expect(manifest.slots?.agentKinds).toEqual(kinds)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('copies the input (no aliasing of the caller array)', () => {
|
|
34
|
+
const kinds = [kind()]
|
|
35
|
+
const manifest = buildAgentCapabilitiesManifest(kinds)
|
|
36
|
+
kinds.push(kind())
|
|
37
|
+
expect(manifest.slots?.agentKinds).toHaveLength(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('derives an identical version for identical content (so an unchanged re-hydrate no-ops)', () => {
|
|
41
|
+
// A fresh, structurally-equal kinds array (a new snapshot re-delivering the same kinds)
|
|
42
|
+
// must produce the same version — that's what lets `hydrateCustomKinds` skip the swap.
|
|
43
|
+
expect(buildAgentCapabilitiesManifest([kind()]).version).toBe(
|
|
44
|
+
buildAgentCapabilitiesManifest([kind()]).version,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('changes the version when a display/pairing field or the kind set differs', () => {
|
|
49
|
+
const base = capabilitiesManifestVersion([kind()])
|
|
50
|
+
expect(capabilitiesManifestVersion([kind({ label: 'Renamed' })])).not.toBe(base)
|
|
51
|
+
expect(capabilitiesManifestVersion([kind({ resultView: 'acme:audit' })])).not.toBe(base)
|
|
52
|
+
expect(capabilitiesManifestVersion([kind({}, 'acme-other')])).not.toBe(base)
|
|
53
|
+
expect(capabilitiesManifestVersion([kind(), kind({}, 'acme-two')])).not.toBe(base)
|
|
54
|
+
expect(capabilitiesManifestVersion([])).not.toBe(base)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('customKindToArchetype', () => {
|
|
59
|
+
it('projects presentation onto the display archetype', () => {
|
|
60
|
+
expect(customKindToArchetype(kind())).toEqual({
|
|
61
|
+
kind: 'acme-audit',
|
|
62
|
+
label: 'Audit',
|
|
63
|
+
icon: 'i-lucide-shield',
|
|
64
|
+
color: '#fff',
|
|
65
|
+
description: 'd',
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('carries category and resultView through when present', () => {
|
|
70
|
+
const a = customKindToArchetype(kind({ category: 'review', resultView: 'acme:audit' }))
|
|
71
|
+
expect(a.category).toBe('review')
|
|
72
|
+
expect(a.resultView).toBe('acme:audit')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('omits category/resultView when absent (no undefined keys)', () => {
|
|
76
|
+
const a = customKindToArchetype(kind())
|
|
77
|
+
expect('category' in a).toBe(false)
|
|
78
|
+
expect('resultView' in a).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RemoteModuleManifest } from '@modular-vue/core'
|
|
2
|
+
import type { AgentArchetype, CustomAgentKind } from '~/types/domain'
|
|
3
|
+
import type { AppSlots } from './slots'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Custom agent kinds as remote capability manifests (slice 2 of the modular-vue
|
|
7
|
+
* adoption — docs/initiatives/modular-vue-adoption.md).
|
|
8
|
+
*
|
|
9
|
+
* A deployment's BACKEND-registered agent kinds arrive in the workspace snapshot
|
|
10
|
+
* as `customAgentKinds` (wire data). Rather than mutating a module-global
|
|
11
|
+
* catalog, they're modeled as a single {@link RemoteModuleManifest} whose
|
|
12
|
+
* `agentKinds` slot carries the data. cat-factory holds ONE active manifest per
|
|
13
|
+
* workspace (swapped on snapshot), so the agents store reads its `.slots`
|
|
14
|
+
* directly — the sanctioned single-active-manifest shape (the merge-many
|
|
15
|
+
* `mergeRemoteManifests` helper is for holding several manifests at once, which
|
|
16
|
+
* we don't). CODE-shipped consumer kinds instead enter via the static
|
|
17
|
+
* `agentKinds` slot (a `registerAppModule` module); the store merges both.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The stable id for the per-workspace capability manifest built from the snapshot. */
|
|
21
|
+
export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A deterministic, key-order-independent content signature of the custom kinds, used as the
|
|
25
|
+
* manifest `version`. The workspace snapshot re-delivers the SAME deployment-registered kinds on
|
|
26
|
+
* every board-event refresh (a full `workspace.refresh()` runs `hydrateCustomKinds` each time), so
|
|
27
|
+
* a content-derived version lets the store skip re-projecting an UNCHANGED catalog — otherwise
|
|
28
|
+
* every refresh would replace the `customAgentKindMeta` read-model and needlessly invalidate the
|
|
29
|
+
* ~17 `agentKindMeta` / `isKnownAgentKind` consumers. It still changes (and swaps wholesale) when a
|
|
30
|
+
* different workspace's kinds actually differ. Serialized as fixed-order tuples of only the fields
|
|
31
|
+
* that affect display/pairing, so a re-serialization with reordered object keys can't spuriously
|
|
32
|
+
* differ.
|
|
33
|
+
*/
|
|
34
|
+
export function capabilitiesManifestVersion(kinds: readonly CustomAgentKind[]): string {
|
|
35
|
+
return JSON.stringify(
|
|
36
|
+
kinds.map((k) => [
|
|
37
|
+
k.kind,
|
|
38
|
+
k.container,
|
|
39
|
+
k.presentation.label,
|
|
40
|
+
k.presentation.icon,
|
|
41
|
+
k.presentation.color,
|
|
42
|
+
k.presentation.description,
|
|
43
|
+
k.presentation.category ?? null,
|
|
44
|
+
k.presentation.resultView ?? null,
|
|
45
|
+
]),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Model the snapshot's `customAgentKinds` as a single remote capability manifest. The `version` is
|
|
51
|
+
* a {@link capabilitiesManifestVersion content signature} so identical snapshots produce an
|
|
52
|
+
* identical manifest — which `useAgentsStore().hydrateCustomKinds` uses to no-op an unchanged
|
|
53
|
+
* re-hydrate. Swapped wholesale (not diffed) when the content genuinely changes.
|
|
54
|
+
*/
|
|
55
|
+
export function buildAgentCapabilitiesManifest(
|
|
56
|
+
kinds: readonly CustomAgentKind[],
|
|
57
|
+
): RemoteModuleManifest<AppSlots> {
|
|
58
|
+
return {
|
|
59
|
+
id: WORKSPACE_CAPABILITIES_MANIFEST_ID,
|
|
60
|
+
version: capabilitiesManifestVersion(kinds),
|
|
61
|
+
slots: { agentKinds: [...kinds] },
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Project a wire `CustomAgentKind` onto the frontend's display `AgentArchetype`
|
|
67
|
+
* (icon/label/color/description + optional category/resultView). The inverse of
|
|
68
|
+
* the backend `agentPresentationSchema` — the SAME mapping the removed
|
|
69
|
+
* `registerCustomKinds` did inline, now pure and shared by the consumer-slot and
|
|
70
|
+
* backend-manifest paths.
|
|
71
|
+
*/
|
|
72
|
+
export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
|
|
73
|
+
const { presentation: p } = kind
|
|
74
|
+
return {
|
|
75
|
+
kind: kind.kind,
|
|
76
|
+
label: p.label,
|
|
77
|
+
icon: p.icon,
|
|
78
|
+
color: p.color,
|
|
79
|
+
description: p.description,
|
|
80
|
+
...(p.category ? { category: p.category } : {}),
|
|
81
|
+
...(p.resultView ? { resultView: p.resultView } : {}),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import enCatalog from '../../i18n/locales/en.json'
|
|
3
|
+
import {
|
|
4
|
+
groupCommands,
|
|
5
|
+
groupSidebar,
|
|
6
|
+
NAV_ACTIONS,
|
|
7
|
+
NAV_CONTRIBUTIONS,
|
|
8
|
+
navSlotFilter,
|
|
9
|
+
sortToolbar,
|
|
10
|
+
} from './nav-contributions'
|
|
11
|
+
import type { AppSlots, NavGates } from './nav-contributions'
|
|
12
|
+
|
|
13
|
+
/** The layer's base i18n catalog, used to prove every referenced key resolves. */
|
|
14
|
+
const en = enCatalog as Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
/** Walk a dotted vue-i18n key path; true when it resolves to a leaf string. */
|
|
17
|
+
function hasKey(path: string): boolean {
|
|
18
|
+
let node: unknown = en
|
|
19
|
+
for (const part of path.split('.')) {
|
|
20
|
+
if (typeof node !== 'object' || node === null || !(part in node)) return false
|
|
21
|
+
node = (node as Record<string, unknown>)[part]
|
|
22
|
+
}
|
|
23
|
+
return typeof node === 'string'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NO_GATES: NavGates = {
|
|
27
|
+
canWriteBoard: false,
|
|
28
|
+
canManageIntegrations: false,
|
|
29
|
+
canManageSettings: false,
|
|
30
|
+
githubAvailable: false,
|
|
31
|
+
libraryAvailable: false,
|
|
32
|
+
infrastructureAvailable: false,
|
|
33
|
+
accountsEnabled: false,
|
|
34
|
+
isAccountAdmin: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ALL_GATES: NavGates = {
|
|
38
|
+
canWriteBoard: true,
|
|
39
|
+
canManageIntegrations: true,
|
|
40
|
+
canManageSettings: true,
|
|
41
|
+
githubAvailable: true,
|
|
42
|
+
libraryAvailable: true,
|
|
43
|
+
infrastructureAvailable: true,
|
|
44
|
+
accountsEnabled: true,
|
|
45
|
+
isAccountAdmin: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const slots = (): AppSlots => ({ nav: [...NAV_CONTRIBUTIONS], resultViews: [], agentKinds: [] })
|
|
49
|
+
const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
|
|
50
|
+
|
|
51
|
+
describe('navSlotFilter', () => {
|
|
52
|
+
it('drops every gated item when no permission/availability is granted', () => {
|
|
53
|
+
const kept = ids(navSlotFilter(slots(), { gates: NO_GATES }))
|
|
54
|
+
// Only the always-visible destinations survive (no `gate`).
|
|
55
|
+
const alwaysVisible = NAV_CONTRIBUTIONS.filter((i) => !i.gate).map((i) => i.id)
|
|
56
|
+
expect(kept.sort()).toEqual(alwaysVisible.sort())
|
|
57
|
+
expect(kept).toContain('kaizen')
|
|
58
|
+
expect(kept).not.toContain('build-pipeline')
|
|
59
|
+
expect(kept).not.toContain('operator-dashboard')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('keeps every item when all gates pass', () => {
|
|
63
|
+
const kept = ids(navSlotFilter(slots(), { gates: ALL_GATES }))
|
|
64
|
+
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reflects a single permission — board.write reveals only its items', () => {
|
|
68
|
+
const gates: NavGates = { ...NO_GATES, canWriteBoard: true, githubAvailable: true }
|
|
69
|
+
const kept = ids(navSlotFilter(slots(), { gates }))
|
|
70
|
+
expect(kept).toContain('build-pipeline')
|
|
71
|
+
expect(kept).toContain('add-from-repo') // needs github + board.write
|
|
72
|
+
expect(kept).not.toContain('bootstrap-repo') // needs integrations.manage
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('passes everything through when no gates service is wired (dev-open parity)', () => {
|
|
76
|
+
const kept = ids(navSlotFilter(slots(), {}))
|
|
77
|
+
expect(kept.sort()).toEqual(NAV_CONTRIBUTIONS.map((i) => i.id).sort())
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
82
|
+
it('has unique ids and every item targets at least one surface', () => {
|
|
83
|
+
const seen = new Set<string>()
|
|
84
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
85
|
+
expect(seen.has(item.id), `duplicate id ${item.id}`).toBe(false)
|
|
86
|
+
seen.add(item.id)
|
|
87
|
+
expect(item.surfaces.length, `${item.id} has no surface`).toBeGreaterThan(0)
|
|
88
|
+
// Every surface it targets must carry that surface's placement.
|
|
89
|
+
if (item.surfaces.includes('sidebar')) expect(item.sidebar, `${item.id} sidebar`).toBeTruthy()
|
|
90
|
+
if (item.surfaces.includes('command')) expect(item.command, `${item.id} command`).toBeTruthy()
|
|
91
|
+
// A first-party item is actionable (an id resolved host-side, or a run closure).
|
|
92
|
+
expect(item.action ?? item.run, `${item.id} has no action`).toBeTruthy()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('every first-party action id is a known NAV_ACTION (no dead buttons)', () => {
|
|
97
|
+
// `useNavContributions` resolves an `action` against an exhaustive
|
|
98
|
+
// `Record<NavActionId, …>` handler map, so a catalog action outside
|
|
99
|
+
// NAV_ACTIONS would be a dead button. The type system already enforces this;
|
|
100
|
+
// this asserts it at runtime too (and that NAV_ACTIONS has no stale ids).
|
|
101
|
+
const declared = new Set<string>(NAV_ACTIONS)
|
|
102
|
+
const used = new Set<string>()
|
|
103
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
104
|
+
if (!item.action) continue
|
|
105
|
+
used.add(item.action)
|
|
106
|
+
expect(declared.has(item.action), `${item.id} → unknown action ${item.action}`).toBe(true)
|
|
107
|
+
}
|
|
108
|
+
// No NAV_ACTION is orphaned (every declared handler id is actually used).
|
|
109
|
+
for (const action of NAV_ACTIONS) {
|
|
110
|
+
expect(used.has(action), `NAV_ACTION ${action} is unused`).toBe(true)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('every referenced i18n key exists in the en catalog (no raw-key leak)', () => {
|
|
115
|
+
const missing: string[] = []
|
|
116
|
+
const check = (key: string | undefined) => {
|
|
117
|
+
if (key && !hasKey(key)) missing.push(key)
|
|
118
|
+
}
|
|
119
|
+
for (const group of ['create', 'repositories', 'integrations', 'workspace', 'account']) {
|
|
120
|
+
check(`layout.commandBar.groups.${group}`)
|
|
121
|
+
}
|
|
122
|
+
for (const group of [
|
|
123
|
+
'create',
|
|
124
|
+
'repositories',
|
|
125
|
+
'integrations',
|
|
126
|
+
'infrastructure',
|
|
127
|
+
'workspaceContext',
|
|
128
|
+
'configuration',
|
|
129
|
+
]) {
|
|
130
|
+
check(`nav.${group}`)
|
|
131
|
+
}
|
|
132
|
+
for (const item of NAV_CONTRIBUTIONS) {
|
|
133
|
+
check(item.labelKey)
|
|
134
|
+
if (item.command) {
|
|
135
|
+
// Palette label falls back to the item's default labelKey.
|
|
136
|
+
check(item.command.labelKey ?? item.labelKey)
|
|
137
|
+
check(item.command.keywordsKey)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
expect(missing).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('nav grouping helpers', () => {
|
|
145
|
+
it('groupSidebar orders sections + items and drops empty sections', () => {
|
|
146
|
+
const groups = groupSidebar(NAV_CONTRIBUTIONS)
|
|
147
|
+
expect(groups.map((g) => g.group)).toEqual([
|
|
148
|
+
'create',
|
|
149
|
+
'repositories',
|
|
150
|
+
'integrations',
|
|
151
|
+
'infrastructure',
|
|
152
|
+
'workspaceContext',
|
|
153
|
+
'configuration',
|
|
154
|
+
])
|
|
155
|
+
const configuration = groups.find((g) => g.group === 'configuration')
|
|
156
|
+
expect(configuration?.items.map((i) => i.id)).toEqual([
|
|
157
|
+
'workspace-settings',
|
|
158
|
+
'model-config',
|
|
159
|
+
'account-settings',
|
|
160
|
+
'operator-dashboard',
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('groupCommands preserves the pre-slice-1 workspace-group order', () => {
|
|
165
|
+
const workspace = groupCommands(NAV_CONTRIBUTIONS).find((g) => g.group === 'workspace')
|
|
166
|
+
// Same order the old CommandBar pushed them in (parity, not a reorder).
|
|
167
|
+
expect(workspace?.items.map((ci) => ci.item.id)).toEqual([
|
|
168
|
+
'fragments',
|
|
169
|
+
'merge-thresholds',
|
|
170
|
+
'workspace-settings',
|
|
171
|
+
'model-config',
|
|
172
|
+
'service-fragment-defaults',
|
|
173
|
+
'local-models',
|
|
174
|
+
'sandbox',
|
|
175
|
+
'keyboard-shortcuts',
|
|
176
|
+
])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('sortToolbar yields nothing first-party (consumer-only extension point)', () => {
|
|
180
|
+
expect(sortToolbar(NAV_CONTRIBUTIONS)).toEqual([])
|
|
181
|
+
})
|
|
182
|
+
})
|