@cat-factory/app 0.33.0 → 0.35.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,15 +1,12 @@
1
1
  <script setup lang="ts">
2
- // Prompt-fragment library manager (ADR 0006): curate this board's best-practice
3
- // fragments, link repos of Markdown guidelines (with a "changes available" badge
4
- // + resync), and review the merged catalog (built-in ∪ account ∪ workspace) an
5
- // agent is selected from per run. Workspace-tier focused; the resolved view shows
6
- // every tier so the inheritance is visible.
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 library = useFragmentLibraryStore()
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
- <div class="flex flex-col gap-4">
185
- <p class="text-sm text-slate-400">
186
- Curate the best-practice guidelines agents follow on this board. Fragments are merged from
187
- the built-in catalog, your account, and this board — later tiers override earlier ones —
188
- then the relevant ones are selected for each agent run.
189
- </p>
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>
@@ -53,11 +53,12 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
53
53
  })),
54
54
  [
55
55
  { label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') },
56
- // Account & team settings (members + roles + invitations + email sender). The panel
57
- // itself handles personal accounts (prompting to create an org), so this is not gated.
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.
58
59
  {
59
60
  label: 'Account settings…',
60
- icon: 'i-lucide-users',
61
+ icon: 'i-lucide-settings',
61
62
  onSelect: () => ui.openAccountSettings(),
62
63
  },
63
64
  // Admins can set the account-wide default provider new services inherit.
@@ -173,6 +173,15 @@ const commands = computed<Command[]>(() => {
173
173
  keywords: 'fragment best practice guideline service default code-aware',
174
174
  run: () => ui.openWorkspaceSettings('fragments'),
175
175
  })
176
+ list.push({
177
+ id: 'account-settings',
178
+ label: 'Account settings',
179
+ group: 'Account',
180
+ icon: 'i-lucide-settings',
181
+ keywords:
182
+ 'account team members roles invitations email api keys fragment best practice library context organization personal',
183
+ run: () => ui.openAccountSettings(),
184
+ })
176
185
  list.push({
177
186
  id: 'local-models',
178
187
  label: 'My local runners',
@@ -8,6 +8,7 @@
8
8
  // Sections gate on the same `available` probes the navbar used, so a system that
9
9
  // the backend has turned off simply doesn't appear here.
10
10
  const ui = useUiStore()
11
+ const auth = useAuthStore()
11
12
  const github = useGitHubStore()
12
13
  const slack = useSlackStore()
13
14
  const documents = useDocumentsStore()
@@ -256,6 +257,18 @@ const groups = computed<IntegrationGroup[]>(() => {
256
257
  onClick: () => go(() => ui.openProviderConnection('runner-pool')),
257
258
  })
258
259
  }
260
+ // Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
261
+ // only on the local-mode service (the controller 503s elsewhere, and `auth.localMode`
262
+ // is set from /auth/config).
263
+ if (auth.localMode?.enabled) {
264
+ infra.push({
265
+ key: 'local-mode',
266
+ icon: 'i-lucide-container',
267
+ label: 'Local mode',
268
+ description: 'Warm container pool + per-repo checkout reuse for the local runner.',
269
+ onClick: () => go(ui.openLocalModeSettings),
270
+ })
271
+ }
259
272
  if (infra.length) out.push({ title: 'Infrastructure', items: infra })
260
273
 
261
274
  return out
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
- // Account & team settings — a modal host for the per-account team panel (members +
3
- // roles, invitations, email sender, account-wide API keys). Account-scoped, distinct
4
- // from Workspace settings. Opened from the SideBar Configuration section and the
5
- // account switcher; bound to the `ui` store so any surface can open it.
2
+ // Account settings — a single tabbed modal for the per-account configuration, distinct
3
+ // from Workspace settings. Hosts the team panel (members + roles, invitations, email
4
+ // sender, account-wide API keys; org-scoped, with a create-org CTA on a personal account)
5
+ // and the account-tier prompt-fragment library (available for every account type).
6
+ // Opened from the SideBar Configuration section, the account switcher and the command
7
+ // bar; bound to the `ui` store so any surface can open it and deep-link to a tab.
6
8
  import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
9
+ import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
7
10
 
8
11
  const ui = useUiStore()
9
12
  const accounts = useAccountsStore()
@@ -12,13 +15,43 @@ const open = computed({
12
15
  get: () => ui.accountSettingsOpen,
13
16
  set: (v: boolean) => (v ? ui.openAccountSettings() : ui.closeAccountSettings()),
14
17
  })
18
+
19
+ // Driven by the ui store so other surfaces (command bar, the workspace-settings
20
+ // cross-link) can deep-link straight to a tab.
21
+ const activeTab = computed({
22
+ get: () => ui.accountSettingsTab,
23
+ set: (v: string) => ui.setAccountSettingsTab(v),
24
+ })
25
+
26
+ const tabs = [
27
+ { value: 'team', label: 'Team & access', icon: 'i-lucide-users', slot: 'team' },
28
+ {
29
+ value: 'fragments',
30
+ label: 'Context fragments',
31
+ icon: 'i-lucide-book-marked',
32
+ slot: 'fragments',
33
+ },
34
+ ]
15
35
  </script>
16
36
 
17
37
  <template>
18
- <UModal v-model:open="open" title="Account & team" :ui="{ content: 'max-w-3xl' }">
38
+ <UModal v-model:open="open" title="Account settings" :ui="{ content: 'max-w-3xl' }">
19
39
  <template #body>
20
- <AccountTeamSettings v-if="accounts.activeAccountId" :account-id="accounts.activeAccountId" />
21
- <p v-else class="text-sm text-slate-400">No account selected.</p>
40
+ <p v-if="!accounts.activeAccountId" class="text-sm text-slate-400">No account selected.</p>
41
+ <UTabs
42
+ v-else
43
+ v-model="activeTab"
44
+ :items="tabs"
45
+ variant="link"
46
+ :ui="{ root: 'gap-4', list: 'overflow-x-auto' }"
47
+ >
48
+ <template #team>
49
+ <AccountTeamSettings :account-id="accounts.activeAccountId" />
50
+ </template>
51
+ <template #fragments>
52
+ <AccountFragmentSettings :account-id="accounts.activeAccountId" />
53
+ </template>
54
+ </UTabs>
22
55
  </template>
23
56
  </UModal>
24
57
  </template>