@cat-factory/app 0.26.7 → 0.28.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.
@@ -8,7 +8,7 @@
8
8
  // The latter three are body-only section components rendered in tabs here (no longer
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
- import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
11
+ import type { CreateTaskType, TaskLimitMode, WorkspaceSettings } from '~/types/domain'
12
12
  import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -36,6 +36,7 @@ const tabs = [
36
36
  icon: 'i-lucide-sliders-horizontal',
37
37
  slot: 'workspace',
38
38
  },
39
+ { value: 'budget', label: 'Budget', icon: 'i-lucide-wallet', slot: 'budget' },
39
40
  { value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
40
41
  {
41
42
  value: 'tracker',
@@ -64,6 +65,10 @@ const draft = reactive({
64
65
  taskLimitMode: 'off' as TaskLimitMode,
65
66
  taskLimitShared: 5 as number,
66
67
  perType: {} as Record<CreateTaskType, number>,
68
+ // Budget: empty string ⇒ "use the built-in default" (null on the wire).
69
+ spendCurrency: '',
70
+ spendMonthlyLimit: '',
71
+ spendModelPrices: '',
67
72
  })
68
73
 
69
74
  function hydrate() {
@@ -73,6 +78,9 @@ function hydrate() {
73
78
  draft.taskLimitShared = s.taskLimitShared ?? 5
74
79
  const pt = s.taskLimitPerType ?? {}
75
80
  for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
81
+ draft.spendCurrency = s.spendCurrency ?? ''
82
+ draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
83
+ draft.spendModelPrices = s.spendModelPrices ? JSON.stringify(s.spendModelPrices, null, 2) : ''
76
84
  }
77
85
 
78
86
  watch(() => store.settings, hydrate, { immediate: true, deep: true })
@@ -109,6 +117,45 @@ async function save() {
109
117
  saving.value = false
110
118
  }
111
119
  }
120
+
121
+ const savingBudget = ref(false)
122
+
123
+ async function saveBudget() {
124
+ // Parse the optional per-model price overrides JSON (blank ⇒ no overrides).
125
+ let prices: WorkspaceSettings['spendModelPrices'] = null
126
+ const raw = draft.spendModelPrices.trim()
127
+ if (raw) {
128
+ try {
129
+ prices = JSON.parse(raw)
130
+ } catch {
131
+ toast.add({
132
+ title: 'Per-model prices must be valid JSON',
133
+ icon: 'i-lucide-triangle-alert',
134
+ color: 'error',
135
+ })
136
+ return
137
+ }
138
+ }
139
+ savingBudget.value = true
140
+ try {
141
+ await store.update({
142
+ spendCurrency: draft.spendCurrency.trim() ? draft.spendCurrency.trim().toUpperCase() : null,
143
+ spendMonthlyLimit:
144
+ draft.spendMonthlyLimit.trim() === '' ? null : Number(draft.spendMonthlyLimit),
145
+ spendModelPrices: prices,
146
+ })
147
+ toast.add({ title: 'Budget saved', icon: 'i-lucide-check', color: 'success' })
148
+ } catch (e) {
149
+ toast.add({
150
+ title: 'Could not save budget',
151
+ description: e instanceof Error ? e.message : String(e),
152
+ icon: 'i-lucide-triangle-alert',
153
+ color: 'error',
154
+ })
155
+ } finally {
156
+ savingBudget.value = false
157
+ }
158
+ }
112
159
  </script>
113
160
 
114
161
  <template>
@@ -195,6 +242,74 @@ async function save() {
195
242
  </div>
196
243
  </template>
197
244
 
245
+ <!-- Budget -->
246
+ <template #budget>
247
+ <div class="space-y-6">
248
+ <section class="space-y-2">
249
+ <h3 class="text-sm font-semibold text-slate-200">Monthly spend budget</h3>
250
+ <p class="text-[11px] text-slate-400">
251
+ Token usage is metered per LLM call, priced, and gated by this budget — when
252
+ reached, runs in this workspace pause and the board shows a warning. Leave blank to
253
+ inherit the built-in default (~100&nbsp;EUR/month).
254
+ </p>
255
+ <div class="grid grid-cols-2 gap-3">
256
+ <label class="block">
257
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
258
+ Monthly limit
259
+ </span>
260
+ <UInput
261
+ v-model="draft.spendMonthlyLimit"
262
+ type="number"
263
+ :min="0"
264
+ placeholder="Default"
265
+ size="sm"
266
+ />
267
+ </label>
268
+ <label class="block">
269
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
270
+ Currency (ISO 4217)
271
+ </span>
272
+ <UInput
273
+ v-model="draft.spendCurrency"
274
+ placeholder="EUR"
275
+ maxlength="3"
276
+ size="sm"
277
+ class="uppercase"
278
+ />
279
+ </label>
280
+ </div>
281
+ </section>
282
+
283
+ <section class="space-y-2">
284
+ <h3 class="text-sm font-semibold text-slate-200">Per-model price overrides</h3>
285
+ <p class="text-[11px] text-slate-400">
286
+ Optional. JSON object of <code>"provider:model"</code> (or a bare
287
+ <code>"provider"</code>) → <code>{ inputPerMillion, outputPerMillion }</code>,
288
+ overlaid on the built-in price table. Leave blank to use the defaults.
289
+ </p>
290
+ <UTextarea
291
+ v-model="draft.spendModelPrices"
292
+ :rows="6"
293
+ size="sm"
294
+ class="w-full font-mono text-[11px]"
295
+ placeholder='{"openai:gpt-4o":{"inputPerMillion":2.3,"outputPerMillion":9.2}}'
296
+ />
297
+ </section>
298
+
299
+ <div class="flex justify-end">
300
+ <UButton
301
+ color="primary"
302
+ icon="i-lucide-save"
303
+ size="sm"
304
+ :loading="savingBudget"
305
+ @click="saveBudget"
306
+ >
307
+ Save budget
308
+ </UButton>
309
+ </div>
310
+ </div>
311
+ </template>
312
+
198
313
  <!-- Merge thresholds -->
199
314
  <template #merge>
200
315
  <MergeThresholdsPanel />
@@ -7,6 +7,7 @@ import type {
7
7
  EmailConnection,
8
8
  UpdateAccountInput,
9
9
  } from '~/types/domain'
10
+ import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
10
11
  import type { ApiContext } from './context'
11
12
 
12
13
  /** Account (tenancy) management: orgs, members, invitations + the email sender. */
@@ -77,5 +78,16 @@ export function accountsApi({ http }: ApiContext) {
77
78
  method: 'POST',
78
79
  body: { to },
79
80
  }),
81
+
82
+ // Per-account deployment settings (admin only): integration secrets (Slack OAuth +
83
+ // web-search keys), sealed at rest. Read returns config + non-secret summary only.
84
+ getAccountSettings: (accountId: string) =>
85
+ http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`),
86
+
87
+ updateAccountSettings: (accountId: string, body: UpdateAccountSettingsInput) =>
88
+ http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`, {
89
+ method: 'PUT',
90
+ body,
91
+ }),
80
92
  }
