@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
@@ -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),
@@ -53,6 +53,8 @@ async function retry() {
53
53
  <div
54
54
  class="nodrag rounded-lg border border-rose-900/60 bg-rose-950/40"
55
55
  :class="compact ? 'px-3 py-2' : 'px-3 py-2.5'"
56
+ data-testid="agent-failure-banner"
57
+ :data-run-kind="run.kind"
56
58
  >
57
59
  <div class="flex items-center gap-1.5" :class="compact ? 'text-[11px]' : 'text-xs'">
58
60
  <UIcon
@@ -95,6 +97,7 @@ async function retry() {
95
97
  class="nodrag mt-2 flex items-center gap-1 rounded-md bg-rose-900/40 text-rose-200 hover:bg-rose-900/70 disabled:opacity-60"
96
98
  :class="compact ? 'px-2 py-0.5 text-[10px]' : 'px-2 py-1 text-[11px]'"
97
99
  :disabled="retrying"
100
+ data-testid="agent-failure-retry"
98
101
  @click.stop="retry"
99
102
  >
100
103
  <UIcon
@@ -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),
@@ -256,7 +256,11 @@ const ITEM_ICON: Record<string, string> = {
256
256
  >
257
257
  <div class="h-1.5 w-full" :style="{ backgroundColor: accent }" />
258
258
  <!-- bootstrap-in-progress banner -->
259
- <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2">
259
+ <div
260
+ v-if="bootstrapping"
261
+ class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2"
262
+ data-testid="bootstrap-progress"
263
+ >
260
264
  <div class="flex items-center gap-1.5 text-[11px]">
261
265
  <UIcon
262
266
  name="i-lucide-loader-circle"
@@ -334,7 +338,11 @@ const ITEM_ICON: Record<string, string> = {
334
338
  >
335
339
  <div class="h-1.5 w-full rounded-t-2xl" :style="{ backgroundColor: accent }" />
336
340
  <!-- bootstrap-in-progress banner -->
337
- <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-4 py-2">
341
+ <div
342
+ v-if="bootstrapping"
343
+ class="border-b border-amber-900/50 bg-amber-950/30 px-4 py-2"
344
+ data-testid="bootstrap-progress"
345
+ >
338
346
  <div class="flex items-center gap-1.5 text-xs">
339
347
  <UIcon
340
348
  name="i-lucide-loader-circle"
@@ -87,7 +87,18 @@ async function run() {
87
87
  // the start was refused — the store surfaces the actionable toast itself). Revert the
88
88
  // optimistic state; on success the button unmounts once the stream pushes in_progress.
89
89
  const started = await execution.start(props.taskId, pipeline)
90
- if (!started) starting.value = false
90
+ if (started) {
91
+ // Confirm the (optimistic) start landed — the button unmounts once the stream pushes
92
+ // in_progress, so without this the successful action gives no feedback.
93
+ toast.add({
94
+ title: t('board.task.startedToast.title'),
95
+ description: t('board.task.startedToast.body', { name: pipeline.name }),
96
+ color: 'success',
97
+ icon: 'i-lucide-play',
98
+ })
99
+ } else {
100
+ starting.value = false
101
+ }
91
102
  }
92
103
 
93
104
  function review() {
@@ -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'),
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ // The single, app-wide confirmation dialog. Mounted once in `pages/index.vue` and driven
3
+ // entirely by the `useConfirm()` singleton, so any caller can `await confirm({...})` without
4
+ // rendering its own modal. UModal already provides the focus trap, Escape-to-close and
5
+ // backdrop, so this component adds none of that — it only resolves the pending promise and,
6
+ // crucially, resolves `false` whenever the modal closes without an explicit choice.
7
+
8
+ const { t } = useI18n()
9
+ const { open, current, accept, cancel, dismissed } = useConfirm()
10
+
11
+ const model = computed({
12
+ get: () => open.value,
13
+ set: (v: boolean) => {
14
+ // The user dismissed via backdrop / Escape — treat as cancel so the promise settles.
15
+ if (!v) dismissed()
16
+ },
17
+ })
18
+
19
+ const isDestructive = computed(() => current.value?.variant === 'destructive')
20
+ </script>
21
+
22
+ <template>
23
+ <UModal
24
+ v-model:open="model"
25
+ :title="current?.title ?? t('common.confirm.defaultTitle')"
26
+ :ui="{ content: 'max-w-md' }"
27
+ >
28
+ <template #body>
29
+ <div class="flex items-start gap-3" data-testid="confirm-dialog">
30
+ <div
31
+ v-if="current?.icon"
32
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
33
+ :class="isDestructive ? 'bg-red-500/10 text-red-400' : 'bg-slate-700/40 text-slate-300'"
34
+ >
35
+ <UIcon :name="current.icon" class="h-5 w-5" />
36
+ </div>
37
+ <p v-if="current?.description" class="text-sm text-slate-300">
38
+ {{ current.description }}
39
+ </p>
40
+ </div>
41
+ </template>
42
+
43
+ <template #footer>
44
+ <div class="flex w-full justify-end gap-2">
45
+ <UButton color="neutral" variant="ghost" data-testid="confirm-cancel" @click="cancel">
46
+ {{ current?.cancelLabel ?? t('common.cancel') }}
47
+ </UButton>
48
+ <UButton
49
+ :color="isDestructive ? 'error' : 'primary'"
50
+ data-testid="confirm-accept"
51
+ autofocus
52
+ @click="accept"
53
+ >
54
+ {{ current?.confirmLabel ?? t('common.confirm.confirm') }}
55
+ </UButton>
56
+ </div>
57
+ </template>
58
+ </UModal>
59
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ // A small, presentational "nothing here yet" placeholder — the reusable version of the
3
+ // hand-coded empty renders scattered across the app (context pickers, dependency lists,
4
+ // execution history, preset lists). Dumb by design: it takes already-resolved copy and an
5
+ // icon, and exposes a default slot for an optional call-to-action button. `compact` shrinks
6
+ // it for inline use inside a narrow panel section.
7
+ withDefaults(
8
+ defineProps<{
9
+ icon?: string
10
+ title: string
11
+ description?: string
12
+ compact?: boolean
13
+ }>(),
14
+ { icon: 'i-lucide-inbox', compact: false },
15
+ )
16
+ </script>
17
+
18
+ <template>
19
+ <div
20
+ class="flex flex-col items-center justify-center text-center text-slate-500"
21
+ :class="compact ? 'gap-1 py-4' : 'gap-2 py-10'"
22
+ data-testid="empty-state"
23
+ >
24
+ <UIcon :name="icon" :class="compact ? 'h-5 w-5' : 'h-8 w-8'" class="text-slate-600" />
25
+ <p :class="compact ? 'text-xs' : 'text-sm'" class="font-medium text-slate-400">
26
+ {{ title }}
27
+ </p>
28
+ <p v-if="description" :class="compact ? 'text-[11px]' : 'text-xs'" class="max-w-xs">
29
+ {{ description }}
30
+ </p>
31
+ <div v-if="$slots.default" :class="compact ? 'mt-1' : 'mt-2'">
32
+ <slot />
33
+ </div>
34
+ </div>
35
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ // The keyboard-shortcuts cheatsheet, opened with "?" (or from the command bar). A plain
3
+ // UModal listing every global shortcut with its keys rendered as UKbd chips. Driven by the
4
+ // ui store so "?" anywhere toggles it.
5
+ const { t } = useI18n()
6
+ const ui = useUiStore()
7
+
8
+ const open = computed({
9
+ get: () => ui.shortcutsHelpOpen,
10
+ set: (v: boolean) => (v ? ui.openShortcutsHelp() : ui.closeShortcutsHelp()),
11
+ })
12
+
13
+ // Each row: the display keys + the action label. `mod` renders as ⌘ on Apple, Ctrl elsewhere.
14
+ // `navigator.platform` is deprecated; prefer the userAgentData hint, fall back to userAgent.
15
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined
16
+ const platformHint =
17
+ (nav as { userAgentData?: { platform?: string } } | undefined)?.userAgentData?.platform ??
18
+ nav?.userAgent ??
19
+ ''
20
+ const isApple = /Mac|iPhone|iPad/.test(platformHint)
21
+ const modKey = isApple ? '⌘' : 'Ctrl'
22
+
23
+ const shortcuts = computed(() => [
24
+ { keys: [modKey, 'K'], label: t('layout.shortcuts.commandBar') },
25
+ { keys: ['Esc'], label: t('layout.shortcuts.deselect') },
26
+ { keys: ['Del'], label: t('layout.shortcuts.deleteBlock') },
27
+ { keys: ['?'], label: t('layout.shortcuts.help') },
28
+ ])
29
+ </script>
30
+
31
+ <template>
32
+ <UModal v-model:open="open" :title="t('layout.shortcuts.title')" :ui="{ content: 'max-w-md' }">
33
+ <template #body>
34
+ <dl class="space-y-2" data-testid="shortcuts-help">
35
+ <div
36
+ v-for="(s, i) in shortcuts"
37
+ :key="i"
38
+ class="flex items-center justify-between gap-4 rounded-md px-1 py-1.5"
39
+ >
40
+ <dt class="text-sm text-slate-300">{{ s.label }}</dt>
41
+ <dd class="flex shrink-0 items-center gap-1">
42
+ <UKbd v-for="k in s.keys" :key="k" :value="k" />
43
+ </dd>
44
+ </div>
45
+ </dl>
46
+ </template>
47
+ </UModal>
48
+ </template>
@@ -9,6 +9,7 @@
9
9
  // useContextLinking). A search hit / pasted ref carries `needsImport: true` so
10
10
  // it's fetched + persisted before linking. Mirrors ContextIssuePicker.
11
11
  import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
12
+ import EmptyState from '~/components/common/EmptyState.vue'
12
13
 
13
14
  const props = defineProps<{
14
15
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -238,15 +239,18 @@ onMounted(() => {
238
239
  </span>
239
240
  </button>
240
241
 
241
- <p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
242
- {{
242
+ <EmptyState
243
+ v-if="empty"
244
+ compact
245
+ icon="i-lucide-file-search"
246
+ :title="
243
247
  query.trim()
244
248
  ? t('documents.picker.noMatches')
245
249
  : searchable
246
250
  ? t('documents.picker.emptySearchable')
247
251
  : t('documents.picker.emptyRefOnly')
248
- }}
249
- </p>
252
+ "
253
+ />
250
254
  </div>
251
255
  </div>
252
256
  </template>
@@ -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)
@@ -31,6 +31,7 @@ const library =
31
31
  const documents = useDocumentsStore()
32
32
  const toast = useToast()
33
33
  const { t, d } = useI18n()
34
+ const { confirm } = useConfirm()
34
35
 
35
36
  const isWorkspace = props.kind === 'workspace'
36
37
  /** Linking a document at the account scope needs a workspace connection to fetch through. */
@@ -121,6 +122,15 @@ async function createFragment() {
121
122
  }
122
123
 
123
124
  async function removeFragment(id: string) {
125
+ const name = library.fragments.find((f) => f.id === id)?.title ?? ''
126
+ const ok = await confirm({
127
+ title: t('fragments.confirmRemove.title'),
128
+ description: t('fragments.confirmRemove.body', { name }),
129
+ variant: 'destructive',
130
+ confirmLabel: t('common.delete'),
131
+ icon: 'i-lucide-trash-2',
132
+ })
133
+ if (!ok) return
124
134
  try {
125
135
  await library.remove(id)
126
136
  toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
@@ -16,6 +16,7 @@ const { t } = useI18n()
16
16
  const ui = useUiStore()
17
17
  const github = useGitHubStore()
18
18
  const toast = useToast()
19
+ const { confirm } = useConfirm()
19
20
 
20
21
  const open = computed({
21
22
  get: () => ui.githubOpen,
@@ -46,6 +47,14 @@ function notifyError(title: string, e: unknown) {
46
47
  }
47
48
 
48
49
  async function disconnect() {
50
+ const ok = await confirm({
51
+ title: t('github.panel.confirmDisconnect.title'),
52
+ description: t('github.panel.confirmDisconnect.body'),
53
+ variant: 'destructive',
54
+ confirmLabel: t('common.disconnect'),
55
+ icon: 'i-lucide-unplug',
56
+ })
57
+ if (!ok) return
49
58
  try {
50
59
  await github.disconnect()
51
60
  toast.add({ title: t('github.panel.toast.disconnected'), icon: 'i-lucide-unplug' })
@@ -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"
@@ -12,6 +12,7 @@ const accounts = useAccountsStore()
12
12
  const workspace = useWorkspaceStore()
13
13
  const ui = useUiStore()
14
14
  const toast = useToast()
15
+ const { confirm } = useConfirm()
15
16
 
16
17
  const busy = ref(false)
17
18
 
@@ -142,6 +143,16 @@ async function switchBoard(id: string) {
142
143
  async function removeBoard() {
143
144
  const id = workspace.workspaceId
144
145
  if (!id) return
146
+ const ok = await confirm({
147
+ title: t('layout.boardSwitcher.confirmDelete.title'),
148
+ description: t('layout.boardSwitcher.confirmDelete.body', {
149
+ name: workspace.activeWorkspace?.name ?? t('layout.boardSwitcher.boardFallback'),
150
+ }),
151
+ variant: 'destructive',
152
+ confirmLabel: t('common.delete'),
153
+ icon: 'i-lucide-trash-2',
154
+ })
155
+ if (!ok) return
145
156
  busy.value = true
146
157
  try {
147
158
  await workspace.remove(id)
@@ -212,6 +212,14 @@ const commands = computed<Command[]>(() => {
212
212
  keywords: t('layout.commandBar.keywords.sandbox'),
213
213
  run: () => ui.openSandbox(),
214
214
  })
215
+ list.push({
216
+ id: 'keyboard-shortcuts',
217
+ label: t('layout.commandBar.cmd.shortcuts'),
218
+ group: groupWorkspace,
219
+ icon: 'i-lucide-keyboard',
220
+ keywords: t('layout.commandBar.keywords.shortcuts'),
221
+ run: () => ui.openShortcutsHelp(),
222
+ })
215
223
 
216
224
  return list
217
225
  })