@cat-factory/app 0.32.1 → 0.33.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,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { VueFlow, useVueFlow, type NodeMouseEvent } from '@vue-flow/core'
3
3
  import { Background } from '@vue-flow/background'
4
- import { Controls } from '@vue-flow/controls'
5
4
  import { MiniMap } from '@vue-flow/minimap'
6
5
  import BlockNode from './nodes/BlockNode.vue'
7
6
  import EpicNode from './nodes/EpicNode.vue'
@@ -177,7 +176,6 @@ async function onDrop(event: DragEvent) {
177
176
  >
178
177
  <Background pattern-color="#1e293b" :gap="22" :size="1.4" />
179
178
  <MiniMap pannable zoomable :node-color="minimapColor" class="!bg-slate-900/80" />
180
- <Controls position="bottom-left" />
181
179
 
182
180
  <template #node-block="props">
183
181
  <BlockNode :id="props.id" />
@@ -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
- onMounted(async () => {
50
+ async function loadAll(accountId: string) {
45
51
  try {
46
- await Promise.all([
47
- accounts.loadRoster(props.accountId),
48
- accounts.loadEmailConnection(props.accountId),
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 &amp; 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,13 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
52
53
  })),
53
54
  [
54
55
  { label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') },
55
- // Team management (members + invitations + email sender) for org accounts.
56
- ...(accounts.activeAccount?.type === 'org'
57
- ? [{ label: 'Manage team…', icon: 'i-lucide-users', onSelect: () => openSettings() }]
58
- : []),
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.
58
+ {
59
+ label: 'Account settings…',
60
+ icon: 'i-lucide-users',
61
+ onSelect: () => ui.openAccountSettings(),
62
+ },
59
63
  // Admins can set the account-wide default provider new services inherit.
60
64
  ...(accounts.activeAccount?.roles?.includes('admin')
61
65
  ? [
@@ -184,12 +188,6 @@ async function submitPrompt() {
184
188
  busy.value = false
185
189
  }
186
190
  }
187
-
188
- // ---- account settings modal (members / invitations / email) ----------------
189
- const settingsOpen = ref(false)
190
- function openSettings() {
191
- settingsOpen.value = true
192
- }
193
191
  </script>
194
192
 
195
193
  <template>
@@ -266,15 +264,5 @@ function openSettings() {
266
264
  </form>
267
265
  </template>
268
266
  </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
267
  </div>
280
268
  </template>
@@ -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,24 @@
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.
6
+ import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
7
+
8
+ const ui = useUiStore()
9
+ const accounts = useAccountsStore()
10
+
11
+ const open = computed({
12
+ get: () => ui.accountSettingsOpen,
13
+ set: (v: boolean) => (v ? ui.openAccountSettings() : ui.closeAccountSettings()),
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <UModal v-model:open="open" title="Account & team" :ui="{ content: 'max-w-3xl' }">
19
+ <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>
22
+ </template>
23
+ </UModal>
24
+ </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>
@@ -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 />
package/app/stores/ui.ts CHANGED
@@ -96,6 +96,9 @@ 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/team-settings modal: the per-account members + roles + invitations + email
100
+ // sender panel (`AccountTeamSettings`). Account-scoped (distinct from workspace settings).
101
+ const accountSettingsOpen = ref(false)
99
102
  // Observability integration: the post-release-health connection panel (Datadog
100
103
  // today, pluggable). NB: distinct from `observabilityInstanceId` below, which is the
101
104
  // LLM per-call observability panel.
@@ -380,6 +383,13 @@ export const useUiStore = defineStore('ui', () => {
380
383
  function setWorkspaceSettingsTab(tab: string) {
381
384
  workspaceSettingsTab.value = tab
382
385
  }
386
+ function openAccountSettings() {
387
+ cameFromIntegrations.value = false
388
+ accountSettingsOpen.value = true
389
+ }
390
+ function closeAccountSettings() {
391
+ accountSettingsOpen.value = false
392
+ }
383
393
  function openObservabilityConnection() {
384
394
  cameFromIntegrations.value = false
385
395
  observabilityConnectionOpen.value = true
@@ -555,6 +565,7 @@ export const useUiStore = defineStore('ui', () => {
555
565
  cameFromIntegrations,
556
566
  workspaceSettingsOpen,
557
567
  workspaceSettingsTab,
568
+ accountSettingsOpen,
558
569
  observabilityConnectionOpen,
559
570
  providerConnectionKind,
560
571
  modelConfigOpen,
@@ -617,6 +628,8 @@ export const useUiStore = defineStore('ui', () => {
617
628
  openWorkspaceSettings,
618
629
  closeWorkspaceSettings,
619
630
  setWorkspaceSettingsTab,
631
+ openAccountSettings,
632
+ closeAccountSettings,
620
633
  openObservabilityConnection,
621
634
  closeObservabilityConnection,
622
635
  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/nuxt.config.ts CHANGED
@@ -49,7 +49,6 @@ export default defineNuxtConfig({
49
49
  css: [
50
50
  '@vue-flow/core/dist/style.css',
51
51
  '@vue-flow/core/dist/theme-default.css',
52
- '@vue-flow/controls/dist/style.css',
53
52
  '@vue-flow/minimap/dist/style.css',
54
53
  '@vue-flow/node-resizer/dist/style.css',
55
54
  join(layerDir, 'app/assets/css/main.css'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.32.1",
3
+ "version": "0.33.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",
@@ -20,7 +20,6 @@
20
20
  "@nuxt/ui": "^4.9.0",
21
21
  "@pinia/nuxt": "^0.11.3",
22
22
  "@vue-flow/background": "^1.3.2",
23
- "@vue-flow/controls": "^1.1.3",
24
23
  "@vue-flow/core": "^1.48.2",
25
24
  "@vue-flow/minimap": "^1.5.4",
26
25
  "@vue-flow/node-resizer": "^1.5.1",