81
93
  }
@@ -4,6 +4,10 @@ import type {
4
4
  UpsertObservabilityConnectionInput,
5
5
  UpsertReleaseHealthConfigInput,
6
6
  } from '~/types/releaseHealth'
7
+ import type {
8
+ IncidentEnrichmentView,
9
+ UpsertIncidentEnrichmentInput,
10
+ } from '~/types/incidentEnrichment'
7
11
  import type { ApiContext } from './context'
8
12
 
9
13
  /** Post-release-health: the observability connection + per-block monitor/SLO mapping. */
@@ -40,5 +44,18 @@ export function releaseHealthApi({ http, ws }: ApiContext) {
40
44
  http(`${ws(workspaceId)}/release-health-configs/${encodeURIComponent(blockId)}`, {
41
45
  method: 'DELETE',
42
46
  }),
47
+
48
+ // ---- Incident enrichment (PagerDuty + incident.io, write-only secrets) --
49
+ getIncidentEnrichment: (workspaceId: string) =>
50
+ http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`),
51
+
52
+ setIncidentEnrichment: (workspaceId: string, body: UpsertIncidentEnrichmentInput) =>
53
+ http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`, {
54
+ method: 'PUT',
55
+ body,
56
+ }),
57
+
58
+ deleteIncidentEnrichment: (workspaceId: string) =>
59
+ http(`${ws(workspaceId)}/incident-enrichment`, { method: 'DELETE' }),
43
60
  }
