@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
@@ -14,6 +14,7 @@ const ui = useUiStore()
14
14
  const workspace = useWorkspaceStore()
15
15
  const creds = useVendorCredentialsStore()
16
16
  const toast = useToast()
17
+ const { confirm } = useConfirm()
17
18
 
18
19
  const open = computed({
19
20
  get: () => ui.vendorCredentialsOpen,
@@ -139,9 +140,17 @@ async function add() {
139
140
  }
140
141
  }
141
142
 
142
- async function remove(id: string) {
143
+ async function remove(cred: { id: string; label: string }) {
144
+ const ok = await confirm({
145
+ title: t('providers.vendorCredentials.confirmRemove.title'),
146
+ description: t('providers.vendorCredentials.confirmRemove.body', { name: cred.label }),
147
+ variant: 'destructive',
148
+ confirmLabel: t('common.remove'),
149
+ icon: 'i-lucide-trash-2',
150
+ })
151
+ if (!ok) return
143
152
  try {
144
- await creds.remove(id)
153
+ await creds.remove(cred.id)
145
154
  } catch (e) {
146
155
  toast.add({
147
156
  title: t('providers.vendorCredentials.toast.removeFailed'),
@@ -258,7 +267,7 @@ function vendorLabel(v: SubscriptionVendor): string {
258
267
  color="error"
259
268
  variant="ghost"
260
269
  size="xs"
261
- @click="remove(c.id)"
270
+ @click="remove(c)"
262
271
  />
263
272
  </div>
264
273
  </div>
@@ -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'),
@@ -31,6 +31,7 @@ const infra = useInfraConfigStore()
31
31
  const auth = useAuthStore()
32
32
  const ui = useUiStore()
33
33
  const toast = useToast()
34
+ const { confirmAction } = useConfirmAction()
34
35
 
35
36
  const isLocal = computed(() => auth.localMode?.enabled === true)
36
37
 
@@ -116,6 +117,26 @@ async function testKube(payload: { config: KubeHandlerConfig; secrets: Record<st
116
117
  }
117
118
  }
118
119
 
120
+ // Test the ALREADY-SAVED workspace kube handler as-is (from its stored non-secret config), so
121
+ // an operator can verify an established connection straight from the list without re-opening the
122
+ // form or re-typing the token — the backend fills the stored token for the probe (empty secrets
123
+ // here ⇒ "use the saved one"). Distinct state from the form's own test so results don't cross.
124
+ const kubeSavedTesting = ref(false)
125
+ const kubeSavedTestResult = ref<TestResult>(null)
126
+ async function testSavedKube() {
127
+ const config = kubeHandler.value?.config
128
+ if (!config) return
129
+ kubeSavedTesting.value = true
130
+ kubeSavedTestResult.value = null
131
+ try {
132
+ kubeSavedTestResult.value = await infra.testHandler({ config, secrets: {} })
133
+ } catch (e) {
134
+ kubeSavedTestResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
135
+ } finally {
136
+ kubeSavedTesting.value = false
137
+ }
138
+ }
139
+
119
140
  async function testKubeOverride(payload: {
120
141
  config: KubeHandlerConfig
121
142
  secrets: Record<string, string>
@@ -154,6 +175,7 @@ async function saveKube(payload: { config: KubeHandlerConfig; secrets: Record<st
154
175
  }
155
176
 
156
177
  async function removeKube() {
178
+ if (!(await confirmAction('remove', t('settings.infrastructure.handler.kubeNoun')))) return
157
179
  busy.value = true
158
180
  try {
159
181
  await infra.unregisterHandler('kubernetes')
@@ -190,6 +212,8 @@ async function saveKubeOverride(payload: {
190
212
  }
191
213
 
192
214
  async function removeKubeOverride() {
215
+ if (!(await confirmAction('remove', t('settings.infrastructure.handler.kubeOverrideNoun'))))
216
+ return
193
217
  busy.value = true
194
218
  try {
195
219
  await infra.removeUserHandler('kubernetes')
@@ -311,6 +335,7 @@ async function saveCustom(payload: {
311
335
 
312
336
  async function removeCustom() {
313
337
  if (!selectedCustomId.value) return
338
+ if (!(await confirmAction('remove', t('settings.infrastructure.handler.customNoun')))) return
314
339
  busy.value = true
315
340
  try {
316
341
  await infra.unregisterHandler('custom', selectedCustomId.value)
@@ -354,22 +379,57 @@ function notifyError(e: unknown) {
354
379
  <h3 class="text-sm font-semibold text-slate-200">
355
380
  {{ t('inspector.testConfig.provisionTypes.kubernetes') }}
356
381
  </h3>
357
- <p
382
+ <!-- Established-connection card: a prominent checkbox signals a connection is stored, and
383
+ a Test button verifies the SAVED connection (the token is reused server-side) without
384
+ re-opening the form. Absent ⇒ the "not connected yet" hint. -->
385
+ <div
358
386
  v-if="kubeHandler"
359
- class="flex items-center justify-between gap-2 text-[12px] text-slate-300"
387
+ class="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2.5"
360
388
  >
361
- <span>
389
+ <div class="flex items-start justify-between gap-2">
390
+ <UCheckbox
391
+ :model-value="true"
392
+ disabled
393
+ size="lg"
394
+ :label="t('settings.infrastructure.handler.connectionEstablished')"
395
+ :ui="{ label: 'text-[13px] font-semibold text-emerald-300' }"
396
+ />
397
+ <UButton
398
+ icon="i-lucide-trash-2"
399
+ color="error"
400
+ variant="ghost"
401
+ size="xs"
402
+ :disabled="busy"
403
+ :aria-label="t('settings.infrastructure.handler.disconnect')"
404
+ @click="removeKube"
405
+ />
406
+ </div>
407
+ <p class="pl-7 text-[11px] text-slate-300">
362
408
  {{ t('settings.infrastructure.handler.activeEngine') }}
363
409
  <span class="text-slate-200">{{ kubeHandlerEngineLabel }}</span>
364
- </span>
365
- <UButton
366
- icon="i-lucide-trash-2"
367
- color="error"
368
- variant="ghost"
369
- size="xs"
370
- :disabled="busy"
371
- @click="removeKube"
372
- />
410
+ </p>
411
+ <div class="flex items-center gap-2 pl-7">
412
+ <UButton
413
+ color="neutral"
414
+ variant="soft"
415
+ size="xs"
416
+ icon="i-lucide-plug-zap"
417
+ :loading="kubeSavedTesting"
418
+ @click="testSavedKube"
419
+ >
420
+ {{ t('settings.providerConnection.test.button') }}
421
+ </UButton>
422
+ <span v-if="kubeSavedTestResult?.ok" class="text-xs text-emerald-400">
423
+ {{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.ok') }}
424
+ </span>
425
+ <span v-else-if="kubeSavedTestResult" class="text-xs text-rose-400">
426
+ {{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.failed') }}
427
+ </span>
428
+ </div>
429
+ </div>
430
+ <p v-else class="flex items-center gap-1.5 text-[12px] text-slate-500">
431
+ <UIcon name="i-lucide-circle-dashed" class="h-3.5 w-3.5" />
432
+ {{ t('settings.infrastructure.handler.notConnected') }}
373
433
  </p>
374
434
 
375
435
  <div class="space-y-1">
@@ -184,9 +184,19 @@ const urlValid = computed(() => {
184
184
  })
185
185
 
186
186
  const connected = computed(() => !!props.handler)
187
+ // Whether a token is already SAVED for this handler. The value is write-only (never returned),
188
+ // so on edit the token field starts blank and the operator may leave it blank to keep the stored
189
+ // token — the backend falls back to it for both the connection test and the save. A new value
190
+ // entered here replaces it.
191
+ const tokenStored = computed(
192
+ () => !!props.handler && props.handler.secretKeys.includes(KUBERNETES_ENV_TOKEN_SECRET_KEY),
193
+ )
187
194
  const canSave = computed(
188
195
  () =>
189
- !!form.label.trim() && !!form.apiServerUrl.trim() && !!apiToken.value.trim() && urlValid.value,
196
+ !!form.label.trim() &&
197
+ !!form.apiServerUrl.trim() &&
198
+ (tokenStored.value || !!apiToken.value.trim()) &&
199
+ urlValid.value,
190
200
  )
191
201
 
192
202
  // Why the Connect button is disabled, surfaced as a red hint next to it so a mandatory-field gap
@@ -198,7 +208,10 @@ const connectBlockedReason = computed(() => {
198
208
  if (!form.label.trim()) missing.push(t('settings.infrastructure.kubernetesEngine.label'))
199
209
  if (!form.apiServerUrl.trim())
200
210
  missing.push(t('settings.infrastructure.kubernetesEngine.apiServerUrl'))
201
- if (!apiToken.value.trim()) missing.push(t('settings.infrastructure.kubernetesEngine.apiToken'))
211
+ // The token is required only for a FIRST connect; on edit a saved token is preserved when
212
+ // the field is left blank, so don't flag it as missing.
213
+ if (!tokenStored.value && !apiToken.value.trim())
214
+ missing.push(t('settings.infrastructure.kubernetesEngine.apiToken'))
202
215
  if (form.urlSource === 'ingressTemplate' && !form.hostTemplate.trim())
203
216
  missing.push(t('settings.infrastructure.kubernetesEngine.hostTemplate'))
204
217
  if (form.urlSource === 'serviceStatus' && !form.serviceName.trim())
@@ -239,9 +252,12 @@ function buildPayload(): KubeHandlerPayload {
239
252
  if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
240
253
  // One honest assertion at the boundary that actually builds the shape (the reactive form is
241
254
  // dynamically assembled, then validated server-side); the emitted config flows typed onward.
255
+ // OMIT the token when the field is blank so the backend preserves the saved one (a blank
256
+ // secret means "keep it") — only a typed value is sent, and it replaces the stored token.
257
+ const token = apiToken.value.trim()
242
258
  return {
243
259
  config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
244
- secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
260
+ secrets: token ? { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: token } : {},
245
261
  }
246
262
  }
247
263
 
@@ -322,9 +338,29 @@ async function copyAutoSetupCommand() {
322
338
 
323
339
  <UFormField
324
340
  :label="t('settings.infrastructure.kubernetesEngine.apiToken')"
325
- :help="t('settings.infrastructure.kubernetesEngine.apiTokenHelp')"
341
+ :help="
342
+ tokenStored
343
+ ? t('settings.infrastructure.kubernetesEngine.apiTokenHelpStored')
344
+ : t('settings.infrastructure.kubernetesEngine.apiTokenHelp')
345
+ "
326
346
  >
327
- <UInput v-model="apiToken" type="password" class="font-mono" autocomplete="off" />
347
+ <template v-if="tokenStored" #hint>
348
+ <span class="inline-flex items-center gap-1 text-[11px] text-emerald-400">
349
+ <UIcon name="i-lucide-check-circle-2" class="h-3.5 w-3.5" />
350
+ {{ t('settings.infrastructure.kubernetesEngine.tokenSaved') }}
351
+ </span>
352
+ </template>
353
+ <UInput
354
+ v-model="apiToken"
355
+ type="password"
356
+ class="font-mono"
357
+ autocomplete="off"
358
+ :placeholder="
359
+ tokenStored
360
+ ? t('settings.infrastructure.kubernetesEngine.apiTokenPlaceholderStored')
361
+ : undefined
362
+ "
363
+ />
328
364
  </UFormField>
329
365
 
330
366
  <!-- URL derivation: how the live environment URL is resolved once the service's
@@ -13,6 +13,7 @@ const { t } = useI18n()
13
13
  const ui = useUiStore()
14
14
  const store = useLocalModelsStore()
15
15
  const toast = useToast()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  const open = computed({
18
19
  get: () => ui.localModelsOpen,
@@ -140,6 +141,16 @@ async function save() {
140
141
  }
141
142
 
142
143
  async function remove(p: LocalRunner) {
144
+ const ok = await confirm({
145
+ title: t('settings.localModelEndpoints.confirmRemove.title'),
146
+ description: t('settings.localModelEndpoints.confirmRemove.body', {
147
+ name: LOCAL_RUNNER_LABELS[p],
148
+ }),
149
+ variant: 'destructive',
150
+ confirmLabel: t('common.remove'),
151
+ icon: 'i-lucide-trash-2',
152
+ })
153
+ if (!ok) return
143
154
  busy.value = true
144
155
  try {
145
156
  await store.remove(p)
@@ -29,6 +29,7 @@ const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string
29
29
 
30
30
  const store = useMergePresetsStore()
31
31
  const toast = useToast()
32
+ const { confirm } = useConfirm()
32
33
 
33
34
  // Local editable copy per preset, kept in sync with the store. Percentages are
34
35
  // edited 0..100 and stored 0..1.
@@ -116,6 +117,14 @@ async function makeDefault(p: MergeThresholdPreset) {
116
117
  }
117
118
 
118
119
  async function remove(p: MergeThresholdPreset) {
120
+ const ok = await confirm({
121
+ title: t('settings.mergeThresholds.confirmDelete.title'),
122
+ description: t('settings.mergeThresholds.confirmDelete.body', { name: p.name }),
123
+ variant: 'destructive',
124
+ confirmLabel: t('common.delete'),
125
+ icon: 'i-lucide-trash-2',
126
+ })
127
+ if (!ok) return
119
128
  busy.value = p.id
120
129
  try {
121
130
  await store.remove(p.id)
@@ -25,6 +25,7 @@ const agents = useAgentsStore()
25
25
  const creds = useVendorCredentialsStore()
26
26
  const workspace = useWorkspaceStore()
27
27
  const toast = useToast()
28
+ const { confirm } = useConfirm()
28
29
 
29
30
  const open = computed({
30
31
  get: () => ui.modelConfigOpen,
@@ -144,6 +145,14 @@ async function setDefault(p: ModelPreset) {
144
145
  }
145
146
 
146
147
  async function remove(p: ModelPreset) {
148
+ const ok = await confirm({
149
+ title: t('settings.modelConfiguration.confirmDelete.title'),
150
+ description: t('settings.modelConfiguration.confirmDelete.body', { name: p.name }),
151
+ variant: 'destructive',
152
+ confirmLabel: t('common.delete'),
153
+ icon: 'i-lucide-trash-2',
154
+ })
155
+ if (!ok) return
147
156
  busy.value = true
148
157
  try {
149
158
  await presets.remove(p.id)
@@ -12,6 +12,7 @@ const { t } = useI18n()
12
12
  const ui = useUiStore()
13
13
  const store = useReleaseHealthStore()
14
14
  const toast = useToast()
15
+ const { confirmAction, toastDone } = useConfirmAction()
15
16
 
16
17
  const open = computed({
17
18
  get: () => ui.observabilityConnectionOpen,
@@ -95,9 +96,12 @@ async function saveIncident() {
95
96
  }
96
97
 
97
98
  async function disconnectIncident() {
99
+ const noun = t('settings.observabilityConnection.incidentNoun')
100
+ if (!(await confirmAction('disconnect', noun))) return
98
101
  incidentBusy.value = true
99
102
  try {
100
103
  await store.removeIncident()
104
+ toastDone('disconnect', noun)
101
105
  } catch (e) {
102
106
  notifyError(t('settings.observabilityConnection.toast.incidentDisconnectFailed'), e)
103
107
  } finally {
@@ -127,9 +131,12 @@ async function saveConnection() {
127
131
  }
128
132
 
129
133
  async function disconnect() {
134
+ const noun = t('settings.observabilityConnection.connectionNoun')
135
+ if (!(await confirmAction('disconnect', noun))) return
130
136
  busy.value = true
131
137
  try {
132
138
  await store.removeConnection()
139
+ toastDone('disconnect', noun)
133
140
  } catch (e) {
134
141
  notifyError(t('settings.observabilityConnection.toast.disconnectFailed'), e)
135
142
  } finally {
@@ -29,6 +29,7 @@ const emit = defineEmits<{ connected: [] }>()
29
29
  const { t } = useI18n()
30
30
  const store = useProviderConnectionsStore()
31
31
  const toast = useToast()
32
+ const { confirmAction } = useConfirmAction()
32
33
 
33
34
  const descriptor = computed(() => store.descriptorFor(props.kind))
34
35
  const connection = computed(() => store.connectionFor(props.kind))
@@ -250,6 +251,7 @@ async function saveConfig(payload: {
250
251
  }
251
252
 
252
253
  async function remove() {
254
+ if (!(await confirmAction('remove', descriptor.value?.label ?? props.kind))) return
253
255
  busy.value = true
254
256
  try {
255
257
  await store.remove(props.kind)
@@ -12,6 +12,7 @@ const { t } = useI18n()
12
12
  const ui = useUiStore()
13
13
  const store = useUserSecretsStore()
14
14
  const toast = useToast()
15
+ const { confirm } = useConfirm()
15
16
 
16
17
  const open = computed({
17
18
  get: () => ui.userSecretsOpen,
@@ -117,6 +118,16 @@ async function save() {
117
118
  }
118
119
 
119
120
  async function remove() {
121
+ const ok = await confirm({
122
+ title: t('settings.userSecrets.confirmRemove.title'),
123
+ description: t('settings.userSecrets.confirmRemove.body', {
124
+ name: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
125
+ }),
126
+ variant: 'destructive',
127
+ confirmLabel: t('common.remove'),
128
+ icon: 'i-lucide-trash-2',
129
+ })
130
+ if (!ok) return
120
131
  busy.value = true
121
132
  try {
122
133
  await store.remove(kind.value)
@@ -13,6 +13,7 @@ const ui = useUiStore()
13
13
  const slack = useSlackStore()
14
14
  const toast = useToast()
15
15
  const { t } = useI18n()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  const open = computed({
18
19
  get: () => ui.slackOpen,
@@ -108,6 +109,14 @@ async function connectWithToken() {
108
109
  }
109
110
 
110
111
  async function disconnect() {
112
+ const ok = await confirm({
113
+ title: t('slack.confirmDisconnect.title'),
114
+ description: t('slack.confirmDisconnect.body'),
115
+ variant: 'destructive',
116
+ confirmLabel: t('common.disconnect'),
117
+ icon: 'i-lucide-unplug',
118
+ })
119
+ if (!ok) return
111
120
  try {
112
121
  await slack.disconnect()
113
122
  } catch (e) {
@@ -7,6 +7,7 @@
7
7
  // them once the block exists (see useContextLinking). A search hit / pasted ref
8
8
  // carries `needsImport: true` so it's fetched + persisted before linking.
9
9
  import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
+ import EmptyState from '~/components/common/EmptyState.vue'
10
11
 
11
12
  const props = defineProps<{
12
13
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -245,15 +246,18 @@ onMounted(() => {
245
246
  </span>
246
247
  </button>
247
248
 
248
- <p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
249
- {{
249
+ <EmptyState
250
+ v-if="empty"
251
+ compact
252
+ icon="i-lucide-search-x"
253
+ :title="
250
254
  query.trim()
251
255
  ? t('tasks.picker.noMatches')
252
256
  : searchable
253
257
  ? t('tasks.picker.emptySearchable')
254
258
  : t('tasks.picker.emptyRefOnly')
255
- }}
256
- </p>
259
+ "
260
+ />
257
261
  </div>
258
262
  </div>
259
263
  </template>
@@ -17,6 +17,7 @@ const { t } = useI18n()
17
17
  const ui = useUiStore()
18
18
  const tasks = useTasksStore()
19
19
  const toast = useToast()
20
+ const { confirmAction } = useConfirmAction()
20
21
 
21
22
  const source = computed(() => ui.taskConnect?.source ?? null)
22
23
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
@@ -99,12 +100,12 @@ async function startOAuth() {
99
100
 
100
101
  async function disconnect() {
101
102
  if (!source.value) return
103
+ const label = descriptor.value?.label ?? t('tasks.connect.sourceFallback')
104
+ if (!(await confirmAction('disconnect', label))) return
102
105
  await tasks.disconnect(source.value)
103
106
  await tasks.probe()
104
107
  toast.add({
105
- title: t('tasks.connect.disconnectedToast', {
106
- label: descriptor.value?.label ?? t('tasks.connect.sourceFallback'),
107
- }),
108
+ title: t('tasks.connect.disconnectedToast', { label }),
108
109
  icon: 'i-lucide-unplug',
109
110
  })
110
111
  ui.closeTaskConnect()
@@ -0,0 +1,63 @@
1
+ import type { Block } from '~/types/domain'
2
+
3
+ /**
4
+ * The single, confirm-gated block deletion used by BOTH the inspector's Delete button and
5
+ * the global keyboard shortcut, so the two paths can never drift (one safe, one not). It
6
+ * mirrors the ordering the inspector always used — close the inspector, cancel any run, then
7
+ * optimistically delete (the store restores + toasts on backend failure via `RemovalSnapshot`)
8
+ * — and only inserts the confirmation prompt in front of the mutation.
9
+ *
10
+ * A recurring-pipeline task owns its reused block + run history, so removing the schedule
11
+ * deletes them server-side; that case deletes the schedule rather than orphaning it.
12
+ */
13
+ export function useBlockDeletion() {
14
+ const board = useBoardStore()
15
+ const execution = useExecutionStore()
16
+ const ui = useUiStore()
17
+ const recurring = useRecurringPipelinesStore()
18
+ const { confirm } = useConfirm()
19
+ const { t } = useI18n()
20
+
21
+ /** Resolve the confirm title/body for a block, matching the inspector's delete-label kinds. */
22
+ function copyFor(block: Block): { title: string; body: string } {
23
+ const schedule = recurring.byBlock(block.id)
24
+ const kind = schedule
25
+ ? 'recurring'
26
+ : block.level === 'task'
27
+ ? 'task'
28
+ : block.level === 'module'
29
+ ? 'module'
30
+ : 'service'
31
+ return {
32
+ title: t(`panels.inspector.confirmDelete.${kind}.title`),
33
+ body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }),
34
+ }
35
+ }
36
+
37
+ async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
38
+ if (!block) return false
39
+ const { title, body } = copyFor(block)
40
+ const ok = await confirm({
41
+ title,
42
+ description: body,
43
+ variant: 'destructive',
44
+ confirmLabel: t('common.delete'),
45
+ icon: 'i-lucide-trash-2',
46
+ })
47
+ if (!ok) return false
48
+
49
+ // Close the inspector right away; the stores hide the block optimistically and restore
50
+ // it (with a toast) only if the backend delete fails.
51
+ ui.select(null)
52
+ const schedule = recurring.byBlock(block.id)
53
+ if (schedule) {
54
+ void recurring.remove(schedule.id)
55
+ return true
56
+ }
57
+ execution.cancel(block.id)
58
+ void board.removeBlock(block.id)
59
+ return true
60
+ }
61
+
62
+ return { deleteBlock }
63
+ }
@@ -0,0 +1,63 @@
1
+ import { readonly, ref } from 'vue'
2
+
3
+ /**
4
+ * A shared, promise-based confirmation dialog. Call `confirm(...)` from anywhere and
5
+ * `await` the boolean the user chose — resolve on Confirm, reject-to-`false` on Cancel or
6
+ * any dismissal (backdrop click, Escape, route change). One `<ConfirmDialog />` is mounted
7
+ * app-wide (in `pages/index.vue`) and reads this singleton, so callers never render a modal
8
+ * themselves — they just `await useConfirm().confirm({...})` and branch on the result.
9
+ *
10
+ * The state lives at module scope (outside the exported function) so every caller and the
11
+ * single mounted dialog share ONE request queue.
12
+ */
13
+ export interface ConfirmRequest {
14
+ title: string
15
+ description?: string
16
+ confirmLabel?: string
17
+ cancelLabel?: string
18
+ /** `destructive` renders the confirm button in the error colour. */
19
+ variant?: 'default' | 'destructive'
20
+ icon?: string
21
+ }
22
+
23
+ const open = ref(false)
24
+ const current = ref<ConfirmRequest | null>(null)
25
+ let resolver: ((ok: boolean) => void) | null = null
26
+
27
+ /** Settle the pending promise (if any) with `ok`, then clear it. */
28
+ function settle(ok: boolean): void {
29
+ const resolve = resolver
30
+ resolver = null
31
+ resolve?.(ok)
32
+ }
33
+
34
+ export function useConfirm() {
35
+ function confirm(request: ConfirmRequest): Promise<boolean> {
36
+ // A new request while one is open supersedes the old one — resolve the previous
37
+ // `false` so its awaiter never hangs, then show the new request.
38
+ if (resolver) settle(false)
39
+ current.value = request
40
+ open.value = true
41
+ return new Promise<boolean>((resolve) => {
42
+ resolver = resolve
43
+ })
44
+ }
45
+
46
+ function accept(): void {
47
+ open.value = false
48
+ settle(true)
49
+ }
50
+
51
+ function cancel(): void {
52
+ open.value = false
53
+ settle(false)
54
+ }
55
+
56
+ // Called by the dialog when `open` flips to false without an explicit accept/cancel
57
+ // (backdrop, Escape, unmount). Any still-pending promise resolves `false`.
58
+ function dismissed(): void {
59
+ if (resolver) settle(false)
60
+ }
61
+
62
+ return { open, current: readonly(current), confirm, accept, cancel, dismissed }
63
+ }