@cat-factory/app 0.82.1 → 0.82.2

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.
@@ -28,10 +28,23 @@ export function useBlockDeletion() {
28
28
  : block.level === 'module'
29
29
  ? 'module'
30
30
  : 'service'
31
- return {
32
- title: t(`panels.inspector.confirmDelete.${kind}.title`),
33
- body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }),
31
+ const title = t(`panels.inspector.confirmDelete.${kind}.title`)
32
+ // For a container (service/module) state the exact cascade size so the blast radius is
33
+ // explicit "and everything inside it" hides how many tasks/modules go with it.
34
+ if (kind === 'module' || kind === 'service') {
35
+ const count = board.descendantsOf(block.id).length
36
+ if (count > 0) {
37
+ return {
38
+ title,
39
+ body: t(
40
+ 'panels.inspector.confirmDelete.containerBodyWithCount',
41
+ { name: block.title, count },
42
+ count,
43
+ ),
44
+ }
45
+ }
34
46
  }
47
+ return { title, body: t(`panels.inspector.confirmDelete.${kind}.body`, { name: block.title }) }
35
48
  }
36
49
 
37
50
  async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
@@ -54,8 +67,11 @@ export function useBlockDeletion() {
54
67
  void recurring.remove(schedule.id)
55
68
  return true
56
69
  }
57
- execution.cancel(block.id)
58
- void board.removeBlock(block.id)
70
+ // Cancelling the run is irreversible, so defer it into the delete's commit: it fires only
71
+ // once the (deferred) delete actually lands, so an undo within the window leaves a running
72
+ // pipeline intact rather than restoring a block whose run was already torn down. Target the
73
+ // workspace the block was deleted from in case the user switched mid-window.
74
+ void board.removeBlock(block.id, { onCommit: (wsId) => execution.cancel(block.id, wsId) })
59
75
  return true
60
76
  }
61
77
 
@@ -71,6 +71,23 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
71
71
  return [...direct, ...nested]
72
72
  }
73
73
 
74
+ /**
75
+ * Every block nested under a container (transitive), excluding the container
76
+ * itself — the exact set a delete cascades over. Used to state the blast radius
77
+ * in the delete confirmation. Structural children only (via `parentId`), so a
78
+ * non-structural epic membership is not counted.
79
+ */
80
+ function descendantsOf(id: string): Block[] {
81
+ const out: Block[] = []
82
+ const stack = [...childrenOf(id)]
83
+ while (stack.length > 0) {
84
+ const b = stack.pop()!
85
+ out.push(b)
86
+ stack.push(...childrenOf(b.id))
87
+ }
88
+ return out
89
+ }
90
+
74
91
  /** The top-level service a block ultimately belongs to. */
75
92
  function serviceOf(block: Block): Block | undefined {
76
93
  let cur: Block | undefined = block
@@ -200,6 +217,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
200
217
  modulesOf,
201
218
  initiativesOf,
202
219
  allTasksUnder,
220
+ descendantsOf,
203
221
  serviceOf,
204
222
  unmetDeps,
205
223
  isRunnable,
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
2
  import { setActivePinia, createPinia } from 'pinia'
3
3
  import type { Block, BlockStatus } from '~/types/domain'
4
4
  import { useBoardStore } from '~/stores/board'
@@ -89,6 +89,26 @@ describe('board store read getters', () => {
89
89
  ).toEqual(['t2', 't3'])
90
90
  })
91
91
 