44
61
  }
@@ -0,0 +1,57 @@
1
+ import type {
2
+ CloneSandboxPromptInput,
3
+ CreateSandboxExperimentInput,
4
+ SandboxExperiment,
5
+ SandboxExperimentDetail,
6
+ SandboxFixture,
7
+ SandboxOverview,
8
+ SandboxPromptVersion,
9
+ SaveSandboxVersionInput,
10
+ } from '~/types/sandbox'
11
+ import type { ApiContext } from './context'
12
+
13
+ /**
14
+ * The Sandbox API (the parallel prompt/model testing surface): manage versioned prompt
15
+ * candidates + the fixture library, define experiments (prompt × model × fixture), and
16
+ * launch one to run + grade every cell. Opt-in: every endpoint 503s when the deployment
17
+ * hasn't wired the Sandbox (its dedicated DB / schema).
18
+ */
19
+ export function sandboxApi({ http, ws }: ApiContext) {
20
+ const base = (workspaceId: string) => `${ws(workspaceId)}/sandbox`
21
+ return {
22
+ getSandboxOverview: (workspaceId: string) =>
23
+ http<SandboxOverview>(`${base(workspaceId)}/overview`),
24
+
25
+ // ---- prompt versions -------------------------------------------------
26
+ cloneSandboxPrompt: (workspaceId: string, body: CloneSandboxPromptInput) =>
27
+ http<SandboxPromptVersion>(`${base(workspaceId)}/prompts/clone`, { method: 'POST', body }),
28
+ saveSandboxVersion: (workspaceId: string, body: SaveSandboxVersionInput) =>
29
+ http<SandboxPromptVersion>(`${base(workspaceId)}/prompts`, { method: 'POST', body }),
30
+ setSandboxPromptLabels: (workspaceId: string, promptId: string, labels: string[]) =>
31
+ http<SandboxPromptVersion>(
32
+ `${base(workspaceId)}/prompts/${encodeURIComponent(promptId)}/labels`,
33
+ { method: 'PATCH', body: { labels } },
34
+ ),
35
+ archiveSandboxPrompt: (workspaceId: string, promptId: string) =>
36
+ http(`${base(workspaceId)}/prompts/${encodeURIComponent(promptId)}`, { method: 'DELETE' }),
37
+
38
+ // ---- fixtures --------------------------------------------------------
39
+ createSandboxFixture: (workspaceId: string, body: Partial<SandboxFixture>) =>
40
+ http<SandboxFixture>(`${base(workspaceId)}/fixtures`, { method: 'POST', body }),
41
+ deleteSandboxFixture: (workspaceId: string, fixtureId: string) =>
42
+ http(`${base(workspaceId)}/fixtures/${encodeURIComponent(fixtureId)}`, { method: 'DELETE' }),
43
+
44
+ // ---- experiments -----------------------------------------------------
45
+ createSandboxExperiment: (workspaceId: string, body: CreateSandboxExperimentInput) =>
46
+ http<SandboxExperiment>(`${base(workspaceId)}/experiments`, { method: 'POST', body }),
47
+ getSandboxExperiment: (workspaceId: string, experimentId: string) =>
48
+ http<SandboxExperimentDetail>(
49
+ `${base(workspaceId)}/experiments/${encodeURIComponent(experimentId)}`,
50
+ ),
51
+ launchSandboxExperiment: (workspaceId: string, experimentId: string) =>
52
+ http<SandboxExperimentDetail>(
53
+ `${base(workspaceId)}/experiments/${encodeURIComponent(experimentId)}/launch`,
54
+ { method: 'POST' },
55
+ ),
56
+ }
57
+ }
@@ -15,6 +15,7 @@ import { presetsApi } from './api/presets'
15
15
  import { providerConnectionsApi } from './api/providerConnections'
16
16
  import { recurringApi } from './api/recurring'
17
17
  import { releaseHealthApi } from './api/releaseHealth'
