@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.
@@ -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',
@@ -14,6 +14,7 @@ const github = useGitHubStore()
14
14
  const slack = useSlackStore()
15
15
  const library = useFragmentLibraryStore()
16
16
  const workspace = useWorkspaceStore()
17
+ const accounts = useAccountsStore()
17
18
  const ui = useUiStore()
18
19
 
19
20
  // Resolve whether the document-source / task-source / GitHub integrations are
@@ -203,6 +204,20 @@ watch(
203
204
  >
204
205
  Model Configuration
205
206
  </UButton>
207
+ <!-- Account & team: members + roles, invitations, email sender, account API keys.
208
+ Shown once accounts (auth) are enabled. -->
209
+ <UButton
210
+ v-if="accounts.enabled"
211
+ block
212
+ color="primary"
213
+ variant="soft"
214
+ size="sm"
215
+ icon="i-lucide-users"
216
+ class="justify-start"
217
+ @click="ui.openAccountSettings()"
218
+ >
219
+ Account settings
220
+ </UButton>
206
221
  </div>
207
222
  </section>
208
223
 
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
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.
8
+ import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
9
+ import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
10
+
11
+ const ui = useUiStore()
12
+ const accounts = useAccountsStore()
13
+
14
+ const open = computed({
15
+ get: () => ui.accountSettingsOpen,
16
+ set: (v: boolean) => (v ? ui.openAccountSettings() : ui.closeAccountSettings()),
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
+ ]
35
+ </script>
36
+
37
+ <template>
38
+ <UModal v-model:open="open" title="Account settings" :ui="{ content: 'max-w-3xl' }">
39
+ <template #body>
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>
55
+ </template>
56
+ </UModal>
57
+ </template>
@@ -44,6 +44,41 @@ const meta = computed(() => (kind.value ? META[kind.value] : null))
44
44
  const descriptor = computed(() => (kind.value ? store.descriptorFor(kind.value) : null))
45
45
  const connection = computed(() => (kind.value ? store.connectionFor(kind.value) : null))
46
46
 
47
+ // --- Local-mode infrastructure delegation -------------------------------------------
48
+ // In local mode this same screen is where a developer chooses, per workspace, whether to
49
+ // run on this machine (host Docker for agents, in-container docker-compose for the Tester)
50
+ // or delegate to an external service. The two opt-ins live here together to make the
51
+ // cross-cutting nature explicit: the environment provider you configure on this screen is
52
+ // one half; the runner pool (its own screen) is the other. Each toggle is enabled only
53
+ // once its provider is registered. Shown only in local mode and only on the environment
54
+ // kind (so it appears once, alongside the env provider it relates to).
55
+ const auth = useAuthStore()
56
+ const settings = useWorkspaceSettingsStore()
57
+ const isLocal = computed(() => auth.localMode?.enabled === true)
58
+ const showLocalDelegation = computed(() => isLocal.value && kind.value === 'environment')
59
+ // Gating: a toggle's external option is selectable only when its provider is registered.
60
+ const runnerPoolRegistered = computed(() => !!store.connectionFor('runner-pool'))
61
+ const envRegistered = computed(() => !!store.connectionFor('environment'))
62
+ const savingDelegation = ref(false)
63
+
64
+ async function setDelegation(patch: {
65
+ delegateAgentsToRunnerPool?: boolean
66
+ delegateTestEnvToProvider?: boolean
67
+ }) {
68
+ savingDelegation.value = true
69
+ try {
70
+ await settings.update(patch)
71
+ } catch (e) {
72
+ notifyError('Could not update delegation', e)
73
+ } finally {
74
+ savingDelegation.value = false
75
+ }
76
+ }
77
+
78
+ function openRunnerPoolPanel() {
79
+ ui.openProviderConnection('runner-pool')
80
+ }
81
+
47
82
  // "View logs": the provisioning event history for this provider's subsystem — every
48
83
  // spin-up / tear-down attempt with its outcome and the exact error. The panel kind
49
84
  // maps 1:1 to the log subsystem ('environment' / 'runner-pool').
@@ -82,6 +117,9 @@ watch(
82
117
  kind,
83
118
  (k) => {
84
119
  if (k) void store.loadKind(k).then(resetDraft)
120
+ // In local mode the env panel also gates the agents toggle on a registered runner
121
+ // pool, so load that provider's connection state too (the env kind already loads above).
122
+ if (k === 'environment' && isLocal.value) void store.loadKind('runner-pool')
85
123
  },
86
124
  { immediate: true },
87
125
  )
@@ -228,6 +266,85 @@ function fieldHelp(key: string): string | undefined {
228
266
  <IntegrationBackTitle :title="meta?.title ?? 'Provider'" @back="back" />
229
267
  </template>
230
268
  <template #body>
269
+ <!-- Local-mode infrastructure delegation: the local-vs-external choice for BOTH
270
+ container agents AND the Tester's ephemeral environments, made once here. -->
271
+ <section
272
+ v-if="showLocalDelegation"
273
+ class="mb-4 space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
274
+ >
275
+ <div>
276
+ <h3 class="text-sm font-semibold text-slate-200">Local delegation</h3>
277
+ <p class="mt-1 text-[11px] text-slate-400">
278
+ By default this machine runs everything locally — container agents on host Docker, the
279
+ Tester's infrastructure via in-container docker-compose. Opt in below to delegate either
280
+ concern to an external service instead. Applies only in local mode.
281
+ </p>
282
+ </div>
283
+
284
+ <!-- Container agents → self-hosted runner pool -->
285
+ <div class="space-y-1">
286
+ <label class="flex items-center gap-2">
287
+ <USwitch
288
+ size="sm"
289
+ :model-value="settings.settings.delegateAgentsToRunnerPool"
290
+ :disabled="savingDelegation || !runnerPoolRegistered"
291
+ @update:model-value="(v) => setDelegation({ delegateAgentsToRunnerPool: v })"
292
+ />
293
+ <span class="text-sm text-slate-200">Run container agents on the runner pool</span>
294
+ </label>
295
+ <p class="pl-9 text-[11px] text-slate-400">
296
+ Dispatch every container agent (coder, tester, merger, bootstrap, …) to this workspace's
297
+ self-hosted runner pool instead of host Docker.
298
+ <template v-if="!runnerPoolRegistered">
299
+ <button
300
+ type="button"
301
+ class="text-sky-400 underline underline-offset-2 hover:text-sky-300"
302
+ @click="openRunnerPoolPanel"
303
+ >
304
+ Register a runner pool
305
+ </button>
306
+ first to enable this.
307
+ </template>
308
+ </p>
309
+ </div>
310
+
311
+ <!-- Tester environments → environment provider -->
312
+ <div class="space-y-1">
313
+ <label class="flex items-center gap-2">
314
+ <USwitch
315
+ size="sm"
316
+ :model-value="settings.settings.delegateTestEnvToProvider"
317
+ :disabled="savingDelegation || !envRegistered"
318
+ @update:model-value="(v) => setDelegation({ delegateTestEnvToProvider: v })"
319
+ />
320
+ <span class="text-sm text-slate-200">
321
+ Provision Tester environments via the provider
322
+ </span>
323
+ </label>
324
+ <p class="pl-9 text-[11px] text-slate-400">
325
+ Stand the Tester's preview environment up through the environment provider configured
326
+ below instead of in-container docker-compose. Connect a provider first to enable this.
327
+ </p>
328
+ </div>
329
+ </section>
330
+
331
+ <!-- In local mode the local-vs-external toggle for agents lives on the Ephemeral
332
+ environments screen (alongside the env toggle), so they're configured together. -->
333
+ <p
334
+ v-if="isLocal && kind === 'runner-pool'"
335
+ class="mb-4 rounded-md border border-slate-700 bg-slate-900/40 px-3 py-2 text-[11px] text-slate-400"
336
+ >
337
+ Register your pool here, then enable "Run container agents on the runner pool" on the
338
+ <button
339
+ type="button"
340
+ class="text-sky-400 underline underline-offset-2 hover:text-sky-300"
341
+ @click="ui.openProviderConnection('environment')"
342
+ >
343
+ Ephemeral environments
344
+ </button>
345
+ screen to route this workspace's agents to it.
346
+ </p>
347
+
231
348
  <div v-if="descriptor" class="space-y-4">
232
349
  <div class="flex items-start justify-between gap-3">
233
350
  <p class="text-xs text-slate-400">{{ meta?.blurb }}</p>
@@ -8,6 +8,7 @@ import { onMounted, ref } from 'vue'
8
8
 
9
9
  const fragments = useFragmentsStore()
10
10
  const defaults = useServiceFragmentDefaultsStore()
11
+ const ui = useUiStore()
11
12
  const toast = useToast()
12
13
 
13
14
  const busy = ref(false)
@@ -105,5 +106,25 @@ function remove(id: string) {
105
106
  <p v-else class="text-[11px] text-slate-500">
106
107
  None — new services start with no service-level fragments.
107
108
  </p>
109
+
110
+ <div class="flex flex-wrap gap-x-4 gap-y-1 border-t border-slate-800 pt-3 text-[11px]">
111
+ <span class="text-slate-500">
112
+ Need to add or edit the fragments themselves (hand-authored, linked docs, repos)?
113
+ </span>
114
+ <button
115
+ type="button"
116
+ class="font-medium text-primary-400 hover:underline"
117
+ @click="ui.openFragmentLibrary()"
118
+ >
119
+ Manage this board's fragment library →
120
+ </button>
121
+ <button
122
+ type="button"
123
+ class="font-medium text-primary-400 hover:underline"
124
+ @click="ui.openAccountSettings('fragments')"
125
+ >
126
+ Manage account fragments →
127
+ </button>
128
+ </div>
108
129
  </div>
109
130
  </template>
@@ -53,10 +53,19 @@ export function fragmentsApi({ http, ws, scope }: ApiContext) {
53
53
  body: CreateDocumentFragmentInput,
54
54
  ) => http<PromptFragment>(`${scope(kind, id)}/document-fragments`, { method: 'POST', body }),
55
55
 
56
- // Force an immediate live re-resolve of a document-backed fragment.
57
- refreshFragment: (kind: FragmentOwnerKind, id: string, fragmentId: string) =>
56
+ // Force an immediate live re-resolve of a document-backed fragment. At the
57
+ // account scope the backend needs a `viaWorkspaceId` (the workspace whose
58
+ // document-source connection to fetch through); it is ignored at workspace scope.
59
+ refreshFragment: (
60
+ kind: FragmentOwnerKind,
61
+ id: string,
62
+ fragmentId: string,
63
+ viaWorkspaceId?: string,
64
+ ) =>
58
65
  http<PromptFragment>(
59
- `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh`,
66
+ `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh${
67
+ viaWorkspaceId ? `?viaWorkspaceId=${encodeURIComponent(viaWorkspaceId)}` : ''
68
+ }`,
60
69
  { method: 'POST' },
61
70
  ),
62
71
 
@@ -29,6 +29,7 @@ import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vu
29
29
  import CommandBar from '~/components/layout/CommandBar.vue'
30
30
  import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
31
31
  import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
32
+ import AccountSettingsPanel from '~/components/settings/AccountSettingsPanel.vue'
32
33
  import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
33
34
  import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPanel.vue'
34
35
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
@@ -188,6 +189,7 @@ watch(
188
189
  <CommandBar />
189
190
  <IntegrationsHub />
190
191
  <WorkspaceSettingsPanel />
192
+ <AccountSettingsPanel />
191
193
  <ObservabilityConnectionPanel />
192
194
  <ProviderConnectionPanel />
193
195
  <ModelConfigurationPanel />
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
3
3
  import type {
4
4
  CreateDocumentFragmentInput,
5
5
  CreatePromptFragmentInput,
6
+ FragmentOwnerKind,
6
7
  FragmentSource,
7
8
  LinkFragmentSourceInput,
8
9
  PromptFragment,
@@ -12,40 +13,62 @@ import type {
12
13
  import { useWorkspaceStore } from '~/stores/workspace'
13
14
 
14
15
  /**
15
- * Prompt-fragment library state (ADR 0006), scoped to the active board. Holds the
16
- * board's own (workspace-tier) fragments, its linked guideline repos, and the
17
- * merged catalog an agent actually sees (built-in account workspace). The
18
- * management surface targets the workspace tier; the resolved read is what every
19
- * agent run is selected from. `available` mirrors the backend's opt-in gate: a
20
- * 503 from the resolve probe means the feature is off and the UI hides its entry.
16
+ * Prompt-fragment library state (ADR 0006), scoped to a single owner a board
17
+ * (`workspace`) or an account. Holds that owner's own (raw) tier fragments, its
18
+ * linked guideline repos, and for the **workspace** tier only the merged
19
+ * catalog an agent actually sees (built-in account workspace). `available`
20
+ * mirrors the backend's opt-in gate: a 503 from the probe means the feature is off
21
+ * and the UI hides its entry.
22
+ *
23
+ * Two entry points share this setup: `useFragmentLibraryStore` (the workspace
24
+ * singleton that follows the active board, used by the navbar + the board modal)
25
+ * and `useFragmentLibrary(kind, ownerId)` (an owner-keyed store, used for the
26
+ * account tier). The account tier has no resolved/merged catalog and, for
27
+ * document-backed fragments, needs a `viaWorkspaceId` (document-source credentials
28
+ * are per-workspace).
21
29
  */
22
- export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
30
+ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => string | null) {
23
31
  const api = useApi()
24
- const workspace = useWorkspaceStore()
32
+
33
+ /** The merged/resolved catalog only exists at the workspace tier. */
34
+ const hasResolved = kind === 'workspace'
25
35
 
26
36
  /** null = not probed yet; true/false = library on/off. */
27
37
  const available = ref<boolean | null>(null)
28
- /** This board's hand-authored + sourced fragments (workspace tier, raw). */
38
+ /** This owner's hand-authored + sourced fragments (its own tier, raw). */
29
39
  const fragments = ref<PromptFragment[]>([])
30
- /** The merged catalog an agent sees (with each entry's winning tier). */
40
+ /** The merged catalog an agent sees (workspace tier only; empty otherwise). */
31
41
  const resolved = ref<ResolvedFragment[]>([])
32
- /** Linked guideline repos for this board. */
42
+ /** Linked guideline repos for this owner. */
33
43
  const sources = ref<FragmentSource[]>([])
34
44
  /** Per-source "changes available" counts from the last status check. */
35
45
  const sourceChanges = ref<Record<string, number>>({})
36
46
  const loading = ref(false)
47
+ /**
48
+ * Account-tier document fragments only: the workspace whose stored
49
+ * document-source connection is used to fetch/refresh the page (credentials are
50
+ * per-workspace). Set by the caller; ignored at the workspace scope (the owner
51
+ * board is used directly).
52
+ */
53
+ const viaWorkspaceId = ref<string | undefined>(undefined)
37
54
 
38
55
  const builtinCount = computed(() => resolved.value.filter((f) => f.tier === 'builtin').length)
39
56
 
40
- /** Probe the feature + load this board's tier, sources and resolved catalog. */
57
+ function requireOwnerId(): string {
58
+ const id = resolveOwnerId()
59
+ if (!id) throw new Error('No fragment-library owner')
60
+ return id
61
+ }
62
+
63
+ /** Probe the feature + load this owner's tier, sources and (ws) resolved catalog. */
41
64
  async function probe() {
42
- if (!workspace.workspaceId) return
43
- const id = workspace.requireId()
65
+ const id = resolveOwnerId()
66
+ if (!id) return
44
67
  try {
45
68
  const [tier, srcs, merged] = await Promise.all([
46
- api.listFragments('workspace', id),
47
- api.listFragmentSources('workspace', id).catch(() => [] as FragmentSource[]),
48
- api.getResolvedFragments(id),
69
+ api.listFragments(kind, id),
70
+ api.listFragmentSources(kind, id).catch(() => [] as FragmentSource[]),
71
+ hasResolved ? api.getResolvedFragments(id) : Promise.resolve([] as ResolvedFragment[]),
49
72
  ])
50
73
  fragments.value = tier
51
74
  sources.value = srcs
@@ -60,13 +83,14 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
60
83
  }
61
84
 
62
85
  async function refreshResolved() {
63
- resolved.value = await api.getResolvedFragments(workspace.requireId())
86
+ if (!hasResolved) return
87
+ resolved.value = await api.getResolvedFragments(requireOwnerId())
64
88
  }
65
89
 
66
90
  async function create(input: CreatePromptFragmentInput) {
67
91
  loading.value = true
68
92
  try {
69
- await api.createFragment('workspace', workspace.requireId(), input)
93
+ await api.createFragment(kind, requireOwnerId(), input)
70
94
  await Promise.all([reloadTier(), refreshResolved()])
71
95
  } finally {
72
96
  loading.value = false
@@ -74,7 +98,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
74
98
  }
75
99
 
76
100
  async function update(fragmentId: string, patch: UpdatePromptFragmentInput) {
77
- await api.updateFragment('workspace', workspace.requireId(), fragmentId, patch)
101
+ await api.updateFragment(kind, requireOwnerId(), fragmentId, patch)
78
102
  await Promise.all([reloadTier(), refreshResolved()])
79
103
  }
80
104
 
@@ -82,7 +106,10 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
82
106
  async function createDocumentFragment(input: CreateDocumentFragmentInput) {
83
107
  loading.value = true
84
108
  try {
85
- await api.createDocumentFragment('workspace', workspace.requireId(), input)
109
+ await api.createDocumentFragment(kind, requireOwnerId(), {
110
+ ...input,
111
+ ...(viaWorkspaceId.value ? { viaWorkspaceId: viaWorkspaceId.value } : {}),
112
+ })
86
113
  await Promise.all([reloadTier(), refreshResolved()])
87
114
  } finally {
88
115
  loading.value = false
@@ -93,31 +120,31 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
93
120
  async function refreshDocumentFragment(fragmentId: string) {
94
121
  loading.value = true
95
122
  try {
96
- await api.refreshFragment('workspace', workspace.requireId(), fragmentId)
123
+ await api.refreshFragment(kind, requireOwnerId(), fragmentId, viaWorkspaceId.value)
97
124
  await Promise.all([reloadTier(), refreshResolved()])
98
125
  } finally {
99
126
  loading.value = false
100
127
  }
101
128
  }
102
129
 
103
- /** Tombstone a fragment at the workspace tier (suppresses an inherited one). */
130
+ /** Tombstone a fragment at this tier (suppresses an inherited one). */
104
131
  async function remove(fragmentId: string) {
105
- await api.deleteFragment('workspace', workspace.requireId(), fragmentId)
132
+ await api.deleteFragment(kind, requireOwnerId(), fragmentId)
106
133
  await Promise.all([reloadTier(), refreshResolved()])
107
134
  }
108
135
 
109
136
  async function reloadTier() {
110
- fragments.value = await api.listFragments('workspace', workspace.requireId())
137
+ fragments.value = await api.listFragments(kind, requireOwnerId())
111
138
  }
112
139
 
113
140
  async function linkSource(input: LinkFragmentSourceInput) {
114
- const source = await api.linkFragmentSource('workspace', workspace.requireId(), input)
141
+ const source = await api.linkFragmentSource(kind, requireOwnerId(), input)
115
142
  sources.value = [source, ...sources.value]
116
143
  return source
117
144
  }
118
145
 
119
146
  async function unlinkSource(sourceId: string) {
120
- await api.unlinkFragmentSource('workspace', workspace.requireId(), sourceId)
147
+ await api.unlinkFragmentSource(kind, requireOwnerId(), sourceId)
121
148
  sources.value = sources.value.filter((s) => s.id !== sourceId)
122
149
  await refreshResolved()
123
150
  }
@@ -126,7 +153,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
126
153
  async function syncSource(sourceId: string) {
127
154
  loading.value = true
128
155
  try {
129
- const result = await api.syncFragmentSource('workspace', workspace.requireId(), sourceId)
156
+ const result = await api.syncFragmentSource(kind, requireOwnerId(), sourceId)
130
157
  delete sourceChanges.value[sourceId]
131
158
  await Promise.all([reloadSources(), refreshResolved()])
132
159
  return result
@@ -137,7 +164,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
137
164
 
138
165
  /** Cheap "check for changes" for a source; caches the changed count. */
139
166
  async function checkSource(sourceId: string) {
140
- const status = await api.fragmentSourceStatus('workspace', workspace.requireId(), sourceId)
167
+ const status = await api.fragmentSourceStatus(kind, requireOwnerId(), sourceId)
141
168
  sourceChanges.value = {
142
169
  ...sourceChanges.value,
143
170
  [sourceId]: status.changed ? status.changedCount : 0,
@@ -146,16 +173,19 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
146
173
  }
147
174
 
148
175
  async function reloadSources() {
149
- sources.value = await api.listFragmentSources('workspace', workspace.requireId())
176
+ sources.value = await api.listFragmentSources(kind, requireOwnerId())
150
177
  }
151
178
 
152
179
  return {
180
+ kind,
181
+ hasResolved,
153
182
  available,
154
183
  fragments,
155
184
  resolved,
156
185
  sources,
157
186
  sourceChanges,
158
187
  loading,
188
+ viaWorkspaceId,
159
189
  builtinCount,
160
190
  probe,
161
191
  refreshResolved,
@@ -169,4 +199,23 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
169
199
  syncSource,
170
200
  checkSource,
171
201
  }
172
- })
202
+ }
203
+
204
+ /**
205
+ * The workspace-tier library for the **active** board — a singleton that resolves
206
+ * the owner lazily, so it follows board switches and is shared by the navbar
207
+ * (SideBar/CommandBar probes) and the board fragment modal.
208
+ */
209
+ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () =>
210
+ fragmentLibrarySetup('workspace', () => useWorkspaceStore().workspaceId),
211
+ )
212
+
213
+ /**
214
+ * An owner-keyed library store, used for the **account** tier (and reusable for any
215
+ * explicit owner). Keyed by `(kind, ownerId)` so each account gets isolated state.
216
+ */
217
+ export function useFragmentLibrary(kind: FragmentOwnerKind, ownerId: string) {
218
+ return defineStore(`fragmentLibrary:${kind}:${ownerId}`, () =>
219
+ fragmentLibrarySetup(kind, () => ownerId),
220
+ )()
221
+ }
package/app/stores/ui.ts CHANGED
@@ -96,6 +96,13 @@ export const useUiStore = defineStore('ui', () => {
96
96
  // `workspaceSettingsTab` lets other surfaces deep-link straight to a tab.
97
97
  const workspaceSettingsOpen = ref(false)
98
98
  const workspaceSettingsTab = ref('workspace')
99
+ // Account-settings modal: a single tabbed window for the per-account configuration —
100
+ // the team panel (members + roles + invitations + email sender + account API keys,
101
+ // `AccountTeamSettings`) and the account-tier prompt-fragment library. Account-scoped
102
+ // (distinct from workspace settings). `accountSettingsTab` lets other surfaces deep-link
103
+ // straight to a tab.
104
+ const accountSettingsOpen = ref(false)
105
+ const accountSettingsTab = ref('team')
99
106
  // Observability integration: the post-release-health connection panel (Datadog
100
107
  // today, pluggable). NB: distinct from `observabilityInstanceId` below, which is the
101
108
  // LLM per-call observability panel.
@@ -380,6 +387,17 @@ export const useUiStore = defineStore('ui', () => {
380
387
  function setWorkspaceSettingsTab(tab: string) {
381
388
  workspaceSettingsTab.value = tab
382
389
  }
390
+ function openAccountSettings(tab = 'team') {
391
+ cameFromIntegrations.value = false
392
+ accountSettingsTab.value = tab
393
+ accountSettingsOpen.value = true
394
+ }
395
+ function closeAccountSettings() {
396
+ accountSettingsOpen.value = false
397
+ }
398
+ function setAccountSettingsTab(tab: string) {
399
+ accountSettingsTab.value = tab
400
+ }
383
401
  function openObservabilityConnection() {
384
402
  cameFromIntegrations.value = false
385
403
  observabilityConnectionOpen.value = true
@@ -555,6 +573,8 @@ export const useUiStore = defineStore('ui', () => {
555
573
  cameFromIntegrations,
556
574
  workspaceSettingsOpen,
557
575
  workspaceSettingsTab,
576
+ accountSettingsOpen,
577
+ accountSettingsTab,
558
578
  observabilityConnectionOpen,
559
579
  providerConnectionKind,
560
580
  modelConfigOpen,
@@ -617,6 +637,9 @@ export const useUiStore = defineStore('ui', () => {
617
637
  openWorkspaceSettings,
618
638
  closeWorkspaceSettings,
619
639
  setWorkspaceSettingsTab,
640
+ openAccountSettings,
641
+ closeAccountSettings,
642
+ setAccountSettingsTab,
620
643
  openObservabilityConnection,
621
644
  closeObservabilityConnection,
622
645
  openProviderConnection,
@@ -11,6 +11,8 @@ const DEFAULTS: WorkspaceSettings = {
11
11
  taskLimitPerType: null,
12
12
  storeAgentContext: true,
13
13
  kaizenEnabled: true,
14
+ delegateAgentsToRunnerPool: false,
15
+ delegateTestEnvToProvider: false,
14
16
  spendCurrency: null,
15
17
  spendMonthlyLimit: null,
16
18
  }
@@ -512,6 +512,10 @@ export interface WorkspaceSettings {
512
512
  storeAgentContext: boolean
513
513
  /** Whether the Kaizen agent grades agent steps after each run. On by default. */
514
514
  kaizenEnabled: boolean
515
+ /** Local mode only: dispatch container agents to the runner pool instead of host Docker. */
516
+ delegateAgentsToRunnerPool: boolean
517
+ /** Local mode only: provision Tester environments via the env provider instead of DinD. */
518
+ delegateTestEnvToProvider: boolean
515
519
  /** Spend budget currency (ISO 4217). Null ⇒ the built-in default (`EUR`). */
516
520
  spendCurrency: string | null
517
521
  /** Monthly spend budget in `spendCurrency`. Null ⇒ the built-in default. */
@@ -526,6 +530,8 @@ export interface UpdateWorkspaceSettingsInput {
526
530
  taskLimitPerType?: Partial<Record<CreateTaskType, number>> | null
527
531
  storeAgentContext?: boolean
528
532
  kaizenEnabled?: boolean
533
+ delegateAgentsToRunnerPool?: boolean
534
+ delegateTestEnvToProvider?: boolean
529
535
  spendCurrency?: string | null
530
536
  spendMonthlyLimit?: number | null
531
537
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.32.2",
3
+ "version": "0.34.0",
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",