@cat-factory/app 0.87.4 → 0.87.5

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,6 +1,8 @@
1
1
  <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
2
3
  import type { Block } from '~/types/domain'
3
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
4
6
 
5
7
  // Service-level best-practice fragments (frame blocks). These are the programming
6
8
  // standards/guidelines for the whole service; at run time their bodies are folded
@@ -19,8 +21,6 @@ const { t } = useI18n()
19
21
 
20
22
  onMounted(() => fragments.ensureLoaded())
21
23
 
22
- type MenuItem = { label: string; icon?: string; onSelect: () => void }
23
-
24
24
  // An id the catalog no longer resolves (removed/suppressed after selection) still
25
25
  // renders — labelled by its raw id — so it stays visible and removable.
26
26
  const selectedFragments = computed(() =>
@@ -32,8 +32,8 @@ const selectedFragments = computed(() =>
32
32
  // A trailing group that jumps from "attach a fragment" to authoring/editing the
33
33
  // library itself (board tier always; account tier when accounts are enabled).
34
34
  // Open to every member — managing fragments is not an admin-only action.
35
- const manageItems = computed<MenuItem[]>(() => {
36
- const items: MenuItem[] = [
35
+ const manageItems = computed<DropdownMenuItem[]>(() => {
36
+ const items: DropdownMenuItem[] = [
37
37
  {
38
38
  label: t('inspector.fragments.manageBoard'),
39
39
  icon: 'i-lucide-book-marked',
@@ -50,18 +50,14 @@ const manageItems = computed<MenuItem[]>(() => {
50
50
  return items
51
51
  })
52
52
 
53
- // Picker menu: every pool fragment not already selected, grouped by category, with
54
- // the management links appended as the final group.
55
- const fragmentMenu = computed<MenuItem[][]>(() => {
53
+ // Picker menu: every pool fragment not already selected, grouped into labelled
54
+ // per-category sections, with the management links appended as the final group.
55
+ const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
56
56
  const selected = new Set(props.block.serviceFragmentIds ?? [])
57
- const groups = new Map<string, MenuItem[]>()
58
- for (const f of fragments.fragments) {
59
- if (selected.has(f.id)) continue
60
- const items = groups.get(f.category) ?? []
61
- items.push({ label: f.title, onSelect: () => addFragment(f.id) })
62
- groups.set(f.category, items)
63
- }
64
- return [...groups.values(), manageItems.value]
57
+ return [
58
+ ...buildFragmentPickerGroups(fragments.fragments, (id) => selected.has(id), addFragment),
59
+ manageItems.value,
60
+ ]
65
61
  })
66
62
 
67
63
  function addFragment(id: string) {
@@ -1,6 +1,8 @@
1
1
  <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
2
3
  import type { Block } from '~/types/domain'
3
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
4
6
 
5
7
  const props = defineProps<{ block: Block }>()
6
8
 
@@ -14,8 +16,6 @@ const { t } = useI18n()
14
16
  // task inspector mounts — mirrors ServiceFragments; ensureLoaded is a no-op while current.
15
17
  onMounted(() => fragments.ensureLoaded())
16
18
 
17
- type MenuItem = { label: string; icon?: string; onSelect: () => void }
18
-
19
19
  // ---- best-practice prompt fragments ----------------------------------------
20
20
  // Selected fragments, resolved against the catalog. An id the catalog no longer
21
21
  // resolves (removed/suppressed after selection) still renders — labelled by its
@@ -29,8 +29,8 @@ const selectedFragments = computed(() =>
29
29
  // A trailing group that jumps from "attach a fragment" to authoring/editing the
30
30
  // library itself (board tier always; account tier when accounts are enabled).
31
31
  // Open to every member — managing fragments is not an admin-only action.
32
- const manageItems = computed<MenuItem[]>(() => {
33
- const items: MenuItem[] = [
32
+ const manageItems = computed<DropdownMenuItem[]>(() => {
33
+ const items: DropdownMenuItem[] = [
34
34
  {
35
35
  label: t('inspector.fragments.manageBoard'),
36
36
  icon: 'i-lucide-book-marked',
@@ -48,18 +48,18 @@ const manageItems = computed<MenuItem[]>(() => {
48
48
  })
49
49
 
50
50
  // Picker menu: fragments suitable for this block's type, not already selected,
51
- // grouped by category so the dropdown reads like the catalog, with the management
52
- // links appended as the final group.
53
- const fragmentMenu = computed<MenuItem[][]>(() => {
51
+ // grouped into labelled per-category sections so the dropdown reads like the catalog,
52
+ // with the management links appended as the final group.
53
+ const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
54
54
  const selected = new Set(props.block.fragmentIds ?? [])
55
- const groups = new Map<string, MenuItem[]>()
56
- for (const f of fragments.forBlockType(props.block.type)) {
57
- if (selected.has(f.id)) continue
58
- const items = groups.get(f.category) ?? []
59
- items.push({ label: f.title, onSelect: () => addFragment(f.id) })
60
- groups.set(f.category, items)
61
- }
62
- return [...groups.values(), manageItems.value]
55
+ return [
56
+ ...buildFragmentPickerGroups(
57
+ fragments.forBlockType(props.block.type),
58
+ (id) => selected.has(id),
59
+ addFragment,
60
+ ),
61
+ manageItems.value,
62
+ ]
63
63
  })
64
64
 
65
65
  function addFragment(id: string) {
@@ -6,6 +6,7 @@
6
6
  // services — each owns its selection from creation. Persisted via the
7
7
  // serviceFragmentDefaults store (the backend replaces the whole list on each change).
8
8
  import { onMounted, ref } from 'vue'
9
+ import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
9
10
 
10
11
  const { t } = useI18n()
11
12
  const fragments = useFragmentsStore()
@@ -24,17 +25,10 @@ const selected = computed(() =>
24
25
  defaults.fragmentIds.map((id) => fragments.getFragment(id) ?? { id, title: id, summary: '' }),
25
26
  )
26
27
 
27
- // Pool fragments not already in the default set, grouped by category.
28
+ // Pool fragments not already in the default set, grouped into labelled per-category sections.
28
29
  const menu = computed(() => {
29
30
  const chosen = new Set(defaults.fragmentIds)
30
- const groups = new Map<string, { label: string; onSelect: () => void }[]>()
31
- for (const f of fragments.fragments) {
32
- if (chosen.has(f.id)) continue
33
- const items = groups.get(f.category) ?? []
34
- items.push({ label: f.title, onSelect: () => add(f.id) })
35
- groups.set(f.category, items)
36
- }
37
- return [...groups.values()]
31
+ return buildFragmentPickerGroups(fragments.fragments, (id) => chosen.has(id), add)
38
32
  })
39
33
 
40
34
  async function save(ids: string[]) {
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { PromptFragment } from '~/types/domain'
3
+ import { buildFragmentPickerGroups } from './fragmentPicker'
4
+
5
+ const frag = (id: string, category: string, title = id): PromptFragment =>
6
+ ({ id, version: '1.0.0', title, category, summary: '', body: '' }) as PromptFragment
7
+
8
+ const pool: PromptFragment[] = [
9
+ frag('node.best-practices', 'Node', 'Node best practices'),
10
+ frag('node.performance', 'Node', 'Node performance'),
11
+ frag('style.anti-llmisms', 'Writing style', 'Avoid LLM tells'),
12
+ frag('style.concise-actionable', 'Writing style', 'Concise and actionable'),
13
+ ]
14
+
15
+ describe('buildFragmentPickerGroups', () => {
16
+ it('buckets fragments into one labelled group per category (technical + writing-style tracks)', () => {
17
+ const groups = buildFragmentPickerGroups(
18
+ pool,
19
+ () => false,
20
+ () => {},
21
+ )
22
+ // One group per category, each led by a non-interactive `type: 'label'` heading.
23
+ expect(groups.map((g) => g[0])).toEqual([
24
+ { type: 'label', label: 'Node' },
25
+ { type: 'label', label: 'Writing style' },
26
+ ])
27
+ // The heading is followed by that category's item labels, in pool order.
28
+ expect(groups[0]!.slice(1).map((i) => i.label)).toEqual([
29
+ 'Node best practices',
30
+ 'Node performance',
31
+ ])
32
+ expect(groups[1]!.slice(1).map((i) => i.label)).toEqual([
33
+ 'Avoid LLM tells',
34
+ 'Concise and actionable',
35
+ ])
36
+ })
37
+
38
+ it('omits already-selected fragments, dropping a category that empties out', () => {
39
+ const selected = new Set(['style.anti-llmisms', 'style.concise-actionable'])
40
+ const groups = buildFragmentPickerGroups(
41
+ pool,
42
+ (id) => selected.has(id),
43
+ () => {},
44
+ )
45
+ // Writing style fully selected → its category disappears entirely (no empty labelled group).
46
+ expect(groups.map((g) => g[0])).toEqual([{ type: 'label', label: 'Node' }])
47
+ })
48
+
49
+ it('invokes onSelect with the fragment id when an item is chosen', () => {
50
+ const picked: string[] = []
51
+ const groups = buildFragmentPickerGroups(
52
+ pool,
53
+ () => false,
54
+ (id) => picked.push(id),
55
+ )
56
+ // Fire the first real item under the first category (index 1, past the label heading).
57
+ ;(groups[0]![1] as { onSelect: () => void }).onSelect()
58
+ expect(picked).toEqual(['node.best-practices'])
59
+ })
60
+ })
@@ -0,0 +1,32 @@
1
+ import type { DropdownMenuItem } from '@nuxt/ui'
2
+ import type { PromptFragment } from '~/types/domain'
3
+
4
+ /**
5
+ * Build the category-grouped groups for a fragment "add" dropdown: the pool fragments not
6
+ * already selected, bucketed by `category`, each bucket prefixed with a non-interactive
7
+ * `type: 'label'` heading so the catalog reads as labelled sections instead of one flat,
8
+ * undifferentiated list. This matters now that the catalog spans distinct tracks that a
9
+ * single block can pin together — the technical collections (Node / React / …) AND the
10
+ * document Writing-style fragments — so the headings keep the longer, mixed list navigable.
11
+ *
12
+ * Category order follows first appearance in `pool`; empty categories are dropped. Each
13
+ * inner array is one Nuxt UI menu group (rendered divider-separated); callers append their
14
+ * own trailing groups (e.g. the library-management links) after the returned groups.
15
+ */
16
+ export function buildFragmentPickerGroups(
17
+ pool: PromptFragment[],
18
+ isSelected: (id: string) => boolean,
19
+ onSelect: (id: string) => void,
20
+ ): DropdownMenuItem[][] {
21
+ const groups = new Map<string, DropdownMenuItem[]>()
22
+ for (const f of pool) {
23
+ if (isSelected(f.id)) continue
24
+ const items = groups.get(f.category) ?? []
25
+ items.push({ label: f.title, onSelect: () => onSelect(f.id) })
26
+ groups.set(f.category, items)
27
+ }
28
+ return [...groups.entries()].map(([category, items]): DropdownMenuItem[] => [
29
+ { type: 'label', label: category },
30
+ ...items,
31
+ ])
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.87.4",
3
+ "version": "0.87.5",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",