18
+ import { sandboxApi } from './api/sandbox'
18
19
  import { reviewsApi } from './api/reviews'
19
20
  import { slackApi } from './api/slack'
20
21
  import { specApi } from './api/spec'
@@ -89,6 +90,7 @@ export function useApi() {
89
90
  ...providerConnectionsApi(ctx),
90
91
  ...releaseHealthApi(ctx),
91
92
  ...recurringApi(ctx),
93
+ ...sandboxApi(ctx),
92
94
  ...githubApi(ctx),
93
95
  ...slackApi(ctx),
94
96
  ...bootstrapApi(ctx),
@@ -33,6 +33,7 @@ import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPan
33
33
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
34
34
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
35
35
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
36
+ import SandboxPanel from '~/components/sandbox/SandboxPanel.vue'
36
37
  import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
37
38
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
38
39
  import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
@@ -189,6 +190,7 @@ watch(
189
190
  <ProviderConnectionPanel />
190
191
  <ModelConfigurationPanel />
191
192
  <LocalModelEndpointsPanel />
193
+ <SandboxPanel />
192
194
  <UserSecretsSection />
193
195
  <OpenRouterCatalogPanel />
194
196
  <VendorCredentialsModal />
@@ -0,0 +1,49 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
4
+
5
+ /**
6
+ * Per-account deployment settings (admin only): the integration secrets — Slack app OAuth
7
+ * credentials + the web-search upstream keys — sealed at rest in the DB. Secrets are
8
+ * write-only: this store only ever holds the non-secret `summary` (which integrations are
9
+ * configured), never the values. `available` mirrors the backend opt-in: a 503 (no
10
+ * ENCRYPTION_KEY) hides the panel. Loaded on demand from the account-settings panel.
11
+ */
12
+ export const useAccountSettingsStore = defineStore('accountSettings', () => {
13
+ const api = useApi()
14
+
15
+ const view = ref<AccountSettingsView | null>(null)
16
+ const loading = ref(false)
17
+ const available = ref<boolean | null>(null)
18
+
19
+ async function load(accountId: string) {
20
+ loading.value = true
21
+ try {
22
+ view.value = await api.getAccountSettings(accountId)
23
+ available.value = true
24
+ } catch (e) {
25
+ // 503 ⇒ the settings store isn't wired (no ENCRYPTION_KEY) ⇒ hide the panel.
26
+ if (
27
+ e &&
28
+ typeof e === 'object' &&
29
+ 'statusCode' in e &&
30
+ (e as { statusCode?: number }).statusCode === 503
31
+ ) {
32
+ available.value = false
33
+ view.value = null
34
+ } else {
35
+ throw e
36
+ }
37
+ } finally {
38
+ loading.value = false
39
+ }
40
+ }
41
+
42
+ async function save(accountId: string, input: UpdateAccountSettingsInput) {
43
+ view.value = await api.updateAccountSettings(accountId, input)
44
+ available.value = true
45
+ return view.value
46
+ }
47
+
48
+ return { view, loading, available, load, save }
49
+ })
@@ -6,6 +6,10 @@ import type {
6
6
  UpsertObservabilityConnectionInput,
7
7
  UpsertReleaseHealthConfigInput,
8
8
  } from '~/types/releaseHealth'
9
+ import type {
10
+ IncidentEnrichmentView,
11
+ UpsertIncidentEnrichmentInput,
12
+ } from '~/types/incidentEnrichment'
9
13
  import { useWorkspaceStore } from '~/stores/workspace'
10
14
 
11
15
  /**
@@ -23,6 +27,10 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
23
27
  summary: null,
24
28
  })
25
29
  const configs = ref<ReleaseHealthConfig[]>([])
30
+ // Incident-enrichment (PagerDuty + incident.io) connection — write-only secrets, the
31
+ // store only ever holds the presence summary. Wired alongside observability.
32
+ const incident = ref<IncidentEnrichmentView>({ connected: false, summary: null })
33
+ const incidentAvailable = ref<boolean | null>(null)
26
34
  const loading = ref(false)
27
35
  // Mirrors the backend's opt-in gate (`OBSERVABILITY_ENABLED`): `null` until first
28
36
  // probed, then `true`/`false`. The hub + inspector hide their observability entry
@@ -95,9 +103,35 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
95
103
  configs.value = configs.value.filter((c) => c.blockId !== blockId)
96
104
  }
97
105
 
106
+ /** Load the incident-enrichment connection (separate opt-in gate from observability). */
107
+ async function loadIncident() {
108
+ const ws = useWorkspaceStore()
109
+ try {
110
+ incident.value = await api.getIncidentEnrichment(ws.requireId())
111
+ incidentAvailable.value = true
112
+ } catch {
113
+ incidentAvailable.value = false
114
+ incident.value = { connected: false, summary: null }
115
+ }
116
+ }
117
+
118
+ async function saveIncident(input: UpsertIncidentEnrichmentInput) {
119
+ const ws = useWorkspaceStore()
120
+ incident.value = await api.setIncidentEnrichment(ws.requireId(), input)
121
+ incidentAvailable.value = true
122
+ }
123
+
124
+ async function removeIncident() {
125
+ const ws = useWorkspaceStore()
126
+ await api.deleteIncidentEnrichment(ws.requireId())
127
+ incident.value = { connected: false, summary: null }
128
+ }
129
+
98
130
  return {
99
131
  connection,
100
132
  configs,
133
+ incident,
134
+ incidentAvailable,
101
135
  loading,
102
136
  available,
103
137
  load,
@@ -107,5 +141,8 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
107
141
  configForBlock,
108
142
  saveConfig,
109
143
  removeConfig,
144
+ loadIncident,
145
+ saveIncident,
146
+ removeIncident,
110
147
  }
111
148
  })
