@cat-factory/app 0.82.1 → 0.83.0

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.
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ // The interactive-planning Q&A window (slice 2) — the dedicated view of the initiative
3
+ // INTERVIEWER gate. While the planning run is parked, the interviewer's clarifying questions
4
+ // (pending `qa` entries with an empty answer) are shown here; the human answers them, then
5
+ // either CONTINUES (the interviewer re-runs and may ask follow-ups) or PROCEEDS (skip
6
+ // remaining questions — the interviewer converges and the run advances to the analyst/planner).
7
+ // Opened via the universal result-view host: from the inspector / card
8
+ // (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
9
+ // stream events patch the store, so an open window follows the interview as it progresses.
10
+ import { computed, reactive, watch } from 'vue'
11
+ import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
12
+
13
+ const board = useBoardStore()
14
+ const initiatives = useInitiativesStore()
15
+ const { t } = useI18n()
16
+
17
+ const { open, blockId, close } = useResultView('initiative-planning', {
18
+ onOpen: (id) => void initiatives.load(id),
19
+ })
20
+
21
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
22
+ const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
23
+
24
+ /** Every interview exchange, with a stable key for the list + draft map. */
25
+ const questions = computed(() =>
26
+ (initiative.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
27
+ )
28
+ const pending = computed(() => questions.value.filter((q) => !(q.answer ?? '').trim()))
29
+ /** The interview converged (or never started with a model): nothing left to answer. */
30
+ const converged = computed(() => initiative.value?.interview?.status === 'done')
31
+
32
+ // Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
33
+ // without clobbering an answer the human is mid-edit on.
34
+ const drafts = reactive<Record<string, string>>({})
35
+ watch(
36
+ questions,
37
+ (list) => {
38
+ for (const q of list) {
39
+ if (!(q.key in drafts)) drafts[q.key] = q.answer ?? ''
40
+ }
41
+ },
42
+ { immediate: true },
43
+ )
44
+
45
+ const resuming = computed(() => initiatives.resuming)
46
+ /** Continue is meaningful once every pending question has a drafted answer. */
47
+ const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
48
+
49
+ /** Persist one answer if its draft differs from what's recorded. */
50
+ async function persist(q: { id?: string; key: string; answer?: string }) {
51
+ const id = q.id
52
+ if (!id || !blockId.value) return
53
+ const next = (drafts[q.key] ?? '').trim()
54
+ if (!next || next === (q.answer ?? '').trim()) return
55
+ await initiatives.answerQuestion(blockId.value, id, next)
56
+ }
57
+
58
+ /** Flush all dirty drafts, then run a window action (continue / proceed). */
59
+ async function flushThen(action: (id: string) => Promise<unknown>) {
60
+ if (!blockId.value) return
61
+ for (const q of questions.value) await persist(q)
62
+ await action(blockId.value)
63
+ }
64
+
65
+ const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
66
+ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
67
+ </script>
68
+
69
+ <template>
70
+ <Teleport to="body">
71
+ <div
72
+ v-if="open"
73
+ class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
74
+ @click.self="close"
75
+ >
76
+ <div
77
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
78
+ role="dialog"
79
+ aria-modal="true"
80
+ data-testid="initiative-planning-window"
81
+ >
82
+ <!-- Header -->
83
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
84
+ <span
85
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
86
+ >
87
+ <UIcon name="i-lucide-messages-square" class="h-4 w-4" />
88
+ </span>
89
+ <div class="min-w-0 flex-1">
90
+ <h2 class="truncate text-sm font-semibold text-slate-100">
91
+ {{ initiative?.title ?? block?.title ?? t('initiative.planning.title') }}
92
+ </h2>
93
+ <p class="truncate text-[11px] text-slate-400">
94
+ {{ t('initiative.planning.subtitle') }}
95
+ </p>
96
+ </div>
97
+ <UBadge v-if="initiative" color="primary" variant="subtle" size="sm">
98
+ {{ t(INITIATIVE_STATUS_LABEL_KEYS[initiative.status]) }}
99
+ </UBadge>
100
+ <button
101
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
102
+ @click="close"
103
+ >
104
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
105
+ </button>
106
+ </header>
107
+
108
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
109
+ <!-- No entity yet -->
110
+ <div
111
+ v-if="!initiative"
112
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
113
+ >
114
+ <UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
115
+ <p class="text-sm">{{ t('initiative.planning.empty') }}</p>
116
+ </div>
117
+
118
+ <template v-else>
119
+ <p class="mb-4 text-[13px] leading-relaxed text-slate-300">
120
+ {{ t('initiative.planning.intro') }}
121
+ </p>
122
+
123
+ <!-- Converged / no pending questions -->
124
+ <div
125
+ v-if="converged || questions.length === 0"
126
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
127
+ data-testid="initiative-planning-converged"
128
+ >
129
+ {{ t('initiative.planning.converged') }}
130
+ </div>
131
+
132
+ <!-- Interview questions -->
133
+ <ul v-else class="space-y-4">
134
+ <li
135
+ v-for="q in questions"
136
+ :key="q.key"
137
+ class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
138
+ data-testid="initiative-planning-question"
139
+ >
140
+ <p class="mb-2 text-[13px] font-medium text-slate-200">{{ q.question }}</p>
141
+ <UTextarea
142
+ v-model="drafts[q.key]"
143
+ :rows="2"
144
+ autoresize
145
+ :placeholder="t('initiative.planning.answerPlaceholder')"
146
+ class="w-full"
147
+ data-testid="initiative-planning-answer"
148
+ @blur="persist(q)"
149
+ />
150
+ </li>
151
+ </ul>
152
+ </template>
153
+ </div>
154
+
155
+ <!-- Action rail -->
156
+ <footer
157
+ v-if="initiative && !converged && questions.length > 0"
158
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
159
+ >
160
+ <p class="text-[11px] text-slate-500">
161
+ {{ t('initiative.planning.hint') }}
162
+ </p>
163
+ <div class="flex items-center gap-2">
164
+ <UButton
165
+ color="neutral"
166
+ variant="ghost"
167
+ size="sm"
168
+ :loading="resuming"
169
+ data-testid="initiative-planning-proceed"
170
+ @click="onProceed"
171
+ >
172
+ {{ t('initiative.planning.proceed') }}
173
+ </UButton>
174
+ <UButton
175
+ color="primary"
176
+ size="sm"
177
+ :loading="resuming"
178
+ :disabled="!allAnswered"
179
+ data-testid="initiative-planning-continue"
180
+ @click="onContinue"
181
+ >
182
+ {{ t('initiative.planning.continue') }}
183
+ </UButton>
184
+ </div>
185
+ </footer>
186
+ </div>
187
+ </div>
188
+ </Teleport>
189
+ </template>
@@ -24,6 +24,7 @@ import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
24
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
25
  import MergerResultView from '~/components/panels/MergerResultView.vue'
26
26
  import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
27
+ import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
27
28
 
28
29
  const ui = useUiStore()
29
30
 
@@ -57,6 +58,7 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
57
58
  // caveats. Opened from the initiative card / inspector (`ui.openInitiativeTracker`) and
58
59
  // as the planner step's result view.
59
60
  'initiative-tracker': InitiativeTrackerWindow,
61
+ 'initiative-planning': InitiativePlanningWindow,
60
62
  }
