@cat-factory/app 0.115.2 → 0.116.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.
@@ -6,7 +6,7 @@
6
6
  // the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
7
7
  // run. The account scope has no resolved/merged catalog and fetches document
8
8
  // fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
9
- import { computed, ref, watch } from 'vue'
9
+ import { computed, reactive, ref, watch } from 'vue'
10
10
  import type {
11
11
  DocumentSourceKind,
12
12
  FragmentOwnerKind,
@@ -111,6 +111,24 @@ function notifyError(title: string, e: unknown) {
111
111
  })
112
112
  }
113
113
 
114
+ // Per-row / per-form in-flight tracking. The store's single `library.loading` flag
115
+ // drove every row's button at once (UX-29) and cross-spun the add/link forms; key
116
+ // each async action so only the control that triggered it shows a spinner.
117
+ const busyRows = reactive(new Set<string>())
118
+ const rowBusy = (key: string) => busyRows.has(key)
119
+ async function withRow(key: string, fn: () => Promise<void>) {
120
+ if (busyRows.has(key)) return
121
+ busyRows.add(key)
122
+ try {
123
+ await fn()
124
+ } finally {
125
+ busyRows.delete(key)
126
+ }
127
+ }
128
+ const creating = ref(false)
129
+ const linkingDoc = ref(false)
130
+ const linkingSource = ref(false)
131
+
114
132
  // ---- create a hand-authored fragment --------------------------------------
115
133
  const draft = ref({ title: '', summary: '', body: '', tags: '' })
