@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.
@@ -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
- try {
150
- await library.remove(id)
151
- toast.add({ title: t('fragments.toast.removed'), icon: 'i-lucide-trash-2' })
152
- } catch (e) {
153
- notifyError(t('fragments.toast.removeFailed'), e)
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
- try {
222
- await library.refreshDocumentFragment(id)
223
- toast.add({ title: t('fragments.toast.refreshed'), icon: 'i-lucide-refresh-cw' })
224
- } catch (e) {
225
- notifyError(t('fragments.toast.refreshFailed'), e)
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
- try {
283
- const result = await library.syncSource(id)
284
- toast.add({
285
- title: t('fragments.toast.synced', {
286
- updated: result.upserted,
287
- removed: result.tombstoned,
288
- }),
289
- icon: 'i-lucide-refresh-cw',
290
- color: 'info',
291
- })
292
- } catch (e) {
293
- notifyError(t('fragments.toast.syncFailed'), e)
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
- try {
299
- const status = await library.checkSource(id)
300
- toast.add({
301
- title: status.changed ? t('fragments.toast.changesAvailable') : t('fragments.toast.upToDate'),
302
- icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
303
- })
304
- } catch (e) {
305
- notifyError(t('fragments.toast.checkSourceFailed'), e)
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
- try {
311
- await library.unlinkSource(id)
312
- toast.add({ title: t('fragments.toast.sourceUnlinked'), icon: 'i-lucide-unplug' })
313
- } catch (e) {
314
- notifyError(t('fragments.toast.unlinkSourceFailed'), e)
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="library.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="library.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="library.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="library.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="library.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 class="text-[10px] uppercase tracking-wide text-slate-500">
326
- {{ t('pipeline.progress.stepOf', { current: i + 1, total }) }}
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 { SlackMemberMappingEntry, SlackMemberRole, SlackRoute } from '~/types/slack'
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
- const mapping = ref<SlackMemberMappingEntry[]>([])
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) => ({ role: 'engineering', ...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({ userId: '', slackUserId: '', role: 'engineering' })
164
+ mapping.value.push(emptyMemberRow(nextUid()))
148
165
  }
149
- function removeMapping(index: number) {
150
- mapping.value.splice(index, 1)
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.filter((e) => e.userId.trim() && e.slackUserId.trim())
184
+ const entries = toMemberEntries(mapping.value)
156
185
  await slack.updateMemberMapping(entries)
157
- mapping.value = slack.memberMapping.map((e) => ({ ...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="(entry, i) in mapping" :key="i" class="flex items-center gap-2">
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(i)"
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
+ })