@cat-factory/app 0.70.0 → 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 (69) 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/AgentFailureCard.vue +3 -0
  4. package/app/components/board/RecurringPipelineModal.vue +6 -1
  5. package/app/components/board/nodes/BlockNode.vue +10 -2
  6. package/app/components/board/nodes/TaskCard.vue +12 -1
  7. package/app/components/bootstrap/BootstrapModal.vue +3 -0
  8. package/app/components/common/ConfirmDialog.vue +59 -0
  9. package/app/components/common/EmptyState.vue +35 -0
  10. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  11. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  12. package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
  13. package/app/components/focus/BlockFocusView.vue +12 -7
  14. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  15. package/app/components/github/GitHubPanel.vue +9 -0
  16. package/app/components/humanTest/HumanTestWindow.vue +15 -1
  17. package/app/components/layout/AccountDeploymentSettings.vue +4 -0
  18. package/app/components/layout/AccountTeamSettings.vue +8 -2
  19. package/app/components/layout/AiProvidersBanner.vue +3 -1
  20. package/app/components/layout/BoardSwitcher.vue +11 -0
  21. package/app/components/layout/CommandBar.vue +8 -0
  22. package/app/components/layout/InfraSetupBanner.vue +192 -0
  23. package/app/components/layout/NotificationsInbox.vue +11 -0
  24. package/app/components/layout/ProviderConfigBanner.vue +3 -1
  25. package/app/components/panels/InspectorPanel.vue +18 -20
  26. package/app/components/panels/StepContainerStatus.vue +37 -0
  27. package/app/components/panels/inspector/FrontendConfig.vue +19 -4
  28. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
  29. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  30. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  31. package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
  32. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  33. package/app/components/providers/ApiKeysSection.vue +4 -0
  34. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  35. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  36. package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
  37. package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
  38. package/app/components/settings/KubernetesEngineForm.vue +41 -5
  39. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  40. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  41. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  42. package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
  43. package/app/components/settings/ProviderConnectionTab.vue +2 -0
  44. package/app/components/settings/UserSecretsSection.vue +11 -0
  45. package/app/components/slack/SlackPanel.vue +9 -0
  46. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  47. package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
  48. package/app/composables/useBlockDeletion.ts +63 -0
  49. package/app/composables/useConfirm.ts +63 -0
  50. package/app/composables/useConfirmAction.ts +96 -0
  51. package/app/composables/useKeyboardShortcuts.ts +76 -0
  52. package/app/composables/usePipelineErrorToast.ts +2 -0
  53. package/app/composables/useWorkspaceStream.ts +26 -6
  54. package/app/pages/index.vue +28 -4
  55. package/app/stores/agentRuns.spec.ts +74 -0
  56. package/app/stores/agentRuns.ts +33 -5
  57. package/app/stores/ui.ts +33 -1
  58. package/app/stores/workspace.ts +10 -2
  59. package/app/types/domain.ts +3 -0
  60. package/app/utils/pipeline.ts +22 -0
  61. package/i18n/locales/en.json +174 -12
  62. package/i18n/locales/es.json +176 -17
  63. package/i18n/locales/fr.json +176 -17
  64. package/i18n/locales/he.json +176 -17
  65. package/i18n/locales/ja.json +176 -17
  66. package/i18n/locales/pl.json +176 -17
  67. package/i18n/locales/tr.json +176 -17
  68. package/i18n/locales/uk.json +176 -17
  69. package/package.json +3 -2
@@ -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>
@@ -12,6 +12,7 @@ const { t, te } = useI18n()
12
12
  const notifications = useNotificationsStore()
13
13
  const ui = useUiStore()
14
14
  const execution = useExecutionStore()
15
+ const toast = useToast()
15
16
 
16
17
  const busy = ref<string | null>(null)
17
18
 
