@cat-factory/app 0.280.0 → 0.280.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.
Files changed (55) hide show
  1. package/README.md +26 -1
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
  3. package/app/components/board/AddTaskModal.vue +9 -0
  4. package/app/components/board/ReviewFrictionDialog.vue +35 -4
  5. package/app/components/board/TaskDependencyEdges.vue +26 -15
  6. package/app/components/board/nodes/TaskCard.vue +6 -1
  7. package/app/components/brainstorm/BrainstormWindow.vue +19 -1
  8. package/app/components/common/AsyncViewError.vue +30 -0
  9. package/app/components/common/ConfirmDialog.vue +26 -0
  10. package/app/components/docs/DocInterviewWindow.vue +40 -38
  11. package/app/components/followUp/FollowUpWindow.vue +32 -2
  12. package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
  13. package/app/components/gates/GateResultView.vue +14 -1
  14. package/app/components/humanTest/HumanTestWindow.vue +13 -1
  15. package/app/components/initiative/InitiativePlanDecision.vue +16 -3
  16. package/app/components/initiative/InitiativePlanReview.vue +16 -1
  17. package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
  18. package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
  19. package/app/components/judge/JudgeResultView.vue +14 -1
  20. package/app/components/panels/AgentStepDetail.vue +651 -659
  21. package/app/components/panels/InspectorPanel.vue +6 -0
  22. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
  23. package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
  24. package/app/components/pipeline/PipelineHealthModal.vue +55 -16
  25. package/app/components/prReview/PrReviewWindow.vue +23 -4
  26. package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
  27. package/app/composables/useBoardActivity.ts +62 -6
  28. package/app/composables/useConfirm.spec.ts +62 -0
  29. package/app/composables/useConfirm.ts +6 -1
  30. package/app/composables/useInterviewDrafts.spec.ts +198 -0
  31. package/app/composables/useInterviewDrafts.ts +184 -0
  32. package/app/composables/useTaskExpansion.ts +10 -25
  33. package/app/docs/consumer-extensions.md +9 -0
  34. package/app/modular/result-views.ts +58 -21
  35. package/app/pages/index.vue +68 -64
  36. package/app/stores/binaryCandidates.ts +26 -3
  37. package/app/stores/ui/modals.ts +11 -0
  38. package/app/utils/asyncView.ts +24 -0
  39. package/app/utils/binaryCandidates.spec.ts +45 -1
  40. package/app/utils/binaryCandidates.ts +33 -0
  41. package/app/utils/blockRects.spec.ts +82 -0
  42. package/app/utils/blockRects.ts +61 -0
  43. package/app/utils/boardWakeGate.spec.ts +101 -0
  44. package/app/utils/boardWakeGate.ts +78 -0
  45. package/i18n/locales/de.json +25 -2
  46. package/i18n/locales/en.json +25 -2
  47. package/i18n/locales/es.json +25 -2
  48. package/i18n/locales/fr.json +25 -2
  49. package/i18n/locales/he.json +25 -2
  50. package/i18n/locales/it.json +25 -2
  51. package/i18n/locales/ja.json +25 -2
  52. package/i18n/locales/pl.json +25 -2
  53. package/i18n/locales/tr.json +25 -2
  54. package/i18n/locales/uk.json +25 -2
  55. package/package.json +1 -1
package/README.md CHANGED
@@ -86,6 +86,29 @@ the animation that follows, parking once the output has held still for a few fra
86
86
  half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and
87
87
  a bare frame loop never stops.
88
88
 
