@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
@@ -1,4 +1,5 @@
1
1
  import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
2
+ import { createWakeGate } from '~/utils/boardWakeGate'
2
3
 
3
4
  /**
4
5
  * The board's shared "something may have moved" pulse.
@@ -16,9 +17,13 @@ import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Re
16
17
  * - a `MutationObserver` over the canvas subtree, watching structure plus `style` / `class`.
17
18
  * That is every Vue-driven render change on the board, Vue Flow's own pan/zoom transform
18
19
  * included. Attribute changes the drivers themselves write (`x1`/`y1` on the edge overlay)
19
- * are outside the filter, so a driver cannot pulse itself awake forever.
20
+ * are outside the filter, so a driver cannot pulse itself awake forever. These wakes are
21
+ * RATE-LIMITED (see `boardWakeGate`): a live board re-renders its cards on every execution
22
+ * event, and admitting each one kept the measuring loops from ever parking on exactly the
23
+ * board where measuring costs the most. The gesture and camera signals below are admitted
24
+ * unthrottled, so nothing the user is actually moving waits on an interval.
20
25
  * - a `ResizeObserver` on the canvas, plus window `resize`: layout changes with no mutation.
21
- * - pointer, wheel and scroll gestures on the canvas: the user moving something.
26
+ * - pointer, wheel and scroll gestures, listened for on the WINDOW: the user moving something.
22
27
  *
23
28
  * What it does NOT catch is a reflow with no mutation and no gesture, such as a late-loading
24
29
  * image or font resizing a card. Those settle on the next pulse of any kind.
@@ -28,6 +33,14 @@ export type BoardActivity = {
28
33
  subscribe: (onPulse: () => void) => () => void
29
34
  /** Fire the pulse from a signal the observers above cannot see. */
30
35
  pulse: () => void
36
+ /**
37
+ * Where the pointer last was over the canvas (viewport coordinates), or null once it left.
38
+ *
39
+ * Owned here because the pulse already listens for the same gestures: a driver that wants the
40
+ * position registered a SECOND `pointermove` listener on the same element to learn what this
41
+ * one had just seen. Read inside a measurement pass, never subscribed to.
42
+ */
43
+ pointer: () => { x: number; y: number } | null
31
44
  }
32
45
 
33
46
  const boardActivityKey: InjectionKey<BoardActivity> = Symbol('boardActivity')
@@ -42,19 +55,62 @@ export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardA
42
55
  for (const onPulse of subscribers) onPulse()
43
56
  }
44
57
 
58
+ // Renders reach the pulse through the gate; everything the user is moving goes straight to it.
59
+ const renderWakes = createWakeGate({
60
+ wake: pulse,
61
+ // `window.setTimeout` rather than the bare global: the DOM overload returns the numeric
62
+ // handle the gate's scheduler is typed on, where Node's returns a `Timeout` object.
63
+ scheduler: {
64
+ schedule: (run, delayMs) => window.setTimeout(run, delayMs),
65
+ cancel: (handle) => window.clearTimeout(handle),
66
+ },
67
+ })
68
+
69
+ let pointer: { x: number; y: number } | null = null
70
+
71
+ /**
72
+ * Track the pointer and pulse, in that order, off the SAME listener.
73
+ *
74
+ * `pointerleave` does not bubble, but a CAPTURE-phase listener sees one fired at any element
75
+ * below it, and the pointer moving from a card onto the canvas around it is exactly that
76
+ * event. So only the canvas's OWN leave clears the position; treating a descendant's as "the
77
+ * pointer is gone" would collapse the hovered card the moment the pointer crossed one of its
78
+ * inner elements. That check is on the TARGET, so it reads the same from the window as it did
79
+ * from the canvas.
80
+ */
81
+ const onGesture = (event: Event) => {
82
+ if (event.type === 'pointerleave') {
83
+ if (event.target === container.value) pointer = null
84
+ } else if (event.type === 'pointermove' || event.type === 'pointerdown') {
85
+ const { clientX, clientY } = event as PointerEvent
86
+ pointer = { x: clientX, y: clientY }
87
+ }
88
+ pulse()
89
+ }
90
+
45
91
  const activity: BoardActivity = {
46
92
  subscribe(onPulse) {
47
93
  subscribers.add(onPulse)
48
94
  return () => subscribers.delete(onPulse)
49
95
  },
50
96
  pulse,
97
+ pointer: () => pointer,
51
98
  }
