@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.
- package/app/components/board/AgentFailureCard.vue +3 -0
- package/app/components/board/TaskDependencyEdges.vue +66 -0
- package/app/components/board/nodes/BlockNode.vue +10 -2
- package/app/components/board/nodes/TaskCard.vue +12 -1
- package/app/components/common/ConfirmDialog.vue +59 -0
- package/app/components/common/EmptyState.vue +35 -0
- package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
- package/app/components/documents/ContextDocumentPicker.vue +8 -4
- package/app/components/fragments/FragmentLibraryManager.vue +10 -0
- package/app/components/github/GitHubPanel.vue +9 -0
- package/app/components/layout/BoardSwitcher.vue +11 -0
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/NotificationsInbox.vue +11 -0
- package/app/components/panels/InspectorPanel.vue +9 -13
- package/app/components/panels/StepContainerStatus.vue +37 -0
- package/app/components/panels/inspector/FrontendConfig.vue +349 -0
- package/app/components/panels/inspector/TaskDependencies.vue +18 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -0
- package/app/components/pipeline/PipelineBuilder.vue +13 -1
- package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
- package/app/components/providers/VendorCredentialsModal.vue +12 -3
- package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
- package/app/components/settings/MergeThresholdsPanel.vue +9 -0
- package/app/components/settings/ModelConfigurationPanel.vue +9 -0
- package/app/components/settings/UserSecretsSection.vue +11 -0
- package/app/components/slack/SlackPanel.vue +9 -0
- package/app/components/tasks/ContextIssuePicker.vue +8 -4
- package/app/composables/useBlockDeletion.ts +63 -0
- package/app/composables/useConfirm.ts +63 -0
- package/app/composables/useKeyboardShortcuts.ts +76 -0
- package/app/pages/index.vue +8 -0
- package/app/stores/agentRuns.spec.ts +56 -0
- package/app/stores/agentRuns.ts +22 -4
- package/app/stores/ui.ts +16 -0
- package/app/types/domain.ts +7 -0
- package/i18n/locales/en.json +131 -5
- package/i18n/locales/es.json +135 -9
- package/i18n/locales/fr.json +135 -9
- package/i18n/locales/he.json +135 -9
- package/i18n/locales/ja.json +135 -9
- package/i18n/locales/pl.json +135 -9
- package/i18n/locales/tr.json +135 -9
- package/i18n/locales/uk.json +135 -9
- 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
|
|
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
|
|
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 (
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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>
|