116
134
  const draftValid = computed(
@@ -119,6 +137,7 @@ const draftValid = computed(
119
137
 
120
138
  async function createFragment() {
121
139
  if (!draftValid.value) return
140
+ creating.value = true
122
141
  try {
123
142
  await library.create({
124
143
  title: draft.value.title.trim(),
@@ -133,6 +152,8 @@ async function createFragment() {
133
152
  toast.add({ title: t('fragments.toast.added'), icon: 'i-lucide-check' })
134
153
  } catch (e) {
135
154
  notifyError(t('fragments.toast.addFailed'), e)
155
+ } finally {
156
+ creating.value = false
136
157
  }
137
158
  }
138
159
 
@@ -146,12 +167,14 @@ async function removeFragment(id: string) {
146
167
  icon: 'i-lucide-trash-2',
147
168
  })
148
169
  if (!ok) return
149
- try {
150
- await library.remove(id)
151
- toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
152
- } catch (e) {
153
- notifyError(t('fragments.toast.removeFailed'), e)
154
- }
170
+ await withRow(`remove:${id}`, async () => {
171
+ try {
172
+ await library.remove(id)
173
+ toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
174
+ } catch (e) {
175
+ notifyError(t('fragments.toast.removeFailed'), e)
176
+ }
177
+ })
155
178
  }
156
179
 
157
180
  // ---- document-backed (living) fragments -----------------------------------
@@ -201,6 +224,7 @@ const documentFragments = computed(() => library.fragments.filter((f) => f.docum
201
224
 
202
225
  async function linkDocumentFragment() {
203
226
  if (!docDraftValid.value) return
227
+ linkingDoc.value = true
204
228
  try {
205
229
  await library.createDocumentFragment({
206
230
  source: docDraft.value.source as DocumentSourceKind,
@@ -214,16 +238,20 @@ async function linkDocumentFragment() {
214
238
  toast.add({ title: t('fragments.toast.documentLinked'), icon: 'i-lucide-link' })
215
239
  } catch (e) {
216
240
  notifyError(t('fragments.toast.linkDocumentFailed'), e)
241
+ } finally {
242
+ linkingDoc.value = false
217
243
  }
218
244
  }
219
245
 
220
246
  async function refreshFragment(id: string) {
221
- try {
222
- await library.refreshDocumentFragment(id)
223
- toast.add({ title: t('fragments.toast.refreshed'), icon: 'i-lucide-refresh-cw' })
224
- } catch (e) {
225
- notifyError(t('fragments.toast.refreshFailed'), e)
226
- }
247
+ await withRow(`refresh:${id}`, async () => {
248
+ try {
249
+ await library.refreshDocumentFragment(id)
250
+ toast.add({ title: t('fragments.toast.refreshed'), icon: 'i-lucide-refresh-cw' })
251
+ } catch (e) {
252
+ notifyError(t('fragments.toast.refreshFailed'), e)
253
+ }
254
+ })
227
255
  }
228
256
 
229
257
  // ---- repo sources ----------------------------------------------------------
@@ -263,6 +291,7 @@ async function linkSource() {
263
291
  if (!ownerName) return
264
292
  const dirPath =
265
293
  (githubReady.value ? sourceDir.value : manualSource.value.dirPath.trim()) || undefined
294
+ linkingSource.value = true
266
295
  try {
267
296
  const source = await library.linkSource({
268
297
  repoOwner: ownerName.owner,
@@ -271,48 +300,71 @@ async function linkSource() {
271
300
  gitRef: sourceRef.value.trim() || undefined,
272
301
  })
273
302
  resetSourceDraft()
303
+ // Auto-sync the freshly-linked source via the store method directly (not the
304
+ // `syncSource` row wrapper): a failure here should surface as a link failure, and
305
+ // the form-level `linkingSource` spinner already covers the whole operation.
274
306
  await library.syncSource(source.id)
275
307
  toast.add({ title: t('fragments.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
276
308
  } catch (e) {
277
309
  notifyError(t('fragments.toast.linkSourceFailed'), e)
310
+ } finally {
311
+ linkingSource.value = false
278
312
  }
279
313
  }
280
314
 
281
315
  async function syncSource(id: string) {
282
- try {
283
- const result = await library.syncSource(id)
284
- toast.add({
285
- title: t('fragments.toast.synced', {
286
- updated: result.upserted,
287
- removed: result.tombstoned,
288
- }),
289
- icon: 'i-lucide-refresh-cw',
290
- color: 'info',
291
- })
292
- } catch (e) {
293
- notifyError(t('fragments.toast.syncFailed'), e)
294
- }
316
+ await withRow(`sync:${id}`, async () => {
317
+ try {
318
+ const result = await library.syncSource(id)
319
+ toast.add({
320
+ title: t('fragments.toast.synced', {
321
+ updated: result.upserted,
322
+ removed: result.tombstoned,
323
+ }),
324
+ icon: 'i-lucide-refresh-cw',
325
+ color: 'info',
326
+ })
327
+ } catch (e) {
328
+ notifyError(t('fragments.toast.syncFailed'), e)
329
+ }
330
+ })
295
331
  }
296
332
 
297
333
  async function checkSource(id: string) {
298
- try {
299
- const status = await library.checkSource(id)
300
- toast.add({
301
- title: status.changed ? t('fragments.toast.changesAvailable') : t('fragments.toast.upToDate'),
302
- icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
303
- })
304
- } catch (e) {
305
- notifyError(t('fragments.toast.checkSourceFailed'), e)
306
- }
334
+ await withRow(`check:${id}`, async () => {
335
+ try {
336
+ const status = await library.checkSource(id)
337
+ toast.add({
338
+ title: status.changed
339
+ ? t('fragments.toast.changesAvailable')
340
+ : t('fragments.toast.upToDate'),
341
+ icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
342
+ })
343
+ } catch (e) {
344
+ notifyError(t('fragments.toast.checkSourceFailed'), e)
345
+ }
346
+ })
307
347
  }
308
348
 
309
349
  async function unlinkSource(id: string) {
310
- try {
311
- await library.unlinkSource(id)
312
- toast.add({ title: t('fragments.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
313
- } catch (e) {
314
- notifyError(t('fragments.toast.unlinkSourceFailed'), e)
315
- }
350
+ const source = library.sources.find((s) => s.id === id)
351
+ const repo = source ? `${source.repoOwner}/${source.repoName}` : ''
352
+ const ok = await confirm({
353
+ title: t('fragments.confirmUnlinkSource.title'),
354
+ description: t('fragments.confirmUnlinkSource.body', { repo }),
355
+ variant: 'destructive',
356
+ confirmLabel: t('fragments.confirmUnlinkSource.confirm'),
357
+ icon: 'i-lucide-unplug',
358
+ })
359
+ if (!ok) return
360
+ await withRow(`unlink:${id}`, async () => {
361
+ try {
362
+ await library.unlinkSource(id)
363
+ toast.add({ title: t('fragments.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
364
+ } catch (e) {
365
+ notifyError(t('fragments.toast.unlinkSourceFailed'), e)
366
+ }
367
+ })
316
368
  }
317
369
  </script>
318
370
 
@@ -417,6 +469,7 @@ async function unlinkSource(id: string) {
417
469
  color="error"
418
470
  variant="ghost"
419
471
  class="ms-auto"
472
+ :loading="rowBusy(`remove:${f.id}`)"
420
473
  @click="removeFragment(f.id)"
421
474
  />
422
475
  </div>
@@ -446,7 +499,7 @@ async function unlinkSource(id: string) {
446
499
  icon="i-lucide-plus"
447
500
  size="sm"
448
501
  :disabled="!draftValid"
449
- :loading="library.loading"
502
+ :loading="creating"
450
503
  class="self-start"
451
504
  @click="createFragment"
452
505
  >
@@ -487,7 +540,7 @@ async function unlinkSource(id: string) {
487
540
  icon="i-lucide-refresh-cw"
488
541
  size="xs"
489
542
  variant="ghost"
490
- :loading="library.loading"
543
+ :loading="rowBusy(`refresh:${f.id}`)"
491
544
  :title="t('fragments.documents.refreshTitle')"
492
545
  @click="refreshFragment(f.id)"
493
546
  />
@@ -496,6 +549,7 @@ async function unlinkSource(id: string) {
496
549
  size="xs"
497
550
  color="error"
498
551
  variant="ghost"
552
+ :loading="rowBusy(`remove:${f.id}`)"
499
553
  @click="removeFragment(f.id)"
500
554
  />
501
555
  </div>
@@ -553,7 +607,7 @@ async function unlinkSource(id: string) {
553
607
  icon="i-lucide-link"
554
608
  size="sm"
555
609
  :disabled="!docDraftValid"
556
- :loading="library.loading"
610
+ :loading="linkingDoc"
557
611
  class="self-start"
558
612
  @click="linkDocumentFragment"
559
613
  >
@@ -598,13 +652,14 @@ async function unlinkSource(id: string) {
598
652
  icon="i-lucide-search-check"
599
653
  size="xs"
600
654
  variant="ghost"
655
+ :loading="rowBusy(`check:${s.id}`)"
601
656
  @click="checkSource(s.id)"
602
657
  />
603
658
  <UButton
604
659
  icon="i-lucide-refresh-cw"
605
660
  size="xs"
606
661
  variant="ghost"
607
- :loading="library.loading"
662
+ :loading="rowBusy(`sync:${s.id}`)"
608
663
  @click="syncSource(s.id)"
609
664
  />
610
665
  <UButton
@@ -612,6 +667,7 @@ async function unlinkSource(id: string) {
612
667
  size="xs"
613
668
  color="error"
614
669
  variant="ghost"
670
+ :loading="rowBusy(`unlink:${s.id}`)"
615
671
  @click="unlinkSource(s.id)"
616
672
  />
617
673
  </div>
@@ -669,7 +725,7 @@ async function unlinkSource(id: string) {
669
725
  icon="i-lucide-link"
670
726
  size="sm"
671
727
  :disabled="!sourceValid"
672
- :loading="library.loading"
728
+ :loading="linkingSource"
673
729
  class="self-start"
674
730
  @click="linkSource"
675
731
  >
@@ -3,11 +3,13 @@ import { computed, onMounted, ref, watch } from 'vue'
3
3
  import type {
4
4
  Block,
5
5
  CloudProvider,
6
+ EnvironmentTestStage,
6
7
  InstanceSize,
7
8
  ProvisionType,
8
9
  ServiceProvisioning,
9
10
  } from '~/types/domain'
10
11
  import type {
12
+ ConflictReason,
11
13
  KubernetesManifestSource,
12
14
  KubernetesRenderer,
13
15
  ProvisioningComposeServiceCandidate,
@@ -19,6 +21,7 @@ import type {
19
21
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
20
22
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
21
23
  import { apiErrorEnvelope } from '~/composables/api/errors'
24
+ import { parseConflict } from '~/composables/usePipelineErrorToast'
22
25
 
23
26
  // Service-level (frame) configuration: the service-owned PROVISIONING — the provision
24
27
  // TYPE this service produces (`infraless` / `docker-compose` / `kubernetes` / `custom`)
@@ -44,7 +47,7 @@ const services = useServicesStore()
44
47
  const infra = useInfraConfigStore()
45
48
  const agentRuns = useAgentRunsStore()
46
49
  const ui = useUiStore()
47
- const { t } = useI18n()
50
+ const { t, te } = useI18n()
48
51
 
49
52
  // The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
50
53
  // The repo list backs the detect-from-repo affordance (owner/name lookup).
@@ -247,6 +250,80 @@ async function generateOrFixManifest() {
247
250
  }
248
251
  }
249
252
 
253
+ // Ephemeral-environment self-test: run the whole create-branch → provision → tear-down →
254
+ // delete-branch cycle against this service's provisioning config and report success / the stage
255
+ // it failed at. The returned run is tracked live (by frame id) via the workspace stream store.
256
+ const envTest = useEnvironmentTestStore()
257
+ const envTestStarting = ref(false)
258
+ const envTestError = ref<string | null>(null)
259
+ // The newest self-test run for this frame — re-attaches after a reconnect (the run is carried in
260
+ // the snapshot while running), so the live stage keeps showing without a locally-held id.
261
+ const envTestRun = computed(() => envTest.runForBlock(props.block.id))
262
+ const envTestRunning = computed(() => envTestRun.value?.status === 'running')
263
+ // Nothing to provision for an `infraless` service, so there is nothing to test.
264
+ const canTestEnv = computed(() => provisionType.value !== 'infraless')
265
+
266
+ // Per-stage label KEYS, exhaustive over the contracts `EnvironmentTestStage` union: a new
267
+ // backend stage fails THIS typecheck until mapped (the key is resolved at runtime, so the
268
+ // typed-message-keys check can't see the `t()` lookup — the map's exhaustiveness is the
269
+ // drift guard, same pattern as `CONFLICT_TITLE_KEYS`).
270
+ const ENV_TEST_STAGE_KEYS: Record<EnvironmentTestStage, string> = {
271
+ creating_branch: 'inspector.testConfig.envTest.stage.creating_branch',
272
+ provisioning: 'inspector.testConfig.envTest.stage.provisioning',
273
+ tearing_down: 'inspector.testConfig.envTest.stage.tearing_down',
274
+ deleting_branch: 'inspector.testConfig.envTest.stage.deleting_branch',
275
+ done: 'inspector.testConfig.envTest.stage.done',
276
+ }
277
+
278
+ function envTestStageLabel(stage: EnvironmentTestStage): string {
279
+ const key = ENV_TEST_STAGE_KEYS[stage]
280
+ // `te`-guarded so a locale missing the key shows the raw stage id, never a raw message key.
281
+ return te(key) ? t(key) : stage
282
+ }
283
+
284
+ // The start preflight's machine-readable 409 reasons, mapped to their localized titles —
285
+ // exhaustive over the contracts `env_test_*` conflict reasons (same drift guard as above).
286
+ // The raw backend `message` is only the last-resort fallback for unmapped/non-conflict errors.
287
+ const ENV_TEST_CONFLICT_KEYS: Record<Extract<ConflictReason, `env_test_${string}`>, string> = {
288
+ env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
289
+ env_test_infraless: 'errors.conflict.title.env_test_infraless',
290
+ env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
291
+ env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
292
+ }
293
+
294
+ function envTestErrorText(e: unknown): string {
295
+ const reason = parseConflict(e)?.reason
296
+ const key =
297
+ reason && reason in ENV_TEST_CONFLICT_KEYS
298
+ ? ENV_TEST_CONFLICT_KEYS[reason as keyof typeof ENV_TEST_CONFLICT_KEYS]
299
+ : undefined
300
+ if (key && te(key)) return t(key)
301
+ return apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e))
302
+ }
303
+
304
+ async function startEnvTest() {
305
+ if (!canTestEnv.value || envTestStarting.value || envTestRunning.value) return
306
+ envTestStarting.value = true
307
+ envTestError.value = null
308
+ try {
309
+ await envTest.start(props.block.id)
310
+ } catch (e) {
311
+ envTestError.value = envTestErrorText(e)
312
+ } finally {
313
+ envTestStarting.value = false
314
+ }
315
+ }
316
+
317
+ async function stopEnvTest() {
318
+ const run = envTestRun.value
319
+ if (!run || run.status !== 'running') return
320
+ try {
321
+ await envTest.stop(run.id)
322
+ } catch (e) {
323
+ envTestError.value = envTestErrorText(e)
324
+ }
325
+ }
326
+
250
327
  // The provisioning hints (cloud provider + instance size) are advisory inputs to the
251
328
  // ephemeral-environment provisioner, not commonly tuned — keep them collapsed by default.
252
329
  const showProvisioning = ref(false)
@@ -923,5 +1000,80 @@ function setSize(value: InstanceSize) {
923
1000
  </div>
924
1001
  </div>
925
1002
  </InspectorSection>
1003
+
1004
+ <!-- Ephemeral-environment self-test: exercise the whole provisioning lifecycle against a
1005
+ throwaway branch and report success / the failing stage. Disabled for `infraless`. -->
1006
+ <div class="mt-3 space-y-2 border-t border-white/5 pt-3" data-testid="env-test-section">
1007
+ <div class="flex items-center justify-between gap-2">
1008
+ <div class="min-w-0">
1009
+ <p class="text-[11px] font-medium text-slate-300">
1010
+ {{ t('inspector.testConfig.envTest.title') }}
1011
+ </p>
1012
+ <p class="text-[11px] text-slate-400">{{ t('inspector.testConfig.envTest.hint') }}</p>
1013
+ </div>
1014
+ <UButton
1015
+ v-if="!envTestRunning"
1016
+ icon="i-lucide-flask-conical"
1017
+ size="xs"
1018
+ color="primary"
1019
+ variant="soft"
1020
+ data-testid="env-test-start"
1021
+ :loading="envTestStarting"
1022
+ :disabled="!canTestEnv"
1023
+ @click="startEnvTest"
1024
+ >
1025
+ {{ t('inspector.testConfig.envTest.start') }}
1026
+ </UButton>
1027
+ <UButton
1028
+ v-else
1029
+ icon="i-lucide-square"
1030
+ size="xs"
1031
+ color="neutral"
1032
+ variant="ghost"
1033
+ data-testid="env-test-stop"
1034
+ @click="stopEnvTest"
1035
+ >
1036
+ {{ t('inspector.testConfig.envTest.stop') }}
1037
+ </UButton>
1038
+ </div>
1039
+
1040
+ <p v-if="!canTestEnv" class="text-[11px] text-slate-500">
1041
+ {{ t('inspector.testConfig.envTest.infraless') }}
1042
+ </p>
1043
+
1044
+ <!-- Live stage + terminal outcome of the tracked run (pushed via the workspace stream). -->
1045
+ <p
1046
+ v-if="envTestRun"
1047
+ class="text-[11px]"
1048
+ :class="{
1049
+ 'text-sky-300/80': envTestRun.status === 'running',
1050
+ 'text-emerald-300/80': envTestRun.status === 'succeeded',
1051
+ 'text-rose-300/80': envTestRun.status === 'failed',
1052
+ }"
1053
+ data-testid="env-test-status"
1054
+ >
1055
+ <template v-if="envTestRun.status === 'running'">
1056
+ {{
1057
+ t('inspector.testConfig.envTest.running', {
1058
+ stage: envTestStageLabel(envTestRun.stage),
1059
+ })
1060
+ }}
1061
+ </template>
1062
+ <template v-else-if="envTestRun.status === 'succeeded'">
1063
+ {{ t('inspector.testConfig.envTest.succeeded') }}
1064
+ </template>
1065
+ <template v-else>
1066
+ {{ t('inspector.testConfig.envTest.failed') }}
1067
+ <template v-if="envTestRun.failedStage">
1068
+ ({{ envTestStageLabel(envTestRun.failedStage) }})
1069
+ </template>
1070
+ <span v-if="envTestRun.error" class="block text-rose-300/70">{{ envTestRun.error }}</span>
1071
+ </template>
1072
+ </p>
1073
+
1074
+ <p v-if="envTestError" class="text-[11px] text-rose-400" data-testid="env-test-error">
1075
+ {{ envTestError }}
1076
+ </p>
1077
+ </div>
926
1078
  </InspectorSection>
927
1079
  </template>
@@ -6,7 +6,14 @@
6
6
  // - Mentions (per-account): toggle + GitHub-user-id → Slack-member-id map.
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { NotificationType } from '~/types/notifications'
9
- import type { SlackMemberMappingEntry, SlackMemberRole, SlackRoute } from '~/types/slack'
9
+ import type { SlackMemberRole, SlackRoute } from '~/types/slack'
10
+ import {
11
+ type MemberRow,
12
+ emptyMemberRow,
13
+ hasHalfFilledRow,
14
+ toMemberEntries,
15
+ toMemberRow,
16
+ } from '~/utils/slackMemberMapping'
10
17
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
18
  import SecretInput from '~/components/common/SecretInput.vue'
12
19
 
@@ -60,9 +67,15 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
60
67
  initiative: { enabled: false, channel: '' },
61
68
  })
62
69
  const mentionsEnabled = ref(false)
63
- const mapping = ref<SlackMemberMappingEntry[]>([])
70
+ // Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
71
+ // a mid-list delete keys the v-model by identity, not the array index (index keys
72
+ // silently rebound a neighbour's inputs — UX-23).
73
+ let uidSeq = 0
74
+ const nextUid = () => `m${++uidSeq}`
75
+ const mapping = ref<MemberRow[]>([])
64
76
  const tokenInput = ref('')
65
77
  const busy = ref(false)
78
+ const connectingOAuth = ref(false)
66
79
 
67
80
  function notifyError(title: string, e: unknown) {
68
81
  toast.add({
@@ -84,7 +97,7 @@ watch(
84
97
  routes[type] = slack.settings?.routes[type] ?? { enabled: false, channel: '' }
85
98
  }
86
99
  mentionsEnabled.value = slack.settings?.mentionsEnabled ?? false
87
- mapping.value = slack.memberMapping.map((e) => ({ role: 'engineering', ...e }))
100
+ mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
88
101
  } catch (e) {
89
102
  notifyError(t('slack.error.loadSettings'), e)
90
103
  }
@@ -94,9 +107,13 @@ watch(
94
107
  )
95
108
 
96
109
  async function connectViaOAuth() {
110
+ connectingOAuth.value = true
97
111
  try {
112
+ // On success the browser navigates away, so `connectingOAuth` never resets here —
113
+ // it only clears on the error path below.
98
114
  window.location.href = await slack.installUrl()
99
115
  } catch (e) {
116
+ connectingOAuth.value = false
100
117
  notifyError(t('slack.error.startOAuth'), e)
101
118
  }
102
119
  }
@@ -144,17 +161,29 @@ async function saveRouting() {
144
161
  }
145
162
 
146
163
  function addMapping() {
147
- mapping.value.push({ userId: '', slackUserId: '', role: 'engineering' })
164
+ mapping.value.push(emptyMemberRow(nextUid()))
148
165
  }
149
- function removeMapping(index: number) {
150
- mapping.value.splice(index, 1)
166
+ function removeMapping(uid: string) {
167
+ mapping.value = mapping.value.filter((e) => e.uid !== uid)
151
168
  }
152
169
  async function saveMapping() {
170
+ // A partially-filled row (one id present, the other blank) used to be silently
171
+ // dropped on save (UX-23) — block instead so the user doesn't lose the entry. A
172
+ // fully-empty row is just an unused slot and is ignored.
173
+ if (hasHalfFilledRow(mapping.value)) {
174
+ toast.add({
175
+ title: t('slack.members.incompleteTitle'),
176
+ description: t('slack.members.incompleteBody'),
177
+ icon: 'i-lucide-triangle-alert',
178
+ color: 'warning',
179
+ })
180
+ return
181
+ }
153
182
  busy.value = true
154
183
  try {
155
- const entries = mapping.value.filter((e) => e.userId.trim() && e.slackUserId.trim())
184
+ const entries = toMemberEntries(mapping.value)
156
185
  await slack.updateMemberMapping(entries)
157
- mapping.value = slack.memberMapping.map((e) => ({ ...e }))
186
+ mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
158
187
  toast.add({ title: t('slack.toast.mapSaved'), icon: 'i-lucide-check', color: 'success' })
159
188
  } catch (e) {
160
189
  notifyError(t('slack.error.saveMap'), e)
@@ -181,6 +210,7 @@ async function saveMapping() {
181
210
  v-if="slack.oauthEnabled"
182
211
  color="primary"
183
212
  icon="i-lucide-slack"
213
+ :loading="connectingOAuth"
184
214
  @click="connectViaOAuth"
185
215
  >
186
216
  {{ t('slack.connect.addToSlack') }}
@@ -287,7 +317,7 @@ async function saveMapping() {
287
317
  </template>
288
318
  </i18n-t>
289
319
  </p>
290
- <div v-for="(entry, i) in mapping" :key="i" class="flex items-center gap-2">
320
+ <div v-for="entry in mapping" :key="entry.uid" class="flex items-center gap-2">
291
321
  <UInput
292
322
  v-model="entry.userId"
293
323
  size="sm"
@@ -312,7 +342,7 @@ async function saveMapping() {
312
342
  variant="ghost"
313
343
  size="xs"
314
344
  icon="i-lucide-trash-2"
315
- @click="removeMapping(i)"
345
+ @click="removeMapping(entry.uid)"
316
346
  />
317
347
  </div>
318
348
  <div class="flex justify-between">
@@ -1,4 +1,10 @@
1
- import { listEnvironmentsContract, provisionEnvironmentContract } from '@cat-factory/contracts'
1
+ import {
2
+ getEnvironmentTestContract,
3
+ listEnvironmentsContract,
4
+ provisionEnvironmentContract,
5
+ startEnvironmentTestContract,
6
+ stopEnvironmentTestContract,
7
+ } from '@cat-factory/contracts'
2
8
  import type { ProvisionEnvironmentInput } from '@cat-factory/contracts'
3
9
  import type { ApiContext } from './context'
4
10
 
@@ -12,5 +18,14 @@ export function environmentsApi({ send, ws }: ApiContext) {
12
18
  // wizard's "trial provision" against the just-saved config. Returns the resulting handle.
13
19
  provisionEnvironment: (workspaceId: string, body: ProvisionEnvironmentInput) =>
14
20
  send(provisionEnvironmentContract, { pathPrefix: ws(workspaceId), body }),
21
+
22
+ // Ephemeral-environment self-test: start a full create-branch → provision → tear-down →
23
+ // delete-branch cycle against a service frame, then read / stop its run.
24
+ startEnvironmentTest: (workspaceId: string, blockId: string) =>
25
+ send(startEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
26
+ getEnvironmentTest: (workspaceId: string, id: string) =>
27
+ send(getEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
28
+ stopEnvironmentTest: (workspaceId: string, id: string) =>
29
+ send(stopEnvironmentTestContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
15
30
  }
16
31
  }
@@ -55,6 +55,10 @@ const CONFLICT_TITLE_KEYS: Record<Exclude<ConflictReason, BespokeConflictReason>
55
55
  model_policy_blocked: 'errors.conflict.title.model_policy_blocked',
56
56
  model_policy_unsupported: 'errors.conflict.title.model_policy_unsupported',
57
57
  deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
58
+ env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
59
+ env_test_infraless: 'errors.conflict.title.env_test_infraless',
60
+ env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
61
+ env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
58
62
  }
59
63
 
60
64
  /**
@@ -18,6 +18,7 @@ export function useWorkspaceStream() {
18
18
  const execution = useExecutionStore()
19
19
  const board = useBoardStore()
20
20
  const agentRuns = useAgentRunsStore()
21
+ const environmentTest = useEnvironmentTestStore()
21
22
  const notifications = useNotificationsStore()
22
23
  const observability = useObservabilityStore()
23
24
  const requirements = useRequirementsStore()
@@ -102,6 +103,11 @@ export function useWorkspaceStream() {
102
103
  // the infrastructure-providers window's "repairing…" indicator updates in place
103
104
  // (then flips to ok / residual issues / a failure) without a refetch. No board block.
104
105
  agentRuns.upsertEnvConfigRepair(event.job)
106
+ } else if (event.type === 'envTest') {
107
+ // An ephemeral-environment self-test advanced a stage — patch the run so the service
108
+ // inspector's "Test environment creation" control shows the live stage + final
109
+ // outcome in place without a refetch. No board block.
110
+ environmentTest.upsert(event.run)
105
111
  } else if (event.type === 'notification') {
106
112
  // A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
107
113
  // inbox + per-block badge in place (resolved ones drop out of the inbox).