@cat-factory/app 0.70.1 → 0.71.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.
Files changed (42) hide show
  1. package/app/components/auth/LoginScreen.vue +3 -2
  2. package/app/components/board/AddTaskModal.vue +7 -1
  3. package/app/components/board/RecurringPipelineModal.vue +6 -1
  4. package/app/components/bootstrap/BootstrapModal.vue +3 -0
  5. package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
  6. package/app/components/focus/BlockFocusView.vue +12 -7
  7. package/app/components/humanTest/HumanTestWindow.vue +15 -1
  8. package/app/components/layout/AccountDeploymentSettings.vue +4 -0
  9. package/app/components/layout/AccountTeamSettings.vue +8 -2
  10. package/app/components/layout/AiProvidersBanner.vue +3 -1
  11. package/app/components/layout/InfraSetupBanner.vue +192 -0
  12. package/app/components/layout/ProviderConfigBanner.vue +3 -1
  13. package/app/components/panels/InspectorPanel.vue +13 -7
  14. package/app/components/panels/inspector/FrontendConfig.vue +19 -4
  15. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
  16. package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
  17. package/app/components/providers/ApiKeysSection.vue +4 -0
  18. package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
  19. package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
  20. package/app/components/settings/KubernetesEngineForm.vue +41 -5
  21. package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
  22. package/app/components/settings/ProviderConnectionTab.vue +2 -0
  23. package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
  24. package/app/composables/useConfirmAction.ts +96 -0
  25. package/app/composables/usePipelineErrorToast.ts +2 -0
  26. package/app/composables/useWorkspaceStream.ts +26 -6
  27. package/app/pages/index.vue +20 -4
  28. package/app/stores/agentRuns.spec.ts +20 -2
  29. package/app/stores/agentRuns.ts +21 -11
  30. package/app/stores/ui.ts +17 -1
  31. package/app/stores/workspace.ts +10 -2
  32. package/app/types/domain.ts +3 -0
  33. package/app/utils/pipeline.ts +22 -0
  34. package/i18n/locales/en.json +74 -10
  35. package/i18n/locales/es.json +71 -10
  36. package/i18n/locales/fr.json +71 -10
  37. package/i18n/locales/he.json +71 -10
  38. package/i18n/locales/ja.json +71 -10
  39. package/i18n/locales/pl.json +71 -10
  40. package/i18n/locales/tr.json +71 -10
  41. package/i18n/locales/uk.json +71 -10
  42. package/package.json +3 -2
@@ -129,8 +129,9 @@ const showOAuthDivider = computed(
129
129
 
130
130
  // Hosted (remote node) PAT login: the user pastes their OWN source-control PAT, which the
131
131
  // server resolves to an account and holds to its login/org/domain allowlist. The available
132
- // providers come from the server (`auth.patProviders`); empty in local mode (which uses the
133
- // configured-token flow above) and on OAuth-only facades like the Worker.
132
+ // providers come from the server (`auth.patProviders`) GitHub always, GitLab when configured,
133
+ // on both hosted facades (Node + Worker); empty in local mode (which uses the configured-token
134
+ // flow above).
134
135
  const remotePatProviders = computed<PatProvider[]>(() =>
135
136
  isLocalMode.value ? [] : (auth.patProviders as PatProvider[]),
136
137
  )
@@ -16,6 +16,7 @@ import { DOC_KINDS } from '~/types/domain'
16
16
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
17
17
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
18
18
  import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
19
+ import { pipelineAllowedForFrame } from '~/utils/pipeline'
19
20
 
20
21
  const ui = useUiStore()
21
22
  const board = useBoardStore()
@@ -195,6 +196,11 @@ const selectedModelPresetLabel = computed(() => {
195
196
  )
196
197
  })
197
198
 
