@cat-factory/app 0.146.1 → 0.146.3

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,14 +1,15 @@
1
1
  <script setup lang="ts">
2
- // Shared best-practice prompt-fragment picker: a category-grouped "add" dropdown (with links out
3
- // to the fragment library) plus the currently-selected fragments as removable badges.
2
+ // Shared best-practice prompt-fragment picker: a category-grouped MULTI-SELECT popover (with links
3
+ // out to the fragment library) plus the currently-selected fragments as removable badges.
4
+ // Selecting a row TOGGLES it and leaves the list open, so several fragments can be picked (and
5
+ // unpicked) in one visit; a dedicated "Done" button closes the panel.
4
6
  // Presentational + `v-model`-driven — the caller owns WHERE the selection lives (a task's
5
7
  // `fragmentIds`, a service's `serviceFragmentIds`, or a not-yet-created task's local draft) and
6
8
  // binds the id list. Authored once and reused by the create-task form and the task/service
7
9
  // inspectors so the three pickers can't drift. Ids the catalog no longer resolves still render
8
10
  // (labelled by their raw id) so they stay visible and removable.
9
- import type { DropdownMenuItem } from '@nuxt/ui'
10
11
  import type { PromptFragment } from '~/types/domain'
11
- import { buildFragmentPickerGroups } from '~/utils/fragmentPicker'
12
+ import { buildFragmentCategoryGroups } from '~/utils/fragmentPicker'
12
13
 