52
99
  provide(boardActivityKey, activity)
53
100
 
54
- const mutations = new MutationObserver(pulse)
101
+ const mutations = new MutationObserver(renderWakes.request)
55
102
  const resizes = new ResizeObserver(pulse)
56
103
  // `scroll` does not bubble, so it is caught in the capture phase; the gestures are
57
104
  // passive listeners because the pulse never wants to cancel one.
105
+ //
106
+ // They are bound to the WINDOW rather than to the canvas, because a drag does not end at the
107
+ // canvas's edge: `useBlockDrag` tracks the pointer on the window precisely so a card keeps
108
+ // following it, and the toolbar region and the inspector are SIBLINGS painted over the canvas,
109
+ // not descendants of it. Bound to the canvas, a drag whose cursor crossed one of them stopped
110
+ // delivering the gesture that keeps the measuring loops awake, and the arrows fell back to the
111
+ // rate-limited mutation wake for as long as the cursor was over it: a visible lag in the one
112
+ // interaction this pulse exists to keep smooth. Capture on the window sees every one of those
113
+ // events wherever it is dispatched, so nothing else about the handler changes.
58
114
  const gestures = [
59
115
  'pointerdown',
60
116
  'pointermove',
@@ -77,15 +133,15 @@ export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardA
77
133
  attributeFilter: ['style', 'class'],
78
134
  })
79
135
  resizes.observe(el)
80
- for (const type of gestures) el.addEventListener(type, pulse, gestureOptions)
136
+ for (const type of gestures) window.addEventListener(type, onGesture, gestureOptions)
81
137
  window.addEventListener('resize', pulse)
82
138
  })
83
139
 
