@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.
@@ -1,98 +1,60 @@
1
1
  <script setup lang="ts">
2
- // Universal dedicated-result-view host. An agent archetype can declare a `resultView`
3
- // id (see `~/utils/catalog`); when a step of that kind is opened, `ui.resultView` is set
4
- // and this host renders the matching registered window instead of the generic
5
- // `AgentStepDetail` prose panel. Adding a bespoke visualization for a new agent is just:
6
- // 1. declare `resultView: '<id>'` on its archetype, and
7
- // 2. register `'<id>': <Component>` below.
8
- // No caller changes every board/inspector entry point already routes through
9
- // `ui` dispatch. Each registered window builds on `useResultView(viewId, { onOpen })`,
10
- // which owns the seam contract (open/blockId/close + Escape + load-on-open) so a new
11
- // window can't reintroduce the route-dependent empty-state bug by forgetting to fetch
12
- // on mount — declare an `onOpen` loader and it fires on every open.
13
- import { computed, type Component } from 'vue'
14
- import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
- import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
- import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
17
- import TestReportWindow from '~/components/testing/TestReportWindow.vue'
18
- import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
19
- import VisualConfirmationWindow from '~/components/visualConfirm/VisualConfirmationWindow.vue'
20
- import GateResultView from '~/components/gates/GateResultView.vue'
21
- import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
22
- import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
- import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
- import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
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
- const STEP_RESULT_VIEWS: Record<string, Component> = {
36
- 'requirements-review': RequirementsReviewWindow,
37
- 'clarity-review': ClarityReviewWindow,
38
- // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
39
- brainstorm: BrainstormWindow,
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
- const component = STEP_RESULT_VIEWS[view] ?? null
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
+ })