@cat-factory/app 0.70.0 → 0.70.1

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 (41) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -0
  2. package/app/components/board/nodes/BlockNode.vue +10 -2
  3. package/app/components/board/nodes/TaskCard.vue +12 -1
  4. package/app/components/common/ConfirmDialog.vue +59 -0
  5. package/app/components/common/EmptyState.vue +35 -0
  6. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  7. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  8. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  9. package/app/components/github/GitHubPanel.vue +9 -0
  10. package/app/components/layout/BoardSwitcher.vue +11 -0
  11. package/app/components/layout/CommandBar.vue +8 -0
  12. package/app/components/layout/NotificationsInbox.vue +11 -0
  13. package/app/components/panels/InspectorPanel.vue +5 -13
  14. package/app/components/panels/StepContainerStatus.vue +37 -0
  15. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  16. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  17. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  18. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  19. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  20. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  21. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  22. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  23. package/app/components/settings/UserSecretsSection.vue +11 -0
  24. package/app/components/slack/SlackPanel.vue +9 -0
  25. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  26. package/app/composables/useBlockDeletion.ts +63 -0
  27. package/app/composables/useConfirm.ts +63 -0
  28. package/app/composables/useKeyboardShortcuts.ts +76 -0
  29. package/app/pages/index.vue +8 -0
  30. package/app/stores/agentRuns.spec.ts +56 -0
  31. package/app/stores/agentRuns.ts +22 -4
  32. package/app/stores/ui.ts +16 -0
  33. package/i18n/locales/en.json +103 -5
  34. package/i18n/locales/es.json +107 -9
  35. package/i18n/locales/fr.json +107 -9
  36. package/i18n/locales/he.json +107 -9
  37. package/i18n/locales/ja.json +107 -9
  38. package/i18n/locales/pl.json +107 -9
  39. package/i18n/locales/tr.json +107 -9
  40. package/i18n/locales/uk.json +107 -9
  41. package/package.json +1 -1
@@ -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
@@ -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() {
@@ -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>
@@ -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' })
@@ -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
  })
@@ -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
  }
@@ -145,20 +145,11 @@ const runMenu = computed(() =>
145
145
  })),
146
146
  )
147
147
 
148
+ // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
149
+ // (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
150
+ const { deleteBlock } = useBlockDeletion()
148
151
  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)
152
+ void deleteBlock(block.value)
162
153
  }
163
154
 
164
155
  // ---- failed agent run (bootstrap or execution) ------------------------------
@@ -492,6 +483,7 @@ const showOriginalDescription = ref(false)
492
483
  size="sm"
493
484
  icon="i-lucide-trash-2"
494
485
  class="ms-auto"
486
+ data-testid="inspector-delete"
495
487
  :title="deleteLabel"
496
488
  @click="remove"
497
489
  >
@@ -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>
@@ -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'"
@@ -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>
@@ -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
@@ -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>