84
140
  onBeforeUnmount(() => {
85
141
  mutations.disconnect()
86
142
  resizes.disconnect()
87
- const el = container.value
88
- for (const type of gestures) el?.removeEventListener(type, pulse, gestureOptions)
143
+ renderWakes.cancel()
144
+ for (const type of gestures) window.removeEventListener(type, onGesture, gestureOptions)
89
145
  window.removeEventListener('resize', pulse)
90
146
  subscribers.clear()
91
147
  })
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { useConfirm } from '~/composables/useConfirm'
3
+
4
+ // The confirm singleton's state lives at module scope so every caller and the one mounted
5
+ // `<ConfirmDialog />` share ONE request queue. That is also why each test starts by cancelling
6
+ // whatever the previous one left pending.
7
+ describe('useConfirm', () => {
8
+ beforeEach(() => {
9
+ useConfirm().cancel()
10
+ })
11
+
12
+ it('opens on request and resolves the choice the user made', async () => {
13
+ const { open, current, confirm, accept } = useConfirm()
14
+
15
+ const pending = confirm({ title: 'Delete it?' })
16
+ expect(open.value).toBe(true)
17
+ expect(current.value?.title).toBe('Delete it?')
18
+
19
+ accept()
20
+ await expect(pending).resolves.toBe(true)
21
+ expect(open.value).toBe(false)
22
+ })
23
+
24
+ // The dismissal path (backdrop, Escape, unmount): the dialog is CONTROLLED, so settling the
25
+ // promise without writing `open` left it on screen with no resolver behind it — a dialog whose
26
+ // buttons resolve nothing, on what is now the primary dismissal path of eleven result windows.
27
+ it('closes the dialog as well as settling the promise when it is dismissed', async () => {
28
+ const { open, confirm, dismissed } = useConfirm()
29
+
30
+ const pending = confirm({ title: 'Discard your changes?' })
31
+ dismissed()
32
+
33
+ await expect(pending).resolves.toBe(false)
34
+ expect(open.value).toBe(false)
35
+ })
36
+
37
+ // A second dismissal with nothing pending must stay a no-op rather than closing a request that
38
+ // arrived in between.
39
+ it('leaves a fresh request alone when a stale dismissal arrives', async () => {
40
+ const { open, confirm, dismissed, accept } = useConfirm()
41
+
42
+ dismissed()
43
+ const pending = confirm({ title: 'Remove the pipeline?' })
44
+ expect(open.value).toBe(true)
45
+
46
+ accept()
47
+ await expect(pending).resolves.toBe(true)
48
+ })
49
+
50
+ // Load-bearing for every surface that can raise two confirms: the superseded awaiter resolves
51
+ // `false`, so its caller treats the choice as declined rather than hanging forever.
52
+ it('settles a superseded request false instead of leaving it pending', async () => {
53
+ const { confirm, accept } = useConfirm()
54
+
55
+ const first = confirm({ title: 'Delete A?' })
56
+ const second = confirm({ title: 'Delete B?' })
57
+
58
+ await expect(first).resolves.toBe(false)
59
+ accept()
60
+ await expect(second).resolves.toBe(true)
61
+ })
62
+ })
@@ -55,8 +55,13 @@ export function useConfirm() {
55
55
 
56
56
  // Called by the dialog when `open` flips to false without an explicit accept/cancel
57
57
  // (backdrop, Escape, unmount). Any still-pending promise resolves `false`.
58
+ //
59
+ // It also has to write `open` itself, exactly as `cancel` does: the dialog is CONTROLLED (its
60
+ // `v-model:open` reads this ref), so a dismissal that only settled the promise left a visible
61
+ // modal behind with no pending resolver, whose buttons then resolve nothing.
58
62
  function dismissed(): void {
59
- if (resolver) settle(false)
63
+ open.value = false
64
+ settle(false)
60
65
  }
61
66
 
62
67
  return { open, current: readonly(current), confirm, accept, cancel, dismissed }
@@ -0,0 +1,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { useInterviewDrafts } from '~/composables/useInterviewDrafts'
4
+
5
+ interface Q {
6
+ id?: string
7
+ key: string
8
+ answer?: string
9
+ status?: 'open' | 'dismissed'
10
+ }
11
+
12
+ const TITLES = { one: 'interview.saveFailed', many: 'interview.saveFailedCount' }
13
+
14
+ let present: ReturnType<typeof vi.fn>
15
+
16
+ beforeEach(() => {
17
+ present = vi.fn()
18
+ vi.stubGlobal('usePipelineErrorToast', () => ({ present }))
19
+ })
20
+
21
+ function harness(questions: Q[], write: (id: string, answer: string) => Promise<unknown>) {
22
+ const list = ref<Q[]>(questions)
23
+ const blockId = ref<string | null>('blk_1')
24
+ const seam = useInterviewDrafts<Q>({
25
+ blockId: () => blockId.value,
26
+ questions: () => list.value,
27
+ pending: () => list.value.filter((q) => !(q.answer ?? '').trim()),
28
+ write: (_block, questionId, answer) => write(questionId, answer),
29
+ writable: (q) => q.status !== 'dismissed',
30
+ failureTitleKeys: TITLES,
31
+ })
32
+ return { ...seam, list, blockId }
33
+ }
34
+
35
+ describe('useInterviewDrafts', () => {
36
+ it('seeds each draft from the entity and leaves an edit alone across rounds', async () => {
37
+ const { drafts, list } = harness([{ id: 'q1', key: 'q1', answer: 'recorded' }], async () => {})
38
+ expect(drafts.q1).toBe('recorded')
39
+
40
+ drafts.q1 = 'the human is mid-edit'
41
+ list.value = [
42
+ { id: 'q1', key: 'q1', answer: 'recorded' },
43
+ { id: 'q2', key: 'q2' },
44
+ ]
45
+ await nextTick()
46
+
47
+ expect(drafts.q1).toBe('the human is mid-edit')
48
+ expect(drafts.q2).toBe('')
49
+ })
50
+
51
+ it('writes only the drafts that changed, and never a question set aside', async () => {
52
+ const written: string[] = []
53
+ const { drafts, flushDrafts } = harness(
54
+ [
55
+ { id: 'q1', key: 'q1', answer: 'already this' },
56
+ { id: 'q2', key: 'q2' },
57
+ { id: 'q3', key: 'q3', status: 'dismissed' },
58
+ ],
59
+ async (id) => {
60
+ written.push(id)
61
+ },
62
+ )
63
+
64
+ drafts.q1 = 'already this'
65
+ drafts.q2 = 'a new answer'
66
+ drafts.q3 = 'a stale draft on a not-relevant question'
67
+ flushDrafts()
68
+ await vi.waitFor(() => expect(written).toEqual(['q2']))
69
+ expect(present).not.toHaveBeenCalled()
70
+ })
71
+
72
+ // The defect: a sequential loop that awaited straight through abandoned every answer after the
73
+ // first rejection, with the window already torn down and nothing left on screen to say so.
74
+ it('keeps flushing after a failed write, then reports how many were lost', async () => {
75
+ const attempted: string[] = []
76
+ const { drafts, flushDrafts } = harness(
77
+ [
78
+ { id: 'q1', key: 'q1' },
79
+ { id: 'q2', key: 'q2' },
80
+ { id: 'q3', key: 'q3' },
81
+ ],
82
+ async (id) => {
83
+ attempted.push(id)
84
+ if (id !== 'q2') throw new Error(`boom ${id}`)
85
+ },
86
+ )
87
+
88
+ drafts.q1 = 'one'
89
+ drafts.q2 = 'two'
90
+ drafts.q3 = 'three'
91
+ flushDrafts()
92
+
93
+ await vi.waitFor(() => expect(present).toHaveBeenCalled())
94
+ expect(attempted).toEqual(['q1', 'q2', 'q3'])
95
+ // The plural title, carrying the count, plus the FIRST cause so the toast's detail names a real
96
+ // failure rather than a synthesised summary.
97
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.many, { count: 2 })
98
+ expect((present.mock.calls[0]![0] as Error).message).toBe('boom q1')
99
+ })
100
+
101
+ it('reports a single lost answer with the singular title', async () => {
102
+ const { drafts, flushDrafts } = harness([{ id: 'q1', key: 'q1' }], async () => {
103
+ throw new Error('nope')
104
+ })
105
+ drafts.q1 = 'one'
106
+ flushDrafts()
107
+
108
+ await vi.waitFor(() => expect(present).toHaveBeenCalled())
109
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, { count: 1 })
110
+ })
111
+
112
+ it('reports a failed single save from the blur path', async () => {
113
+ const { drafts, saveAnswer } = harness([{ id: 'q1', key: 'q1' }], async () => {
114
+ throw new Error('nope')
115
+ })
116
+ drafts.q1 = 'one'
117
+ saveAnswer({ id: 'q1', key: 'q1' })
118
+
119
+ await vi.waitFor(() =>
120
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, {
121
+ count: 1,
122
+ }),
123
+ )
124
+ })
125
+
126
+ // A missing answer may not be submitted as if it were there, and the window has to stay put so the
127
+ // same button is still on screen with the text still in its box.
128
+ it('withholds the action when a draft could not be written', async () => {
129
+ const action = vi.fn().mockResolvedValue(undefined)
130
+ const { drafts, flushThen } = harness([{ id: 'q1', key: 'q1' }], async () => {
131
+ throw new Error('nope')
132
+ })
133
+ drafts.q1 = 'one'
134
+
135
+ await flushThen(action, 'interview.continueFailed')
136
+
137
+ expect(action).not.toHaveBeenCalled()
138
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, { count: 1 })
139
+ })
140
+
141
+ it('runs the action once every draft is written, and reports the action itself failing', async () => {
142
+ const written: string[] = []
143
+ const { drafts, flushThen } = harness([{ id: 'q1', key: 'q1' }], async (id) => {
144
+ written.push(id)
145
+ })
146
+ drafts.q1 = 'one'
147
+
148
+ const ok = vi.fn().mockResolvedValue(undefined)
149
+ await flushThen(ok, 'interview.continueFailed')
150
+ expect(written).toEqual(['q1'])
151
+ expect(ok).toHaveBeenCalledWith('blk_1')
152
+ expect(present).not.toHaveBeenCalled()
153
+
154
+ // The backing stores rethrow and Vue discards a click handler's promise, so the action's own
155
+ // failure is reported here or nowhere.
156
+ await flushThen(
157
+ vi.fn().mockRejectedValue(new Error('resume failed')),
158
+ 'interview.continueFailed',
159
+ )
160
+ expect(present).toHaveBeenCalledWith(expect.any(Error), 'interview.continueFailed')
161
+ })
162
+
163
+ it('writes nothing once the view has torn down and the block id is gone', async () => {
164
+ const written: string[] = []
165
+ const { drafts, flushDrafts, flushThen, blockId } = harness(
166
+ [{ id: 'q1', key: 'q1' }],
167
+ async (id) => {
168
+ written.push(id)
169
+ },
170
+ )
171
+ drafts.q1 = 'one'
172
+ blockId.value = null
173
+
174
+ flushDrafts()
175
+ const action = vi.fn()
176
+ await flushThen(action, 'interview.continueFailed')
177
+ await nextTick()
178
+
179
+ expect(written).toEqual([])
180
+ expect(action).not.toHaveBeenCalled()
181
+ })
182
+
183
+ // An exchange with no id cannot be addressed by the answer write at all. It must not hold the
184
+ // submit button hostage, since nothing the human types would ever clear it.
185
+ it('excludes an unaddressable question from the unanswered count', () => {
186
+ const { drafts, addressable, unanswered } = harness(
187
+ [{ id: 'q1', key: 'q1' }, { key: 'q-1' }],
188
+ async () => {},
189
+ )
190
+
191
+ expect(addressable({ id: 'q1', key: 'q1' })).toBe(true)
192
+ expect(addressable({ key: 'q-1' })).toBe(false)
193
+ expect(unanswered.value).toBe(1)
194
+
195
+ drafts.q1 = 'answered'
196
+ expect(unanswered.value).toBe(0)
197
+ })
198
+ })
@@ -0,0 +1,184 @@
1
+ import { computed, reactive, watch } from 'vue'
2
+
3
+ /**
4
+ * Per-question answer drafts for an INTERVIEW gate window (the initiative planner's interviewer and
5
+ * the doc-authoring interviewer), plus the two ways they leave the browser: one answer on blur, and
6
+ * every dirty answer on the way out.
7
+ *
8
+ * Both windows hold the same shape of draft for the same reason, and recording one is a PLAIN SAVE:
9
+ * it writes the reply without resolving the interview, which is what the window's own two commands
10
+ * do. That is what makes the FLUSH disposition correct here rather than a discard prompt (see
11
+ * `ResultWindowDrafts.logic.spec.ts` for the rule and the per-window table).
12
+ *
13
+ * It is ONE seam because the two windows held byte-identical copies of this logic and only ever got
14
+ * fixed one at a time: the doc interview grew a close-time flush while the planner kept dropping
15
+ * answers on close, and neither reported a failed write. Three properties are easy to lose when this
16
+ * is hand-rolled per window, and each one was:
17
+ *
18
+ * - The flush CAPTURES what it needs synchronously. `blockId` and everything derived from it go
19
+ * null the instant the view tears down, so an awaited loop that re-reads them writes nowhere.
20
+ * - Each answer settles INDEPENDENTLY. A loop that awaited straight through dropped every answer
21
+ * after the first rejection, with the window already gone.
22
+ * - Every path REPORTS its own failure. Both backing stores rethrow and Vue discards a handler's
23
+ * returned promise, so an unreported write is an unhandled rejection and, to the user, a no-op.
24
+ */
25
+
26
+ /** The minimum an exchange has to carry for a draft answer to be held against it. */
27
+ export interface InterviewDraftQuestion {
28
+ /**
29
+ * The id the answer write addresses. Optional because the wire shape leaves it optional (a
30
+ * hand-authored or fixture exchange parses without one); see {@link useInterviewDrafts}'s
31
+ * `addressable`.
32
+ */
33
+ id?: string
34
+ /** Stable key for the list and the draft map: the question id, or its index as a fallback. */
35
+ key: string
36
+ /** What is already recorded. Seeds the draft, and decides whether the draft is dirty. */
37
+ answer?: string
38
+ }
39
+
40
+ export function useInterviewDrafts<Q extends InterviewDraftQuestion>(opts: {
41
+ /** The block the answers are written against, or null while no view is open. */
42
+ blockId: () => string | null
43
+ /** Every exchange the session holds, settled rounds included. */
44
+ questions: () => Q[]
45
+ /** The subset still owing an answer, which is what the submit button waits on. */
46
+ pending: () => Q[]
47
+ /** Record ONE answer against the block. */
48
+ write: (blockId: string, questionId: string, answer: string) => Promise<unknown>
49
+ /**
50
+ * Whether this question's draft may be written at all. A question set aside as not-relevant had
51
+ * its recorded answer cleared, so writing a stale local draft back would silently re-answer it.
52
+ */
53
+ writable?: (question: Q) => boolean
54
+ /** Toast titles for a failed write: one answer lost, and several (which takes a `{ count }`). */
55
+ failureTitleKeys: { one: string; many: string }
56
+ }) {
57
+ const { present } = usePipelineErrorToast()
58
+
59
+ const drafts = reactive<Record<string, string>>({})
60
+ // Seeded from the entity and refreshed as new rounds arrive, without clobbering an answer the
61
+ // human is mid-edit on.
62
+ watch(
63
+ opts.questions,
64
+ (list) => {
65
+ for (const question of list) {
66
+ if (!(question.key in drafts)) drafts[question.key] = question.answer ?? ''
67
+ }
68
+ },
69
+ { immediate: true },
70
+ )
71
+
72
+ /**
73
+ * Whether an answer to this exchange can be RECORDED at all: the write addresses a question BY
74
+ * ID, so one without an id has nowhere for an answer to go.
75
+ *
76
+ * Callers disable that question's input and say why. Accepting text into it instead would take an
77
+ * answer the flush could only drop, which is the silent loss this seam exists to end.
78
+ */
79
+ function addressable(question: Q): boolean {
80
+ return typeof question.id === 'string' && question.id.length > 0
81
+ }
82
+
83
+ /**
84
+ * Questions still missing a drafted answer, which is what a submit button gates on (and renders,
85
+ * because a disabled button with no stated reason is itself a "nothing happened").
86
+ *
87
+ * Unaddressable questions are excluded: nothing the human can type clears one, so counting it
88
+ * would disable the submit for good.
89
+ */
90
+ const unanswered = computed(
91
+ () => opts.pending().filter((q) => addressable(q) && !drafts[q.key]?.trim()).length,
92
+ )
93
+
94
+ /**
95
+ * Persist one answer when its draft differs from what is recorded. The block id is threaded in
96
+ * rather than read off `opts.blockId()`, so a flush that started as the window closed still writes
97
+ * to the right board.
98
+ */
99
+ async function persist(blockId: string, question: Q): Promise<void> {
100
+ const id = question.id
101
+ if (!id || opts.writable?.(question) === false) return
102
+ const next = (drafts[question.key] ?? '').trim()
103
+ if (!next || next === (question.answer ?? '').trim()) return
104
+ await opts.write(blockId, id, next)
105
+ }
106
+
107
+ /** Report a failed write, naming how many answers did not make it. */
108
+ function report(failed: number, cause: unknown): void {
109
+ present(cause, failed === 1 ? opts.failureTitleKeys.one : opts.failureTitleKeys.many, {
110
+ count: failed,
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Persist every dirty draft, each on its OWN: one rejection may not cost the answers after it.
116
+ * Returns how many could not be written plus the first cause, which is what the caller reports.
117
+ *
118
+ * Sequential rather than concurrent because each write is a read-modify-write of the session's one
119
+ * question-and-answer array, so two in flight would race to overwrite each other's answer.
120
+ */
121
+ async function flushAll(blockId: string, list: Q[]): Promise<{ failed: number; cause: unknown }> {
122
+ let failed = 0
123
+ let cause: unknown
124
+ for (const question of list) {
125
+ try {
126
+ await persist(blockId, question)
127
+ } catch (error) {
128
+ failed += 1
129
+ if (failed === 1) cause = error
130
+ }
131
+ }
132
+ return { failed, cause }
133
+ }
134
+
135
+ /**
136
+ * Save this ONE answer against the block that is open right now: the blur handler, and the same
137
+ * call an adopted recommendation makes. Detached, because Vue discards a handler's returned
138
+ * promise, so the failure is reported here or nowhere.
139
+ */
140
+ function saveAnswer(question: Q): void {
141
+ const blockId = opts.blockId()
142
+ if (!blockId) return
143
+ void persist(blockId, question).catch((error) => report(1, error))
144
+ }
145
+
146
+ /**
147
+ * Persist every dirty draft on the way out, detached so it can be called from the synchronous
148
+ * close hook. A close-time flush is the one path with no button left on screen to have reported a
149
+ * failure, so it reports the loss itself.
150
+ */
151
+ function flushDrafts(): void {
152
+ const blockId = opts.blockId()
153
+ if (!blockId) return
154
+ const list = [...opts.questions()]
155
+ void (async () => {
156
+ const { failed, cause } = await flushAll(blockId, list)
157
+ if (failed > 0) report(failed, cause)
158
+ })()
159
+ }
160
+
161
+ /**
162
+ * Flush every dirty draft, then run a window action, but ONLY if every draft was written: an
163
+ * answer that failed to save may not be submitted as if it were there. On a failure the report is
164
+ * the whole outcome, and the window is left as it is, with the same button on screen and the text
165
+ * still in its box.
166
+ */
167
+ async function flushThen(
168
+ action: (blockId: string) => Promise<unknown>,
169
+ failureTitleKey: string,
170
+ ): Promise<void> {
171
+ const blockId = opts.blockId()
172
+ if (!blockId) return
173
+ const { failed, cause } = await flushAll(blockId, [...opts.questions()])
174
+ if (failed > 0) {
175
+ report(failed, cause)
176
+ return
177
+ }
178
+ // The action rejects too (both backing stores rethrow) and it is reached from a click handler
179
+ // whose promise Vue discards, so its failure is reported here or nowhere.
180
+ await action(blockId).catch((error) => present(error, failureTitleKey))
181
+ }
182
+
183
+ return { drafts, addressable, unanswered, saveAnswer, flushDrafts, flushThen }
184
+ }
@@ -3,6 +3,7 @@ import { onMounted, onBeforeUnmount } from 'vue'
3
3
  import { lodAtLeast } from '~/composables/useSemanticZoom'
4
4
  import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
5
5
  import { useSettlingRaf } from '~/composables/useSettlingRaf'
6
+ import { measureBlocks, type BlockMeasurements } from '~/utils/blockRects'
6
7
  import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
7
8
 
8
9
  function intersects(a: Rect, b: Rect) {
@@ -54,21 +55,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
54
55
  // card is still tested at its expanded extent and stays denied. Stable.
55
56
  const expandedHeight = new Map<string, number>()
56
57
 
57
- // Last pointer position over the board (viewport coords), or null when the pointer has
58
- // left it. The card under the pointer is expanded on hover (see `hoveredTaskId`).
59
- let pointer: { x: number; y: number } | null = null
60
- function onPointerMove(e: PointerEvent) {
61
- pointer = { x: e.clientX, y: e.clientY }
62
- }
63
- function onPointerLeave() {
64
- pointer = null
65
- }
66
-
67
- function rectOf(id: string): DOMRect | null {
68
- const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
69
- return el ? el.getBoundingClientRect() : null
70
- }
71
-
72
58
  // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
73
59
  // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
74
60
  // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
@@ -77,6 +63,9 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
77
63
  // a frame, a module, or a task with no run expands to nothing, and granting it would
78
64
  // still lift an empty card over its neighbours (see LaneTask's z-index).
79
65
  function hoveredTaskId(): string | null {
66
+ // Where the pointer is comes from the pulse, which already listens for the same gestures on
67
+ // the same element (see `BoardActivity.pointer`).
68
+ const pointer = activity.pointer()
80
69
  if (!pointer) return null
81
70
  const hit = document.elementFromPoint(pointer.x, pointer.y)
82
71
  const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
@@ -107,6 +96,8 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
107
96
  }
108
97
  const view = container.value?.getBoundingClientRect()
109
98
  if (!view) return changed
99
+ // One DOM query for the whole sweep instead of one per candidate task (see `measureBlocks`).
100
+ const blocks: BlockMeasurements = measureBlocks()
110
101
  const cx = view.left + view.width / 2
111
102
  const cy = view.top + view.height / 2
112
103
 
@@ -115,8 +106,9 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
115
106
  for (const t of board.allTasks) {
116
107
  // Only tasks whose run actually has steps would expand a pipeline list.
117
108
  if (!execution.getByBlock(t.id)?.steps.length) continue
118
- const rect = rectOf(t.id)
119
- if (!rect) continue
109
+ const el = blocks.elementFor(t.id)
110
+ if (!el) continue
111
+ const rect = blocks.rectFor(el)
120
112
  liveIds.add(t.id)
121
113
  // While a card is granted it's rendered expanded, so its live height is its
122
114
  // expanded footprint — cache it. A denied card keeps its last cached value.
@@ -166,19 +158,12 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
166
158
  }
167
159
 
168
160
  const { poke } = useSettlingRaf(recompute)
169
- // The pointer listeners below only record where the pointer IS; the pulse (which watches the
170
- // same gestures) is what schedules the frame that acts on it.
161
+ // The pulse both records where the pointer is and schedules the frame that acts on it.
171
162
  onBoardActivity(activity, poke)
172
163
  onMounted(() => {
173
164
  store.setDriverActive(true)
174
- const el = container.value
175
- el?.addEventListener('pointermove', onPointerMove)
176
- el?.addEventListener('pointerleave', onPointerLeave)
177
165
  })
178
166
  onBeforeUnmount(() => {
179
- const el = container.value
180
- el?.removeEventListener('pointermove', onPointerMove)
181
- el?.removeEventListener('pointerleave', onPointerLeave)
182
167
  store.setDriverActive(false)
183
168
  })
184
169
  }
@@ -102,6 +102,15 @@ An unpaired id degrades to the generic prose panel (a dev-console warning names
102
102
  id); a structured kind with no bespoke window gets the built-in `generic-structured` viewer
103
103
  for free.
104
104
 
105
+ **Contribute the component asynchronously.** Every first-party window is registered as
106
+ `defineAsyncView(() => import('...'))`, so its code is fetched on the click that opens it rather
107
+ than on every board load; an async component is an ordinary `Component`, so the slot entry and the
108
+ host's mount are identical either way. A window is a modal opened deliberately, so copy that shape
109
+ unless yours is small enough not to matter. Prefer the seam over a bare `defineAsyncComponent`: it
110
+ attaches the shared failure notice, so a chunk that 404s (which is what a deploy landing under an
111
+ open tab makes of every chunk the session had not yet fetched) states itself instead of opening
112
+ your window onto a blank screen.
113
+
105
114
  ### Navigation
106
115
 
107
116
  A consumer nav item carries its own `run` closure (first-party items use a typed `action`