@cat-factory/app 0.69.1 → 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 (44) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -0
  2. package/app/components/board/TaskDependencyEdges.vue +66 -0
  3. package/app/components/board/nodes/BlockNode.vue +10 -2
  4. package/app/components/board/nodes/TaskCard.vue +12 -1
  5. package/app/components/common/ConfirmDialog.vue +59 -0
  6. package/app/components/common/EmptyState.vue +35 -0
  7. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  8. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  9. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  10. package/app/components/github/GitHubPanel.vue +9 -0
  11. package/app/components/layout/BoardSwitcher.vue +11 -0
  12. package/app/components/layout/CommandBar.vue +8 -0
  13. package/app/components/layout/NotificationsInbox.vue +11 -0
  14. package/app/components/panels/InspectorPanel.vue +9 -13
  15. package/app/components/panels/StepContainerStatus.vue +37 -0
  16. package/app/components/panels/inspector/FrontendConfig.vue +349 -0
  17. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  18. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  19. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  20. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  21. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  22. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  23. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  24. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  25. package/app/components/settings/UserSecretsSection.vue +11 -0
  26. package/app/components/slack/SlackPanel.vue +9 -0
  27. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  28. package/app/composables/useBlockDeletion.ts +63 -0
  29. package/app/composables/useConfirm.ts +63 -0
  30. package/app/composables/useKeyboardShortcuts.ts +76 -0
  31. package/app/pages/index.vue +8 -0
  32. package/app/stores/agentRuns.spec.ts +56 -0
  33. package/app/stores/agentRuns.ts +22 -4
  34. package/app/stores/ui.ts +16 -0
  35. package/app/types/domain.ts +7 -0
  36. package/i18n/locales/en.json +131 -5
  37. package/i18n/locales/es.json +135 -9
  38. package/i18n/locales/fr.json +135 -9
  39. package/i18n/locales/he.json +135 -9
  40. package/i18n/locales/ja.json +135 -9
  41. package/i18n/locales/pl.json +135 -9
  42. package/i18n/locales/tr.json +135 -9
  43. package/i18n/locales/uk.json +135 -9
  44. package/package.json +2 -2
@@ -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
@@ -18,6 +18,9 @@ const segments = ref<Seg[]>([])
18
18
  // Epic→member membership links (distinct style from dependency edges).
19
19
  type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
20
20
  const memberSegments = ref<MemberSeg[]>([])
21
+ // Frontend frame → bound service frame links (from a frontend's backend bindings).
22
+ type FrontendSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
23
+ const frontendSegments = ref<FrontendSeg[]>([])
21
24
 
22
25
  // task → its dependencies, both ends being tasks