@@ -0,0 +1,174 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { ModelOption } from '~/types/domain'
4
+ import type {
5
+ CreateSandboxExperimentInput,
6
+ SandboxAgentKindMeta,
7
+ SandboxExperiment,
8
+ SandboxExperimentDetail,
9
+ SandboxFixture,
10
+ SandboxOverview,
11
+ SandboxPromptVersion,
12
+ } from '~/types/sandbox'
13
+ import { useWorkspaceStore } from '~/stores/workspace'
14
+
15
+ /**
16
+ * The Sandbox (parallel prompt/model testing surface). Loaded on demand when the panel
17
+ * opens (it's an opt-in, secondary surface, not part of the board snapshot): the testable
18
+ * agent-kind catalog, the shipped baselines + stored candidate prompt versions, the
19
+ * fixture library, and experiment definitions. Running an experiment grades every cell
20
+ * with a judge model; `launch` returns the full result grid.
21
+ */
22
+ export const useSandboxStore = defineStore('sandbox', () => {
23
+ const api = useApi()
24
+
25
+ const available = ref(true)
26
+ const loading = ref(false)
27
+ const error = ref<string | null>(null)
28
+
29
+ const agentKinds = ref<SandboxAgentKindMeta[]>([])
30
+ const prompts = ref<SandboxPromptVersion[]>([])
31
+ const fixtures = ref<SandboxFixture[]>([])
32
+ const experiments = ref<SandboxExperiment[]>([])
33
+ const models = ref<ModelOption[]>([])
34
+ /** The matrix cell cap (from the backend overview, so the builder gates on the same limit). */
35
+ const maxCells = ref(100)
36
+
37
+ /** The currently-opened experiment's full detail (result grid), if any. */
38
+ const detail = ref<SandboxExperimentDetail | null>(null)
39
+ const launching = ref(false)
40
+
41
+ function hydrate(overview: SandboxOverview) {
42
+ agentKinds.value = overview.agentKinds
43
+ prompts.value = overview.prompts
44
+ fixtures.value = overview.fixtures
45
+ experiments.value = [...overview.experiments].sort((a, b) => b.createdAt - a.createdAt)
46
+ maxCells.value = overview.maxCells
47
+ }
48
+
49
+ /** Patch one experiment into the list in place (newest-first), without a full reload. */
50
+ function upsertExperiment(experiment: SandboxExperiment) {
51
+ const next = experiments.value.filter((e) => e.id !== experiment.id)
52
+ next.push(experiment)
53
+ experiments.value = next.sort((a, b) => b.createdAt - a.createdAt)
54
+ }
55
+
56
+ /** Load the overview + the workspace model catalog. The 503 (feature off) is surfaced. */
57
+ async function load() {
58
+ const ws = useWorkspaceStore()
59
+ if (!ws.workspaceId) return
60
+ loading.value = true
61
+ error.value = null
62
+ try {
63
+ const [overview, modelList] = await Promise.all([
64
+ api.getSandboxOverview(ws.requireId()),
65
+ api.getWorkspaceModels(ws.requireId()),
66
+ ])
67
+ hydrate(overview)
68
+ models.value = modelList
69
+ available.value = true
70
+ } catch (e) {
71
+ const status =
72
+ (e as { statusCode?: number; response?: { status?: number } })?.statusCode ??
73
+ (e as { response?: { status?: number } })?.response?.status
74
+ if (status === 503) {
75
+ available.value = false
76
+ } else {
77
+ error.value = e instanceof Error ? e.message : String(e)
78
+ }
79
+ } finally {
80
+ loading.value = false
81
+ }
82
+ }
83
+
84
+ /** Selectable models for the experiment picker (the backend computed `available`). */
85
+ const selectableModels = computed(() => models.value.filter((m) => m.available !== false))
86
+
87
+ /** Prompt versions for one agent kind (baselines first, then candidates). */
88
+ function promptsForKind(agentKind: string): SandboxPromptVersion[] {
89
+ return prompts.value.filter((p) => p.agentKind === agentKind)
90
+ }
91
+
92
+ /** Fixtures authored for one agent kind, filtered by the catalog's `fixtureKinds`. */
93
+ function fixturesForKind(agentKind: string): SandboxFixture[] {
94
+ const meta = agentKinds.value.find((k) => k.agentKind === agentKind)
95
+ if (!meta) return fixtures.value
96
+ // The backend catalog is the source of truth for the fixture↔kind mapping.
97
+ const wanted = meta.fixtureKinds
98
+ return fixtures.value.filter((f) => wanted.includes(f.kind))
99
+ }
100
+
101
+ async function clonePrompt(agentKind: string, basePromptId: string | null, name?: string) {
102
+ const ws = useWorkspaceStore()
103
+ const created = await api.cloneSandboxPrompt(ws.requireId(), { agentKind, basePromptId, name })
104
+ await load()
105
+ return created
106
+ }
107
+
108
+ async function saveVersion(parentId: string, systemText: string) {
109
+ const ws = useWorkspaceStore()
110
+ const saved = await api.saveSandboxVersion(ws.requireId(), { parentId, systemText })
111
+ await load()
112
+ return saved
113
+ }
114
+
115
+ async function archivePrompt(promptId: string) {
116
+ const ws = useWorkspaceStore()
117
+ await api.archiveSandboxPrompt(ws.requireId(), promptId)
118
+ await load()
119
+ }
120
+
121
+ async function createExperiment(input: CreateSandboxExperimentInput) {
122
+ const ws = useWorkspaceStore()
123
+ const created = await api.createSandboxExperiment(ws.requireId(), input)
124
+ await load()
125
+ return created
126
+ }
127
+
128
+ async function openExperiment(experimentId: string) {
129
+ const ws = useWorkspaceStore()
130
+ detail.value = await api.getSandboxExperiment(ws.requireId(), experimentId)
131
+ return detail.value
132
+ }
133
+
134
+ async function launch(experimentId: string) {
135
+ const ws = useWorkspaceStore()
136
+ launching.value = true
137
+ try {
138
+ // `launch` returns the full graded grid AND the updated experiment, so patch both in
139
+ // place rather than calling `load()`: a transient failure in that follow-up fetch
140
+ // would otherwise set `error` and hide the freshly-returned result grid behind the
141
+ // error panel (and re-fetch the whole overview + model catalog for nothing).
142
+ const result = await api.launchSandboxExperiment(ws.requireId(), experimentId)
143
+ detail.value = result
144
+ upsertExperiment(result.experiment)
145
+ return result
146
+ } finally {
147
+ launching.value = false
148
+ }
149
+ }
150
+
151
+ return {
152
+ available,
153
+ loading,
154
+ error,
155
+ agentKinds,
156
+ prompts,
157
+ fixtures,
158
+ experiments,
159
+ models,
160
+ maxCells,
161
+ selectableModels,
162
+ detail,
163
+ launching,
164
+ load,
165
+ promptsForKind,
166
+ fixturesForKind,
167
+ clonePrompt,
168
+ saveVersion,
169
+ archivePrompt,
170
+ createExperiment,
171
+ openExperiment,
172
+ launch,
173
+ }
174
+ })
package/app/stores/ui.ts CHANGED
@@ -104,6 +104,8 @@ export const useUiStore = defineStore('ui', () => {
104
104
  const vendorCredentialsOpen = ref(false)
105
105
  // Per-user settings panel: the signed-in user's own-machine local model runners.
106
106
  const localModelsOpen = ref(false)
107
+ // The Sandbox (parallel prompt/model testing) surface — an opt-in, on-demand window.
108
+ const sandboxOpen = ref(false)
107
109
  const userSecretsOpen = ref(false)
108
110
  // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
109
111
  const openRouterOpen = ref(false)
@@ -364,6 +366,12 @@ export const useUiStore = defineStore('ui', () => {
364
366
  function closeLocalModels() {
365
367
  localModelsOpen.value = false
366
368
  }
369
+ function openSandbox() {
370
+ sandboxOpen.value = true
371
+ }
372
+ function closeSandbox() {
373
+ sandboxOpen.value = false
374
+ }
367
375
  function openUserSecrets() {
368
376
  userSecretsOpen.value = true
369
377
  }
@@ -464,6 +472,7 @@ export const useUiStore = defineStore('ui', () => {
464
472
  modelConfigOpen,
465
473
  vendorCredentialsOpen,
466
474
  localModelsOpen,
475
+ sandboxOpen,
467
476
  userSecretsOpen,
468
477
  openRouterOpen,
469
478
  aiProviderSetupOpen,
@@ -528,6 +537,8 @@ export const useUiStore = defineStore('ui', () => {
528
537
  closeVendorCredentials,
529
538
  openLocalModels,
530
539
  closeLocalModels,
540
+ openSandbox,
541
+ closeSandbox,
531
542
  openUserSecrets,
532
543
  closeUserSecrets,
533
544
  openOpenRouter,
@@ -9,6 +9,9 @@ const DEFAULTS: WorkspaceSettings = {
9
9
  taskLimitMode: 'off',
10
10
  taskLimitShared: null,
11
11
  taskLimitPerType: null,
12
+ spendCurrency: null,
13
+ spendMonthlyLimit: null,
14
+ spendModelPrices: null,
12
15
  }
13
16
 
14
17
  /**
@@ -0,0 +1,39 @@
1
+ // Per-account (deployment-wide) integration settings. Mirrors
2
+ // `@cat-factory/contracts` accountSettings. Secrets are write-only — the read view
3
+ // returns only `config` + a non-secret presence `summary`.
4
+
5
+ export interface SlackOAuthSecret {
6
+ clientId: string
7
+ clientSecret: string
8
+ redirectUrl: string
9
+ }
10
+
11
+ export interface WebSearchSecret {
12
+ braveApiKey?: string
13
+ searxngUrl?: string
14
+ searxngApiKey?: string
15
+ }
16
+
17
+ /** Non-secret per-account config (empty today; reserved for forward-compatible tuning). */
18
+ export type AccountSettingsConfig = Record<string, never>
19
+
20
+ export interface AccountSettingsSummary {
21
+ slackOAuthConfigured: boolean
22
+ webSearch: 'brave' | 'searxng' | null
23
+ }
24
+
25
+ export interface AccountSettingsView {
26
+ config: AccountSettingsConfig
27
+ summary: AccountSettingsSummary
28
+ }
29
+
30
+ /**
31
+ * Admin write. Each secrets group: omit ⇒ leave unchanged, `null` ⇒ clear, value ⇒ set.
32
+ */
33
+ export interface UpdateAccountSettingsInput {
34
+ config?: AccountSettingsConfig
35
+ secrets?: {
36
+ slackOAuth?: SlackOAuthSecret | null
37
+ webSearch?: WebSearchSecret | null
38
+ }
39
+ }