@@ -92,6 +93,11 @@ async function act(n: Notification) {
92
93
  busy.value = n.id
93
94
  try {
94
95
  await notifications.act(n.id)
96
+ toast.add({
97
+ title: t('layout.notifications.toast.acted'),
98
+ color: 'success',
99
+ icon: 'i-lucide-check',
100
+ })
95
101
  } finally {
96
102
  busy.value = null
97
103
  }
@@ -101,6 +107,11 @@ async function dismiss(n: Notification) {
101
107
  busy.value = n.id
102
108
  try {
103
109
  await notifications.dismiss(n.id)
110
+ toast.add({
111
+ title: t('layout.notifications.toast.dismissed'),
112
+ color: 'neutral',
113
+ icon: 'i-lucide-check',
114
+ })
104
115
  } finally {
105
116
  busy.value = null
106
117
  }
@@ -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,28 +138,24 @@ 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
 
154
+ // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
155
+ // (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
156
+ const { deleteBlock } = useBlockDeletion()
148
157
  function remove() {
149
- if (!block.value) return
150
- const id = block.value.id
151
- // Close the inspector right away; the stores hide the block optimistically and
152
- // restore it (with a toast) only if the backend delete fails.
153
- ui.select(null)
154
- // A schedule owns its reused block + run history — removing the schedule deletes
155
- // them server-side, so delete the schedule rather than orphaning it.
156
- if (schedule.value) {
157
- void recurring.remove(schedule.value.id)
158
- return
159
- }
160
- execution.cancel(id)
161
- void board.removeBlock(id)
158
+ void deleteBlock(block.value)
162
159
  }
163
160
 
164
161
  // ---- failed agent run (bootstrap or execution) ------------------------------
@@ -492,6 +489,7 @@ const showOriginalDescription = ref(false)
492
489
  size="sm"
493
490
  icon="i-lucide-trash-2"
494
491
  class="ms-auto"
492
+ data-testid="inspector-delete"
495
493
  :title="deleteLabel"
496
494
  @click="remove"
497
495
  >
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { useClipboard } from '@vueuse/core'
3
4
  import type { PipelineStep, RunContainerStatus } from '~/types/execution'
4
5
  import { containerPhaseLabel } from '~/utils/pipelineRender'
5
6
 
@@ -61,6 +62,22 @@ const CONTAINER_STATUS_META: Record<
61
62
 
62
63
  // The friendly phase label (clone → "Preparing workspace", …); only meaningful while up.
63
64
  const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phase, { t, te }))
65
+
66
+ // Make the container id / URL one-click copyable (they're long and used to be
67
+ // select-and-copy-by-hand), with a toast confirming the copy landed.
68
+ const toast = useToast()
69
+ const { copy, isSupported } = useClipboard()
70
+ async function copyText(text: string) {
71
+ // Only claim success once the write actually landed — a failed/unsupported clipboard
72
+ // (insecure context, denied permission) must not show a misleading "Copied" toast.
73
+ try {
74
+ if (!isSupported.value) throw new Error('clipboard unsupported')
75
+ await copy(text)
76
+ toast.add({ title: t('common.copied'), color: 'success', icon: 'i-lucide-check' })
77
+ } catch {
78
+ toast.add({ title: t('common.copyFailed'), color: 'error', icon: 'i-lucide-x' })
79
+ }
80
+ }
64
81
  </script>
65
82
 
66
83
  <template>
@@ -94,6 +111,16 @@ const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phas
94
111
  <dd class="truncate font-mono text-[11px] text-slate-300" :title="step.container.id">
95
112
  {{ step.container.id }}
96
113
  </dd>
114
+ <UButton
115
+ icon="i-lucide-copy"
116
+ color="neutral"
117
+ variant="ghost"
118
+ size="xs"
119
+ class="ms-auto shrink-0"
120
+ :title="t('panels.stepMeta.container.copyId')"
121
+ :aria-label="t('panels.stepMeta.container.copyId')"
122
+ @click="copyText(step.container.id)"
123
+ />
97
124
  </div>