92
+ it('descendantsOf returns the transitive structural subtree, excluding the root', () => {
93
+ store.hydrate([
94
+ frame('f1'),
95
+ moduleBlock('m1', 'f1'),
96
+ task('t1', 'f1'),
97
+ task('t2', 'm1'),
98
+ frame('f2'),
99
+ task('t3', 'f2'),
100
+ ])
101
+ expect(
102
+ store
103
+ .descendantsOf('f1')
104
+ .map((b) => b.id)
105
+ .sort(),
106
+ ).toEqual(['m1', 't1', 't2'])
107
+ // a leaf task has no descendants; unknown ids are a safe empty
108
+ expect(store.descendantsOf('t1')).toEqual([])
109
+ expect(store.descendantsOf('missing')).toEqual([])
110
+ })
111
+
92
112
  it('epicMembers groups blocks by their epicId (indexed lookup)', () => {
93
113
  store.hydrate([
94
114
  frame('f1'),
@@ -289,4 +309,179 @@ describe('board store optimistic rollback', () => {
289
309
  expect(store.getBlock('t1')?.title).toBe('orig')
290
310
  expect(store.getBlock('t1')?.description).toBe('keep')
291
311
  })
312
+
313
+ it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
314
+ vi.stubGlobal('useApi', () => ({
315
+ reparentBlock: async (
316
+ _ws: string,
317
+ id: string,
318
+ body: { parentId: string; position: unknown },
319
+ ) => task(id, body.parentId, { position: body.position as { x: number; y: number } }),
320
+ }))
321
+ interface ToastAction {
322
+ onClick: () => void
323
+ }
324
+ const actions: ToastAction[] = []
325
+ vi.stubGlobal('useToast', () => ({
326
+ add: (t: { actions?: ToastAction[] }) => {
327
+ if (t.actions) actions.push(...t.actions)
328
+ },
329
+ }))
330
+ setActivePinia(createPinia())
331
+ useWorkspaceStore().workspaceId = 'ws1'
332
+ const store = useBoardStore()
333
+ store.hydrate([
334
+ frame('f1'),
335
+ moduleBlock('m1', 'f1'),
336
+ task('t1', 'f1', { position: { x: 1, y: 2 } }),
337
+ ])
338
+ await store.reparentBlock('t1', 'm1', { x: 5, y: 6 })
339
+ expect(store.getBlock('t1')?.parentId).toBe('m1')
340
+ // the undo action returns the block to its original parent + position
341
+ expect(actions).toHaveLength(1)
342
+ actions[0]!.onClick()
343
+ await vi.waitFor(() => {
344
+ expect(store.getBlock('t1')?.parentId).toBe('f1')
345
+ expect(store.getBlock('t1')?.position).toEqual({ x: 1, y: 2 })
346
+ })
347
+ // the undo move is itself non-undoable, so no second toast is queued
348
+ expect(actions).toHaveLength(1)
349
+ })
350
+ })
351
+
352
+ describe('board store deferred delete + undo', () => {
353
+ interface ToastAction {
354
+ onClick: () => void
355
+ }
356
+ /** Build a store with a stubbed api/toast, capturing the undo action offered on delete. */
357
+ function setup(removeImpl: () => Promise<void>) {
358
+ const removeSpy = vi.fn(removeImpl)
359
+ const addSpy = vi.fn()
360
+ const actions: ToastAction[] = []
361
+ vi.stubGlobal('useApi', () => ({ removeBlock: removeSpy }))
362
+ vi.stubGlobal('useToast', () => ({
363
+ add: (t: { actions?: ToastAction[] }) => {
364
+ addSpy(t)
365
+ if (t.actions) actions.push(...t.actions)
366
+ },
367
+ }))
368
+ setActivePinia(createPinia())
369
+ useWorkspaceStore().workspaceId = 'ws1'
370
+ return { store: useBoardStore(), removeSpy, addSpy, actions }
371
+ }
372
+
373
+ beforeEach(() => {
374
+ vi.useFakeTimers()
375
+ })
376
+ afterEach(() => {
377
+ vi.useRealTimers()
378
+ })
379
+
380
+ it('hides the subtree immediately but defers the backend delete', () => {
381
+ const { store, removeSpy } = setup(async () => {})
382
+ store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
383
+ store.removeBlock('f1')
384
+ // the whole subtree disappears at once…
385
+ expect(store.getBlock('f1')).toBeUndefined()
386
+ expect(store.getBlock('m1')).toBeUndefined()
387
+ expect(store.getBlock('t1')).toBeUndefined()
388
+ // …but nothing is deleted server-side yet.
389
+ expect(removeSpy).not.toHaveBeenCalled()
390
+ })
391
+
392
+ it('keeps a pending-delete subtree hidden across a coarse refresh, and prunes its edges', () => {
393
+ const { store } = setup(async () => {})
394
+ store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
395
+ store.removeBlock('t1')
396
+ // A full re-hydrate (e.g. a `board` live event) that still carries the deleted block and
397
+ // the now-dangling dependency edge must not resurrect either.
398
+ store.hydrate([frame('f1'), task('t1', 'f1'), task('t2', 'f1', { dependsOn: ['t1'] })])
399
+ expect(store.getBlock('t1')).toBeUndefined()
400
+ expect(store.getBlock('t2')?.dependsOn).toEqual([])
401
+ })
402
+
403
+ it('ignores a live upsert for a block awaiting its deferred delete', () => {
404
+ const { store } = setup(async () => {})
405
+ store.hydrate([frame('f1'), task('t1', 'f1')])
406
+ store.removeBlock('t1')
407
+ store.upsert(task('t1', 'f1', { title: 'resurrected' }))
408
+ expect(store.getBlock('t1')).toBeUndefined()
409
+ })
410
+
411
+ it('undo cancels the pending delete and restores the subtree', async () => {
412
+ const { store, removeSpy, actions } = setup(async () => {})
413
+ store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
414
+ store.removeBlock('f1')
415
+ expect(actions).toHaveLength(1)
416
+ actions[0]!.onClick()
417
+ expect(store.getBlock('f1')?.id).toBe('f1')
418
+ expect(store.getBlock('t1')?.id).toBe('t1')
419
+ // the deferred delete never fires after an undo
420
+ await vi.runAllTimersAsync()
421
+ expect(removeSpy).not.toHaveBeenCalled()
422
+ })
423
+
424
+ it('fires the backend delete for the captured workspace once the window elapses', async () => {
425
+ const { store, removeSpy } = setup(async () => {})
426
+ store.hydrate([frame('f1')])
427
+ store.removeBlock('f1')
428
+ await vi.runAllTimersAsync()
429
+ expect(removeSpy).toHaveBeenCalledWith('ws1', 'f1')
430
+ })
431
+
432
+ it('restores the subtree and toasts an error if the deferred delete fails', async () => {
433
+ const { store, addSpy } = setup(() => Promise.reject(new Error('boom')))
434
+ store.hydrate([frame('f1'), task('t1', 'f1')])
435
+ store.removeBlock('f1')
436
+ await vi.runAllTimersAsync()
437
+ expect(store.getBlock('f1')?.id).toBe('f1')
438
+ expect(store.getBlock('t1')?.id).toBe('t1')
439
+ expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
440
+ })
441
+
442
+ it('does not reattach a failed deferred delete onto a different workspace', async () => {
443
+ const { store } = setup(() => Promise.reject(new Error('boom')))
444
+ store.hydrate([frame('f1'), task('t1', 'f1')])
445
+ store.removeBlock('f1')
446
+ // The user switches workspace during the undo window; when the delete then fails, the
447
+ // ws1 subtree must NOT be injected onto the ws2 board now on screen.
448
+ useWorkspaceStore().workspaceId = 'ws2'
449
+ await vi.runAllTimersAsync()
450
+ expect(store.getBlock('f1')).toBeUndefined()
451
+ expect(store.getBlock('t1')).toBeUndefined()
452
+ })
453
+
454
+ it('runs onCommit with the captured workspace only when the window elapses', async () => {
455
+ const { store } = setup(async () => {})
456
+ const onCommit = vi.fn(async () => {})
457
+ store.hydrate([frame('f1')])
458
+ store.removeBlock('f1', { onCommit })
459
+ await vi.runAllTimersAsync()
460
+ expect(onCommit).toHaveBeenCalledWith('ws1')
461
+ })
462
+
463
+ it('skips onCommit (the irreversible side effect) when the delete is undone', async () => {
464
+ const { store, actions } = setup(async () => {})
465
+ const onCommit = vi.fn(async () => {})
466
+ store.hydrate([frame('f1')])
467
+ store.removeBlock('f1', { onCommit })
468
+ actions[0]!.onClick() // undo before the window elapses
469
+ await vi.runAllTimersAsync()
470
+ expect(onCommit).not.toHaveBeenCalled()
471
+ })
472
+
473
+ it('undo re-adds pruned edges without clobbering ones gained during the window', () => {
474
+ const { store, actions } = setup(async () => {})
475
+ store.hydrate([
476
+ frame('f1'),
477
+ task('t1', 'f1'),
478
+ task('t2', 'f1', { dependsOn: ['t1'] }),
479
+ task('t3', 'f1'),
480
+ ])
481
+ store.removeBlock('t1')
482
+ // A live event adds a new dependency to the survivor mid-window (t1's edge was pruned).
483
+ store.upsert(task('t2', 'f1', { dependsOn: ['t3'] }))
484
+ actions[0]!.onClick() // undo restores t1 and its edge, keeping the newly-added t3 edge
485
+ expect(store.getBlock('t2')?.dependsOn.slice().sort()).toEqual(['t1', 't3'])
486
+ })
292
487
  })
