@cat-factory/app 0.110.2 → 0.110.4

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.
@@ -40,7 +40,7 @@ const { linkPending } = useContextLinking()
40
40
  const open = computed({
41
41
  get: () => ui.addTaskContainerId !== null,
42
42
  set: (v: boolean) => {
43
- if (!v) ui.closeAddTask()
43
+ if (!v) void requestClose()
44
44
  },
45
45
  })
46
46
 
@@ -405,6 +405,34 @@ watch(open, (isOpen) => {
405
405
  resolvePendingIssueBodies().catch(() => {})
406
406
  })
407
407
 
408
+ // UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
409
+ // after the reset watcher so the baseline is the seeded form (a prefill is the clean
410
+ // starting point, not a spurious edit). The snapshot covers the user-owned fields — the
411
+ // cheap task-type / technical toggles are excluded, and only the *stable* context keys are
412
+ // compared so the async issue-body resolution never reads as a change.
413
+ const { requestClose } = useUnsavedGuard({
414
+ open,
415
+ close: () => ui.closeAddTask(),
416
+ saving: () => saving.value,
417
+ snapshot: () => ({
418
+ title: title.value.trim(),
419
+ description: description.value.trim(),
420
+ severity: severity.value,
421
+ stepsToReproduce: stepsToReproduce.value.trim(),
422
+ timeboxHours: timeboxHours.value ?? null,
423
+ docKind: docKind.value,
424
+ docAudience: docAudience.value.trim(),
425
+ docTargetPath: docTargetPath.value.trim(),
426
+ docOutlineHints: docOutlineHints.value.trim(),
427
+ docKindFieldValues: { ...docKindFieldValues },
428
+ riskPolicyId: riskPolicyId.value,
429
+ modelPresetId: modelPresetId.value,
430
+ pipelineId: pipelineId.value,
431
+ agentConfig: { ...agentConfigValues.value },
432
+ context: pendingContext.value.map(contextKey),
433
+ }),
434
+ })
435
+
408
436
  // A recurring task only needs a target frame (its details are filled in the schedule
409
437
  // modal); every other type needs a title.