98
125
  <div v-if="step.container?.url" class="flex items-center gap-2">
99
126
  <dt class="shrink-0 text-[11px] uppercase tracking-wide text-slate-500">
@@ -109,6 +136,16 @@ const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phas
109
136
  {{ step.container.url }}
110
137
  </a>
111
138
  </dd>
139
+ <UButton
140
+ icon="i-lucide-copy"
141
+ color="neutral"
142
+ variant="ghost"
143
+ size="xs"
144
+ class="ms-auto shrink-0"
145
+ :title="t('panels.stepMeta.container.copyUrl')"
146
+ :aria-label="t('panels.stepMeta.container.copyUrl')"
147
+ @click="copyText(step.container.url)"
148
+ />
112
149
  </div>
113
150
  </dl>
114
151
  </div>
@@ -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 {
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
+ import EmptyState from '~/components/common/EmptyState.vue'
3
4
 
4
5
  const props = defineProps<{ block: Block }>()
5
6
 
6
7
  const board = useBoardStore()
7
8
  const { depLabel } = useDepLabels()
9
+ const { confirm } = useConfirm()
8
10
  const { t } = useI18n()
9
11
 
10
12
  const deps = computed(() =>
@@ -27,8 +29,15 @@ const depMenu = computed(() => {
27
29
  }))
28
30
  })
29
31
 