@@ -36,13 +36,55 @@ export const useBoardStore = defineStore('board', () => {
36
36
  // global i18n instance (the same handle `plugins/locale.client.ts` uses) rather than
37
37
  // `useI18n()`, which requires an active component instance.
38
38
  const nuxtApp = useNuxtApp()
39
- const tr = (key: string): string => (nuxtApp.$i18n as { t: (k: string) => string }).t(key)
39
+ const tr = (key: string, params?: Record<string, unknown>): string =>
40
+ (nuxtApp.$i18n as { t: (k: string, p?: Record<string, unknown>) => string }).t(
41
+ key,
42
+ params ?? {},
43
+ )
40
44
  const blocks = ref<Block[]>([])
41
45
 
42
46
  // Pure derivations (hierarchy, status/progress, sizing) live in the composable.
43
47
  const queries = useBlockQueries(blocks)
44
48
  const { getBlock } = queries
45
49
 
50
+ /**
51
+ * How long a deleted block stays undoable. The backend delete is DEFERRED for this
52
+ * window (a real "undo", not a client illusion) — the block is hidden immediately but
53
+ * only actually deleted once the window elapses, so undo just cancels the pending call.
54
+ */
55
+ const UNDO_WINDOW_MS = 6000
56
+ /**
57
+ * Blocks hidden by an optimistic delete whose backend call hasn't fired yet, keyed by
58
+ * the deleted root's id. Their subtree stays filtered out of every incoming server
59
+ * snapshot (`hydrate`) and single-block live event (`upsert`) for the undo window, so a
60
+ * coarse refresh or a stray event can't resurrect a block the user just deleted.
61
+ */
62
+ const pendingRemovals = new Map<
63
+ string,
64
+ { snap: RemovalSnapshot; timer: ReturnType<typeof setTimeout>; wsId: string }
65
+ >()
66
+ // Flat set of every id in a pending removal (root + descendants), for O(1) checks in the
67
+ // hot upsert path. Kept in lockstep with `pendingRemovals`.
68
+ const pendingDoomed = new Set<string>()
69
+
70
+ /**
71
+ * Drop any pending-removal subtree from a reconciled block list and prune survivors'
72
+ * edges to it — the same detach the backend will perform once the deferred delete fires.
73
+ * Applied to every hydrate so the undo window survives a full refresh.
74
+ */
75
+ function applyPendingRemovals(list: Block[]): Block[] {
76
+ if (pendingDoomed.size === 0) return list
77
+ const survivors = list.filter((b) => !pendingDoomed.has(b.id))
78
+ for (const b of survivors) {
79
+ if (b.dependsOn.some((d) => pendingDoomed.has(d))) {
80
+ b.dependsOn = b.dependsOn.filter((d) => !pendingDoomed.has(d))
81
+ }
82
+ if (b.epicId != null && pendingDoomed.has(b.epicId)) b.epicId = null
83
+ if (b.initiativeId != null && pendingDoomed.has(b.initiativeId)) b.initiativeId = null
84
+ }
85
+ return survivors
86
+ }
87
+
46
88
  /**
47
89
  * Reconcile the cached blocks against a server snapshot, reusing the existing
48
90
  * object for any block whose content is unchanged. The server stays authoritative
@@ -67,14 +109,18 @@ export const useBoardStore = defineStore('board', () => {
67
109
  }
68
110
  function hydrate(next: Block[]) {
69
111
  const prev = new Map(blocks.value.map((b) => [b.id, b]))
70
- blocks.value = next.map((n) => {
112
+ const reconciled = next.map((n) => {
71
113
  const existing = prev.get(n.id)
72
114
  return existing && jsonFor(existing) === jsonFor(n) ? existing : n
73
115
  })
116
+ // Keep blocks the user just deleted hidden while their delete is still pending.
117
+ blocks.value = applyPendingRemovals(reconciled)
74
118
  }
75
119
 
76
120
  /** Insert or replace a block returned by the backend. */
77
121
  function upsert(block: Block) {
122
+ // A live event for a block awaiting its deferred delete must not resurrect it.
123
+ if (pendingDoomed.has(block.id)) return
78
124
  const i = blocks.value.findIndex((b) => b.id === block.id)
79
125
  if (i >= 0) blocks.value[i] = block
80
126
  else blocks.value.push(block)
@@ -199,11 +245,16 @@ export const useBoardStore = defineStore('board', () => {
199
245
  return block
200
246
  }
201
247
 
202
- /** Move a block into a new container at a new local position. */
248
+ /**
249
+ * Move a block into a new container at a new local position. Drag-reparent commits
250
+ * silently on a small overshoot, so a successful move (into a *different* container,
251
+ * not an undo of one) offers a one-click undo back to its previous home.
252
+ */
203
253
  async function reparentBlock(
204
254
  id: string,
205
255
  newParentId: string,
206
256
  position: { x: number; y: number },
257
+ opts: { undoable?: boolean } = { undoable: true },
207
258
  ) {
208
259
  const b = getBlock(id)
209
260
  const parent = getBlock(newParentId)
@@ -217,6 +268,7 @@ export const useBoardStore = defineStore('board', () => {
217
268
  // block in the wrong container (a structural lie that survives until re-hydrate).
218
269
  const prevParentId = b.parentId
219
270
  const prevPosition = b.position
271
+ const name = b.title
220
272
  b.parentId = newParentId
221
273
  b.position = position
222
274
  try {
@@ -226,6 +278,24 @@ export const useBoardStore = defineStore('board', () => {
226
278
  position,
227
279
  }),
228
280
  )
281
+ // Offer an undo back to the previous container (a drag overshoot is easy). The undo
282
+ // move is itself non-undoable so the toast doesn't ping-pong.
283
+ if (opts.undoable && prevParentId) {
284
+ toast.add({
285
+ title: tr('board.toast.moved', { name }),
286
+ icon: 'i-lucide-move',
287
+ color: 'neutral',
288
+ duration: UNDO_WINDOW_MS,
289
+ actions: [
290
+ {
291
+ label: tr('common.undo'),
292
+ icon: 'i-lucide-undo-2',
293
+ onClick: () =>
294
+ void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
295
+ },
296
+ ],
297
+ })
298
+ }
229
299
  } catch (e) {
230
300
  b.parentId = prevParentId
231
301
  b.position = prevPosition
@@ -291,35 +361,99 @@ export const useBoardStore = defineStore('board', () => {
291
361
  /** Re-insert a detached subtree and restore its broken edges (delete rollback). */
292
362
  function reattach(snap: RemovalSnapshot) {
293
363
  for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
364
+ const restored = new Set(snap.removed.map((b) => b.id))
294
365
  for (const e of snap.edges) {
295
366
  const b = getBlock(e.id)
296
- if (b) {
297
- b.dependsOn = e.dependsOn
298
- b.epicId = e.epicId
367
+ if (!b) continue
368
+ // Re-establish only the links that pointed at a now-restored block, merged with
369
+ // whatever the survivor gained meanwhile — so a delayed undo (the delete is deferred by
370
+ // a window during which a live event may add edges) doesn't clobber a newer dependency /
371
+ // epic / initiative link with the stale detach-time snapshot.
372
+ const readd = e.dependsOn.filter((d) => restored.has(d) && !b.dependsOn.includes(d))
373
+ if (readd.length) b.dependsOn = [...b.dependsOn, ...readd]
374
+ if (b.epicId == null && e.epicId != null && restored.has(e.epicId)) b.epicId = e.epicId
375
+ if (b.initiativeId == null && e.initiativeId != null && restored.has(e.initiativeId)) {
299
376
  b.initiativeId = e.initiativeId
300
377
  }
301
378
  }
302
379
  }
303
380
 
304
381
  /**
305
- * Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board
306
- * feels instant; if the backend rejects the delete we put it back and surface a
307
- * toast rather than silently leaving a ghost.
382
+ * Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board feels
383
+ * instant, and the real backend delete is DEFERRED by {@link UNDO_WINDOW_MS} so the
384
+ * accompanying "Deleted — Undo" toast can cancel it in place (a genuine undo, since
385
+ * nothing was destroyed server-side yet). The subtree stays filtered out of any
386
+ * hydrate/upsert in the meantime (see {@link applyPendingRemovals}). If the deferred
387
+ * call ultimately fails we put the subtree back and surface an error toast.
308
388
  */
309
- async function removeBlock(id: string) {
389
+ function removeBlock(
390
+ id: string,
391
+ opts: { onCommit?: (wsId: string) => void | Promise<void> } = {},
392
+ ) {
393
+ const block = getBlock(id)
310
394
  const snap = detach(id)
311
- if (!snap) return
312
- try {
313
- await api.removeBlock(useWorkspaceStore().requireId(), id)
314
- } catch (e) {
315
- reattach(snap)
316
- toast.add({
317
- title: tr('board.toast.deleteFailed'),
318
- description: e instanceof Error ? e.message : String(e),
319
- icon: 'i-lucide-triangle-alert',
320
- color: 'error',
321
- })
395
+ if (!block || !snap) return
396
+ // Capture the workspace now: the deferred delete must target the workspace the block
397
+ // was deleted from even if the user has since switched.
398
+ const wsId = useWorkspaceStore().requireId()
399
+ for (const b of snap.removed) pendingDoomed.add(b.id)
400
+
401
+ const finalize = async () => {
402
+ const pending = pendingRemovals.get(id)
403
+ if (!pending) return
404
+ pendingRemovals.delete(id)
405
+ try {
406
+ // Any irreversible side effect the delete implies (e.g. cancelling the block's run)
407
+ // is deferred to here so it fires only once the delete truly commits — undo within
408
+ // the window then leaves the run untouched instead of restoring an already-cancelled one.
409
+ await opts.onCommit?.(pending.wsId)
410
+ await api.removeBlock(pending.wsId, id)
411
+ // Stop filtering the subtree only after the server has actually dropped it, so a
412
+ // snapshot that raced the in-flight delete can't briefly resurrect it.
413
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
414
+ } catch (e) {
415
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
416
+ // Only restore into the board the block was deleted from; a mid-window workspace
417
+ // switch must not inject the old subtree onto the workspace now on screen (it
418
+ // re-hydrates from the server there on the next refresh anyway).
419
+ if (useWorkspaceStore().workspaceId === pending.wsId) reattach(pending.snap)
420
+ toast.add({
421
+ title: tr('board.toast.deleteFailed'),
422
+ description: e instanceof Error ? e.message : String(e),
423
+ icon: 'i-lucide-triangle-alert',
424
+ color: 'error',
425
+ })
426
+ }
322
427
  }
428
+ const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
429
+ pendingRemovals.set(id, { snap, timer, wsId })
430
+
431
+ toast.add({
432
+ title: tr('board.toast.deleted', { name: block.title }),
433
+ icon: 'i-lucide-trash-2',
434
+ color: 'neutral',
435
+ duration: UNDO_WINDOW_MS,
436
+ actions: [
437
+ {
438
+ label: tr('common.undo'),
439
+ icon: 'i-lucide-undo-2',
440
+ onClick: () => undoRemove(id),
441
+ },
442
+ ],
443
+ })
444
+ }
445
+
446
+ /** Cancel a still-pending delete and restore the hidden subtree. */
447
+ function undoRemove(id: string) {
448
+ const pending = pendingRemovals.get(id)
449
+ if (!pending) return
450
+ clearTimeout(pending.timer)
451
+ pendingRemovals.delete(id)
452
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
453
+ // Don't resurrect the subtree into a different workspace if the user navigated away
454
+ // mid-window; the delete was already cancelled, which is the safe outcome there.
455
+ if (useWorkspaceStore().workspaceId !== pending.wsId) return
456
+ reattach(pending.snap)
323
457
  }
324
458
 
325
459
  /**
@@ -357,7 +491,7 @@ export const useBoardStore = defineStore('board', () => {
357
491
  // spot the server never stored (a lie that survives until the next re-hydrate).
358
492
  b.position = prevPosition
359
493
  toast.add({
360
- title: 'Could not move',
494
+ title: tr('board.toast.moveFailed'),
361
495
  description: e instanceof Error ? e.message : String(e),
362
496
  icon: 'i-lucide-triangle-alert',
363
497
  color: 'error',
@@ -304,10 +304,14 @@ export const useExecutionStore = defineStore('execution', () => {
304
304
  }
305
305
  }
306
306
 
307
- /** Cancel the execution running against a block and reset it to planned. */
308
- async function cancel(blockId: string) {
307
+ /**
308
+ * Cancel the execution running against a block and reset it to planned. `workspaceId`
309
+ * defaults to the current workspace but can be pinned by callers that cancel a run for a
310
+ * board the user may have since navigated away from (e.g. a deferred delete's commit).
311
+ */
312
+ async function cancel(blockId: string, workspaceId?: string) {
309
313
  const ws = useWorkspaceStore()
310
- await api.cancelExecution(ws.requireId(), blockId)
314
+ await api.cancelExecution(workspaceId ?? ws.requireId(), blockId)
311
315
  instances.value = instances.value.filter((e) => e.blockId !== blockId)
312
316
  await ws.refresh()
313
317
  }
@@ -59,7 +59,8 @@
59
59
  },
60
60
  "@clear": {
61
61
  "description": "Verb meaning empty / reset a stored value (a config, a saved connection), NOT the adjective 'transparent / obvious'. Used as a destructive-action button label."
62
- }
62
+ },
63
+ "undo": "Undo"
63
64
  },
64
65
  "nav": {
65
66
  "menu": "Navigation menu",
@@ -95,7 +96,9 @@
95
96
  "moveFailed": "Could not move",
96
97
  "deleteFailed": "Could not delete",
97
98
  "linkFailed": "Could not link tasks",
98
- "recurringDeleteFailed": "Could not delete recurring pipeline"
99
+ "recurringDeleteFailed": "Could not delete recurring pipeline",
100
+ "deleted": "Deleted \"{name}\"",
101
+ "moved": "Moved \"{name}\""
99
102
  },
100
103
  "repoTypes": {
101
104
  "service": "Service",
@@ -985,7 +988,8 @@
985
988
  "recurring": {
986
989
  "title": "Delete this recurring pipeline?",
987
990
  "body": "\"{name}\", its schedule and its run history will be removed. This can't be undone."
988
- }
991
+ },
992
+ "containerBodyWithCount": "\"{name}\" and the {count} item inside it will be removed. This can't be undone. | \"{name}\" and the {count} items inside it will be removed. This can't be undone."
989
993
  },
990
994
  "titlePlaceholder": "Title…",
991
995
  "reworkedRequirements": "Reworked requirements",
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} revocado",
51
51
  "cleared": "{name} borrado",
52
52
  "destroyed": "{name} destruido"
53
- }
53
+ },
54
+ "undo": "Deshacer"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "Menú de navegación",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "No se pudo mover",
81
82
  "deleteFailed": "No se pudo eliminar",
82
83
  "linkFailed": "No se pudieron vincular las tareas",
83
- "recurringDeleteFailed": "No se pudo eliminar el pipeline recurrente"
84
+ "recurringDeleteFailed": "No se pudo eliminar el pipeline recurrente",
85
+ "deleted": "\"{name}\" eliminado",
86
+ "moved": "\"{name}\" movido"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "Servicio",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "¿Eliminar este pipeline recurrente?",
962
965
  "body": "Se eliminarán \"{name}\", su programación y su historial de ejecución. Esta acción no se puede deshacer."
963
- }
966
+ },
967
+ "containerBodyWithCount": "Se eliminará \"{name}\" y el {count} elemento que contiene. Esta acción no se puede deshacer. | Se eliminará \"{name}\" y los {count} elementos que contiene. Esta acción no se puede deshacer."
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} révoqué",
51
51
  "cleared": "{name} effacé",
52
52
  "destroyed": "{name} détruit"
53
- }
53
+ },
54
+ "undo": "Annuler"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "Menu de navigation",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "Impossible de déplacer",
81
82
  "deleteFailed": "Impossible de supprimer",
82
83
  "linkFailed": "Impossible de lier les tâches",
83
- "recurringDeleteFailed": "Impossible de supprimer le pipeline récurrent"
84
+ "recurringDeleteFailed": "Impossible de supprimer le pipeline récurrent",
85
+ "deleted": "\"{name}\" supprimé",
86
+ "moved": "\"{name}\" déplacé"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "Service",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "Supprimer ce pipeline récurrent ?",
962
965
  "body": "\"{name}\", sa planification et son historique d'exécution seront supprimés. Cette action est irréversible."
963
- }
966
+ },
967
+ "containerBodyWithCount": "\"{name}\" et le {count} élément qu'il contient seront supprimés. Cette action est irréversible. | \"{name}\" et les {count} éléments qu'il contient seront supprimés. Cette action est irréversible."
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} בוטל",
51
51
  "cleared": "{name} נוקה",