410
438
  const canAdd = computed(() =>
@@ -921,7 +949,7 @@ async function add() {
921
949
 
922
950
  <template #footer>
923
951
  <div class="flex w-full justify-end gap-2">
924
- <UButton color="neutral" variant="ghost" @click="ui.closeAddTask()">{{
952
+ <UButton color="neutral" variant="ghost" @click="requestClose()">{{
925
953
  t('common.cancel')
926
954
  }}</UButton>
927
955
  <UButton
@@ -21,7 +21,7 @@ const { t } = useI18n()
21
21
  const open = computed({
22
22
  get: () => ui.addRecurringFrameId !== null,
23
23
  set: (v: boolean) => {
24
- if (!v) ui.closeAddRecurring()
24
+ if (!v) void requestClose()
25
25
  },
26
26
  })
27
27
 
@@ -131,6 +131,33 @@ watch(open, (isOpen) => {
131
131
  void tasks.probe()
132
132
  })
133
133
 
134
+ // UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
135
+ // after the reset watcher so the baseline is the seeded form (the default pipeline + the
136
+ // workspace tracker settings are the clean starting point, not a spurious edit).
137
+ const { requestClose } = useUnsavedGuard({
138
+ open,
139
+ close: () => ui.closeAddRecurring(),
140
+ saving: () => saving.value,
141
+ snapshot: () => ({
142
+ name: name.value.trim(),
143
+ description: description.value.trim(),
144
+ pipelineId: pipelineId.value,
145
+ onDemand: onDemand.value,
146
+ recurrence: recurrence.value,
147
+ trackerKind: trackerKind.value,
148
+ jiraProjectKey: jiraProjectKey.value.trim(),
149
+ linearTeamId: linearTeamId.value.trim(),
150
+ intakeSource: intakeSource.value,
151
+ intakeJiraProjectKey: intakeJiraProjectKey.value.trim(),
152
+ intakeLinearTeamId: intakeLinearTeamId.value.trim(),
153
+ intakeGithubRepo: intakeGithubRepo.value.trim(),
154
+ intakeTitleFragment: intakeTitleFragment.value.trim(),
155
+ intakeLabels: intakeLabels.value.trim(),
156
+ intakeIssueType: intakeIssueType.value.trim(),
157
+ intakeInProgressLabel: intakeInProgressLabel.value.trim(),
158
+ }),
159
+ })
160
+
134
161
  // The board field required for the picked source must be filled before a bug-intake schedule saves.
135
162
  const intakeReady = computed(() => {
136
163
  if (!isBugIntake.value) return true
@@ -423,7 +450,7 @@ async function add() {
423
450
 
424
451
  <template #footer>
425
452
  <div class="flex w-full justify-end gap-2">
426
- <UButton color="neutral" variant="ghost" @click="ui.closeAddRecurring()">{{
453
+ <UButton color="neutral" variant="ghost" @click="requestClose()">{{
427
454
  t('common.cancel')
428
455
  }}</UButton>
429
456
  <UButton
@@ -22,7 +22,7 @@ const { confirmAction, toastDone } = useConfirmAction()
22
22
  const open = computed({
23
23
  get: () => ui.bootstrapOpen,
24
24
  set: (v: boolean) => {
25
- if (!v) ui.closeBootstrap()
25
+ if (!v) void requestClose()
26
26
  },
27
27
  })
28
28
 
@@ -84,6 +84,23 @@ const typeItems = useFrameRepoTypeItems()
84
84
 
85
85
  const usingReference = computed(() => mode.value === 'reference')
86
86
 
87
+ // UX-18: prompt before discarding a half-filled launch form on Escape / backdrop / the X.
88
+ // The modal keeps its fields across opens (no reset watcher), so the baseline is whatever
89
+ // the form held when it opened — a close only prompts once the user has typed something
90
+ // new. Only the typed launch fields are guarded; the reference-architecture sub-form has
91
+ // its own Cancel/Save. Declared here (below the launch-form refs) because the guard reads
92
+ // its initial baseline synchronously.
93
+ const { requestClose } = useUnsavedGuard({
94
+ open,
95
+ close: () => ui.closeBootstrap(),
96
+ saving: () => launching.value,
97
+ snapshot: () => ({
98
+ repoName: repoName.value.trim(),
99
+ description: description.value.trim(),
100
+ instructions: instructions.value.trim(),
101
+ }),
102
+ })
103
+
87
104
  // Mirror of the backend `slugField` rule (@cat-factory/contracts bootstrap
88
105
  // schema): the new repo name is a SINGLE GitHub name segment — no "owner/"
89
106
  // prefix — so reject a bad value inline before we hit the API. Kept in sync with
@@ -65,13 +65,19 @@ watch(
65
65
  // The integration is on but this workspace isn't bound yet — connect first.
66
66
  const needsGitHub = computed(() => github.available === true && !github.connected)
67
67
 
68
- // Repos already backing a board service can't be added again — UNLESS they're a
69
- // monorepo, which can host several services (each at its own subdirectory). Derived from
70
- // the account's service catalog (each service carries the repo it targets), since the repo
71
- // projection no longer carries a repo→block link.
68
+ // Repos whose service is ALREADY mounted on THIS board can't be added again — adding here would
69
+ // be a no-op. A repo whose service lives on ANOTHER board in the org stays addable: adding it
70
+ // MOUNTS the shared service onto this board (backend `addServiceFromRepo`). Monorepos are exempt
71
+ // (each subdirectory is its own service). Derived from the account's catalog cross-referenced with
72
+ // this board's mounts (`byServiceId`), since the repo projection carries no repo→block link.
72
73
  const onBoardIds = computed(
73
74
  () =>
74
- new Set(services.catalog.map((s) => s.repoGithubId).filter((id): id is number => id != null)),
75
+ new Set(
76
+ services.catalog
77
+ .filter((s) => services.byServiceId[s.id])
78
+ .map((s) => s.repoGithubId)
79
+ .filter((id): id is number => id != null),
80
+ ),
75
81
  )
76
82
 
77
83
  // Map an available repo to a combobox item. The label carries the private/monorepo/
@@ -44,6 +44,34 @@ const mountableItems = computed(() =>
44
44
  }),
45
45
  )
46
46
 
47
+ async function restoreService(id: string, title: string) {
48
+ try {
49
+ await board.restoreService(id)
50
+ toast.add({
51
+ title: t('board.toast.restored', { name: title }),
52
+ icon: 'i-lucide-archive-restore',
53
+ color: 'neutral',
54
+ })
55
+ } catch (e) {
56
+ toast.add({
57
+ title: t('board.toast.restoreFailed'),
58
+ description: e instanceof Error ? e.message : String(e),
59
+ color: 'error',
60
+ })
61
+ }
62
+ }
63
+
64
+ // Archived services — hidden from the board but restorable here with no expiry.
65
+ const archivedItems = computed(() =>
66
+ board.archived.map((b) => ({
67
+ label: b.title,
68
+ icon: 'i-lucide-box',
69
+ onSelect: () => {
70
+ void restoreService(b.id, b.title)
71
+ },
72
+ })),
73
+ )
74
+
47
75
  const zoomPct = computed(() => Math.round(ui.zoom * 100))
48
76
  // Disable the zoom buttons once the camera hits a clamp (Vue Flow pins the zoom exactly
49
77
  // at the limit, so a small epsilon guards against float drift). UX-16.
@@ -186,6 +214,21 @@ const decisionItems = computed(() =>
186
214
  </UButton>
187
215
  </UDropdownMenu>
188
216
 
217
+ <!-- archived services: hidden from the board, restorable with no expiry -->
218
+ <UDropdownMenu v-if="archivedItems.length" :items="archivedItems">
219
+ <UButton
220
+ color="neutral"
221
+ variant="ghost"
222
+ size="sm"
223
+ icon="i-lucide-archive"
224
+ :title="t('board.toolbar.archivedServices')"
225
+ data-testid="board-archived-services"
226
+ >
227
+ <span class="hidden sm:inline">{{ t('board.toolbar.archivedServices') }}</span>
228
+ <span>{{ n(archivedItems.length) }}</span>
229
+ </UButton>
230
+ </UDropdownMenu>
231
+
189
232
  <!-- human-actionable notifications (merge review, pipeline complete, CI failed) -->
190
233
  <NotificationsInbox />
191
234
 
@@ -4,6 +4,7 @@ import { agentKindMeta } from '~/utils/catalog'
4
4
  const execution = useExecutionStore()
5
5
  const board = useBoardStore()
6
6
  const ui = useUiStore()
7
+ const toast = useToast()
7
8
  const { t } = useI18n()
8
9
 
9
10
  const ctx = computed(() => ui.decisionContext)
@@ -16,17 +17,42 @@ const decision = computed(() => step.value?.decision ?? null)
16
17
  const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
17
18
  const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
18
19
 
20
+ // UX-25: which option is being resolved (null = idle). Guards against a fire-and-forget
21
+ // double-submit — the resolve is awaited, all options disable while it runs, and a failed
22
+ // resolve keeps the modal open with an error toast instead of closing silently.
23
+ const resolvingOption = ref<string | null>(null)
24
+
19
25
  const open = computed({
20
26
  get: () => !!ctx.value && !!decision.value,
21
27
  set: (v: boolean) => {
22
- if (!v) ui.closeDecision()
28
+ // While a resolve is in flight the options are disabled, so the dismiss affordances
29
+ // (Escape / backdrop) are locked too — the awaited resolve settles the modal itself.
30
+ if (!v && !resolvingOption.value) ui.closeDecision()
23
31
  },
24
32
  })
25
33
 
26
- function choose(option: string) {
27
- if (!ctx.value) return
28
- execution.resolveDecision(ctx.value.instanceId, ctx.value.decisionId, option)
29
- ui.closeDecision()
34
+ async function choose(option: string) {
35
+ if (!ctx.value || resolvingOption.value) return
36
+ resolvingOption.value = option
37
+ try {
38
+ // `resolveDecision` returns false when a required-credential prompt is cancelled — keep
39
+ // the modal open in that case so the choice isn't silently dropped.
40
+ const resolved = await execution.resolveDecision(
41
+ ctx.value.instanceId,
42
+ ctx.value.decisionId,
43
+ option,
44
+ )
45
+ if (resolved) ui.closeDecision()
46
+ } catch (e) {
47
+ toast.add({
48
+ title: t('panels.decision.resolveFailed'),
49
+ description: e instanceof Error ? e.message : String(e),
50
+ icon: 'i-lucide-triangle-alert',
51
+ color: 'error',
52
+ })
53
+ } finally {
54
+ resolvingOption.value = null
55
+ }
30
56
  }
31
57
  </script>
32
58
 
@@ -65,6 +91,8 @@ function choose(option: string) {
65
91
  block
66
92
  data-testid="decision-option"
67
93
  class="justify-start"
94
+ :loading="resolvingOption === opt"
95
+ :disabled="resolvingOption !== null && resolvingOption !== opt"
68
96
  @click="choose(opt)"
69
97
  >
70
98
  {{ opt }}
@@ -186,10 +186,17 @@ const runMenu = computed(() => {
186
186
 
187
187
  // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
188
188
  // (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
189
- const { deleteBlock } = useBlockDeletion()
189
+ const { deleteBlock, archiveBlock } = useBlockDeletion()
190
190
  function remove() {
191
191
  void deleteBlock(block.value)
192
192
  }
193
+ function archive() {
194
+ void archiveBlock(block.value)
195
+ }
196
+ // A service (top-level frame) can be archived — hidden but restorable with no expiry.
197
+ const isServiceFrame = computed(
198
+ () => block.value?.level === 'frame' && block.value?.parentId === null,
199
+ )
193
200
 
194
201
  // ---- failed agent run (bootstrap or execution) ------------------------------
195
202
  // A block whose current run failed surfaces the shared failure banner + retry,
@@ -545,12 +552,25 @@ const showOriginalDescription = ref(false)
545
552
  >
546
553
  {{ t('panels.inspector.focus') }}
547
554
  </UButton>
555
+ <UButton
556
+ v-if="isServiceFrame"
557
+ color="neutral"
558
+ variant="ghost"
559
+ size="sm"
560
+ icon="i-lucide-archive"
561
+ :class="isServiceFrame ? 'ms-auto' : ''"
562
+ data-testid="inspector-archive"
563
+ :title="t('panels.inspector.archiveService')"
564
+ @click="archive"
565
+ >
566
+ {{ t('panels.inspector.archiveService') }}
567
+ </UButton>
548
568
  <UButton
549
569
  color="error"
550
570
  variant="ghost"
551
571
  size="sm"
552
572
  icon="i-lucide-trash-2"
553
- class="ms-auto"
573
+ :class="isServiceFrame ? '' : 'ms-auto'"
554
574
  data-testid="inspector-delete"
555
575
  :title="deleteLabel"
556
576
  @click="remove"
@@ -4,6 +4,7 @@ import {
4
4
  addModuleContract,
5
5
  addServiceFromRepoContract,
6
6
  addTaskContract,
7
+ archiveBlockContract,
7
8
  assignEpicContract,
8
9
  clonePipelineContract,
9
10
  createPipelineContract,
@@ -14,6 +15,7 @@ import {
14
15
  removeBlockContract,
15
16
  reparentBlockContract,
16
17
  reseedPipelineContract,
18
+ restoreBlockContract,
17
19
  toggleDependencyContract,
18
20
  updateBlockContract,
19
21
  updatePipelineContract,
@@ -87,6 +89,13 @@ export function boardApi({ send, ws }: ApiContext) {
87
89
  removeBlock: (workspaceId: string, blockId: string) =>
88
90
  send(removeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
89
91
 
92
+ // Archive a service (hide it + its subtree, restorable with no expiry) instead of deleting.
93
+ archiveBlock: (workspaceId: string, blockId: string) =>
94
+ send(archiveBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
95
+
96
+ restoreBlock: (workspaceId: string, blockId: string) =>
97
+ send(restoreBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
98
+
90
99
  toggleDependency: (workspaceId: string, blockId: string, body: { sourceId: string }) =>
91
100
  send(toggleDependencyContract, {
92
101
  pathPrefix: ws(workspaceId),
@@ -15,9 +15,54 @@ export function useBlockDeletion() {
15
15
  const execution = useExecutionStore()
16
16
  const ui = useUiStore()
17
17
  const recurring = useRecurringPipelinesStore()
18
+ const toast = useToast()
18
19
  const { confirm } = useConfirm()
19
20
  const { t } = useI18n()
20
21
 
22
+ /** A service is any top-level frame; only services are archivable. */
23
+ function isService(block: Block): boolean {
24
+ return block.level === 'frame' && block.parentId === null
25
+ }
26
+
27
+ /** Unfinished (`status !== 'done'`) task descendants of a service — the delete blocker. */
28
+ function unfinishedTaskCount(block: Block): number {
29
+ return board.descendantsOf(block.id).filter((b) => b.level === 'task' && b.status !== 'done')
30
+ .length
31
+ }
32
+
33
+ /**
34
+ * Archive a service: hide it (restorable with no expiry) instead of deleting. Used both as the
35
+ * explicit inspector action and as the automatic fallback when a service that still has
36
+ * unfinished work can't be deleted.
37
+ */
38
+ async function archiveBlock(block: Block | undefined | null): Promise<boolean> {
39
+ if (!block || !isService(block)) return false
40
+ const ok = await confirm({
41
+ title: t('panels.inspector.confirmArchive.title'),
42
+ description: t('panels.inspector.confirmArchive.body', { name: block.title }),
43
+ confirmLabel: t('panels.inspector.archiveService'),
44
+ icon: 'i-lucide-archive',
45
+ })
46
+ if (!ok) return false
47
+ ui.select(null)
48
+ try {
49
+ await board.archiveService(block.id)
50
+ toast.add({
51
+ title: t('board.toast.archived', { name: block.title }),
52
+ icon: 'i-lucide-archive',
53
+ color: 'neutral',
54
+ })
55
+ } catch (e) {
56
+ toast.add({
57
+ title: t('board.toast.archiveFailed'),
58
+ description: e instanceof Error ? e.message : String(e),
59
+ icon: 'i-lucide-triangle-alert',
60
+ color: 'error',
61
+ })
62
+ }
63
+ return true
64
+ }
65
+
21
66
  /** Resolve the confirm title/body for a block, matching the inspector's delete-label kinds. */
22
67
  function copyFor(block: Block): { title: string; body: string } {
23
68
  const schedule = recurring.byBlock(block.id)
@@ -49,6 +94,9 @@ export function useBlockDeletion() {
49
94
 
50
95
  async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
51
96
  if (!block) return false
97
+ // A service with unfinished work can't be deleted (the backend rejects it) — archive it
98
+ // instead. Route straight to the archive flow so the user is never handed a dead-end error.
99
+ if (isService(block) && unfinishedTaskCount(block) > 0) return archiveBlock(block)
52
100
  const { title, body } = copyFor(block)
53
101
  const ok = await confirm({
54
102
  title,
@@ -75,5 +123,5 @@ export function useBlockDeletion() {
75
123
  return true
76
124
  }
77
125
 
78
- return { deleteBlock }
126
+ return { deleteBlock, archiveBlock }
79
127
  }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { useUnsavedGuard } from '~/composables/useUnsavedGuard'
4
+
5
+ // Control the shared confirm dialog: `confirm()` resolves to the user's choice (discard
6
+ // vs keep). `useI18n` is already stubbed to a passthrough in test/setup.ts.
7
+ function stubConfirm(result: boolean) {
8
+ const confirm = vi.fn().mockResolvedValue(result)
9
+ vi.stubGlobal('useConfirm', () => ({ confirm }))
10
+ return confirm
11
+ }
12
+
13
+ describe('useUnsavedGuard', () => {
14
+ it('closes immediately without prompting when the form is unchanged', async () => {
15
+ const confirm = stubConfirm(true)
16
+ const close = vi.fn()
17
+ const value = ref('hello')
18
+ const { requestClose } = useUnsavedGuard({
19
+ open: ref(true),
20
+ close,
21
+ snapshot: () => ({ value: value.value }),
22
+ })
23
+
24
+ await requestClose()
25
+
26
+ expect(confirm).not.toHaveBeenCalled()
27
+ expect(close).toHaveBeenCalledOnce()
28
+ })
29
+
30
+ it('prompts and closes when dirty and the user confirms discard', async () => {
31
+ const confirm = stubConfirm(true)
32
+ const close = vi.fn()
33
+ const value = ref('hello')
34
+ const { requestClose, isDirty } = useUnsavedGuard({
35
+ open: ref(true),
36
+ close,
37
+ snapshot: () => ({ value: value.value }),
38
+ })
39
+
40
+ value.value = 'changed'
41
+ expect(isDirty()).toBe(true)
42
+ await requestClose()
43
+
44
+ expect(confirm).toHaveBeenCalledOnce()
45
+ expect(close).toHaveBeenCalledOnce()
46
+ })
47
+
48
+ it('prompts and keeps the modal open when the user cancels discard', async () => {
49
+ const confirm = stubConfirm(false)
50
+ const close = vi.fn()
51
+ const value = ref('hello')
52
+ const { requestClose } = useUnsavedGuard({
53
+ open: ref(true),
54
+ close,
55
+ snapshot: () => ({ value: value.value }),
56
+ })
57
+
58
+ value.value = 'changed'
59
+ await requestClose()
60
+
61
+ expect(confirm).toHaveBeenCalledOnce()
62
+ expect(close).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('never prompts or closes while a submit is in flight', async () => {
66
+ const confirm = stubConfirm(true)
67
+ const close = vi.fn()
68
+ const value = ref('hello')
69
+ const { requestClose } = useUnsavedGuard({
70
+ open: ref(true),
71
+ close,
72
+ saving: () => true,
73
+ snapshot: () => ({ value: value.value }),
74
+ })
75
+
76
+ value.value = 'changed'
77
+ await requestClose()
78
+
79
+ expect(confirm).not.toHaveBeenCalled()
80
+ expect(close).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it('re-baselines the seeded form each time the modal opens', async () => {
84
+ stubConfirm(true)
85
+ const open = ref(false)
86
+ const value = ref('')
87
+ const { isDirty } = useUnsavedGuard({
88
+ open,
89
+ close: vi.fn(),
90
+ snapshot: () => ({ value: value.value }),
91
+ })
92
+
93
+ // A reset watcher seeds a prefill; the guard re-snapshots on open, so the seeded value
94
+ // is the clean baseline rather than a spurious edit.
95
+ value.value = 'seeded prefill'
96
+ open.value = true
97
+ await nextTick()
98
+ expect(isDirty()).toBe(false)
99
+
100
+ // A genuine edit after the modal is open is dirty.
101
+ value.value = 'user typed more'
102
+ expect(isDirty()).toBe(true)
103
+ })
104
+ })
@@ -0,0 +1,66 @@
1
+ import { watch } from 'vue'
2
+ import type { Ref } from 'vue'
3
+
4
+ /**
5
+ * Guard a content-heavy modal against silently discarding unsaved input when the user
6
+ * dismisses it (Escape, backdrop click, or a Cancel button). Wire it into a controlled
7
+ * `UModal` whose `open` is a store-backed writable computed: route the setter's close and
8
+ * the Cancel button through `requestClose()` instead of closing directly.
9
+ *
10
+ * `snapshot()` returns a serialisable view of the user-facing form state. The baseline is
11
+ * captured every time the modal opens, so register this AFTER the component's own reset
12
+ * watcher — it then snapshots the *seeded* form (a prefill or an existing edit is the
13
+ * clean baseline, not a spurious change). A close request only prompts when the current
14
+ * snapshot diverges from that baseline; when nothing changed — or a submit is in flight —
15
+ * the close proceeds immediately, so the common path is unchanged.
16
+ *
17
+ * Keep `snapshot()` to stable, user-owned values: exclude fields mutated by async loads
18
+ * (they would read as dirty the instant a background fetch settles) and prefer stable ids
19
+ * over objects that a best-effort resolve rewrites in place.
20
+ */
21
+ export function useUnsavedGuard(opts: {
22
+ /** The modal's open state (the writable computed's underlying getter). */
23
+ open: Ref<boolean>
24
+ /** A serialisable view of the current form state. */
25
+ snapshot: () => unknown
26
+ /** Actually close the modal (the store close action). */
27
+ close: () => void
28
+ /** True while a submit is in flight — a close is then a no-op (the submit closes itself). */
29
+ saving?: () => boolean
30
+ }) {
31
+ const { confirm } = useConfirm()
32
+ const { t } = useI18n()
33
+
34
+ let baseline = serialize(opts.snapshot())
35
+ watch(opts.open, (isOpen) => {
36
+ if (isOpen) baseline = serialize(opts.snapshot())
37
+ })
38
+
39
+ function isDirty(): boolean {
40
+ return serialize(opts.snapshot()) !== baseline
41
+ }
42
+
43
+ async function requestClose(): Promise<void> {
44
+ // A submit in flight closes itself on success — don't interrupt it or prompt.
45
+ if (opts.saving?.()) return
46
+ if (!isDirty()) {
47
+ opts.close()
48
+ return
49
+ }
50
+ const discard = await confirm({
51
+ title: t('common.discard.title'),
52
+ description: t('common.discard.body'),
53
+ confirmLabel: t('common.discard.confirm'),
54
+ cancelLabel: t('common.discard.keep'),
55
+ variant: 'destructive',
56
+ icon: 'i-lucide-triangle-alert',
57
+ })
58
+ if (discard) opts.close()
59
+ }
60
+
61
+ return { requestClose, isDirty }
62
+ }
63
+
64
+ function serialize(value: unknown): string {
65
+ return JSON.stringify(value ?? null)
66
+ }