@cat-factory/app 0.32.2 → 0.34.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/fragments/FragmentLibraryManager.vue +511 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +12 -458
- package/app/components/layout/AccountFragmentSettings.vue +37 -0
- package/app/components/layout/AccountTeamSettings.vue +58 -9
- package/app/components/layout/BoardSwitcher.vue +9 -20
- package/app/components/layout/CommandBar.vue +9 -0
- package/app/components/layout/SideBar.vue +15 -0
- package/app/components/settings/AccountSettingsPanel.vue +57 -0
- package/app/components/settings/ProviderConnectionPanel.vue +117 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +21 -0
- package/app/composables/api/fragments.ts +12 -3
- package/app/pages/index.vue +2 -0
- package/app/stores/fragmentLibrary.ts +80 -31
- package/app/stores/ui.ts +23 -0
- package/app/stores/workspaceSettings.ts +2 -0
- package/app/types/domain.ts +6 -0
- package/package.json +1 -1
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import type { DocumentSourceKind, ResolvedFragment } from '~/types/domain'
|
|
2
|
+
// The board (workspace-tier) prompt-fragment library modal (ADR 0006). A thin shell
|
|
3
|
+
// around the shared FragmentLibraryManager at the active board's scope — including
|
|
4
|
+
// the resolved/merged catalog view so the built-in ∪ account ∪ workspace inheritance
|
|
5
|
+
// is visible. Opened from the navbar / command bar via the ui store.
|
|
6
|
+
import FragmentLibraryManager from '~/components/fragments/FragmentLibraryManager.vue'
|
|
8
7
|
|
|
9
8
|
const ui = useUiStore()
|
|
10
|
-
const
|
|
11
|
-
const documents = useDocumentsStore()
|
|
12
|
-
const toast = useToast()
|
|
9
|
+
const workspace = useWorkspaceStore()
|
|
13
10
|
|
|
14
11
|
const open = computed({
|
|
15
12
|
get: () => ui.fragmentLibraryOpen,
|
|
@@ -17,460 +14,17 @@ const open = computed({
|
|
|
17
14
|
if (!v) ui.closeFragmentLibrary()
|
|
18
15
|
},
|
|
19
16
|
})
|
|
20
|
-
|
|
21
|
-
watch(open, (isOpen) => {
|
|
22
|
-
if (isOpen) {
|
|
23
|
-
void library.probe()
|
|
24
|
-
void documents.probe()
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
type Tab = 'catalog' | 'authored' | 'documents' | 'sources'
|
|
29
|
-
const tab = ref<Tab>('catalog')
|
|
30
|
-
|
|
31
|
-
const tierLabel: Record<ResolvedFragment['tier'], string> = {
|
|
32
|
-
builtin: 'Built-in',
|
|
33
|
-
account: 'Account',
|
|
34
|
-
workspace: 'This board',
|
|
35
|
-
}
|
|
36
|
-
// `as const` keeps the literal color names (assignable to UBadge's `color`
|
|
37
|
-
// union) instead of widening to `string`; `satisfies` still checks the shape.
|
|
38
|
-
const tierColor = {
|
|
39
|
-
builtin: 'neutral',
|
|
40
|
-
account: 'info',
|
|
41
|
-
workspace: 'primary',
|
|
42
|
-
} as const satisfies Record<ResolvedFragment['tier'], string>
|
|
43
|
-
|
|
44
|
-
function notifyError(title: string, e: unknown) {
|
|
45
|
-
toast.add({
|
|
46
|
-
title,
|
|
47
|
-
description: e instanceof Error ? e.message : String(e),
|
|
48
|
-
icon: 'i-lucide-triangle-alert',
|
|
49
|
-
color: 'error',
|
|
50
|
-
})
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ---- create a hand-authored fragment --------------------------------------
|
|
54
|
-
const draft = ref({ title: '', summary: '', body: '', tags: '' })
|
|
55
|
-
const draftValid = computed(
|
|
56
|
-
() => draft.value.title.trim() && draft.value.summary.trim() && draft.value.body.trim(),
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
async function createFragment() {
|
|
60
|
-
if (!draftValid.value) return
|
|
61
|
-
try {
|
|
62
|
-
await library.create({
|
|
63
|
-
title: draft.value.title.trim(),
|
|
64
|
-
summary: draft.value.summary.trim(),
|
|
65
|
-
body: draft.value.body.trim(),
|
|
66
|
-
tags: draft.value.tags
|
|
67
|
-
.split(',')
|
|
68
|
-
.map((t) => t.trim())
|
|
69
|
-
.filter(Boolean),
|
|
70
|
-
})
|
|
71
|
-
draft.value = { title: '', summary: '', body: '', tags: '' }
|
|
72
|
-
toast.add({ title: 'Fragment added', icon: 'i-lucide-check' })
|
|
73
|
-
} catch (e) {
|
|
74
|
-
notifyError('Could not add fragment', e)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function removeFragment(id: string) {
|
|
79
|
-
try {
|
|
80
|
-
await library.remove(id)
|
|
81
|
-
toast.add({ title: 'Fragment removed', icon: 'i-lucide-trash-2' })
|
|
82
|
-
} catch (e) {
|
|
83
|
-
notifyError('Could not remove fragment', e)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ---- document-backed (living) fragments -----------------------------------
|
|
88
|
-
// Link a Confluence/Notion page or GitHub file as a fragment that is re-resolved
|
|
89
|
-
// from the source at run time (a living source of truth, not a frozen snapshot).
|
|
90
|
-
const docDraft = ref({ source: '' as DocumentSourceKind | '', ref: '', tags: '' })
|
|
91
|
-
const docDraftValid = computed(() => docDraft.value.source && docDraft.value.ref.trim())
|
|
92
|
-
|
|
93
|
-
/** The board's existing document-backed fragments (workspace tier). */
|
|
94
|
-
const documentFragments = computed(() => library.fragments.filter((f) => f.documentRef))
|
|
95
|
-
|
|
96
|
-
async function linkDocumentFragment() {
|
|
97
|
-
if (!docDraftValid.value) return
|
|
98
|
-
try {
|
|
99
|
-
await library.createDocumentFragment({
|
|
100
|
-
source: docDraft.value.source as DocumentSourceKind,
|
|
101
|
-
ref: docDraft.value.ref.trim(),
|
|
102
|
-
tags: docDraft.value.tags
|
|
103
|
-
.split(',')
|
|
104
|
-
.map((t) => t.trim())
|
|
105
|
-
.filter(Boolean),
|
|
106
|
-
})
|
|
107
|
-
docDraft.value = { source: '', ref: '', tags: '' }
|
|
108
|
-
toast.add({ title: 'Document linked as a living fragment', icon: 'i-lucide-link' })
|
|
109
|
-
} catch (e) {
|
|
110
|
-
notifyError('Could not link document', e)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function refreshFragment(id: string) {
|
|
115
|
-
try {
|
|
116
|
-
await library.refreshDocumentFragment(id)
|
|
117
|
-
toast.add({ title: 'Fragment re-resolved from source', icon: 'i-lucide-refresh-cw' })
|
|
118
|
-
} catch (e) {
|
|
119
|
-
notifyError('Could not refresh fragment', e)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ---- repo sources ----------------------------------------------------------
|
|
124
|
-
const sourceDraft = ref({ repoOwner: '', repoName: '', dirPath: '', gitRef: '' })
|
|
125
|
-
const sourceValid = computed(
|
|
126
|
-
() => sourceDraft.value.repoOwner.trim() && sourceDraft.value.repoName.trim(),
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
async function linkSource() {
|
|
130
|
-
if (!sourceValid.value) return
|
|
131
|
-
try {
|
|
132
|
-
const source = await library.linkSource({
|
|
133
|
-
repoOwner: sourceDraft.value.repoOwner.trim(),
|
|
134
|
-
repoName: sourceDraft.value.repoName.trim(),
|
|
135
|
-
dirPath: sourceDraft.value.dirPath.trim() || undefined,
|
|
136
|
-
gitRef: sourceDraft.value.gitRef.trim() || undefined,
|
|
137
|
-
})
|
|
138
|
-
sourceDraft.value = { repoOwner: '', repoName: '', dirPath: '', gitRef: '' }
|
|
139
|
-
await library.syncSource(source.id)
|
|
140
|
-
toast.add({ title: 'Source linked & synced', icon: 'i-lucide-git-branch' })
|
|
141
|
-
} catch (e) {
|
|
142
|
-
notifyError('Could not link source', e)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function syncSource(id: string) {
|
|
147
|
-
try {
|
|
148
|
-
const result = await library.syncSource(id)
|
|
149
|
-
toast.add({
|
|
150
|
-
title: `Synced: ${result.upserted} updated, ${result.tombstoned} removed`,
|
|
151
|
-
icon: 'i-lucide-refresh-cw',
|
|
152
|
-
color: 'info',
|
|
153
|
-
})
|
|
154
|
-
} catch (e) {
|
|
155
|
-
notifyError('Could not sync source', e)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function checkSource(id: string) {
|
|
160
|
-
try {
|
|
161
|
-
const status = await library.checkSource(id)
|
|
162
|
-
toast.add({
|
|
163
|
-
title: status.changed ? `${status.changedCount} change(s) available` : 'Up to date',
|
|
164
|
-
icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
|
|
165
|
-
})
|
|
166
|
-
} catch (e) {
|
|
167
|
-
notifyError('Could not check source', e)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async function unlinkSource(id: string) {
|
|
172
|
-
try {
|
|
173
|
-
await library.unlinkSource(id)
|
|
174
|
-
toast.add({ title: 'Source unlinked', icon: 'i-lucide-unplug' })
|
|
175
|
-
} catch (e) {
|
|
176
|
-
notifyError('Could not unlink source', e)
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
17
|
</script>
|
|
180
18
|
|
|
181
19
|
<template>
|
|
182
20
|
<UModal v-model:open="open" title="Prompt-fragment library" :ui="{ content: 'max-w-3xl' }">
|
|
183
21
|
<template #body>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<div class="flex gap-2">
|
|
192
|
-
<UButton
|
|
193
|
-
v-for="t in ['catalog', 'authored', 'documents', 'sources'] as Tab[]"
|
|
194
|
-
:key="t"
|
|
195
|
-
:color="tab === t ? 'primary' : 'neutral'"
|
|
196
|
-
:variant="tab === t ? 'solid' : 'ghost'"
|
|
197
|
-
size="sm"
|
|
198
|
-
@click="tab = t"
|
|
199
|
-
>
|
|
200
|
-
{{
|
|
201
|
-
t === 'catalog'
|
|
202
|
-
? 'Resolved catalog'
|
|
203
|
-
: t === 'authored'
|
|
204
|
-
? 'This board'
|
|
205
|
-
: t === 'documents'
|
|
206
|
-
? 'Documents'
|
|
207
|
-
: 'Repo sources'
|
|
208
|
-
}}
|
|
209
|
-
</UButton>
|
|
210
|
-
</div>
|
|
211
|
-
|
|
212
|
-
<!-- Resolved (merged) catalog -->
|
|
213
|
-
<div v-if="tab === 'catalog'" class="flex flex-col gap-2">
|
|
214
|
-
<p class="text-xs text-slate-500">
|
|
215
|
-
{{ library.resolved.length }} fragment(s) resolved ·
|
|
216
|
-
{{ library.builtinCount }} built-in.
|
|
217
|
-
</p>
|
|
218
|
-
<div
|
|
219
|
-
v-for="f in library.resolved"
|
|
220
|
-
:key="f.id"
|
|
221
|
-
class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
222
|
-
>
|
|
223
|
-
<div class="flex items-center gap-2">
|
|
224
|
-
<span class="font-medium text-slate-100">{{ f.title }}</span>
|
|
225
|
-
<UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
|
|
226
|
-
{{ tierLabel[f.tier] }}
|
|
227
|
-
</UBadge>
|
|
228
|
-
<UBadge
|
|
229
|
-
v-if="f.documentRef"
|
|
230
|
-
size="xs"
|
|
231
|
-
color="success"
|
|
232
|
-
variant="subtle"
|
|
233
|
-
icon="i-lucide-radio"
|
|
234
|
-
>
|
|
235
|
-
Live · {{ f.documentRef.source }}
|
|
236
|
-
</UBadge>
|
|
237
|
-
<span class="ml-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
|
|
238
|
-
</div>
|
|
239
|
-
<p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
|
|
240
|
-
<div v-if="f.tags?.length" class="mt-1 flex flex-wrap gap-1">
|
|
241
|
-
<UBadge v-for="tag in f.tags" :key="tag" size="xs" variant="outline" color="neutral">
|
|
242
|
-
{{ tag }}
|
|
243
|
-
</UBadge>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
|
|
248
|
-
<!-- Hand-authored (workspace tier) -->
|
|
249
|
-
<div v-else-if="tab === 'authored'" class="flex flex-col gap-3">
|
|
250
|
-
<div
|
|
251
|
-
v-for="f in library.fragments"
|
|
252
|
-
:key="f.id"
|
|
253
|
-
class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
254
|
-
>
|
|
255
|
-
<div class="min-w-0">
|
|
256
|
-
<div class="flex items-center gap-2">
|
|
257
|
-
<span class="font-medium text-slate-100">{{ f.title }}</span>
|
|
258
|
-
<UBadge v-if="f.source" size="xs" color="info" variant="subtle">from repo</UBadge>
|
|
259
|
-
</div>
|
|
260
|
-
<p class="text-sm text-slate-400">{{ f.summary }}</p>
|
|
261
|
-
</div>
|
|
262
|
-
<UButton
|
|
263
|
-
icon="i-lucide-trash-2"
|
|
264
|
-
size="xs"
|
|
265
|
-
color="error"
|
|
266
|
-
variant="ghost"
|
|
267
|
-
class="ml-auto"
|
|
268
|
-
@click="removeFragment(f.id)"
|
|
269
|
-
/>
|
|
270
|
-
</div>
|
|
271
|
-
<p v-if="!library.fragments.length" class="text-sm text-slate-500">
|
|
272
|
-
No board-specific fragments yet. Add one below, or override a built-in by using its id.
|
|
273
|
-
</p>
|
|
274
|
-
|
|
275
|
-
<div class="rounded-md border border-slate-800 p-3">
|
|
276
|
-
<p class="mb-2 text-sm font-medium">Add a fragment</p>
|
|
277
|
-
<div class="flex flex-col gap-2">
|
|
278
|
-
<UInput v-model="draft.title" placeholder="Title" />
|
|
279
|
-
<UInput
|
|
280
|
-
v-model="draft.summary"
|
|
281
|
-
placeholder="One-line summary (used by the selector)"
|
|
282
|
-
/>
|
|
283
|
-
<UTextarea
|
|
284
|
-
v-model="draft.body"
|
|
285
|
-
placeholder="Guidance body (injected into the prompt)"
|
|
286
|
-
:rows="4"
|
|
287
|
-
/>
|
|
288
|
-
<UInput v-model="draft.tags" placeholder="Tags, comma-separated (e.g. backend, db)" />
|
|
289
|
-
<UButton
|
|
290
|
-
icon="i-lucide-plus"
|
|
291
|
-
size="sm"
|
|
292
|
-
:disabled="!draftValid"
|
|
293
|
-
:loading="library.loading"
|
|
294
|
-
class="self-start"
|
|
295
|
-
@click="createFragment"
|
|
296
|
-
>
|
|
297
|
-
Add fragment
|
|
298
|
-
</UButton>
|
|
299
|
-
</div>
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
|
|
303
|
-
<!-- Document-backed (living) fragments -->
|
|
304
|
-
<div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
|
|
305
|
-
<p class="text-xs text-slate-500">
|
|
306
|
-
Link a Confluence/Notion page or a GitHub file as a best-practice fragment. Its guidance
|
|
307
|
-
is re-resolved from the source at run time — edit the doc and the next agent run follows
|
|
308
|
-
the new version (no re-import).
|
|
309
|
-
</p>
|
|
310
|
-
|
|
311
|
-
<div
|
|
312
|
-
v-for="f in documentFragments"
|
|
313
|
-
:key="f.id"
|
|
314
|
-
class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
315
|
-
>
|
|
316
|
-
<UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
|
|
317
|
-
<div class="min-w-0">
|
|
318
|
-
<div class="flex items-center gap-2">
|
|
319
|
-
<span class="font-medium text-slate-100">{{ f.title }}</span>
|
|
320
|
-
<UBadge size="xs" color="success" variant="subtle">
|
|
321
|
-
{{ f.documentRef?.source }}
|
|
322
|
-
</UBadge>
|
|
323
|
-
</div>
|
|
324
|
-
<p class="text-sm text-slate-400">{{ f.summary }}</p>
|
|
325
|
-
<p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
|
|
326
|
-
last resolved {{ new Date(f.resolvedAt).toLocaleString() }}
|
|
327
|
-
</p>
|
|
328
|
-
</div>
|
|
329
|
-
<div class="ml-auto flex gap-1">
|
|
330
|
-
<UButton
|
|
331
|
-
icon="i-lucide-refresh-cw"
|
|
332
|
-
size="xs"
|
|
333
|
-
variant="ghost"
|
|
334
|
-
:loading="library.loading"
|
|
335
|
-
title="Re-resolve from source now"
|
|
336
|
-
@click="refreshFragment(f.id)"
|
|
337
|
-
/>
|
|
338
|
-
<UButton
|
|
339
|
-
icon="i-lucide-trash-2"
|
|
340
|
-
size="xs"
|
|
341
|
-
color="error"
|
|
342
|
-
variant="ghost"
|
|
343
|
-
@click="removeFragment(f.id)"
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
</div>
|
|
347
|
-
<p v-if="!documentFragments.length" class="text-sm text-slate-500">
|
|
348
|
-
No document-backed fragments yet. Link one below.
|
|
349
|
-
</p>
|
|
350
|
-
|
|
351
|
-
<div class="rounded-md border border-slate-800 p-3">
|
|
352
|
-
<p class="mb-2 text-sm font-medium">Link a document</p>
|
|
353
|
-
<div v-if="!documents.connectedSources.length" class="text-sm text-slate-500">
|
|
354
|
-
Connect a document source (Confluence, Notion or GitHub) under Integrations first.
|
|
355
|
-
</div>
|
|
356
|
-
<div v-else class="flex flex-col gap-2">
|
|
357
|
-
<div class="flex flex-wrap gap-2">
|
|
358
|
-
<UButton
|
|
359
|
-
v-for="s in documents.connectedSources"
|
|
360
|
-
:key="s.source"
|
|
361
|
-
size="xs"
|
|
362
|
-
:color="docDraft.source === s.source ? 'primary' : 'neutral'"
|
|
363
|
-
:variant="docDraft.source === s.source ? 'solid' : 'outline'"
|
|
364
|
-
@click="docDraft.source = s.source"
|
|
365
|
-
>
|
|
366
|
-
{{ s.label }}
|
|
367
|
-
</UButton>
|
|
368
|
-
</div>
|
|
369
|
-
<UInput
|
|
370
|
-
v-model="docDraft.ref"
|
|
371
|
-
placeholder="Page id or URL (e.g. a Confluence/Notion page or GitHub file URL)"
|
|
372
|
-
/>
|
|
373
|
-
<UInput v-model="docDraft.tags" placeholder="Tags, comma-separated (optional)" />
|
|
374
|
-
<UButton
|
|
375
|
-
icon="i-lucide-link"
|
|
376
|
-
size="sm"
|
|
377
|
-
:disabled="!docDraftValid"
|
|
378
|
-
:loading="library.loading"
|
|
379
|
-
class="self-start"
|
|
380
|
-
@click="linkDocumentFragment"
|
|
381
|
-
>
|
|
382
|
-
Link as living fragment
|
|
383
|
-
</UButton>
|
|
384
|
-
</div>
|
|
385
|
-
</div>
|
|
386
|
-
</div>
|
|
387
|
-
|
|
388
|
-
<!-- Repo sources -->
|
|
389
|
-
<div v-else class="flex flex-col gap-3">
|
|
390
|
-
<div
|
|
391
|
-
v-for="s in library.sources"
|
|
392
|
-
:key="s.id"
|
|
393
|
-
class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
|
|
394
|
-
>
|
|
395
|
-
<UIcon name="i-lucide-git-branch" class="h-4 w-4 text-slate-400" />
|
|
396
|
-
<div class="min-w-0">
|
|
397
|
-
<span class="font-mono text-sm text-slate-100">
|
|
398
|
-
{{ s.repoOwner }}/{{ s.repoName
|
|
399
|
-
}}<span class="text-slate-500">/{{ s.dirPath || '' }}</span>
|
|
400
|
-
</span>
|
|
401
|
-
<p class="text-xs text-slate-500">
|
|
402
|
-
{{ s.lastSyncedAt ? 'synced' : 'never synced' }} · ref {{ s.gitRef }}
|
|
403
|
-
</p>
|
|
404
|
-
</div>
|
|
405
|
-
<UBadge
|
|
406
|
-
v-if="library.sourceChanges[s.id]"
|
|
407
|
-
size="xs"
|
|
408
|
-
color="warning"
|
|
409
|
-
variant="subtle"
|
|
410
|
-
class="ml-auto"
|
|
411
|
-
>
|
|
412
|
-
{{ library.sourceChanges[s.id] }} change(s)
|
|
413
|
-
</UBadge>
|
|
414
|
-
<div class="ml-auto flex gap-1">
|
|
415
|
-
<UButton
|
|
416
|
-
icon="i-lucide-search-check"
|
|
417
|
-
size="xs"
|
|
418
|
-
variant="ghost"
|
|
419
|
-
@click="checkSource(s.id)"
|
|
420
|
-
/>
|
|
421
|
-
<UButton
|
|
422
|
-
icon="i-lucide-refresh-cw"
|
|
423
|
-
size="xs"
|
|
424
|
-
variant="ghost"
|
|
425
|
-
:loading="library.loading"
|
|
426
|
-
@click="syncSource(s.id)"
|
|
427
|
-
/>
|
|
428
|
-
<UButton
|
|
429
|
-
icon="i-lucide-unplug"
|
|
430
|
-
size="xs"
|
|
431
|
-
color="error"
|
|
432
|
-
variant="ghost"
|
|
433
|
-
@click="unlinkSource(s.id)"
|
|
434
|
-
/>
|
|
435
|
-
</div>
|
|
436
|
-
</div>
|
|
437
|
-
<p v-if="!library.sources.length" class="text-sm text-slate-500">
|
|
438
|
-
No linked guideline repos. Link one below to import its Markdown files as fragments.
|
|
439
|
-
</p>
|
|
440
|
-
|
|
441
|
-
<div class="rounded-md border border-slate-800 p-3">
|
|
442
|
-
<p class="mb-2 text-sm font-medium">Link a guideline repo</p>
|
|
443
|
-
<div class="flex flex-col gap-2">
|
|
444
|
-
<div class="flex gap-2">
|
|
445
|
-
<UInput v-model="sourceDraft.repoOwner" placeholder="owner" class="flex-1" />
|
|
446
|
-
<UInput v-model="sourceDraft.repoName" placeholder="repo" class="flex-1" />
|
|
447
|
-
</div>
|
|
448
|
-
<div class="flex gap-2">
|
|
449
|
-
<UInput
|
|
450
|
-
v-model="sourceDraft.dirPath"
|
|
451
|
-
placeholder="dir path (e.g. guidelines)"
|
|
452
|
-
class="flex-1"
|
|
453
|
-
/>
|
|
454
|
-
<UInput
|
|
455
|
-
v-model="sourceDraft.gitRef"
|
|
456
|
-
placeholder="ref (default HEAD)"
|
|
457
|
-
class="flex-1"
|
|
458
|
-
/>
|
|
459
|
-
</div>
|
|
460
|
-
<UButton
|
|
461
|
-
icon="i-lucide-link"
|
|
462
|
-
size="sm"
|
|
463
|
-
:disabled="!sourceValid"
|
|
464
|
-
:loading="library.loading"
|
|
465
|
-
class="self-start"
|
|
466
|
-
@click="linkSource"
|
|
467
|
-
>
|
|
468
|
-
Link & sync
|
|
469
|
-
</UButton>
|
|
470
|
-
</div>
|
|
471
|
-
</div>
|
|
472
|
-
</div>
|
|
473
|
-
</div>
|
|
22
|
+
<FragmentLibraryManager
|
|
23
|
+
v-if="workspace.workspaceId"
|
|
24
|
+
kind="workspace"
|
|
25
|
+
:owner-id="workspace.workspaceId"
|
|
26
|
+
show-catalog
|
|
27
|
+
/>
|
|
474
28
|
</template>
|
|
475
29
|
</UModal>
|
|
476
30
|
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Account-tier prompt-fragment library (ADR 0006): best-practice guidelines shared by
|
|
3
|
+
// every board in the account, which each board inherits and can override. A body-only
|
|
4
|
+
// section rendered in the "Context fragments" tab of AccountSettingsPanel; available for
|
|
5
|
+
// ALL account types (unlike the org-only Team tab).
|
|
6
|
+
import { computed } from 'vue'
|
|
7
|
+
import FragmentLibraryManager from '~/components/fragments/FragmentLibraryManager.vue'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{ accountId: string }>()
|
|
10
|
+
const workspace = useWorkspaceStore()
|
|
11
|
+
|
|
12
|
+
// Account-tier document fragments are fetched through a board's stored
|
|
13
|
+
// document-source connection (credentials are per-workspace). Prefer the active
|
|
14
|
+
// board when it belongs to this account, else the account's first board.
|
|
15
|
+
const viaWorkspaceId = computed(() => {
|
|
16
|
+
const boards = workspace.accountWorkspaces
|
|
17
|
+
return boards.find((w) => w.id === workspace.workspaceId)?.id ?? boards[0]?.id
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="space-y-6 text-sm">
|
|
23
|
+
<section>
|
|
24
|
+
<p class="mb-3 text-[11px] text-slate-400">
|
|
25
|
+
Best-practice guidelines shared by every board in this account. Each board inherits these
|
|
26
|
+
and can override or add its own. Code-aware agents (coder, reviewer, architect, fixers) fold
|
|
27
|
+
the relevant ones into their prompt.
|
|
28
|
+
</p>
|
|
29
|
+
<FragmentLibraryManager
|
|
30
|
+
kind="account"
|
|
31
|
+
:owner-id="accountId"
|
|
32
|
+
:via-workspace-id="viaWorkspaceId"
|
|
33
|
+
:show-catalog="false"
|
|
34
|
+
/>
|
|
35
|
+
</section>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, ref } from 'vue'
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import type { AccountRole } from '~/types/domain'
|
|
4
4
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
5
5
|
|
|
@@ -21,6 +21,12 @@ const ROLE_ITEMS: { label: string; value: AccountRole }[] = [
|
|
|
21
21
|
|
|
22
22
|
/** Whether the signed-in caller is an admin of this account (drives edit affordances). */
|
|
23
23
|
const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
|
|
24
|
+
/**
|
|
25
|
+
* Members / roles / invitations are org-scoped — the backend rejects membership on a
|
|
26
|
+
* personal account. For a personal account we show a "create an organization" CTA in
|
|
27
|
+
* their place; the email sender + account API keys remain available either way.
|
|
28
|
+
*/
|
|
29
|
+
const isOrg = computed(() => accounts.activeAccount?.type === 'org')
|
|
24
30
|
|
|
25
31
|
async function updateMemberRoles(userId: string, roles: AccountRole[]) {
|
|
26
32
|
try {
|
|
@@ -41,16 +47,44 @@ function notifyError(title: string, e: unknown) {
|
|
|
41
47
|
})
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
async function loadAll(accountId: string) {
|
|
45
51
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
const jobs: Promise<unknown>[] = [accounts.loadEmailConnection(accountId)]
|
|
53
|
+
// The roster only applies to org accounts.
|
|
54
|
+
if (isOrg.value) jobs.push(accounts.loadRoster(accountId))
|
|
55
|
+
await Promise.all(jobs)
|
|
50
56
|
} catch (e) {
|
|
51
57
|
notifyError('Could not load team settings', e)
|
|
52
58
|
}
|
|
53
|
-
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onMounted(() => void loadAll(props.accountId))
|
|
62
|
+
// Reload when the active account changes while the panel is open (e.g. after creating an
|
|
63
|
+
// organization from the CTA below, which switches the active account to the new org).
|
|
64
|
+
watch(
|
|
65
|
+
() => props.accountId,
|
|
66
|
+
(id) => {
|
|
67
|
+
if (id) void loadAll(id)
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// ---- create organization (personal-account CTA) ---------------------------
|
|
72
|
+
const newOrgName = ref('')
|
|
73
|
+
|
|
74
|
+
async function createOrganization() {
|
|
75
|
+
const name = newOrgName.value.trim()
|
|
76
|
+
if (!name) return
|
|
77
|
+
busy.value = true
|
|
78
|
+
try {
|
|
79
|
+
await accounts.createOrg(name)
|
|
80
|
+
newOrgName.value = ''
|
|
81
|
+
toast.add({ title: 'Organization created', icon: 'i-lucide-check' })
|
|
82
|
+
} catch (e) {
|
|
83
|
+
notifyError('Could not create organization', e)
|
|
84
|
+
} finally {
|
|
85
|
+
busy.value = false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
54
88
|
|
|
55
89
|
// ---- invitations ----------------------------------------------------------
|
|
56
90
|
const inviteEmail = ref('')
|
|
@@ -125,8 +159,23 @@ async function disconnectEmail() {
|
|
|
125
159
|
|
|
126
160
|
<template>
|
|
127
161
|
<div class="space-y-6 text-sm">
|
|
162
|
+
<!-- personal-account CTA: members/roles/invitations need an organization -->
|
|
163
|
+
<section v-if="!isOrg" class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
|
|
164
|
+
<h3 class="mb-1 font-semibold text-white">Invite teammates & manage roles</h3>
|
|
165
|
+
<p class="mb-3 text-slate-400">
|
|
166
|
+
Members, roles and invitations live on an organization. Create one to invite teammates and
|
|
167
|
+
manage their roles — your personal boards stay as they are.
|
|
168
|
+
</p>
|
|
169
|
+
<form class="flex gap-2" @submit.prevent="createOrganization">
|
|
170
|
+
<UInput v-model="newOrgName" placeholder="Acme Inc." class="flex-1" />
|
|
171
|
+
<UButton type="submit" color="primary" :loading="busy" icon="i-lucide-plus">
|
|
172
|
+
Create organization
|
|
173
|
+
</UButton>
|
|
174
|
+
</form>
|
|
175
|
+
</section>
|
|
176
|
+
|
|
128
177
|
<!-- members -->
|
|
129
|
-
<section>
|
|
178
|
+
<section v-if="isOrg">
|
|
130
179
|
<h3 class="mb-2 font-semibold text-white">Members</h3>
|
|
131
180
|
<ul class="space-y-1">
|
|
132
181
|
<li
|
|
@@ -153,7 +202,7 @@ async function disconnectEmail() {
|
|
|
153
202
|
</section>
|
|
154
203
|
|
|
155
204
|
<!-- invitations -->
|
|
156
|
-
<section>
|
|
205
|
+
<section v-if="isOrg">
|
|
157
206
|
<h3 class="mb-2 font-semibold text-white">Invite a teammate</h3>
|
|
158
207
|
<form class="flex gap-2" @submit.prevent="sendInvite">
|
|
159
208
|
<UInput
|
|
@@ -8,6 +8,7 @@ import type { CloudProvider } from '~/types/domain'
|
|
|
8
8
|
// board switcher over the single unscoped context.
|
|
9
9
|
const accounts = useAccountsStore()
|
|
10
10
|
const workspace = useWorkspaceStore()
|
|
11
|
+
const ui = useUiStore()
|
|
11
12
|
const toast = useToast()
|
|
12
13
|
|
|
13
14
|
const busy = ref(false)
|
|
@@ -52,10 +53,14 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
52
53
|
})),
|
|
53
54
|
[
|
|
54
55
|
{ label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') },
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// Account settings: the unified per-account panel (team + roles + invitations + email
|
|
57
|
+
// sender + account-tier fragment library). The panel itself handles personal accounts
|
|
58
|
+
// (prompting to create an org for the team tab), so this is not gated.
|
|
59
|
+
{
|
|
60
|
+
label: 'Account settings…',
|
|
61
|
+
icon: 'i-lucide-settings',
|
|
62
|
+
onSelect: () => ui.openAccountSettings(),
|
|
63
|
+
},
|
|
59
64
|
// Admins can set the account-wide default provider new services inherit.
|
|
60
65
|
...(accounts.activeAccount?.roles?.includes('admin')
|
|
61
66
|
? [
|
|
@@ -184,12 +189,6 @@ async function submitPrompt() {
|
|
|
184
189
|
busy.value = false
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
|
-
|
|
188
|
-
// ---- account settings modal (members / invitations / email) ----------------
|
|
189
|
-
const settingsOpen = ref(false)
|
|
190
|
-
function openSettings() {
|
|
191
|
-
settingsOpen.value = true
|
|
192
|
-
}
|
|
193
192
|
</script>
|
|
194
193
|
|
|
195
194
|
<template>
|
|
@@ -266,15 +265,5 @@ function openSettings() {
|
|
|
266
265
|
</form>
|
|
267
266
|
</template>
|
|
268
267
|
</UModal>
|
|
269
|
-
|
|
270
|
-
<!-- account team settings: members, invitations, email sender -->
|
|
271
|
-
<UModal v-model:open="settingsOpen" title="Team settings">
|
|
272
|
-
<template #body>
|
|
273
|
-
<AccountTeamSettings
|
|
274
|
-
v-if="accounts.activeAccountId"
|
|
275
|
-
:account-id="accounts.activeAccountId"
|
|
276
|
-
/>
|
|
277
|
-
</template>
|
|
278
|
-
</UModal>
|
|
279
268
|
</div>
|
|
280
269
|
</template>
|