52
52
  "destroyed": "{name} הושמד"
53
- }
53
+ },
54
+ "undo": "בטל"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "תפריט ניווט",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "לא ניתן היה להעביר",
81
82
  "deleteFailed": "לא ניתן היה למחוק",
82
83
  "linkFailed": "לא ניתן היה לקשר משימות",
83
- "recurringDeleteFailed": "לא ניתן היה למחוק את הצינור החוזר"
84
+ "recurringDeleteFailed": "לא ניתן היה למחוק את הצינור החוזר",
85
+ "deleted": "\"{name}\" נמחק",
86
+ "moved": "\"{name}\" הועבר"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "שירות",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "למחוק את ה־pipeline החוזר הזה?",
962
965
  "body": "\"{name}\", התזמון שלו והיסטוריית ההרצות יימחקו. לא ניתן לבטל פעולה זו."
963
- }
966
+ },
967
+ "containerBodyWithCount": "\"{name}\" ו-{count} פריט שבתוכו יימחקו. לא ניתן לבטל פעולה זו. | \"{name}\" ו-{count} פריטים שבתוכו יימחקו. לא ניתן לבטל פעולה זו."
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} を取り消しました",
51
51
  "cleared": "{name} をクリアしました",
52
52
  "destroyed": "{name} を破棄しました"