89
+ **The pulse does not treat its signals alike, and a driver must not assume it does.** What the
90
+ user is moving (pointer, wheel, scroll, resize, the camera's own `pulse()`) wakes the loops
91
+ immediately, because a lagging arrow under a drag is the bug this whole design exists to fix.
92
+ RENDERS do not: a live board re-renders its cards on every execution event, and admitting each
93
+ one kept the loops awake forever on exactly the board where measuring costs most, so mutations
94
+ go through a rate limit (`utils/boardWakeGate.ts`, one wake led in immediately and then at most
95
+ one per 250ms while the stream lasts). The cost is stated rather than hidden: a geometry change
96
+ caused purely by a re-render, a badge appearing and growing a card, can take up to that interval
97
+ to be followed. A driver that needs a signal the DOM cannot show, a link set changing with no
98
+ card moving, watches its own reactive source and pokes, the way `TaskDependencyEdges` watches
99
+ its four link lists.
100
+
101
+ The gesture listeners are on the WINDOW, not on the canvas element. A drag does not stop at the
102
+ canvas's edge (`useBlockDrag` tracks the pointer on the window for exactly that reason) and the
103
+ top overlay region and the inspector are siblings painted OVER the canvas, so a canvas-bound
104
+ listener went quiet for as long as the cursor crossed one of them.
105
+
106
+ **Measure through `utils/blockRects.ts`, never a `querySelector` per card.** `measureBlocks()`
107
+ hands a pass one snapshot: the cards resolved in one query, first-in-document-order per id, and
108
+ each rect read at most once. It is what makes a wake cheap enough for the rate limit above to be
109
+ a saving rather than a way of hiding an expensive pass, and it is lazy, so a pass that resolves
110
+ nothing (a board with no links at all) touches no DOM.
111
+
89
112
  Two things this cost, both worth knowing before adding a third driver. `compute` returning
90
113
  `true` unconditionally silently restores the old behaviour, which is why the loop's contract is
91
114
  stated in terms of what the user can see rather than what the function did. And the pulse
@@ -102,7 +125,9 @@ font resizing a card. That leaves an arrow stale until the next pulse of any kin
102
125
  deliberate trade: firing too often costs a handful of frames, and the alternative is the loop
103
126
  that never sleeps.
104
127
 
105
- `app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock.
128
+ `app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock;
129
+ `boardWakeGate.spec.ts` pins that the rate limit delivers every suppressed wake rather than
130
+ dropping it, and `blockRects.spec.ts` that a snapshot resolves and measures each card once.
106
131
 
107
132
  ### A store must be instantiable outside a component `setup`
108
133
 
@@ -22,6 +22,7 @@ import { useBoardStore } from '~/stores/board'
22
22
  import { useBinaryCandidatesStore } from '~/stores/binaryCandidates'
23
23
  import {
24
24
  BINARY_CANDIDATE_NO_CHOICE_KEYS,
25
+ binaryCandidateAbsence,
25
26
  binaryCandidateHasWarnings,
26
27
  binaryCandidateView,
27
28
  } from '~/utils/binaryCandidates'
@@ -45,6 +46,16 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('binary-ca
45
46
  },
46
47
  })
47
48
 