199
+ // Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
200
+ // UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate).
201
+ const selectablePipelines = computed(() =>
202
+ pipelines.pipelines.filter((p) => pipelineAllowedForFrame(p, frame.value, board.blocks)),
203
+ )
198
204
  const pipelineMenu = computed(() => [
199
205
  [
200
206
  {
@@ -202,7 +208,7 @@ const pipelineMenu = computed(() => [
202
208
  icon: 'i-lucide-rotate-ccw',
203
209
  onSelect: () => (pipelineId.value = ''),
204
210
  },
205
- ...pipelines.pipelines.map((p) => ({
211
+ ...selectablePipelines.value.map((p) => ({
206
212
  label: p.name,
207
213
  icon: 'i-lucide-workflow',
208
214
  onSelect: () => (pipelineId.value = p.id),
@@ -6,6 +6,7 @@
6
6
  // pipeline is picked, the workspace issue-tracker choice is surfaced inline (it is
7
7
  // where that pipeline files its ticket) and saved alongside.
8
8
  import type { Recurrence, ScheduleTemplate } from '~/types/recurring'
9
+ import { pipelineAllowedForFrame } from '~/utils/pipeline'
9
10
 
10
11
  const ui = useUiStore()
11
12
  const board = useBoardStore()
@@ -47,8 +48,12 @@ function defaultRecurrence(): Recurrence {
47
48
  }
48
49
  }
49
50
 
51
+ // Hide UI-testing pipelines when the frame has no UI to exercise — they'd be refused at run start.
52
+ const selectablePipelines = computed(() =>
53
+ pipelines.pipelines.filter((p) => pipelineAllowedForFrame(p, frame.value, board.blocks)),
54
+ )
50
55
  const pipelineMenu = computed(() => [
51
- pipelines.pipelines.map((p) => ({
56
+ selectablePipelines.value.map((p) => ({
52
57
  label: p.name,
53
58
  icon: 'i-lucide-workflow',
54
59
  onSelect: () => (pipelineId.value = p.id),
@@ -15,6 +15,7 @@ const agentRuns = useAgentRunsStore()
15
15
  const github = useGitHubStore()
16
16
  const toast = useToast()
17
17
  const { t } = useI18n()
18
+ const { confirmAction, toastDone } = useConfirmAction()
18
19
 
19
20
  const open = computed({
20
21
  get: () => ui.bootstrapOpen,
@@ -349,9 +350,11 @@ async function saveArch() {
349
350
  }
350
351
 
351
352
  async function removeArch(a: ReferenceArchitecture) {
353
+ if (!(await confirmAction('remove', a.name))) return
352
354
  try {
353
355
  await bootstrap.deleteArchitecture(a.id)
354
356
  if (selectedArchId.value === a.id) selectedArchId.value = undefined
357
+ toastDone('remove', a.name)
355
358
  } catch (e) {
356
359
  toast.add({
357
360
  title: t('bootstrap.toast.deleteFailed'),
@@ -10,6 +10,7 @@ const { t } = useI18n()
10
10
  const ui = useUiStore()
11
11
  const documents = useDocumentsStore()
12
12
  const toast = useToast()
13
+ const { confirmAction } = useConfirmAction()
13
14
 
14
15
  const source = computed(() => ui.documentConnect?.source ?? null)
15
16
  const descriptor = computed(() =>
@@ -70,11 +71,11 @@ async function submit() {
70
71
 
71
72
  async function disconnect() {
72
73
  if (!source.value) return
74
+ const label = descriptor.value?.label ?? t('documents.connect.sourceFallback')
75
+ if (!(await confirmAction('disconnect', label))) return
73
76
  await documents.disconnect(source.value)
74
77
  toast.add({
75
- title: t('documents.connect.disconnected', {
76
- source: descriptor.value?.label ?? t('documents.connect.sourceFallback'),
77
- }),
78
+ title: t('documents.connect.disconnected', { source: label }),
78
79
  icon: 'i-lucide-unplug',
79
80
  })
80
81
  ui.closeDocumentConnect()
@@ -2,6 +2,7 @@
2
2
  import { onKeyStroke } from '@vueuse/core'
3
3
  import type { Block } from '~/types/domain'
4
4
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
+ import { pipelineAllowedForFrame } from '~/utils/pipeline'
5
6
  import PipelineProgress from '~/components/pipeline/PipelineProgress.vue'
6
7
 
7
8
  const board = useBoardStore()
@@ -26,13 +27,17 @@ const deps = computed(() =>
26
27
  (block.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
27
28
  )
28
29
 
29
- const runMenu = computed(() =>
30
- pipelines.pipelines.map((p) => ({
31
- label: p.name,
32
- icon: 'i-lucide-play',
33
- onSelect: () => block.value && execution.start(block.value.id, p),
34
- })),
35
- )
30
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise (see the backend gate).
31
+ const runMenu = computed(() => {
32
+ const frame = block.value ? board.serviceOf(block.value) : undefined
33
+ return pipelines.pipelines
34
+ .filter((p) => pipelineAllowedForFrame(p, frame, board.blocks))
35
+ .map((p) => ({
36
+ label: p.name,
37
+ icon: 'i-lucide-play',
38
+ onSelect: () => block.value && execution.start(block.value.id, p),
39
+ }))
40
+ })
36
41
 
37
42
  function close() {
38
43
  ui.focus(null)
@@ -19,6 +19,8 @@ const board = useBoardStore()
19
19
  const execution = useExecutionStore()
20
20
  const humanTest = useHumanTestStore()
21
21
  const { t, d } = useI18n()
22
+ const toast = useToast()
23
+ const { confirmAction, toastDone } = useConfirmAction()
22
24
 
23
25
  // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: the gate state
24
26
  // rides on the execution step, pushed over the stream.
@@ -102,7 +104,19 @@ async function recreate() {
102
104
  }
103
105
  async function destroy() {
104
106
  if (!blockId.value) return
105
- await humanTest.destroyEnv(blockId.value)
107
+ const noun = t('humanTest.envNoun')
108
+ if (!(await confirmAction('destroy', noun))) return
109
+ try {
110
+ await humanTest.destroyEnv(blockId.value)
111
+ toastDone('destroy', noun)
112
+ } catch (e) {
113
+ toast.add({
114
+ title: t('humanTest.destroyFailed'),
115
+ description: e instanceof Error ? e.message : String(e),
116
+ icon: 'i-lucide-triangle-alert',
117
+ color: 'error',
118
+ })
119
+ }
106
120
  }
107
121
 
108
122
  /** Env actions need a provider (an env is/was present, or it's provisioning) — disabled in degraded mode. */
@@ -14,6 +14,7 @@ const store = useAccountSettingsStore()
14
14
  const ui = useUiStore()
15
15
  const toast = useToast()
16
16
  const { t } = useI18n()
17
+ const { confirmAction } = useConfirmAction()
17
18
 
18
19
  // Deep-link anchor: the pipeline-start "configure storage" prompt opens this tab with the
19
20
  // ui store's scroll target set to `content-storage`, so we bring the storage section (which
@@ -207,6 +208,7 @@ async function saveSlack() {
207
208
  }
208
209
 
209
210
  async function clearSlack() {
211
+ if (!(await confirmAction('clear', 'Slack'))) return
210
212
  savingSlack.value = true
211
213
  try {
212
214
  await store.save(props.accountId, { secrets: { slackOAuth: null } })
@@ -262,6 +264,7 @@ async function saveLinear() {
262
264
  }
263
265
 
264
266
  async function clearLinear() {
267
+ if (!(await confirmAction('clear', 'Linear'))) return
265
268
  savingLinear.value = true
266
269
  try {
267
270
  await store.save(props.accountId, { secrets: { linearOAuth: null } })
@@ -319,6 +322,7 @@ async function saveWeb() {
319
322
  }
320
323
 
321
324
  async function clearWeb() {
325
+ if (!(await confirmAction('clear', t('layout.accountDeployment.web.title')))) return
322
326
  savingWeb.value = true
323
327
  try {
324
328
  await store.save(props.accountId, { secrets: { webSearch: null } })
@@ -14,6 +14,7 @@ const props = defineProps<{ accountId: string }>()
14
14
  const accounts = useAccountsStore()
15
15
  const toast = useToast()
16
16
  const { t, te } = useI18n()
17
+ const { confirmAction, toastDone } = useConfirmAction()
17
18
  const busy = ref(false)
18
19
 
19
20
  const ROLE_ITEMS = computed<{ label: string; value: AccountRole }[]>(() => [
@@ -132,9 +133,11 @@ async function sendInvite() {
132
133
  }
133
134
  }
134
135
 
135
- async function revoke(id: string) {
136
+ async function revoke(id: string, email: string) {
137
+ if (!(await confirmAction('revoke', email))) return
136
138
  try {
137
139
  await accounts.revokeInvite(props.accountId, id)
140
+ toastDone('revoke', email)
138
141
  } catch (e) {
139
142
  notifyError(t('layout.accountTeam.errors.revokeInvite'), e)
140
143
  }
@@ -164,9 +167,12 @@ async function connectEmail() {
164
167
  }
165
168
 
166
169
  async function disconnectEmail() {
170
+ const noun = t('layout.accountTeam.emailNoun')
171
+ if (!(await confirmAction('disconnect', noun))) return
167
172
  busy.value = true
168
173
  try {
169
174
  await accounts.disconnectEmail(props.accountId)
175
+ toastDone('disconnect', noun)
170
176
  } catch (e) {
171
177
  notifyError(t('layout.accountTeam.errors.disconnectEmail'), e)
172
178
  } finally {
@@ -258,7 +264,7 @@ async function disconnectEmail() {
258
264
  variant="ghost"
259
265
  icon="i-lucide-x"
260
266
  :aria-label="t('layout.accountTeam.invite.revoke')"
261
- @click="revoke(inv.id)"
267
+ @click="revoke(inv.id, inv.email)"
262
268
  />
263
269
  </span>
264
270
  </li>
@@ -18,7 +18,9 @@ const show = computed(() => showSetup.value || showPreset.value)
18
18
 
19
19
  <template>
20
20
  <Transition name="fade">
21
- <div v-if="show" class="absolute inset-x-0 top-0 z-40 flex justify-center px-4 pt-4">
21
+ <!-- Positioning/stacking is owned by the shared banner column in `pages/index.vue`; this
22
+ renders only its card and re-enables pointer events on it. -->
23
+ <div v-if="show" class="pointer-events-auto w-full max-w-3xl">
22
24
  <!-- (1) No usable AI source -->
23
25
  <div
24
26
  v-if="showSetup"
@@ -0,0 +1,192 @@
1
+ <script setup lang="ts">
2
+ // Loud prompt that this deployment needs a piece of infrastructure the operator hasn't set up
3
+ // yet, so a whole class of agents can't run. Driven off the server-computed `infraSetup`
4
+ // snapshot projection (`not_defined` per area) — so it only fires on a runtime that actually
5
+ // requires the piece (the runner-pool executor matters on remote Node; binary storage on any
6
+ // runtime whose account picked no backend — incl. Cloudflare without an ARTIFACT_BUCKET binding;
7
+ // ephemeral test environments on any runtime that wires the integration).
8
+ //
9
+ // Positioning/stacking against the sibling advisory banners (AI-readiness, provider-config) is
10
+ // owned by the shared, click-through banner column in `pages/index.vue` — so concurrent prompts
11
+ // stack vertically instead of drawing on top of each other. This component only stacks its OWN
12
+ // (up to three) area cards; each card re-enables pointer events while the column stays inert.
13
+ //
14
+ // Dismissal offers the two choices the product asks for: hide for THIS SESSION (a ui-store flag,
15
+ // cleared on workspace switch, re-nags next load) or "I'm OK with the limitations, don't notify
16
+ // me again" — a PERMANENT, per-USER dismissal persisted in localStorage keyed by the signed-in
17
+ // user id (so it's this-user-only and survives reloads).
18
+ //
19
+ // Scope note: the permanent dismissal is per-USER and DEPLOYMENT-wide, not per-account. That is
20
+ // exact for `agentExecutor`/`ephemeralEnvironments` (deployment-level wiring). `binaryStorage` is
21
+ // per-account, so a user who permanently silences it on one account won't be re-nagged on another
22
+ // account that also has no storage — an accepted trade-off (the setting stays reachable from
23
+ // account settings, and the SESSION dismissal re-nags on the next load regardless).
24
+ //
25
+ // Freshness note: `infraSetup` is a server projection recomputed only on snapshot (re)load, so a
26
+ // banner clears on the next board load after the operator configures the area via the deep-link,
27
+ // not the instant the config panel saves.
28
+ import { useLocalStorage } from '@vueuse/core'
29
+ import { computed } from 'vue'
30
+ // The localStorage key holding the permanent per-user dismissals lives in `@cat-factory/contracts`
31
+ // (a dependency-free package the SPA and the e2e suite both import), so the key + shape can't drift
32
+ // between this component and the e2e seed in `backend/internal/e2e/tests/helpers.ts` (`pinWorkspace`).
33
+ import { INFRA_SETUP_DISMISSED_STORAGE_KEY } from '@cat-factory/contracts'
34
+ import type { DropdownMenuItem } from '@nuxt/ui'
35
+ import type { InfraSetupArea } from '~/types/domain'
36
+
37
+ const { t } = useI18n()
38
+ const ui = useUiStore()
39
+ const auth = useAuthStore()
40
+ const workspace = useWorkspaceStore()
41
+
42
+ // Severity order: no executor blocks EVERY agent, so it leads; a missing test environment blocks
43
+ // only testing agents; missing storage only degrades the UI-tester's screenshots.
44
+ const AREAS: InfraSetupArea[] = ['agentExecutor', 'ephemeralEnvironments', 'binaryStorage']
45
+
46
+ // Exhaustive per-area presentation (an exhaustive `Record<InfraSetupArea, …>`, so adding an area
47
+ // without a meta entry fails typecheck — the tier-2 guard). The i18n keys are resolved
48
+ // dynamically (`t(AREA_META[area].titleKey)`), so tier-1 typed-key checking doesn't cover them;
49
+ // the `i18n:check` drift guard (tier 3) catches any that are absent from the catalog. `action`
50
+ // deep-links into the relevant setup surface.
51
+ const AREA_META: Record<
52
+ InfraSetupArea,
53
+ { icon: string; titleKey: string; bodyKey: string; actionKey: string; onConfigure: () => void }
54
+ > = {
55
+ agentExecutor: {
56
+ icon: 'i-lucide-server-cog',
57
+ titleKey: 'layout.infraSetupBanner.agentExecutor.title',
58
+ bodyKey: 'layout.infraSetupBanner.agentExecutor.body',
59
+ actionKey: 'layout.infraSetupBanner.agentExecutor.action',
60
+ onConfigure: () => ui.openProviderConnection('runner-pool'),
61
+ },
62
+ ephemeralEnvironments: {
63
+ icon: 'i-lucide-flask-conical',
64
+ titleKey: 'layout.infraSetupBanner.ephemeralEnvironments.title',
65
+ bodyKey: 'layout.infraSetupBanner.ephemeralEnvironments.body',
66
+ actionKey: 'layout.infraSetupBanner.ephemeralEnvironments.action',
67
+ onConfigure: () => ui.openProviderConnection('environment'),
68
+ },
69
+ binaryStorage: {
70
+ icon: 'i-lucide-hard-drive',
71
+ titleKey: 'layout.infraSetupBanner.binaryStorage.title',
72
+ bodyKey: 'layout.infraSetupBanner.binaryStorage.body',
73
+ actionKey: 'layout.infraSetupBanner.binaryStorage.action',
74
+ onConfigure: () => ui.openContentStorageSettings(),
75
+ },
76
+ }
77
+
78
+ // Permanent, per-user dismissals: one shared localStorage record keyed BY user id (so it's
79
+ // scoped to the signed-in user and doesn't leak across accounts on a shared browser). No
80
+ // signed-in user (local/auth-off single-user mode) ⇒ the `local` bucket.
81
+ const permanentDismissed = useLocalStorage<Record<string, InfraSetupArea[]>>(
82
+ INFRA_SETUP_DISMISSED_STORAGE_KEY,
83
+ {},
84
+ )
85
+ const userKey = computed(() => auth.user?.id ?? 'local')
86
+ const dismissedForUser = computed(() => permanentDismissed.value[userKey.value] ?? [])
87
+ function dismissPermanently(area: InfraSetupArea) {
88
+ const current = permanentDismissed.value[userKey.value] ?? []
89
+ if (!current.includes(area)) {
90
+ permanentDismissed.value = {
91
+ ...permanentDismissed.value,
92
+ [userKey.value]: [...current, area],
93
+ }
94
+ }
95
+ }
96
+
97
+ const visible = computed<InfraSetupArea[]>(() => {
98
+ const status = workspace.infraSetup
99
+ if (!status) return []
100
+ return AREAS.filter(
101
+ (area) =>
102
+ status[area] === 'not_defined' &&
103
+ !ui.infraSetupSessionDismissed.includes(area) &&
104
+ !dismissedForUser.value.includes(area),
105
+ )
106
+ })
107
+
108
+ // The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close.
109
+ function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
110
+ return [
111
+ [
112
+ {
113
+ label: t('layout.infraSetupBanner.dismiss.session'),
114
+ icon: 'i-lucide-clock',
115
+ onSelect: () => ui.dismissInfraSetupForSession(area),
116
+ },
117
+ {
118
+ label: t('layout.infraSetupBanner.dismiss.permanent'),
119
+ icon: 'i-lucide-bell-off',
120
+ onSelect: () => dismissPermanently(area),
121
+ },
122
+ ],
123
+ ]
124
+ }
125
+ </script>
126
+
127
+ <template>
128
+ <Transition name="fade">
129
+ <!-- One polite live region for ALL area cards (plus the sibling AI/provider banners) rather
130
+ than an assertive `role="alert"` per card — an advisory setup nag shouldn't interrupt a
131
+ screen reader, and up to three stacked alerts would spam it. -->
132
+ <div
133
+ v-if="visible.length > 0"
134
+ class="flex w-full flex-col items-center gap-2"
135
+ role="status"
136
+ aria-live="polite"
137
+ >
138
+ <div
139
+ v-for="area in visible"
140
+ :key="area"
141
+ class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
142
+ :data-testid="`infra-setup-banner-${area}`"
143
+ >
144
+ <div class="flex items-start gap-4">
145
+ <UIcon :name="AREA_META[area].icon" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
146
+ <div class="min-w-0 flex-1">
147
+ <div class="flex items-start justify-between gap-3">
148
+ <h2 class="text-lg font-semibold text-amber-100">
149
+ {{ t(AREA_META[area].titleKey) }}
150
+ </h2>
151
+ <UDropdownMenu :items="dismissMenu(area)" :content="{ align: 'end' }">
152
+ <UButton
153
+ color="neutral"
154
+ variant="ghost"
155
+ size="xs"
156
+ icon="i-lucide-x"
157
+ :aria-label="t('common.close')"
158
+ :data-testid="`infra-setup-dismiss-${area}`"
159
+ />
160
+ </UDropdownMenu>
161
+ </div>
162
+ <p class="mt-1 text-sm text-amber-200/90">
163
+ {{ t(AREA_META[area].bodyKey) }}
164
+ </p>
165
+ <div class="mt-4">
166
+ <UButton
167
+ color="warning"
168
+ variant="solid"
169
+ icon="i-lucide-settings"
170
+ :data-testid="`infra-setup-configure-${area}`"
171
+ @click="AREA_META[area].onConfigure()"
172
+ >
173
+ {{ t(AREA_META[area].actionKey) }}
174
+ </UButton>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </Transition>
181
+ </template>
182
+
183
+ <style scoped>
184
+ .fade-enter-active,
185
+ .fade-leave-active {
186
+ transition: opacity 0.2s ease;
187
+ }
188
+ .fade-enter-from,
189
+ .fade-leave-to {
190
+ opacity: 0;
191
+ }
192
+ </style>
@@ -38,7 +38,9 @@ const show = computed(() => pending.value.length > 0 && !dismissed.value)
38
38
 
39
39
  <template>
40
40
  <Transition name="fade">
41
- <div v-if="show" class="absolute inset-x-0 top-0 z-40 flex justify-center px-4 pt-4">
41
+ <!-- Positioning/stacking is owned by the shared banner column in `pages/index.vue`; this
42
+ renders only its card and re-enables pointer events on it. -->
43
+ <div v-if="show" class="pointer-events-auto w-full max-w-3xl">
42
44
  <div
43
45
  class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
44
46
  role="alert"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block, BlockStatus } from '~/types/domain'
3
3
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
4
+ import { pipelineAllowedForFrame } from '~/utils/pipeline'
4
5
  import TaskContextDocs from '~/components/documents/TaskContextDocs.vue'
5
6
  import TaskContextIssues from '~/components/tasks/TaskContextIssues.vue'
6
7
  import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
@@ -137,13 +138,18 @@ const taskBranchUrl = computed(() => {
137
138
  return base ? `${base}/tree/${pr.branch}` : null
138
139
  })
139
140
 
140
- const runMenu = computed(() =>
141
- pipelines.pipelines.map((p) => ({
142
- label: p.name,
143
- icon: 'i-lucide-play',
144
- onSelect: () => block.value && execution.start(block.value.id, p),
145
- })),
146
- )
141
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise — they'd be refused
142
+ // at run start (see utils/pipeline + the backend gate).
143
+ const runMenu = computed(() => {
144
+ const frame = block.value ? board.serviceOf(block.value) : undefined
145
+ return pipelines.pipelines
146
+ .filter((p) => pipelineAllowedForFrame(p, frame, board.blocks))
147
+ .map((p) => ({
148
+ label: p.name,
149
+ icon: 'i-lucide-play',
150
+ onSelect: () => block.value && execution.start(block.value.id, p),
151
+ }))
152
+ })
147
153
 
148
154
  // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
149
155
  // (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
@@ -18,8 +18,15 @@ import type {
18
18
  const props = defineProps<{ block: Block }>()
19
19
 
20
20
  const board = useBoardStore()
21
+ const auth = useAuthStore()
21
22
  const { t } = useI18n()
22
23
 
24
+ // A browsable preview needs a long-lived host serve, so it is a local/node runtime capability
25
+ // the deployment advertises (`infrastructure.frontendPreview.supported`); the Worker reports it
26
+ // unsupported. Default to true until the auth handshake resolves so the toggle isn't briefly
27
+ // disabled on a runtime that does support it.
28
+ const previewSupported = computed(() => auth.infrastructure?.frontendPreview?.supported !== false)
29
+
23
30
  const config = computed<FrontendConfig>(() => props.block.frontendConfig ?? { backendBindings: [] })
24
31
  const bindings = computed(() => config.value.backendBindings ?? [])
25
32
 
@@ -226,7 +233,7 @@ function removeBinding(index: number) {
226
233
  step="1"
227
234
  size="xs"
228
235
  class="font-mono"
229
- placeholder="8080"
236
+ placeholder="4173"
230
237
  @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
231
238
  />
232
239
  </div>
@@ -247,6 +254,9 @@ function removeBinding(index: number) {
247
254
  (e: KeyboardEvent) => saveText('mockMappingsPath', (e.target as HTMLInputElement).value)
248
255
  "
249
256
  />
257
+ <p class="col-span-2 text-[11px] leading-snug text-slate-500">
258
+ {{ t('inspector.frontendConfig.mockMappingsHint') }}
259
+ </p>
250
260
  </div>
251
261
  </div>
252
262
 
@@ -331,18 +341,23 @@ function removeBinding(index: number) {
331
341
  </div>
332
342
  </div>
333
343
 
334
- <!-- Browsable preview (local/node only). -->
344
+ <!-- Browsable preview (local/node only; the Worker reports it unsupported). -->
335
345
  <div class="border-t border-slate-800 pt-2">
336
346
  <UCheckbox
337
- :model-value="config.previewEnabled === true"
347
+ :model-value="previewSupported && config.previewEnabled === true"
338
348
  :label="t('inspector.frontendConfig.previewEnabled')"
349
+ :disabled="!previewSupported"
339
350
  size="xs"
340
351
  @update:model-value="
341
352
  (v: boolean | 'indeterminate') => save({ previewEnabled: v === true ? true : undefined })
342
353
  "
343
354
  />
344
355
  <p class="mt-1 text-[11px] leading-snug text-slate-500">
345
- {{ t('inspector.frontendConfig.previewHint') }}
356
+ {{
357
+ previewSupported
358
+ ? t('inspector.frontendConfig.previewHint')
359
+ : t('inspector.frontendConfig.previewUnsupported')
360
+ }}
346
361
  </p>
347
362
  </div>
348
363
  </div>
@@ -12,6 +12,7 @@ const store = useReleaseHealthStore()
12
12
  const ui = useUiStore()
13
13
  const toast = useToast()
14
14
  const { t } = useI18n()
15
+ const { confirmAction, toastDone } = useConfirmAction()
15
16
 
16
17
  const busy = ref(false)
17
18
  const draft = reactive({ monitorIds: '', sloIds: '', envTag: '' })
@@ -70,12 +71,15 @@ async function save() {
70
71
  }
71
72
 
72
73
  async function clear() {
74
+ const noun = t('inspector.releaseHealth.configNoun')
75
+ if (!(await confirmAction('clear', noun))) return
73
76
  busy.value = true
74
77
  try {
75
78
  await store.removeConfig(props.block.id)
76
79
  draft.monitorIds = ''
77
80
  draft.sloIds = ''
78
81
  draft.envTag = ''
82
+ toastDone('clear', noun)
79
83
  } catch (e) {
80
84
  notifyError(t('inspector.releaseHealth.clearFailed'), e)
81
85
  } finally {
@@ -3,6 +3,7 @@ import { computed, onMounted } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import type { WritebackOverride } from '~/types/tracker'
5
5
  import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
6
+ import { pipelineAllowedForFrame } from '~/utils/pipeline'
6
7
 
7
8
  const props = defineProps<{ block: Block }>()
8
9
 
@@ -129,6 +130,12 @@ function setModelPreset(id: string) {
129
130
  const selectedPipeline = computed(() =>
130
131
  props.block.pipelineId ? pipelines.getPipeline(props.block.pipelineId) : undefined,
131
132
  )
133
+ // Hide UI-testing pipelines when this task's frame has no UI to exercise — they'd be refused at
134
+ // run start (see utils/pipeline + the backend gate).
135
+ const taskFrame = computed(() => board.serviceOf(props.block))
136
+ const selectablePipelines = computed(() =>
137
+ pipelines.pipelines.filter((p) => pipelineAllowedForFrame(p, taskFrame.value, board.blocks)),
138
+ )
132
139
  const pipelineMenu = computed(() => [
133
140
  [
134
141
  {
@@ -136,7 +143,7 @@ const pipelineMenu = computed(() => [
136
143
  icon: 'i-lucide-rotate-ccw',
137
144
  onSelect: () => setPipeline(''),
138
145
  },
139
- ...pipelines.pipelines.map((p) => ({
146
+ ...selectablePipelines.value.map((p) => ({
140
147
  label: p.name,
141
148
  icon: 'i-lucide-workflow',
142
149
  onSelect: () => setPipeline(p.id),
@@ -27,6 +27,7 @@ const keys = useApiKeysStore()
27
27
  const models = useModelsStore()
28
28
  const auth = useAuthStore()
29
29
  const toast = useToast()
30
+ const { confirmAction, toastDone } = useConfirmAction()
30
31
 
31
32
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
32
33
  const isAccount = computed(() => !!props.accountId)
@@ -209,11 +210,14 @@ async function add() {
209
210
  }
210
211
 
211
212
  async function remove(k: ApiKey) {
213
+ const noun = t('providers.apiKeys.keyNoun')
214
+ if (!(await confirmAction('remove', noun))) return
212
215
  try {
213
216
  if (k.scope === 'account') await keys.removeAccountKey(k.id)
214
217
  else if (k.scope === 'workspace') await keys.removeWorkspaceKey(k.id)
215
218
  else await keys.removeUserKey(k.id)
216
219
  if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
220
+ toastDone('remove', noun)
217
221
  } catch (e) {
218
222
  toast.add({
219
223
  title: t('providers.apiKeys.toast.removeFailed'),
@@ -10,6 +10,7 @@ import type { CustomManifestType } from '@cat-factory/contracts'
10
10
  const { t } = useI18n()
11
11
  const infra = useInfraConfigStore()
12
12
  const toast = useToast()
13
+ const { confirmAction, toastDone } = useConfirmAction()
13
14
 
14
15
  // A draft for the add/edit form. `manifestId` is locked on edit (it's the PK).
15
16
  const draft = reactive({
@@ -79,10 +80,12 @@ async function save() {
79
80
  }
80
81
 
81
82
  async function remove(type: CustomManifestType) {
83
+ if (!(await confirmAction('remove', type.label))) return
82
84
  busy.value = true
83
85
  try {
84
86
  await infra.removeCustomType(type.manifestId)
85
87
  if (editing.value && draft.manifestId === type.manifestId) startAdd()
88
+ toastDone('remove', type.label)
86
89
  } catch (e) {
87
90
  toast.add({
88
91
  title: t('settings.infrastructure.customType.removeFailed'),