@cat-factory/app 0.115.1 → 0.115.3
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/AgentStopButton.vue +11 -0
- package/app/components/fragments/FragmentLibraryManager.vue +102 -46
- package/app/components/panels/InspectorPanel.vue +29 -0
- package/app/components/panels/inspector/TaskExecution.vue +28 -0
- package/app/components/pipeline/PipelineProgress.vue +23 -3
- package/app/components/slack/SlackPanel.vue +40 -10
- package/app/composables/useStepTimer.spec.ts +80 -0
- package/app/composables/useStepTimer.ts +64 -28
- package/app/stores/consensus.spec.ts +76 -0
- package/app/stores/consensus.ts +11 -1
- package/app/stores/docInterview.spec.ts +64 -0
- package/app/stores/kaizen.spec.ts +134 -0
- package/app/stores/kaizen.ts +50 -3
- package/app/utils/slackMemberMapping.spec.ts +94 -0
- package/app/utils/slackMemberMapping.ts +46 -0
- package/i18n/locales/de.json +20 -5
- package/i18n/locales/en.json +22 -4
- package/i18n/locales/es.json +20 -5
- package/i18n/locales/fr.json +20 -5
- package/i18n/locales/he.json +20 -5
- package/i18n/locales/it.json +20 -5
- package/i18n/locales/ja.json +20 -5
- package/i18n/locales/pl.json +20 -5
- package/i18n/locales/tr.json +20 -5
- package/i18n/locales/uk.json +20 -5
- package/package.json +6 -6
|
@@ -22,12 +22,23 @@ const props = withDefaults(
|
|
|
22
22
|
const { t } = useI18n()
|
|
23
23
|
const agentRuns = useAgentRunsStore()
|
|
24
24
|
const toast = useToast()
|
|
25
|
+
const { confirm } = useConfirm()
|
|
25
26
|
const stopping = ref(false)
|
|
26
27
|
|
|
27
28
|
const displayLabel = computed(() => props.label ?? t('board.stop.label'))
|
|
28
29
|
|
|
29
30
|
async function stop() {
|
|
30
31
|
if (stopping.value) return
|
|
32
|
+
// Killing a running container discards its in-flight work — gate it behind a confirm,
|
|
33
|
+
// matching the confirm-then-mutate contract the task reset path uses.
|
|
34
|
+
const ok = await confirm({
|
|
35
|
+
title: t('board.stop.confirm.title'),
|
|
36
|
+
description: t('board.stop.confirm.body'),
|
|
37
|
+
confirmLabel: t('board.stop.confirm.confirm'),
|
|
38
|
+
variant: 'destructive',
|
|
39
|
+
icon: 'i-lucide-circle-stop',
|
|
40
|
+
})
|
|
41
|
+
if (!ok) return
|
|
31
42
|
stopping.value = true
|
|
32
43
|
try {
|
|
33
44
|
const kind = await agentRuns.stop(props.runId)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
|
|
7
7
|
// run. The account scope has no resolved/merged catalog and fetches document
|
|
8
8
|
// fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
|
|
9
|
-
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
10
10
|
import type {
|
|
11
11
|
DocumentSourceKind,
|
|
12
12
|
FragmentOwnerKind,
|
|
@@ -111,6 +111,24 @@ function notifyError(title: string, e: unknown) {
|
|
|
111
111
|
})
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// Per-row / per-form in-flight tracking. The store's single `library.loading` flag
|
|
115
|
+
// drove every row's button at once (UX-29) and cross-spun the add/link forms; key
|
|
116
|
+
// each async action so only the control that triggered it shows a spinner.
|
|
117
|
+
const busyRows = reactive(new Set<string>())
|
|
118
|
+
const rowBusy = (key: string) => busyRows.has(key)
|
|
119
|
+
async function withRow(key: string, fn: () => Promise<void>) {
|
|
120
|
+
if (busyRows.has(key)) return
|
|
121
|
+
busyRows.add(key)
|
|
122
|
+
try {
|
|
123
|
+
await fn()
|
|
124
|
+
} finally {
|
|
125
|
+
busyRows.delete(key)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const creating = ref(false)
|
|
129
|
+
const linkingDoc = ref(false)
|
|
130
|
+
const linkingSource = ref(false)
|
|
131
|
+
|
|
114
132
|
// ---- create a hand-authored fragment --------------------------------------
|
|
115
133
|
const draft = ref({ title: '', summary: '', body: '', tags: '' })
|
|
116
134
|
const draftValid = computed(
|
|
@@ -119,6 +137,7 @@ const draftValid = computed(
|
|
|
119
137
|
|
|
120
138
|
async function createFragment() {
|
|
121
139
|
if (!draftValid.value) return
|
|
140
|
+
creating.value = true
|
|
122
141
|
try {
|
|
123
142
|
await library.create({
|
|
124
143
|
title: draft.value.title.trim(),
|
|
@@ -133,6 +152,8 @@ async function createFragment() {
|
|
|
133
152
|
toast.add({ title: t('fragments.toast.added'), icon: 'i-lucide-check' })
|
|
134
153
|
} catch (e) {
|
|
135
154
|
notifyError(t('fragments.toast.addFailed'), e)
|
|
155
|
+
} finally {
|
|
156
|
+
creating.value = false
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
@@ -146,12 +167,14 @@ async function removeFragment(id: string) {
|
|
|
146
167
|
icon: 'i-lucide-trash-2',
|
|
147
168
|
})
|
|
148
169
|
if (!ok) return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
170
|
+
await withRow(`remove:${id}`, async () => {
|
|
171
|
+
try {
|
|
172
|
+
await library.remove(id)
|
|
173
|
+
toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
|
|
174
|
+
} catch (e) {
|
|
175
|
+
notifyError(t('fragments.toast.removeFailed'), e)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
// ---- document-backed (living) fragments -----------------------------------
|
|
@@ -201,6 +224,7 @@ const documentFragments = computed(() => library.fragments.filter((f) => f.docum
|
|
|
201
224
|
|
|
202
225
|
async function linkDocumentFragment() {
|
|
203
226
|
if (!docDraftValid.value) return
|
|
227
|
+
linkingDoc.value = true
|
|
204
228
|
try {
|
|
205
229
|
await library.createDocumentFragment({
|
|
206
230
|
source: docDraft.value.source as DocumentSourceKind,
|
|
@@ -214,16 +238,20 @@ async function linkDocumentFragment() {
|
|
|
214
238
|
toast.add({ title: t('fragments.toast.documentLinked'), icon: 'i-lucide-link' })
|
|
215
239
|
} catch (e) {
|
|
216
240
|
notifyError(t('fragments.toast.linkDocumentFailed'), e)
|
|
241
|
+
} finally {
|
|
242
|
+
linkingDoc.value = false
|
|
217
243
|
}
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
async function refreshFragment(id: string) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
247
|
+
await withRow(`refresh:${id}`, async () => {
|
|
248
|
+
try {
|
|
249
|
+
await library.refreshDocumentFragment(id)
|
|
250
|
+
toast.add({ title: t('fragments.toast.refreshed'), icon: 'i-lucide-refresh-cw' })
|
|
251
|
+
} catch (e) {
|
|
252
|
+
notifyError(t('fragments.toast.refreshFailed'), e)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
227
255
|
}
|
|
228
256
|
|
|
229
257
|
// ---- repo sources ----------------------------------------------------------
|
|
@@ -263,6 +291,7 @@ async function linkSource() {
|
|
|
263
291
|
if (!ownerName) return
|
|
264
292
|
const dirPath =
|
|
265
293
|
(githubReady.value ? sourceDir.value : manualSource.value.dirPath.trim()) || undefined
|
|
294
|
+
linkingSource.value = true
|
|
266
295
|
try {
|
|
267
296
|
const source = await library.linkSource({
|
|
268
297
|
repoOwner: ownerName.owner,
|
|
@@ -271,48 +300,71 @@ async function linkSource() {
|
|
|
271
300
|
gitRef: sourceRef.value.trim() || undefined,
|
|
272
301
|
})
|
|
273
302
|
resetSourceDraft()
|
|
303
|
+
// Auto-sync the freshly-linked source via the store method directly (not the
|
|
304
|
+
// `syncSource` row wrapper): a failure here should surface as a link failure, and
|
|
305
|
+
// the form-level `linkingSource` spinner already covers the whole operation.
|
|
274
306
|
await library.syncSource(source.id)
|
|
275
307
|
toast.add({ title: t('fragments.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
|
|
276
308
|
} catch (e) {
|
|
277
309
|
notifyError(t('fragments.toast.linkSourceFailed'), e)
|
|
310
|
+
} finally {
|
|
311
|
+
linkingSource.value = false
|
|
278
312
|
}
|
|
279
313
|
}
|
|
280
314
|
|
|
281
315
|
async function syncSource(id: string) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
316
|
+
await withRow(`sync:${id}`, async () => {
|
|
317
|
+
try {
|
|
318
|
+
const result = await library.syncSource(id)
|
|
319
|
+
toast.add({
|
|
320
|
+
title: t('fragments.toast.synced', {
|
|
321
|
+
updated: result.upserted,
|
|
322
|
+
removed: result.tombstoned,
|
|
323
|
+
}),
|
|
324
|
+
icon: 'i-lucide-refresh-cw',
|
|
325
|
+
color: 'info',
|
|
326
|
+
})
|
|
327
|
+
} catch (e) {
|
|
328
|
+
notifyError(t('fragments.toast.syncFailed'), e)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
295
331
|
}
|
|
296
332
|
|
|
297
333
|
async function checkSource(id: string) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
await withRow(`check:${id}`, async () => {
|
|
335
|
+
try {
|
|
336
|
+
const status = await library.checkSource(id)
|
|
337
|
+
toast.add({
|
|
338
|
+
title: status.changed
|
|
339
|
+
? t('fragments.toast.changesAvailable')
|
|
340
|
+
: t('fragments.toast.upToDate'),
|
|
341
|
+
icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
|
|
342
|
+
})
|
|
343
|
+
} catch (e) {
|
|
344
|
+
notifyError(t('fragments.toast.checkSourceFailed'), e)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
307
347
|
}
|
|
308
348
|
|
|
309
349
|
async function unlinkSource(id: string) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
350
|
+
const source = library.sources.find((s) => s.id === id)
|
|
351
|
+
const repo = source ? `${source.repoOwner}/${source.repoName}` : ''
|
|
352
|
+
const ok = await confirm({
|
|
353
|
+
title: t('fragments.confirmUnlinkSource.title'),
|
|
354
|
+
description: t('fragments.confirmUnlinkSource.body', { repo }),
|
|
355
|
+
variant: 'destructive',
|
|
356
|
+
confirmLabel: t('fragments.confirmUnlinkSource.confirm'),
|
|
357
|
+
icon: 'i-lucide-unplug',
|
|
358
|
+
})
|
|
359
|
+
if (!ok) return
|
|
360
|
+
await withRow(`unlink:${id}`, async () => {
|
|
361
|
+
try {
|
|
362
|
+
await library.unlinkSource(id)
|
|
363
|
+
toast.add({ title: t('fragments.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
|
|
364
|
+
} catch (e) {
|
|
365
|
+
notifyError(t('fragments.toast.unlinkSourceFailed'), e)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
316
368
|
}
|
|
317
369
|
</script>
|
|
318
370
|
|
|
@@ -417,6 +469,7 @@ async function unlinkSource(id: string) {
|
|
|
417
469
|
color="error"
|
|
418
470
|
variant="ghost"
|
|
419
471
|
class="ms-auto"
|
|
472
|
+
:loading="rowBusy(`remove:${f.id}`)"
|
|
420
473
|
@click="removeFragment(f.id)"
|
|
421
474
|
/>
|
|
422
475
|
</div>
|
|
@@ -446,7 +499,7 @@ async function unlinkSource(id: string) {
|
|
|
446
499
|
icon="i-lucide-plus"
|
|
447
500
|
size="sm"
|
|
448
501
|
:disabled="!draftValid"
|
|
449
|
-
:loading="
|
|
502
|
+
:loading="creating"
|
|
450
503
|
class="self-start"
|
|
451
504
|
@click="createFragment"
|
|
452
505
|
>
|
|
@@ -487,7 +540,7 @@ async function unlinkSource(id: string) {
|
|
|
487
540
|
icon="i-lucide-refresh-cw"
|
|
488
541
|
size="xs"
|
|
489
542
|
variant="ghost"
|
|
490
|
-
:loading="
|
|
543
|
+
:loading="rowBusy(`refresh:${f.id}`)"
|
|
491
544
|
:title="t('fragments.documents.refreshTitle')"
|
|
492
545
|
@click="refreshFragment(f.id)"
|
|
493
546
|
/>
|
|
@@ -496,6 +549,7 @@ async function unlinkSource(id: string) {
|
|
|
496
549
|
size="xs"
|
|
497
550
|
color="error"
|
|
498
551
|
variant="ghost"
|
|
552
|
+
:loading="rowBusy(`remove:${f.id}`)"
|
|
499
553
|
@click="removeFragment(f.id)"
|
|
500
554
|
/>
|
|
501
555
|
</div>
|
|
@@ -553,7 +607,7 @@ async function unlinkSource(id: string) {
|
|
|
553
607
|
icon="i-lucide-link"
|
|
554
608
|
size="sm"
|
|
555
609
|
:disabled="!docDraftValid"
|
|
556
|
-
:loading="
|
|
610
|
+
:loading="linkingDoc"
|
|
557
611
|
class="self-start"
|
|
558
612
|
@click="linkDocumentFragment"
|
|
559
613
|
>
|
|
@@ -598,13 +652,14 @@ async function unlinkSource(id: string) {
|
|
|
598
652
|
icon="i-lucide-search-check"
|
|
599
653
|
size="xs"
|
|
600
654
|
variant="ghost"
|
|
655
|
+
:loading="rowBusy(`check:${s.id}`)"
|
|
601
656
|
@click="checkSource(s.id)"
|
|
602
657
|
/>
|
|
603
658
|
<UButton
|
|
604
659
|
icon="i-lucide-refresh-cw"
|
|
605
660
|
size="xs"
|
|
606
661
|
variant="ghost"
|
|
607
|
-
:loading="
|
|
662
|
+
:loading="rowBusy(`sync:${s.id}`)"
|
|
608
663
|
@click="syncSource(s.id)"
|
|
609
664
|
/>
|
|
610
665
|
<UButton
|
|
@@ -612,6 +667,7 @@ async function unlinkSource(id: string) {
|
|
|
612
667
|
size="xs"
|
|
613
668
|
color="error"
|
|
614
669
|
variant="ghost"
|
|
670
|
+
:loading="rowBusy(`unlink:${s.id}`)"
|
|
615
671
|
@click="unlinkSource(s.id)"
|
|
616
672
|
/>
|
|
617
673
|
</div>
|
|
@@ -669,7 +725,7 @@ async function unlinkSource(id: string) {
|
|
|
669
725
|
icon="i-lucide-link"
|
|
670
726
|
size="sm"
|
|
671
727
|
:disabled="!sourceValid"
|
|
672
|
-
:loading="
|
|
728
|
+
:loading="linkingSource"
|
|
673
729
|
class="self-start"
|
|
674
730
|
@click="linkSource"
|
|
675
731
|
>
|
|
@@ -100,6 +100,21 @@ const statusLabel = computed(() =>
|
|
|
100
100
|
|
|
101
101
|
const runnable = computed(() => (block.value ? board.isRunnable(block.value.id) : false))
|
|
102
102
|
|
|
103
|
+
// A task runs only once every dependency has merged. When the Run trigger is locked
|
|
104
|
+
// it must say WHY — name the unfinished dependencies rather than showing a bare lock.
|
|
105
|
+
const unmetDepTitles = computed(() =>
|
|
106
|
+
block.value && isTask.value ? board.unmetDeps(block.value.id).map((b) => b.title) : [],
|
|
107
|
+
)
|
|
108
|
+
const runBlockedReason = computed(() =>
|
|
109
|
+
unmetDepTitles.value.length
|
|
110
|
+
? t(
|
|
111
|
+
'panels.inspector.runBlocked',
|
|
112
|
+
{ count: unmetDepTitles.value.length, names: unmetDepTitles.value.join(', ') },
|
|
113
|
+
unmetDepTitles.value.length,
|
|
114
|
+
)
|
|
115
|
+
: null,
|
|
116
|
+
)
|
|
117
|
+
|
|
103
118
|
// The delete control names what it removes, so selecting a task and deleting it
|
|
104
119
|
// reads as "Delete task" rather than ambiguously removing the whole service.
|
|
105
120
|
const deleteLabel = computed(() =>
|
|
@@ -532,6 +547,19 @@ const showOriginalDescription = ref(false)
|
|
|
532
547
|
<!-- initiative: status + goal, run-planning + tracker controls -->
|
|
533
548
|
<InitiativeInspector v-else-if="isInitiative" :block="block" />
|
|
534
549
|
|
|
550
|
+
<!-- Locked-run explanation: a disabled task Run button reads as a dead lock unless
|
|
551
|
+
it says what's holding it. Named here (and on the button title) so the blocking
|
|
552
|
+
dependencies are visible to pointer, keyboard, and touch alike — a native title
|
|
553
|
+
on a disabled button doesn't fire hover events. -->
|
|
554
|
+
<p
|
|
555
|
+
v-if="isTask && runBlockedReason"
|
|
556
|
+
class="flex items-start gap-1.5 text-[11px] text-amber-300/90"
|
|
557
|
+
data-testid="run-blocked-reason"
|
|
558
|
+
>
|
|
559
|
+
<UIcon name="i-lucide-lock" class="mt-px h-3 w-3 shrink-0" />
|
|
560
|
+
<span>{{ runBlockedReason }}</span>
|
|
561
|
+
</p>
|
|
562
|
+
|
|
535
563
|
<!-- actions -->
|
|
536
564
|
<div class="flex items-center gap-2">
|
|
537
565
|
<UDropdownMenu v-if="isTask" :items="runMenu">
|
|
@@ -542,6 +570,7 @@ const showOriginalDescription = ref(false)
|
|
|
542
570
|
:icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
|
|
543
571
|
trailing-icon="i-lucide-chevron-down"
|
|
544
572
|
:disabled="!runnable"
|
|
573
|
+
:title="runBlockedReason ?? undefined"
|
|
545
574
|
>
|
|
546
575
|
{{ instance ? t('panels.inspector.reRun') : t('panels.inspector.run') }}
|
|
547
576
|
</UButton>
|
|
@@ -11,6 +11,8 @@ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
|
11
11
|
import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
|
|
12
12
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
13
13
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
14
|
+
import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
|
|
15
|
+
import type { PipelineStep } from '~/types/execution'
|
|
14
16
|
|
|
15
17
|
const props = defineProps<{ block: Block }>()
|
|
16
18
|
|
|
@@ -85,6 +87,13 @@ function stepFailed(s: { state: string }) {
|
|
|
85
87
|
return runFailed.value && s.state === 'working'
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// A shared 1s tick drives every step's live elapsed clock, so a running step that
|
|
91
|
+
// hasn't yet emitted subtask counts reads as progressing rather than hung.
|
|
92
|
+
const nowTick = useNowTick()
|
|
93
|
+
function stepElapsed(s: PipelineStep): string | null {
|
|
94
|
+
return stepDurationLabel(s, nowTick.value, runFailed.value, instance.value?.failure?.occurredAt)
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
/** A gated step parked for approval reads "Needs approval", not "Needs decision". */
|
|
89
98
|
function labelForStep(s: {
|
|
90
99
|
state: string
|
|
@@ -148,6 +157,17 @@ function openForkFor(i: number) {
|
|
|
148
157
|
const stopping = ref(false)
|
|
149
158
|
async function stopRun() {
|
|
150
159
|
if (!instance.value || stopping.value) return
|
|
160
|
+
// Killing the running container discards its in-flight work — gate it behind the same
|
|
161
|
+
// confirm the board card's stop uses (via `AgentStopButton`), so every stop surface for
|
|
162
|
+
// a run is confirm-gated identically.
|
|
163
|
+
const ok = await confirm({
|
|
164
|
+
title: t('board.stop.confirm.title'),
|
|
165
|
+
description: t('board.stop.confirm.body'),
|
|
166
|
+
confirmLabel: t('board.stop.confirm.confirm'),
|
|
167
|
+
variant: 'destructive',
|
|
168
|
+
icon: 'i-lucide-circle-stop',
|
|
169
|
+
})
|
|
170
|
+
if (!ok) return
|
|
151
171
|
stopping.value = true
|
|
152
172
|
try {
|
|
153
173
|
await execution.stop(instance.value.id)
|
|
@@ -306,6 +326,14 @@ async function mergePr() {
|
|
|
306
326
|
>
|
|
307
327
|
<UIcon v-if="stepFailed(s)" name="i-lucide-circle-x" class="h-3 w-3 shrink-0" />
|
|
308
328
|
{{ labelForStep(s) }}
|
|
329
|
+
<!-- live elapsed clock: a running step counts up, a finished one shows total -->
|
|
330
|
+
<span
|
|
331
|
+
v-if="stepElapsed(s)"
|
|
332
|
+
class="inline-flex items-center gap-0.5 font-mono tabular-nums text-slate-500"
|
|
333
|
+
:title="t('inspector.execution.elapsedTooltip')"
|
|
334
|
+
>
|
|
335
|
+
· {{ stepElapsed(s) }}
|
|
336
|
+
</span>
|
|
309
337
|
</span>
|
|
310
338
|
<UButton
|
|
311
339
|
v-if="s.decision && !s.decision.chosen"
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
containerPhaseLabel,
|
|
13
13
|
} from '~/utils/pipelineRender'
|
|
14
14
|
import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
|
|
15
|
+
import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
|
|
15
16
|
|
|
16
17
|
const props = defineProps<{ instance: ExecutionInstance }>()
|
|
17
18
|
const emit = defineEmits<{
|
|
@@ -136,6 +137,13 @@ const STATUS_META = computed<Record<ExecutionInstance['status'], { label: string
|
|
|
136
137
|
const steps = computed(() => props.instance.steps)
|
|
137
138
|
const total = computed(() => steps.value.length)
|
|
138
139
|
|
|
140
|
+
// A shared 1s tick drives every step's live elapsed clock, so a step that hasn't yet
|
|
141
|
+
// emitted subtask counts still shows it is progressing rather than reading as hung.
|
|
142
|
+
const nowTick = useNowTick()
|
|
143
|
+
function stepElapsed(s: PipelineStep): string | null {
|
|
144
|
+
return stepDurationLabel(s, nowTick.value, runFailed.value, props.instance.failure?.occurredAt)
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
// The conditionally-run companion (e.g. the Tester's `fixer`) each step drives, with
|
|
140
148
|
// its possible/running/completed/skipped state — rendered as a distinct sub-node so a
|
|
141
149
|
// human can see at a glance whether the fixer ran or was skipped.
|
|
@@ -322,8 +330,20 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
322
330
|
{{ t('pipeline.progress.companion') }}
|
|
323
331
|
</span>
|
|
324
332
|
</div>
|
|
325
|
-
<div
|
|
326
|
-
|
|
333
|
+
<div
|
|
334
|
+
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-slate-500"
|
|
335
|
+
>
|
|
336
|
+
<span>{{ t('pipeline.progress.stepOf', { current: i + 1, total }) }}</span>
|
|
337
|
+
<!-- live elapsed clock: a running step counts up (so no-subtask steps
|
|
338
|
+
don't read as hung), a finished step shows its total duration -->
|
|
339
|
+
<span
|
|
340
|
+
v-if="stepElapsed(s)"
|
|
341
|
+
class="inline-flex items-center gap-0.5 font-mono normal-case tabular-nums text-slate-400"
|
|
342
|
+
:title="t('pipeline.progress.elapsedTooltip')"
|
|
343
|
+
>
|
|
344
|
+
<UIcon name="i-lucide-clock" class="h-2.5 w-2.5 shrink-0" />
|
|
345
|
+
{{ stepElapsed(s) }}
|
|
346
|
+
</span>
|
|
327
347
|
</div>
|
|
328
348
|
</div>
|
|
329
349
|
<span
|
|
@@ -343,7 +363,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
343
363
|
color="neutral"
|
|
344
364
|
variant="ghost"
|
|
345
365
|
size="xs"
|
|
346
|
-
class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
|
366
|
+
class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
|
|
347
367
|
:title="t('pipeline.progress.restartTooltip')"
|
|
348
368
|
@click.stop="
|
|
349
369
|
() => {
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
// - Mentions (per-account): toggle + GitHub-user-id → Slack-member-id map.
|
|
7
7
|
import { computed, reactive, ref, watch } from 'vue'
|
|
8
8
|
import type { NotificationType } from '~/types/notifications'
|
|
9
|
-
import type {
|
|
9
|
+
import type { SlackMemberRole, SlackRoute } from '~/types/slack'
|
|
10
|
+
import {
|
|
11
|
+
type MemberRow,
|
|
12
|
+
emptyMemberRow,
|
|
13
|
+
hasHalfFilledRow,
|
|
14
|
+
toMemberEntries,
|
|
15
|
+
toMemberRow,
|
|
16
|
+
} from '~/utils/slackMemberMapping'
|
|
10
17
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
11
18
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
12
19
|
|
|
@@ -60,9 +67,15 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
60
67
|
initiative: { enabled: false, channel: '' },
|
|
61
68
|
})
|
|
62
69
|
const mentionsEnabled = ref(false)
|
|
63
|
-
|
|
70
|
+
// Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
|
|
71
|
+
// a mid-list delete keys the v-model by identity, not the array index (index keys
|
|
72
|
+
// silently rebound a neighbour's inputs — UX-23).
|
|
73
|
+
let uidSeq = 0
|
|
74
|
+
const nextUid = () => `m${++uidSeq}`
|
|
75
|
+
const mapping = ref<MemberRow[]>([])
|
|
64
76
|
const tokenInput = ref('')
|
|
65
77
|
const busy = ref(false)
|
|
78
|
+
const connectingOAuth = ref(false)
|
|
66
79
|
|
|
67
80
|
function notifyError(title: string, e: unknown) {
|
|
68
81
|
toast.add({
|
|
@@ -84,7 +97,7 @@ watch(
|
|
|
84
97
|
routes[type] = slack.settings?.routes[type] ?? { enabled: false, channel: '' }
|
|
85
98
|
}
|
|
86
99
|
mentionsEnabled.value = slack.settings?.mentionsEnabled ?? false
|
|
87
|
-
mapping.value = slack.memberMapping.map((e) => (
|
|
100
|
+
mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
|
|
88
101
|
} catch (e) {
|
|
89
102
|
notifyError(t('slack.error.loadSettings'), e)
|
|
90
103
|
}
|
|
@@ -94,9 +107,13 @@ watch(
|
|
|
94
107
|
)
|
|
95
108
|
|
|
96
109
|
async function connectViaOAuth() {
|
|
110
|
+
connectingOAuth.value = true
|
|
97
111
|
try {
|
|
112
|
+
// On success the browser navigates away, so `connectingOAuth` never resets here —
|
|
113
|
+
// it only clears on the error path below.
|
|
98
114
|
window.location.href = await slack.installUrl()
|
|
99
115
|
} catch (e) {
|
|
116
|
+
connectingOAuth.value = false
|
|
100
117
|
notifyError(t('slack.error.startOAuth'), e)
|
|
101
118
|
}
|
|
102
119
|
}
|
|
@@ -144,17 +161,29 @@ async function saveRouting() {
|
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
function addMapping() {
|
|
147
|
-
mapping.value.push(
|
|
164
|
+
mapping.value.push(emptyMemberRow(nextUid()))
|
|
148
165
|
}
|
|
149
|
-
function removeMapping(
|
|
150
|
-
mapping.value.
|
|
166
|
+
function removeMapping(uid: string) {
|
|
167
|
+
mapping.value = mapping.value.filter((e) => e.uid !== uid)
|
|
151
168
|
}
|
|
152
169
|
async function saveMapping() {
|
|
170
|
+
// A partially-filled row (one id present, the other blank) used to be silently
|
|
171
|
+
// dropped on save (UX-23) — block instead so the user doesn't lose the entry. A
|
|
172
|
+
// fully-empty row is just an unused slot and is ignored.
|
|
173
|
+
if (hasHalfFilledRow(mapping.value)) {
|
|
174
|
+
toast.add({
|
|
175
|
+
title: t('slack.members.incompleteTitle'),
|
|
176
|
+
description: t('slack.members.incompleteBody'),
|
|
177
|
+
icon: 'i-lucide-triangle-alert',
|
|
178
|
+
color: 'warning',
|
|
179
|
+
})
|
|
180
|
+
return
|
|
181
|
+
}
|
|
153
182
|
busy.value = true
|
|
154
183
|
try {
|
|
155
|
-
const entries = mapping.value
|
|
184
|
+
const entries = toMemberEntries(mapping.value)
|
|
156
185
|
await slack.updateMemberMapping(entries)
|
|
157
|
-
mapping.value = slack.memberMapping.map((e) => (
|
|
186
|
+
mapping.value = slack.memberMapping.map((e) => toMemberRow(e, nextUid()))
|
|
158
187
|
toast.add({ title: t('slack.toast.mapSaved'), icon: 'i-lucide-check', color: 'success' })
|
|
159
188
|
} catch (e) {
|
|
160
189
|
notifyError(t('slack.error.saveMap'), e)
|
|
@@ -181,6 +210,7 @@ async function saveMapping() {
|
|
|
181
210
|
v-if="slack.oauthEnabled"
|
|
182
211
|
color="primary"
|
|
183
212
|
icon="i-lucide-slack"
|
|
213
|
+
:loading="connectingOAuth"
|
|
184
214
|
@click="connectViaOAuth"
|
|
185
215
|
>
|
|
186
216
|
{{ t('slack.connect.addToSlack') }}
|
|
@@ -287,7 +317,7 @@ async function saveMapping() {
|
|
|
287
317
|
</template>
|
|
288
318
|
</i18n-t>
|
|
289
319
|
</p>
|
|
290
|
-
<div v-for="
|
|
320
|
+
<div v-for="entry in mapping" :key="entry.uid" class="flex items-center gap-2">
|
|
291
321
|
<UInput
|
|
292
322
|
v-model="entry.userId"
|
|
293
323
|
size="sm"
|
|
@@ -312,7 +342,7 @@ async function saveMapping() {
|
|
|
312
342
|
variant="ghost"
|
|
313
343
|
size="xs"
|
|
314
344
|
icon="i-lucide-trash-2"
|
|
315
|
-
@click="removeMapping(
|
|
345
|
+
@click="removeMapping(entry.uid)"
|
|
316
346
|
/>
|
|
317
347
|
</div>
|
|
318
348
|
<div class="flex justify-between">
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { stepDurationLabel, stepDurationMs, stepIsRunning } from '~/composables/useStepTimer'
|
|
4
|
+
|
|
5
|
+
// The pure helpers encode one freeze rule shared by the list surfaces (pipeline timeline,
|
|
6
|
+
// inspector run list) and the single-step overlay, so pin the precedence here:
|
|
7
|
+
// finishedAt > (runFailed ? failureAt ?? startedAt) > pausedAt > live now.
|
|
8
|
+
function step(overrides: Partial<PipelineStep> = {}): PipelineStep {
|
|
9
|
+
return { agentKind: 'coder', state: 'working', ...overrides } as PipelineStep
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const NOW = 10_000
|
|
13
|
+
|
|
14
|
+
describe('stepIsRunning', () => {
|
|
15
|
+
it('is false for a null step', () => {
|
|
16
|
+
expect(stepIsRunning(null, false)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('is false until the step has started', () => {
|
|
20
|
+
expect(stepIsRunning(step({ startedAt: undefined }), false)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is true for a started, unfinished, unparked step on a live run', () => {
|
|
24
|
+
expect(stepIsRunning(step({ startedAt: 1000 }), false)).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('is false once finished, parked, or the run failed', () => {
|
|
28
|
+
expect(stepIsRunning(step({ startedAt: 1000, finishedAt: 2000 }), false)).toBe(false)
|
|
29
|
+
expect(stepIsRunning(step({ startedAt: 1000, pausedAt: 1500 }), false)).toBe(false)
|
|
30
|
+
expect(stepIsRunning(step({ startedAt: 1000 }), true)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('stepDurationMs', () => {
|
|
35
|
+
it('is null until the step has started', () => {
|
|
36
|
+
expect(stepDurationMs(step({ startedAt: undefined }), NOW, false, null)).toBeNull()
|
|
37
|
+
expect(stepDurationMs(null, NOW, false, null)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('counts up to now while live', () => {
|
|
41
|
+
expect(stepDurationMs(step({ startedAt: 4000 }), NOW, false, null)).toBe(6000)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('freezes at finishedAt once finished (ignoring now)', () => {
|
|
45
|
+
expect(stepDurationMs(step({ startedAt: 4000, finishedAt: 7000 }), NOW, false, null)).toBe(3000)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('freezes at the run failure time when the run failed', () => {
|
|
49
|
+
expect(stepDurationMs(step({ startedAt: 4000 }), NOW, true, 6000)).toBe(2000)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('falls back to startedAt (zero) when the run failed with no failure time', () => {
|
|
53
|
+
expect(stepDurationMs(step({ startedAt: 4000 }), NOW, true, null)).toBe(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('freezes at the park time when parked on a human', () => {
|
|
57
|
+
expect(stepDurationMs(step({ startedAt: 4000, pausedAt: 5500 }), NOW, false, null)).toBe(1500)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('prefers finishedAt over the failure time', () => {
|
|
61
|
+
expect(stepDurationMs(step({ startedAt: 4000, finishedAt: 5000 }), NOW, true, 6000)).toBe(1000)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('never returns a negative duration', () => {
|
|
65
|
+
expect(stepDurationMs(step({ startedAt: 8000 }), NOW, true, 6000)).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('stepDurationLabel', () => {
|
|
70
|
+
it('is null until the step has started', () => {
|
|
71
|
+
expect(stepDurationLabel(step({ startedAt: undefined }), NOW, false, null)).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('formats seconds and minutes', () => {
|
|
75
|
+
expect(stepDurationLabel(step({ startedAt: 4000 }), NOW, false, null)).toBe('6s')
|
|
76
|
+
expect(stepDurationLabel(step({ startedAt: 0, finishedAt: 90_000 }), NOW, false, null)).toBe(
|
|
77
|
+
'1m 30s',
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
})
|