30
- function removeDep(depId: string) {
31
- board.removeDependency(props.block.id, depId)
32
+ async function removeDep(dep: Block) {
33
+ const ok = await confirm({
34
+ title: t('inspector.dependencies.confirmRemove.title'),
35
+ description: t('inspector.dependencies.confirmRemove.body', { name: label(dep) }),
36
+ variant: 'destructive',
37
+ confirmLabel: t('common.remove'),
38
+ icon: 'i-lucide-unlink',
39
+ })
40
+ if (ok) board.removeDependency(props.block.id, dep.id)
32
41
  }
33
42
  </script>
34
43
 
@@ -61,13 +70,18 @@ function removeDep(depId: string) {
61
70
  ? t('inspector.dependencies.merged')
62
71
  : t('inspector.dependencies.notMerged')
63
72
  "
64
- @click="removeDep(d.id)"
73
+ @click="removeDep(d)"
65
74
  >
66
75
  {{ label(d) }}
67
76
  <UIcon name="i-lucide-x" class="ms-0.5 h-3 w-3" />
68
77
  </UBadge>
69
78
  </div>
70
- <div v-else class="text-[11px] text-slate-500">{{ t('inspector.dependencies.empty') }}</div>
79
+ <EmptyState
80
+ v-else
81
+ compact
82
+ icon="i-lucide-git-branch"
83
+ :title="t('inspector.dependencies.empty')"
84
+ />
71
85
  <div v-if="!runnable" class="mt-1 text-[10px] text-amber-400">
72
86
  {{ t('inspector.dependencies.blocked') }}
73
87
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  containerPhaseLabel,
9
9
  } from '~/utils/pipelineRender'
10
10
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
11
+ import EmptyState from '~/components/common/EmptyState.vue'
11
12
 
12
13
  const props = defineProps<{ block: Block }>()
13
14
 
@@ -34,6 +35,16 @@ const reviewStageLabel = computed(() =>
34
35
  )
35
36
 
36
37
  const instance = computed(() => execution.getInstance(props.block.executionId))
38
+
39
+ // Nothing to show yet: no run, no failed run, no PR, and not awaiting a merge — render an
40
+ // empty state instead of a blank gap so the section reads as "no runs yet" rather than broken.
41
+ const isEmpty = computed(
42
+ () =>
43
+ !instance.value &&
44
+ !failedRun.value &&
45
+ !props.block.pullRequest &&
46
+ props.block.status !== 'pr_ready',
47
+ )
37
48
  // A failed run is no longer executing: a step left mid-flight must stop showing
38
49
  // its live "Spinning up…" phase (the shared failure banner renders below).
39
50
  const runFailed = computed(() => instance.value?.status === 'failed')
@@ -184,6 +195,9 @@ async function resetRun() {
184
195
  :key="i"
185
196
  class="rounded-md px-2 py-1"
186
197
  :class="i === instance.currentStep ? 'bg-slate-800/70' : ''"
198
+ data-testid="run-step"
199
+ :data-step-kind="s.agentKind"
200
+ :data-step-state="s.state"
187
201
  >
188
202
  <div class="flex items-center gap-2">
189
203
  <!-- Every agent is clickable: it opens the step-detail overlay (timing,
@@ -221,6 +235,7 @@ async function resetRun() {
221
235
  <span
222
236
  v-if="s.subtasks && s.subtasks.total > 0"
223
237
  class="ms-auto font-mono text-[10px] tabular-nums text-slate-300"
238
+ data-testid="run-subtasks"
224
239
  :title="
225
240
  s.subtasks.inProgress > 0
226
241
  ? t('inspector.execution.subtasksProgress', {
@@ -399,6 +414,15 @@ async function resetRun() {
399
414
  </p>
400
415
  </div>
401
416
 
417
+ <!-- No run yet: read as "nothing here" rather than a blank gap. -->
418
+ <EmptyState
419
+ v-if="isEmpty"
420
+ compact
421
+ icon="i-lucide-play-circle"
422
+ :title="t('inspector.execution.empty.title')"
423
+ :description="t('inspector.execution.empty.body')"
424
+ />
425
+
402
426
  <!-- PR ready: merge -->
403
427
  <UButton
404
428
  v-if="block.status === 'pr_ready'"
@@ -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),
@@ -198,6 +198,18 @@ function edit(p: Pipeline) {
198
198
  pipelines.loadForEdit(p)
199
199
  }
200
200
 
201
+ const { confirm } = useConfirm()
202
+ async function removePipeline(p: Pipeline) {
203
+ const ok = await confirm({
204
+ title: t('pipeline.builder.confirmDeletePipeline.title'),
205
+ description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
206
+ variant: 'destructive',
207
+ confirmLabel: t('common.delete'),
208
+ icon: 'i-lucide-trash-2',
209
+ })
210
+ if (ok) void pipelines.removePipeline(p.id)
211
+ }
212
+
201
213
  /** Clone any pipeline (incl. a read-only built-in) into an editable copy, then edit it. */
202
214
  async function clone(p: Pipeline) {
203
215
  try {
@@ -776,7 +788,7 @@ async function clone(p: Pipeline) {
776
788
  variant="ghost"
777
789
  size="xs"
778
790
  :title="t('pipeline.builder.delete')"
779
- @click="pipelines.removePipeline(p.id)"
791
+ @click="removePipeline(p)"
780
792
  />
781
793
  </div>
782
794
  </div>
@@ -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'),
@@ -13,6 +13,7 @@ const workspace = useWorkspaceStore()
13
13
  const models = useModelsStore()
14
14
  const toast = useToast()
15
15
  const { t, d } = useI18n()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  // Personal subscriptions are stored per-user, so they need a signed-in user. When there
18
19
  // isn't one (a deployment running without sign-in), block the form so the user doesn't
@@ -141,6 +142,14 @@ async function connect() {
141
142
  }
142
143
 
143
144
  async function disconnect(v: SubscriptionVendor) {
145
+ const ok = await confirm({
146
+ title: t('personalSubscriptions.confirmDisconnect.title'),
147
+ description: t('personalSubscriptions.confirmDisconnect.body', { vendor: vendorLabel(v) }),
148
+ variant: 'destructive',
149
+ confirmLabel: t('common.disconnect'),
150
+ icon: 'i-lucide-unplug',
151
+ })
152
+ if (!ok) return
144
153
  try {
145
154
  await personal.remove(v)
146
155
  // Removing the subscription may drop the workspace's last usable model — refresh so the