13
14
  const props = withDefaults(
14
15
  defineProps<{
@@ -30,6 +31,8 @@ const ui = useUiStore()
30
31
  const accounts = useAccountsStore()
31
32
  const { t } = useI18n()
32
33
 
34
+ const open = ref(false)
35
+
33
36
  // The catalog is per-board and invalidated on a workspace switch; (re)load it lazily — a no-op
34
37
  // while current.
35
38
  onMounted(() => fragments.ensureLoaded())
@@ -38,39 +41,18 @@ const selectedFragments = computed(() =>
38
41
  props.modelValue.map((id) => fragments.getFragment(id) ?? { id, title: id, summary: '' }),
39
42
  )
40
43
 
41
- // A trailing group that jumps from "attach a fragment" to authoring/editing the library itself
42
- // (board tier always; account tier when accounts are enabled). Open to every member.
43
- const manageItems = computed<DropdownMenuItem[]>(() => {
44
- const items: DropdownMenuItem[] = [
45
- {
46
- label: t('inspector.fragments.manageBoard'),
47
- icon: 'i-lucide-book-marked',
48
- onSelect: () => ui.openFragmentLibrary(),
49
- },
50
- ]
51
- if (accounts.enabled) {
52
- items.push({
53
- label: t('inspector.fragments.manageAccount'),
54
- icon: 'i-lucide-users',
55
- onSelect: () => ui.openAccountSettings('fragments'),
56
- })
57
- }
58
- return items
59
- })
44
+ const selectedSet = computed(() => new Set(props.modelValue))
60
45
 
61
- // Picker menu: pool fragments not already selected, grouped into labelled per-category sections,
62
- // with the management links appended as the final group.
63
- const fragmentMenu = computed<DropdownMenuItem[][]>(() => {
64
- const selected = new Set(props.modelValue)
65
- return [
66
- ...buildFragmentPickerGroups(props.pool, (id) => selected.has(id), addFragment),
67
- manageItems.value,
68
- ]
69
- })
46
+ // Pool fragments bucketed into labelled per-category sections. Selected rows stay in the list so
47
+ // they can be unpicked in place (a check marks the current selection).
48
+ const categoryGroups = computed(() => buildFragmentCategoryGroups(props.pool))
70
49
 
71
- function addFragment(id: string) {
72
- if (props.modelValue.includes(id)) return
73
- emit('update:modelValue', [...props.modelValue, id])
50
+ function toggleFragment(id: string) {
51
+ if (props.modelValue.includes(id)) {
52
+ removeFragment(id)
53
+ } else {
54
+ emit('update:modelValue', [...props.modelValue, id])
55
+ }
74
56
  }
75
57
 
76
58
  function removeFragment(id: string) {
@@ -79,6 +61,16 @@ function removeFragment(id: string) {
79
61
  props.modelValue.filter((x) => x !== id),
80
62
  )
81
63
  }
64
+
65
+ function manageBoard() {
66
+ open.value = false
67
+ ui.openFragmentLibrary()
68
+ }
69
+
70
+ function manageAccount() {
71
+ open.value = false
72
+ ui.openAccountSettings('fragments')
73
+ }
82
74
  </script>
83
75
 
84
76
  <template>
@@ -88,7 +80,7 @@ function removeFragment(id: string) {
88
80
  {{ label }}
89
81
  </span>
90
82
  <span v-else />
91
- <UDropdownMenu :items="fragmentMenu">
83
+ <UPopover v-model:open="open" :content="{ align: 'end' }">
92
84
  <UButton
93
85
  size="xs"
94
86
  variant="ghost"
@@ -97,7 +89,79 @@ function removeFragment(id: string) {
97
89
  trailing-icon="i-lucide-chevron-down"
98
90
  data-testid="fragment-add"
99
91
  />
100
- </UDropdownMenu>
92
+
93
+ <template #content>
94
+ <div
95
+ class="flex max-h-[24rem] w-[min(22rem,92vw)] flex-col"
96
+ data-testid="fragment-picker-panel"
97
+ >
98
+ <div class="min-h-0 flex-1 overflow-y-auto p-1">
99
+ <template v-if="categoryGroups.length">
100
+ <div v-for="group in categoryGroups" :key="group.category">
101
+ <p
102
+ class="px-2 pb-0.5 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
103
+ >
104
+ {{ group.category }}
105
+ </p>
106
+ <button
107
+ v-for="f in group.fragments"
108
+ :key="f.id"
109
+ type="button"
110
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
111
+ :class="selectedSet.has(f.id) ? 'text-slate-100' : 'text-slate-300'"
112
+ :title="f.summary"
113
+ :data-testid="`fragment-option-${f.id}`"
114
+ :aria-pressed="selectedSet.has(f.id)"
115
+ @click="toggleFragment(f.id)"
116
+ >
117
+ <UIcon
118
+ :name="selectedSet.has(f.id) ? 'i-lucide-check' : 'i-lucide-plus'"
119
+ class="h-4 w-4 shrink-0"
120
+ :class="selectedSet.has(f.id) ? 'text-primary-400' : 'text-slate-500'"
121
+ />
122
+ <span class="flex-1 truncate">{{ f.title }}</span>
123
+ </button>
124
+ </div>
125
+ </template>
126
+ <p v-else class="px-2 py-3 text-[12px] text-slate-500">
127
+ {{ t('inspector.fragments.pickerEmpty') }}
128
+ </p>
129
+
130
+ <div class="mt-1 border-t border-slate-800 pt-1">
131
+ <button
132
+ type="button"
133
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm text-slate-300 hover:bg-slate-800/60"
134
+ @click="manageBoard"
135
+ >
136
+ <UIcon name="i-lucide-book-marked" class="h-4 w-4 shrink-0 text-slate-400" />
137
+ <span class="flex-1 truncate">{{ t('inspector.fragments.manageBoard') }}</span>
138
+ </button>
139
+ <button
140
+ v-if="accounts.enabled"
141
+ type="button"
142
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm text-slate-300 hover:bg-slate-800/60"
143
+ @click="manageAccount"
144
+ >
145
+ <UIcon name="i-lucide-users" class="h-4 w-4 shrink-0 text-slate-400" />
146
+ <span class="flex-1 truncate">{{ t('inspector.fragments.manageAccount') }}</span>
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="flex justify-end border-t border-slate-800 p-1.5">
152
+ <UButton
153
+ size="xs"
154
+ color="neutral"
155
+ variant="soft"
156
+ data-testid="fragment-picker-done"
157
+ @click="open = false"
158
+ >
159
+ {{ t('inspector.fragments.done') }}
160
+ </UButton>
161
+ </div>
162
+ </div>
163
+ </template>
164
+ </UPopover>
101
165
  </div>
102
166
  <div v-if="selectedFragments.length" class="flex flex-wrap gap-1">
103
167
  <UBadge
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import type { PromptFragment } from '~/types/domain'
3
- import { buildFragmentPickerGroups } from './fragmentPicker'
3
+ import { buildFragmentCategoryGroups, buildFragmentPickerGroups } from './fragmentPicker'
4
4
 
5
5
  const frag = (id: string, category: string, title = id): PromptFragment =>
6
6
  ({ id, version: '1.0.0', title, category, summary: '', body: '' }) as PromptFragment
@@ -58,3 +58,24 @@ describe('buildFragmentPickerGroups', () => {
58
58
  expect(picked).toEqual(['node.best-practices'])
59
59
  })
60
60
  })
61
+
62
+ describe('buildFragmentCategoryGroups', () => {
63
+ it('buckets every fragment by category in first-appearance order, keeping pool order within', () => {
64
+ const groups = buildFragmentCategoryGroups(pool)
65
+ expect(groups.map((g) => g.category)).toEqual(['Node', 'Writing style'])
66
+ expect(groups[0]!.fragments.map((f) => f.id)).toEqual([
67
+ 'node.best-practices',
68
+ 'node.performance',
69
+ ])
70
+ expect(groups[1]!.fragments.map((f) => f.id)).toEqual([
71
+ 'style.anti-llmisms',
72
+ 'style.concise-actionable',
73
+ ])
74
+ })
75
+
76
+ it('keeps selected fragments in their bucket (multi-select toggles in place, never hides)', () => {
77
+ // Unlike the dropdown builder, the category grouping does no selection filtering.
78
+ const groups = buildFragmentCategoryGroups(pool)
79
+ expect(groups.flatMap((g) => g.fragments)).toHaveLength(pool.length)
80
+ })
81
+ })
@@ -1,6 +1,28 @@
1
1
  import type { DropdownMenuItem } from '@nuxt/ui'
2
2
  import type { PromptFragment } from '~/types/domain'
3
3
 
4
+ /** One category bucket: its heading plus the pool fragments that fall under it, in pool order. */
5
+ export interface FragmentCategoryGroup {
6
+ category: string
7
+ fragments: PromptFragment[]
8
+ }
9
+
10
+ /**
11
+ * Bucket the pool fragments by `category`, preserving first-appearance category order and
12
+ * per-category pool order. Unlike {@link buildFragmentPickerGroups} this keeps ALL fragments
13
+ * (selected included) and returns the raw fragments, so a multi-select surface can render each
14
+ * as a toggle that stays visible whether or not it is currently picked.
15
+ */
16
+ export function buildFragmentCategoryGroups(pool: PromptFragment[]): FragmentCategoryGroup[] {
17
+ const groups = new Map<string, PromptFragment[]>()
18
+ for (const f of pool) {
19
+ const items = groups.get(f.category) ?? []
20
+ items.push(f)
21
+ groups.set(f.category, items)
22
+ }
23
+ return [...groups.entries()].map(([category, fragments]) => ({ category, fragments }))
24
+ }
25
+
4
26
  /**
5
27
  * Build the category-grouped groups for a fragment "add" dropdown: the pool fragments not
6
28
  * already selected, bucketed by `category`, each bucket prefixed with a non-interactive
@@ -18,15 +40,12 @@ export function buildFragmentPickerGroups(
18
40
  isSelected: (id: string) => boolean,
19
41
  onSelect: (id: string) => void,
20
42
  ): 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
- ])
43
+ return buildFragmentCategoryGroups(pool)
44
+ .map(({ category, fragments }): DropdownMenuItem[] => {
45
+ const items = fragments
46
+ .filter((f) => !isSelected(f.id))
47
+ .map((f): DropdownMenuItem => ({ label: f.title, onSelect: () => onSelect(f.id) }))
48
+ return items.length ? [{ type: 'label', label: category }, ...items] : []
49
+ })
50
+ .filter((group) => group.length > 0)
32
51
  }
@@ -931,7 +931,9 @@
931
931
  "serviceHint": "Programmierstandards für den gesamten Service. Der Inhalt jedes ausgewählten Fragments wird dem Prompt jedes code-bewussten Agents hinzugefügt, der an Aufgaben unter diesem Service arbeitet.",
932
932
  "serviceEmpty": "Keine. Code-bewusste Agents dieses Service folgen ihren Standardvorgaben.",
933
933
  "manageBoard": "Fragment-Bibliothek dieses Boards verwalten…",
934
- "manageAccount": "Konto-Fragmente verwalten…"
934
+ "manageAccount": "Konto-Fragmente verwalten…",
935
+ "done": "Fertig",
936
+ "pickerEmpty": "Keine Fragmente verfügbar."
935
937
  },
936
938
  "frontendConfig": {
937
939
  "title": "Frontend",
@@ -706,7 +706,9 @@
706
706
  "serviceHint": "Programming standards for the whole service. The content of each selected fragment is added to the prompt of every code-aware agent working on tasks under this service.",
707
707
  "serviceEmpty": "None. Code-aware agents on this service follow their default guidance.",
708
708
  "manageBoard": "Manage this board's fragment library…",
709
- "manageAccount": "Manage account fragments…"
709
+ "manageAccount": "Manage account fragments…",
710
+ "done": "Done",
711
+ "pickerEmpty": "No fragments available."
710
712
  },
711
713
  "frontendConfig": {
712
714
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "Estándares de programación para todo el servicio. El contenido de cada fragmento seleccionado se añade al prompt de cada agente que conoce el código y trabaja en tareas de este servicio.",
650
650
  "serviceEmpty": "Ninguna. Los agentes que conocen el código en este servicio siguen su guía predeterminada.",
651
651
  "manageBoard": "Gestionar la biblioteca de fragmentos de este tablero…",
652
- "manageAccount": "Gestionar los fragmentos de la cuenta…"
652
+ "manageAccount": "Gestionar los fragmentos de la cuenta…",
653
+ "done": "Listo",
654
+ "pickerEmpty": "No hay fragmentos disponibles."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "Normes de programmation pour tout le service. Le contenu de chaque fragment sélectionné est ajouté au prompt de chaque agent conscient du code travaillant sur les tâches de ce service.",
650
650
  "serviceEmpty": "Aucune. Les agents conscients du code sur ce service suivent leurs consignes par défaut.",
651
651
  "manageBoard": "Gérer la bibliothèque de fragments de ce tableau…",
652
- "manageAccount": "Gérer les fragments du compte…"
652
+ "manageAccount": "Gérer les fragments du compte…",
653
+ "done": "Terminé",
654
+ "pickerEmpty": "Aucun fragment disponible."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "תקני תכנות לכל השירות. התוכן של כל פרגמנט שנבחר מתווסף לפרומפט של כל סוכן מודע-קוד שעובד על משימות תחת שירות זה.",
650
650
  "serviceEmpty": "אין. סוכנים מודעי-קוד בשירות זה פועלים לפי ההנחיות המוגדרות כברירת מחדל.",
651
651
  "manageBoard": "נהל את ספריית הקטעים של לוח זה…",
652
- "manageAccount": "נהל קטעי חשבון…"
652
+ "manageAccount": "נהל קטעי חשבון…",
653
+ "done": "סיום",
654
+ "pickerEmpty": "אין קטעים זמינים."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
@@ -931,7 +931,9 @@
931
931
  "serviceHint": "Standard di programmazione per l'intero servizio. Il contenuto di ogni frammento selezionato viene aggiunto al prompt di ogni agente code-aware che lavora sulle attivita' di questo servizio.",
932
932
  "serviceEmpty": "Nessuno. Gli agenti code-aware su questo servizio seguono le loro linee guida predefinite.",
933
933
  "manageBoard": "Gestisci la libreria di frammenti di questa board…",
934
- "manageAccount": "Gestisci i frammenti dell'account…"
934
+ "manageAccount": "Gestisci i frammenti dell'account…",
935
+ "done": "Fatto",
936
+ "pickerEmpty": "Nessun frammento disponibile."
935
937
  },
936
938
  "frontendConfig": {
937
939
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "サービス全体のプログラミング標準です。選択した各フラグメントの内容は、このサービスのタスクに取り組むコード対応エージェントすべてのプロンプトに追加されます。",
650
650
  "serviceEmpty": "なし。このサービスのコード対応エージェントはデフォルトのガイダンスに従います。",
651
651
  "manageBoard": "このボードのフラグメントライブラリを管理…",
652
- "manageAccount": "アカウントのフラグメントを管理…"
652
+ "manageAccount": "アカウントのフラグメントを管理…",
653
+ "done": "完了",
654
+ "pickerEmpty": "利用可能なフラグメントがありません。"
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "フロントエンド",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "Standardy programowania dla całej usługi. Treść każdego wybranego fragmentu jest dodawana do promptu każdego agenta świadomego kodu pracującego nad zadaniami tej usługi.",
650
650
  "serviceEmpty": "Brak. Agenci świadomi kodu w tej usłudze stosują swoje domyślne wytyczne.",
651
651
  "manageBoard": "Zarządzaj biblioteką fragmentów tej tablicy…",
652
- "manageAccount": "Zarządzaj fragmentami konta…"
652
+ "manageAccount": "Zarządzaj fragmentami konta…",
653
+ "done": "Gotowe",
654
+ "pickerEmpty": "Brak dostępnych fragmentów."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "Tüm servis için programlama standartları. Seçilen her parçanın içeriği, bu servisin görevlerinde çalışan kod farkında her ajanın prompt'una eklenir.",
650
650
  "serviceEmpty": "Yok. Bu servisteki kod farkında ajanlar varsayılan yönergelerini izler.",
651
651
  "manageBoard": "Bu pano'nun fragman kütüphanesini yönet…",
652
- "manageAccount": "Hesap fragmanlarını yönet…"
652
+ "manageAccount": "Hesap fragmanlarını yönet…",
653
+ "done": "Tamam",
654
+ "pickerEmpty": "Kullanılabilir parça yok."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
@@ -649,7 +649,9 @@
649
649
  "serviceHint": "Стандарти програмування для всього сервісу. Вміст кожного вибраного фрагмента додається до промпту кожного агента, що працює з кодом завдань цього сервісу.",
650
650
  "serviceEmpty": "Немає. Агенти, що працюють з кодом цього сервісу, дотримуються типових інструкцій.",
651
651
  "manageBoard": "Керувати бібліотекою фрагментів цієї дошки…",
652
- "manageAccount": "Керувати фрагментами облікового запису…"
652
+ "manageAccount": "Керувати фрагментами облікового запису…",
653
+ "done": "Готово",
654
+ "pickerEmpty": "Немає доступних фрагментів."
653
655
  },
654
656
  "frontendConfig": {
655
657
  "title": "Frontend",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.146.1",
3
+ "version": "0.146.3",
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",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.152.2"
43
+ "@cat-factory/contracts": "0.153.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",