49
+ /**
50
+ * Re-run the warm-up read after a failed one (the Retry beside the error state). Refuses while one
51
+ * is already in flight: the store sequences its attempts so a superseded settle can't write, and
52
+ * this is the authoritative half, so the refusal holds for any future caller.
53
+ */
54
+ function retryLoad(): void {
55
+ const id = instanceId.value
56
+ if (id && !candidates.loading) void candidates.load(id)
57
+ }
58
+
48
59
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
49
60
  const headerTitle = computed(() =>
50
61
  block.value
@@ -59,6 +70,14 @@ const step = computed(() => {
59
70
  return instance.value.steps[stepIndex.value] ?? null
60
71
  })
61
72
  const view = computed(() => binaryCandidateView(step.value))
73
+ /**
74
+ * Which of the four no-comparison states the body renders; see `binaryCandidateAbsence`. The run id
75
+ * is part of the question: with no run there was no read, so neither the spinner nor "nothing to
76
+ * compare" would be about this window.
77
+ */
78
+ const absence = computed(() =>
79
+ binaryCandidateAbsence(candidates.loading, candidates.error, instanceId.value),
80
+ )
62
81
  const warnings = computed(() => (view.value ? binaryCandidateHasWarnings(view.value) : false))
63
82
  const noChoiceKey = computed(() => {
64
83
  const reason = view.value?.state.noChoiceReason
@@ -95,6 +114,15 @@ function toggle(id: string): void {
95
114
  selected.value = [id]
96
115
  }
97
116
 
117
+ /**
118
+ * The accessible name for a candidate's select control. Ticking one is the ONLY way to keep an
119
+ * asset, so this control is the gate's critical path and cannot be an unlabelled input: falls back
120
+ * through what the agent actually declared, ending at the id, which is always present.
121
+ */
122
+ function candidateLabel(row: { label?: string; subject?: string; generator?: string; id: string }) {
123
+ return row.label ?? row.subject ?? row.generator ?? row.id
124
+ }
125
+
98
126
  /**
99
127
  * Whether the request would be accepted. Mirrors the backend's own refusals rather than only
100
128
  * disabling on emptiness: keeping several candidates under one name would store one artifact and
@@ -125,8 +153,37 @@ async function onKeep() {
125
153
  return alias ? { candidateId, storeAs: alias } : { candidateId }
126
154
  })
127
155
  const text = note.value.trim()
128
- await candidates.keep(id, { keep, ...(text ? { note: text } : {}) }).catch(() => {})
156
+ const kept = await candidates
157
+ .keep(id, { keep, ...(text ? { note: text } : {}) })
158
+ .then(() => true)
159
+ // The store records the message; the inline error strip below renders it.
160
+ .catch(() => false)
161
+ // Drop the drafts only once they are actually recorded — the window stays open as the RECORD of
162
+ // the decision, and leaving a submitted note in the box would make the unsaved guard below prompt
163
+ // over text that is already saved.
164
+ if (kept) {
165
+ note.value = ''
166
+ aliases.value = {}
167
+ }
129
168
  }
169
+
170
+ /**
171
+ * Confirm before discarding a typed rationale or a store-as alias (UX-79). The note is the only
172
+ * place the reasoning behind this choice is ever written down, and the aliases are what keeps two
173
+ * candidates from overwriting each other, so an Escape or a stray backdrop click used to cost work
174
+ * with no way to get it back. Nothing typed still closes straight through.
175
+ */
176
+ const { requestClose } = useUnsavedGuard({
177
+ open,
178
+ close: () => close(),
179
+ saving: () => candidates.keeping,
180
+ snapshot: () => ({
181
+ note: note.value.trim(),
182
+ // Only the aliases of candidates still ticked count: one left behind on an unticked candidate
183
+ // is not going anywhere on Keep either, so prompting over it would be a false alarm.
184
+ aliases: selected.value.map((id) => (aliases.value[id] ?? '').trim()).filter(Boolean),
185
+ }),
186
+ })
130
187
  </script>
131
188
 
132
189
  <template>
@@ -138,7 +195,7 @@ async function onKeep() {
138
195
  :subtitle="t('binaryCandidates.subtitle')"
139
196
  width="5xl"
140
197
  testid="binary-candidates-window"
141
- @close="close"
198
+ @close="requestClose"
142
199
  >
143
200
  <div v-if="view" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
144
201
  <!-- Why there was nothing to choose between. Its own line per reason: a model that never
@@ -187,14 +244,44 @@ async function onKeep() {
187
244
  v-for="row in group.rows"
188
245
  :key="row.id"
189
246
  class="rounded border p-2 transition"
190
- :class="
247
+ :class="[
191
248
  selected.includes(row.id) || row.kept
192
249
  ? 'border-sky-400/60 bg-sky-500/5'
193
- : 'border-slate-700/60'
194
- "
250
+ : 'border-slate-700/60',
251
+ view.awaiting ? 'cursor-pointer' : '',
252
+ ]"
195
253
  data-testid="binary-candidate-card"
196
254
  @click="toggle(row.id)"
197
255
  >
256
+ <!-- The REAL control, not the card's click handler: ticking a candidate is the only
257
+ way to keep an asset, so without a focusable input the gate could not be completed
258
+ by keyboard at all (UX-80). The whole window is ONE radio group in single-select
259
+ mode, because `toggle` replaces the selection across every subject rather than per
260
+ group.
261
+
262
+ `@click.stop` belongs on the LABEL, which is the element the card's own toggle has
263
+ to be shielded from. On the input alone it stopped the wrong click: activating a
264
+ label forwards a synthetic click to its input (which `.stop` there does not
265
+ prevent, only its propagation), so a click on the label TEXT bubbled to the card
266
+ and toggled, then the forwarded click toggled back. On a checkbox that nets to no
267
+ change, which means no re-render, which leaves the box ticked over a candidate
268
+ that is no longer selected. -->
269
+ <label
270
+ v-if="view.awaiting"
271
+ class="mb-1.5 flex cursor-pointer items-center gap-2 text-[11px] text-slate-400"
272
+ @click.stop
273
+ >
274
+ <input
275
+ :type="view.multiSelect ? 'checkbox' : 'radio'"
276
+ name="binary-candidate"
277
+ class="accent-sky-500"
278
+ :checked="selected.includes(row.id)"
279
+ :aria-label="candidateLabel(row)"
280
+ data-testid="binary-candidate-select"
281
+ @change="toggle(row.id)"
282
+ />
283
+ <span class="truncate">{{ candidateLabel(row) }}</span>
284
+ </label>
198
285
  <!-- Staged through the platform's OWN asset storage: we hold the bytes, so the card
199
286
  renders them (and offers to open or save one) rather than waiting for a public
200
287
  link the shipped storage never issues. Checked first because a candidate can
@@ -292,5 +379,55 @@ async function onKeep() {
292
379
  </div>
293
380
  </div>
294
381
  </div>
382
+
383
+ <!-- No state on the step. The four ways that happens need different reactions, so they render
384
+ as four different things rather than as the blank body this window used to leave behind
385
+ (UX-80): there is no run to read, the read is still in flight, the read FAILED (offer a
386
+ Retry), or the step genuinely compared nothing. Collapsing any of the first three into the
387
+ last would put "nothing to compare" in front of a person whose candidates exist and were
388
+ simply not fetched. -->
389
+ <div
390
+ v-else-if="absence === 'no_run'"
391
+ class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
392
+ data-testid="binary-candidates-no-run"
393
+ >
394
+ <UIcon name="i-lucide-unlink" class="h-8 w-8 opacity-40" />
395
+ <p class="text-sm">{{ t('binaryCandidates.noRun.title') }}</p>
396
+ <p class="max-w-md text-[11px] text-slate-500">{{ t('binaryCandidates.noRun.hint') }}</p>
397
+ </div>
398
+ <div
399
+ v-else-if="absence === 'loading'"
400
+ class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
401
+ data-testid="binary-candidates-loading"
402
+ >
403
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
404
+ </div>
405
+ <div
406
+ v-else-if="absence === 'load_failed'"
407
+ class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
408
+ data-testid="binary-candidates-load-error"
409
+ >
410
+ <UIcon name="i-lucide-triangle-alert" class="h-8 w-8 text-amber-400/70" />
411
+ <p class="text-sm">{{ t('binaryCandidates.loadFailed') }}</p>
412
+ <p class="max-w-md break-words text-[11px] text-slate-500">{{ candidates.error }}</p>
413
+ <UButton
414
+ size="xs"
415
+ color="neutral"
416
+ variant="subtle"
417
+ icon="i-lucide-refresh-cw"
418
+ data-testid="binary-candidates-retry"
419
+ @click="retryLoad"
420
+ >
421
+ {{ t('common.retry') }}
422
+ </UButton>
423
+ </div>
424
+ <div
425
+ v-else
426
+ class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-5 py-10 text-center text-slate-400"
427
+ data-testid="binary-candidates-empty"
428
+ >
429
+ <UIcon name="i-lucide-images" class="h-8 w-8 opacity-40" />
430
+ <p class="text-sm">{{ t('binaryCandidates.empty.title') }}</p>
431
+ </div>
295
432
  </ResultWindowShell>
296
433
  </template>
@@ -694,6 +694,12 @@ async function add() {
694
694
  async function submitCreate(acknowledgeReviewDebt: boolean) {
695
695
  const containerId = ui.addTaskContainerId
696
696
  if (!containerId) return
697
+ // Entry guard (UX-78). `saving` gates the form's own Add button, but the friction dialog's
698
+ // "Create anyway" is a SECOND entry point into this same function, and the first await below is
699
+ // a network round-trip per staged attachment — long enough for a second click to file a second
700
+ // task and start a second pipeline run. The dialog also disables its button; this is the
701
+ // authoritative half, since it holds for any future caller.
702
+ if (saving.value) return
697
703
  saving.value = true
698
704
  try {
699
705
  // Attachments are fetched BEFORE the task is written. A page that moved, a token without
@@ -825,6 +831,9 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
825
831
  threshold: typeof details.threshold === 'number' ? details.threshold : null,
826
832
  debt,
827
833
  onConfirm: isWarn ? () => void submitCreate(true) : null,
834
+ // A getter, not a snapshot: the dialog reads it inside a computed so its button spins and
835
+ // locks for as long as the retry actually runs (UX-78).
836
+ pending: () => saving.value,
828
837
  })
829
838
  }
830
839
  </script>
@@ -12,10 +12,22 @@ const ui = useUiStore()
12
12
 
13
13
  const ctx = computed(() => ui.reviewFrictionContext)
14
14
 
15
+ // True while the opener's create is actually in flight. Read through the context's getter inside a
16
+ // computed so it tracks the opener's own `saving` ref (UX-78): the context object itself is
17
+ // captured once at open, so a copied boolean would never update.
18
+ //
19
+ // While it holds, EVERY way out is closed, not just the buttons. Gating the two actions and leaving
20
+ // Close, Escape and the backdrop live was the worse half of the asymmetry: it locked the safe exit
21
+ // (go review the waiting tasks) and left open the one that tears the dialog down mid-create, so the
22
+ // user could not tell whether a task had been filed.
23
+ const pending = computed(() => ctx.value?.pending?.() ?? false)
24
+
15
25
  const open = computed({
16
26
  get: () => ctx.value !== null,
17
27
  set: (v: boolean) => {
18
- if (!v) ui.closeReviewFriction()
28
+ // Refuse a USER dismissal while the create is in flight (see `pending`). The opener still closes
29
+ // this dialog directly through the store on success, so only the human's exits are gated.
30
+ if (!v && !pending.value) ui.closeReviewFriction()
19
31
  },
20
32
  })
21
33
 
@@ -54,12 +66,19 @@ function goReview() {
54
66
  }
55
67
 
56
68
  function createAnyway() {
69
+ if (pending.value) return
57
70
  ctx.value?.onConfirm?.()
58
71
  }
59
72
  </script>
60
73
 
61
74
  <template>
62
- <UModal v-model:open="open" :title="title" :ui="{ content: 'max-w-xl' }">
75
+ <UModal
76
+ v-model:open="open"
77
+ :title="title"
78
+ :dismissible="!pending"
79
+ :close="{ disabled: pending }"
80
+ :ui="{ content: 'max-w-xl' }"
81
+ >
63
82
  <template #body>
64
83
  <div v-if="ctx" class="space-y-5">
65
84
  <p class="text-sm text-slate-300">{{ body }}</p>
@@ -72,7 +91,8 @@ function createAnyway() {
72
91
  <li v-for="item in ctx.debt" :key="item.blockId">
73
92
  <button
74
93
  type="button"
75
- class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60"
94
+ class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60 disabled:opacity-50"
95
+ :disabled="pending"
76
96
  @click="goToBlock(item.blockId)"
77
97
  >
78
98
  <span class="truncate text-slate-200">
@@ -91,6 +111,8 @@ function createAnyway() {
91
111
  color="neutral"
92
112
  variant="ghost"
93
113
  size="sm"
114
+ :disabled="pending"
115
+ data-testid="review-friction-close"
94
116
  @click="
95
117
  () => {
96
118
  open = false
@@ -104,11 +126,20 @@ function createAnyway() {
104
126
  color="neutral"
105
127
  variant="subtle"
106
128
  size="sm"
129
+ :loading="pending"
130
+ :disabled="pending"
131
+ data-testid="review-friction-create-anyway"
107
132
  @click="createAnyway"
108
133
  >
109
134
  {{ t('errors.reviewFriction.createAnyway') }}
110
135
  </UButton>
111
- <UButton color="primary" size="sm" icon="i-lucide-list-checks" @click="goReview">
136
+ <UButton
137
+ color="primary"
138
+ size="sm"
139
+ icon="i-lucide-list-checks"
140
+ :disabled="pending"
141
+ @click="goReview"
142
+ >
112
143
  {{ t('errors.reviewFriction.goReview') }}
113
144
  </UButton>
114
145
  </div>
@@ -3,6 +3,7 @@ import { ref, shallowRef, computed, watch } from 'vue'
3
3
  import { useBoardActivity } from '~/composables/useBoardActivity'
4
4
  import { useSettlingRaf } from '~/composables/useSettlingRaf'
5
5
  import { commitSegments, type EdgeSegment } from '~/utils/edgeSegments'
6
+ import { measureBlocks, type BlockMeasurements } from '~/utils/blockRects'
6
7
 
7
8
  /**
8
9
  * Draws dependency arrows between task cards as an SVG overlay on top of the
@@ -11,9 +12,11 @@ import { commitSegments, type EdgeSegment } from '~/utils/edgeSegments'
11
12
  * zoom / drag / expand for free. When a task's frame is collapsed (its card
12
13
  * isn't rendered), the arrow anchors to the frame card instead.
13
14
  *
14
- * Measuring is O(edges) `querySelector` + forced layout reads, so it runs only
15
- * while something is actually moving: the board's activity pulse wakes it and
16
- * `useSettlingRaf` parks it again once the resolved segments hold still.
15
+ * Measuring costs forced layout reads, so it runs only while something is actually
16
+ * moving: the board's activity pulse wakes it and `useSettlingRaf` parks it again
17
+ * once the resolved segments hold still. Within one pass the cards are resolved and
18
+ * measured through a single shared snapshot (`measureBlocks`), so a task with five
19
+ * dependencies is found and measured once rather than five times.
17
20
  */
18
21
  const board = useBoardStore()
19
22
 
@@ -91,10 +94,10 @@ const connectionLinks = computed(() => {
91
94
 
92
95
  /** Resolve a task's anchor: walk up task → module → service to the first card
93
96
  * that's actually rendered (a container may be collapsed). */
94
- function anchorEl(taskId: string): HTMLElement | null {
97
+ function anchorEl(taskId: string, blocks: BlockMeasurements): HTMLElement | null {
95
98
  let cur = board.getBlock(taskId)
96
99
  while (cur) {
97
- const el = document.querySelector(`[data-block-id="${cur.id}"]`) as HTMLElement | null
100
+ const el = blocks.elementFor(cur.id)
98
101
  if (el) return el
99
102
  cur = cur.parentId ? board.getBlock(cur.parentId) : undefined
100
103
  }
@@ -112,12 +115,17 @@ function border(cx: number, cy: number, hw: number, hh: number, tx: number, ty:
112
115
 
113
116
  /** Resolve the on-screen, origin-relative border-to-border segment between two blocks,
114
117
  * or null when either end is missing or both collapsed into the same frame. */
115
- function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
116
- const a = anchorEl(sourceId)
117
- const b = anchorEl(targetId)
118
+ function segmentBetween(
119
+ sourceId: string,
120
+ targetId: string,
121
+ origin: DOMRect,
122
+ blocks: BlockMeasurements,
123
+ ) {
124
+ const a = anchorEl(sourceId, blocks)
125
+ const b = anchorEl(targetId, blocks)
118
126
  if (!a || !b || a === b) return null // missing, or both collapsed into the same frame
119
- const ra = a.getBoundingClientRect()
120
- const rb = b.getBoundingClientRect()
127
+ const ra = blocks.rectFor(a)
128
+ const rb = blocks.rectFor(b)
121
129
  const ax = ra.left + ra.width / 2 - origin.left
122
130
  const ay = ra.top + ra.height / 2 - origin.top
123
131
  const bx = rb.left + rb.width / 2 - origin.left
@@ -131,10 +139,11 @@ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
131
139
  function linkSegments(
132
140
  links: { id: string; source: string; target: string }[],
133
141
  origin: DOMRect,
142
+ blocks: BlockMeasurements,
134
143
  ): EdgeSegment[] {
135
144
  const out: EdgeSegment[] = []
136
145
  for (const link of links) {
137
- const seg = segmentBetween(link.source, link.target, origin)
146
+ const seg = segmentBetween(link.source, link.target, origin, blocks)
138
147
  if (seg) out.push({ id: link.id, ...seg })
139
148
  }
140
149
  return out
@@ -145,10 +154,12 @@ function recompute(): boolean {
145
154
  const el = svg.value
146
155
  if (!el) return false
147
156
  const origin = el.getBoundingClientRect()
157
+ // One snapshot for the whole pass: every overlay below resolves and measures through it.
158
+ const blocks = measureBlocks()
148
159
 
149
160
  const deps: EdgeSegment[] = []
150
161
  for (const d of taskDeps.value) {
151
- const seg = segmentBetween(d.source, d.target, origin)
162
+ const seg = segmentBetween(d.source, d.target, origin, blocks)
152
163
  if (!seg) continue
153
164
  deps.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
154
165
  }
@@ -157,9 +168,9 @@ function recompute(): boolean {
157
168
  // would short-circuit and leave the later overlays drawn at stale coordinates.
158
169
  return [
159
170
  commitSegments(segments, deps),
160
- commitSegments(memberSegments, linkSegments(epicLinks.value, origin)),
161
- commitSegments(frontendSegments, linkSegments(frontendLinks.value, origin)),
162
- commitSegments(connectionSegments, linkSegments(connectionLinks.value, origin)),
171
+ commitSegments(memberSegments, linkSegments(epicLinks.value, origin, blocks)),
172
+ commitSegments(frontendSegments, linkSegments(frontendLinks.value, origin, blocks)),
173
+ commitSegments(connectionSegments, linkSegments(connectionLinks.value, origin, blocks)),
163
174
  ].some(Boolean)
164
175
  }
165
176
 
@@ -372,8 +372,13 @@ function selectTask() {
372
372
  </div>
373
373
 
374
374
  <!-- title gets a full-width row so long titles wrap to two lines rather than
375
- truncating to an unreadable stub; the full text stays available on hover. -->
375
+ truncating to an unreadable stub; the full text stays available on hover.
376
+
377
+ It is also the card's SELECTION affordance for tests: every action button below stops
378
+ propagation, so a click resolved to one of them never reaches `selectTask`, and the
379
+ title is the one always-rendered part of the body that no control can occupy. -->
376
380
  <div
381
+ data-testid="task-title"
377
382
  class="mt-1 line-clamp-2 break-words text-[11px] font-semibold leading-snug text-slate-100"
378
383
  :title="task.title"
379
384
  >
@@ -156,6 +156,24 @@ async function submitReply(item: BrainstormItem) {
156
156
  }
157
157
  }
158
158
 
159
+ /**
160
+ * Confirm before discarding typed replies (UX-79). Each draft answers one of the brainstorm's open
161
+ * questions and the redo comment steers a re-run; both are held here until their own button is
162
+ * pressed, on a window Escape and a backdrop click both close. Sending them on close is not the
163
+ * fix: a reply RESOLVES an item and the redo comment starts another agent pass.
164
+ */
165
+ const { requestClose } = useUnsavedGuard({
166
+ open,
167
+ close: () => close(),
168
+ saving: () => acting.value || reworking.value,
169
+ snapshot: () => ({
170
+ replies: Object.values(drafts.value)
171
+ .map((text) => text.trim())
172
+ .filter(Boolean),
173
+ redo: redoComment.value.trim(),
174
+ }),
175
+ })
176
+
159
177
  async function setStatus(item: BrainstormItem, itemStatus: BrainstormItemStatus) {
160
178
  if (!session.value) return
161
179
  try {
@@ -247,7 +265,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
247
265
  :subtitle="block?.title"
248
266
  variant="centered"
249
267
  width="full"
250
- @close="close"
268
+ @close="requestClose"
251
269
  >
252
270
  <template v-if="session" #header-extras>
253
271
  <UBadge color="neutral" variant="subtle" size="sm">
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ // Shown in place of a code-split surface whose chunk failed to load. See `utils/asyncView.ts`
3
+ // for why every async surface gets one.
4
+ const { t } = useI18n()
5
+
6
+ function reload() {
7
+ window.location.reload()
8
+ }
9
+ </script>
10
+
11
+ <template>
12
+ <div
13
+ data-testid="async-view-error"
14
+ class="fixed inset-0 z-50 grid place-items-center bg-slate-950/80 p-6 backdrop-blur-sm"
15
+ role="alert"
16
+ >
17
+ <div class="max-w-sm rounded-2xl border border-slate-700 bg-slate-900 p-5 shadow-2xl">
18
+ <div class="flex items-center gap-2 text-sm font-semibold text-slate-100">
19
+ <UIcon name="i-lucide-unplug" class="h-4 w-4 shrink-0 text-amber-400" />
20
+ {{ t('errors.asyncView.title') }}
21
+ </div>
22
+ <p class="mt-2 text-[12px] leading-relaxed text-slate-400">
23
+ {{ t('errors.asyncView.body') }}
24
+ </p>
25
+ <UButton class="mt-4" color="primary" variant="soft" size="xs" @click="reload">
26
+ {{ t('errors.asyncView.reload') }}
27
+ </UButton>
28
+ </div>
29
+ </div>
30
+ </template>
@@ -4,10 +4,36 @@
4
4
  // rendering its own modal. UModal already provides the focus trap, Escape-to-close and
5
5
  // backdrop, so this component adds none of that — it only resolves the pending promise and,
6
6
  // crucially, resolves `false` whenever the modal closes without an explicit choice.
7
+ import { computed, onUnmounted, watch } from 'vue'
8
+ import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
7
9
 
8
10
  const { t } = useI18n()
9
11
  const { open, current, accept, cancel, dismissed } = useConfirm()
10
12
 
13
+ // Register on the app's ONE overlay stack for as long as the dialog is up.
14
+ //
15
+ // The stack is what `useModalBehavior` (`ResultWindowShell`, the lightbox, every modular overlay)
16
+ // asks before it handles Escape, and this dialog is a Nuxt UI modal, so without a registration it
17
+ // is invisible to that question. A confirm opened FROM a result window then lost the race for its
18
+ // own Escape key: the shell's capture-phase listener still believed it was topmost, so it
19
+ // preventDefault-ed and re-entered its close request, which supersedes the confirm the user was
20
+ // trying to cancel. Pushing a ticket makes the shell stand down while this is on top, which is
21
+ // exactly the ordering the stack exists to state.
22
+ let ticket: OverlayStackTicket | null = null
23
+ function release(): void {
24
+ ticket?.release()
25
+ ticket = null
26
+ }
27
+ watch(
28
+ open,
29
+ (isOpen) => {
30
+ if (isOpen) ticket ??= sharedOverlayStack.push()
31
+ else release()
32
+ },
33
+ { immediate: true },
34
+ )
35
+ onUnmounted(release)
36
+
11
37
  const model = computed({
12
38
  get: () => open.value,
13
39
  set: (v: boolean) => {