61
63
 
62
64
  const active = computed<Component | null>(() => {
@@ -22,12 +22,22 @@ const status = computed<InitiativeStatus>(() => initiative.value?.status ?? 'pla
22
22
  const planningPipeline = computed(() => pipelines.pipelines.find((p) => p.id === 'pl_initiative'))
23
23
  const running = computed(() => !!props.block.executionId)
24
24
 
25
+ // The interviewer has parked the planning run with questions awaiting answers.
26
+ const awaitingAnswers = computed(
27
+ () =>
28
+ initiative.value?.interview?.status === 'awaiting' &&
29
+ (initiative.value?.qa ?? []).some((q) => !(q.answer ?? '').trim()),
30
+ )
31
+
25
32
  function runPlanning() {
26
33
  if (planningPipeline.value) void execution.start(props.block.id, planningPipeline.value)
27
34
  }
28
35
  function openTracker() {
29
36
  ui.openInitiativeTracker(props.block.id)
30
37
  }
38
+ function openPlanning() {
39
+ ui.openInitiativePlanning(props.block.id)
40
+ }
31
41
 
32
42
  const progress = computed(() => initiativeProgress(initiative.value?.items))
33
43
  </script>
@@ -48,6 +58,17 @@ const progress = computed(() => initiativeProgress(initiative.value?.items))
48
58
  </p>
49
59
 
50
60
  <div class="flex flex-wrap items-center gap-2">
61
+ <UButton
62
+ v-if="awaitingAnswers"
63
+ data-testid="initiative-answer-planning"
64
+ color="primary"
65
+ variant="solid"
66
+ size="sm"
67
+ icon="i-lucide-messages-square"
68
+ @click="openPlanning"
69
+ >
70
+ {{ t('initiative.inspector.answerPlanning') }}
71
+ </UButton>
51
72
  <UButton
52
73
  data-testid="initiative-run-planning"
53
74
  color="primary"
@@ -1,8 +1,11 @@
1
1
  import {
2
+ answerInitiativeQuestionContract,
3
+ continueInitiativePlanningContract,
2
4
  createInitiativeContract,
3
5
  getInitiativeByBlockContract,
4
6
  getInitiativeContract,
5
7
  listInitiativesContract,
8
+ proceedInitiativePlanningContract,
6
9
  } from '@cat-factory/contracts'
7
10
  import type { ApiContext } from './context'
8
11
 
@@ -24,5 +27,31 @@ export function initiativeApi({ send, ws }: ApiContext) {
24
27
  // The tracker window's load path: the initiative anchored to a board block.
25
28
  getInitiativeByBlock: (workspaceId: string, blockId: string) =>
26
29
  send(getInitiativeByBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
30
+
31
+ // Interactive planning (slice 2): answer one interview question (no run resume), then
32
+ // continue (interviewer re-runs, may ask more) or proceed (skip remaining, plan now).
33
+ answerInitiativeQuestion: (
34
+ workspaceId: string,
35
+ blockId: string,
36
+ questionId: string,
37
+ answer: string,
38
+ ) =>
39
+ send(answerInitiativeQuestionContract, {
40
+ pathPrefix: ws(workspaceId),
41
+ pathParams: { blockId },
42
+ body: { questionId, answer },
43
+ }),
44
+
45
+ continueInitiativePlanning: (workspaceId: string, blockId: string) =>
46
+ send(continueInitiativePlanningContract, {
47
+ pathPrefix: ws(workspaceId),
48
+ pathParams: { blockId },
49
+ }),
50
+
51
+ proceedInitiativePlanning: (workspaceId: string, blockId: string) =>
52
+ send(proceedInitiativePlanningContract, {
53
+ pathPrefix: ws(workspaceId),
54
+ pathParams: { blockId },
55
+ }),
27
56
  }
28
57
  }
@@ -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
  })