53
- }
53
+ },
54
+ "undo": "元に戻す"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "ナビゲーションメニュー",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "移動できませんでした",
81
82
  "deleteFailed": "削除できませんでした",
82
83
  "linkFailed": "タスクをリンクできませんでした",
83
- "recurringDeleteFailed": "定期パイプラインを削除できませんでした"
84
+ "recurringDeleteFailed": "定期パイプラインを削除できませんでした",
85
+ "deleted": "\"{name}\" を削除しました",
86
+ "moved": "\"{name}\" を移動しました"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "サービス",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "この定期パイプラインを削除しますか?",
962
965
  "body": "「{name}」、そのスケジュール、実行履歴が削除されます。この操作は取り消せません。"
963
- }
966
+ },
967
+ "containerBodyWithCount": "「{name}」とその中の {count} 件の項目が削除されます。この操作は取り消せません。 | 「{name}」とその中の {count} 件の項目が削除されます。この操作は取り消せません。"
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "Unieważniono {name}",
51
51
  "cleared": "Wyczyszczono {name}",
52
52
  "destroyed": "Zniszczono {name}"
53
- }
53
+ },
54
+ "undo": "Cofnij"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "Menu nawigacji",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "Nie udało się przenieść",
81
82
  "deleteFailed": "Nie udało się usunąć",