23
26
  const taskDeps = computed(() => {
@@ -43,6 +46,26 @@ const epicLinks = computed(() => {
43
46
  return out
44
47
  })
45
48
 
49
+ // frontend frame → each service it binds a backend upstream to (its ephemeral env is the
50
+ // "service under test"). The link IS a `service`-sourced backend binding on the frontend's
51
+ // config; deduped so multiple env vars bound to the same service draw one edge.
52
+ const frontendLinks = computed(() => {
53
+ const out: { id: string; source: string; target: string }[] = []
54
+ for (const f of board.frames) {
55
+ if (f.type !== 'frontend') continue
56
+ const seen = new Set<string>()
57
+ for (const binding of f.frontendConfig?.backendBindings ?? []) {
58
+ if (binding.source.kind !== 'service') continue
59
+ const serviceId = binding.source.serviceBlockId
60
+ if (seen.has(serviceId)) continue
61
+ seen.add(serviceId)
62
+ if (board.getBlock(serviceId))
63
+ out.push({ id: `${f.id}__fe__${serviceId}`, source: f.id, target: serviceId })
64
+ }
65
+ }
66
+ return out
67
+ })
68
+
46
69
  /** Resolve a task's anchor: walk up task → module → service to the first card
47
70
  * that's actually rendered (a container may be collapsed). */
48
71
  function anchorEl(taskId: string): HTMLElement | null {
@@ -112,6 +135,23 @@ function recompute() {
112
135
  members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
113
136
  }
114
137
  memberSegments.value = members
138
+
139
+ const fes: FrontendSeg[] = []
140
+ for (const link of frontendLinks.value) {
141
+ const a = anchorEl(link.source)
142
+ const b = anchorEl(link.target)
143
+ if (!a || !b || a === b) continue
144
+ const ra = a.getBoundingClientRect()
145
+ const rb = b.getBoundingClientRect()
146
+ const ax = ra.left + ra.width / 2 - origin.left
147
+ const ay = ra.top + ra.height / 2 - origin.top
148
+ const bx = rb.left + rb.width / 2 - origin.left
149
+ const by = rb.top + rb.height / 2 - origin.top
150
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
151
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
152
+ fes.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
153
+ }
154
+ frontendSegments.value = fes
115
155
  }
116
156
 
117
157
  const { pause, resume } = useRafFn(recompute, { immediate: false })
@@ -144,8 +184,34 @@ onBeforeUnmount(pause)
144
184
  >
145
185
  <path d="M0,0 L10,5 L0,10 z" fill="#64748b" />
146
186
  </marker>
187
+ <marker
188
+ id="frontend-arrow"
189
+ viewBox="0 0 10 10"
190
+ refX="8"
191
+ refY="5"
192
+ markerWidth="6"
193
+ markerHeight="6"
194
+ orient="auto-start-reverse"
195
+ >
196
+ <path d="M0,0 L10,5 L0,10 z" fill="#22d3ee" />
197
+ </marker>
147
198
  </defs>
148
199
 
200
+ <!-- frontend frame → bound service frame links (cyan, arrow toward the service under test) -->
201
+ <line
202
+ v-for="s in frontendSegments"
203
+ :key="s.id"
204
+ :x1="s.x1"
205
+ :y1="s.y1"
206
+ :x2="s.x2"
207
+ :y2="s.y2"
208
+ stroke="#22d3ee"
209
+ :stroke-width="1.5"
210
+ stroke-dasharray="1 4"
211
+ :stroke-opacity="0.6"
212
+ marker-end="url(#frontend-arrow)"
213
+ />
214
+
149
215
  <!-- epic → member membership links (soft violet, no arrowhead) -->
150
216
  <line
151
217
  v-for="s in memberSegments"
@@ -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
  }
@@ -7,6 +7,7 @@ import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
7
7
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
8
8
  import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
9
9
  import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
10
+ import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
10
11
  import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
11
12
  import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
12
13
  import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
@@ -144,20 +145,11 @@ const runMenu = computed(() =>
144
145
  })),
145
146
  )
146
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()
147
151
  function remove() {
148
- if (!block.value) return
149
- const id = block.value.id
150
- // Close the inspector right away; the stores hide the block optimistically and
151
- // restore it (with a toast) only if the backend delete fails.
152
- ui.select(null)
153
- // A schedule owns its reused block + run history — removing the schedule deletes
154
- // them server-side, so delete the schedule rather than orphaning it.
155
- if (schedule.value) {
156
- void recurring.remove(schedule.value.id)
157
- return
158
- }
159
- execution.cancel(id)
160
- void board.removeBlock(id)
152
+ void deleteBlock(block.value)
161
153
  }
162
154
 
163
155
  // ---- failed agent run (bootstrap or execution) ------------------------------
@@ -435,6 +427,9 @@ const showOriginalDescription = ref(false)
435
427
 
436
428
  <!-- service / module: tasks summary -->
437
429
  <ContainerSummary v-if="isContainer" :block="block" />
430
+ <!-- frontend (frame): build/serve/mock config + backend bindings (board links) -->
431
+ <FrontendConfig v-if="isFrame && block.type === 'frontend'" :block="block" />
432
+
438
433
  <!-- service (frame): test infra + provisioning configuration -->
439
434
  <ServiceTestConfig v-if="isFrame" :block="block" />
440
435
 
@@ -488,6 +483,7 @@ const showOriginalDescription = ref(false)
488
483
  size="sm"
489
484
  icon="i-lucide-trash-2"
490
485
  class="ms-auto"
486
+ data-testid="inspector-delete"
491
487
  :title="deleteLabel"
492
488
  @click="remove"
493
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>