82
83
  "linkFailed": "Nie udało się powiązać zadań",
83
- "recurringDeleteFailed": "Nie udało się usunąć cyklicznego pipeline'u"
84
+ "recurringDeleteFailed": "Nie udało się usunąć cyklicznego pipeline'u",
85
+ "deleted": "Usunięto \"{name}\"",
86
+ "moved": "Przeniesiono \"{name}\""
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "Usługa",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "Usunąć ten cykliczny pipeline?",
962
965
  "body": "\"{name}\", jego harmonogram i historia uruchomień zostaną usunięte. Tej operacji nie można cofnąć."
963
- }
966
+ },
967
+ "containerBodyWithCount": "\"{name}\" i {count} element w środku zostaną usunięte. Tej operacji nie można cofnąć. | \"{name}\" i {count} elementy w środku zostaną usunięte. Tej operacji nie można cofnąć. | \"{name}\" i {count} elementów w środku zostanie usuniętych. Tej operacji nie można cofnąć."
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} iptal edildi",
51
51
  "cleared": "{name} temizlendi",
52
52
  "destroyed": "{name} yok edildi"
53
- }
53
+ },
54
+ "undo": "Geri al"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "Gezinme menüsü",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "Taşınamadı",
81
82
  "deleteFailed": "Silinemedi",
82
83
  "linkFailed": "Görevler bağlanamadı",
83
- "recurringDeleteFailed": "Yinelenen pipeline silinemedi"
84
+ "recurringDeleteFailed": "Yinelenen pipeline silinemedi",
85
+ "deleted": "\"{name}\" silindi",
86
+ "moved": "\"{name}\" taşındı"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "Servis",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "Bu yinelenen pipeline silinsin mi?",
962
965
  "body": "\"{name}\", zamanlaması ve çalıştırma geçmişi kaldırılacak. Bu işlem geri alınamaz."
963
- }
966
+ },
967
+ "containerBodyWithCount": "\"{name}\" ve içindeki {count} öğe kaldırılacak. Bu işlem geri alınamaz. | \"{name}\" ve içindeki {count} öğe kaldırılacak. Bu işlem geri alınamaz."
964
968
  }
965
969
  }
966
970
  },
@@ -50,7 +50,8 @@
50
50
  "revoked": "{name} відкликано",
51
51
  "cleared": "{name} очищено",
52
52
  "destroyed": "{name} знищено"
53
- }
53
+ },
54
+ "undo": "Відмінити"
54
55
  },
55
56
  "nav": {
56
57
  "menu": "Меню навігації",
@@ -80,7 +81,9 @@
80
81
  "moveFailed": "Не вдалося перемістити",
81
82
  "deleteFailed": "Не вдалося видалити",
82
83
  "linkFailed": "Не вдалося звʼязати завдання",
83
- "recurringDeleteFailed": "Не вдалося видалити повторюваний пайплайн"
84
+ "recurringDeleteFailed": "Не вдалося видалити повторюваний пайплайн",
85
+ "deleted": "\"{name}\" видалено",
86
+ "moved": "\"{name}\" переміщено"
84
87
  },
85
88
  "repoTypes": {
86
89
  "service": "Сервіс",
@@ -960,7 +963,8 @@
960
963
  "recurring": {
961
964
  "title": "Видалити цей повторюваний pipeline?",
962
965
  "body": "\"{name}\", його розклад та історію запусків буде видалено. Цю дію не можна скасувати."
963
- }
966
+ },
967
+ "containerBodyWithCount": "\"{name}\" і {count} елемент усередині буде видалено. Цю дію не можна скасувати. | \"{name}\" і {count} елементи всередині буде видалено. Цю дію не можна скасувати. | \"{name}\" і {count} елементів усередині буде видалено. Цю дію не можна скасувати."
964
968
  }
965
969
  }
966
970
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.82.1",
3
+ "version": "0.82.2",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",