@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.
- package/README.md +26 -1
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
- package/app/components/board/AddTaskModal.vue +9 -0
- package/app/components/board/ReviewFrictionDialog.vue +35 -4
- package/app/components/board/TaskDependencyEdges.vue +26 -15
- package/app/components/board/nodes/TaskCard.vue +6 -1
- package/app/components/brainstorm/BrainstormWindow.vue +19 -1
- package/app/components/common/AsyncViewError.vue +30 -0
- package/app/components/common/ConfirmDialog.vue +26 -0
- package/app/components/docs/DocInterviewWindow.vue +40 -38
- package/app/components/followUp/FollowUpWindow.vue +32 -2
- package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
- package/app/components/gates/GateResultView.vue +14 -1
- package/app/components/humanTest/HumanTestWindow.vue +13 -1
- package/app/components/initiative/InitiativePlanDecision.vue +16 -3
- package/app/components/initiative/InitiativePlanReview.vue +16 -1
- package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
- package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
- package/app/components/judge/JudgeResultView.vue +14 -1
- package/app/components/panels/AgentStepDetail.vue +651 -659
- package/app/components/panels/InspectorPanel.vue +6 -0
- package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
- package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
- package/app/components/pipeline/PipelineHealthModal.vue +55 -16
- package/app/components/prReview/PrReviewWindow.vue +23 -4
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
- package/app/composables/useBoardActivity.ts +62 -6
- package/app/composables/useConfirm.spec.ts +62 -0
- package/app/composables/useConfirm.ts +6 -1
- package/app/composables/useInterviewDrafts.spec.ts +198 -0
- package/app/composables/useInterviewDrafts.ts +184 -0
- package/app/composables/useTaskExpansion.ts +10 -25
- package/app/docs/consumer-extensions.md +9 -0
- package/app/modular/result-views.ts +58 -21
- package/app/pages/index.vue +68 -64
- package/app/stores/binaryCandidates.ts +26 -3
- package/app/stores/ui/modals.ts +11 -0
- package/app/utils/asyncView.ts +24 -0
- package/app/utils/binaryCandidates.spec.ts +45 -1
- package/app/utils/binaryCandidates.ts +33 -0
- package/app/utils/blockRects.spec.ts +82 -0
- package/app/utils/blockRects.ts +61 -0
- package/app/utils/boardWakeGate.spec.ts +101 -0
- package/app/utils/boardWakeGate.ts +78 -0
- package/i18n/locales/de.json +25 -2
- package/i18n/locales/en.json +25 -2
- package/i18n/locales/es.json +25 -2
- package/i18n/locales/fr.json +25 -2
- package/i18n/locales/he.json +25 -2
- package/i18n/locales/it.json +25 -2
- package/i18n/locales/ja.json +25 -2
- package/i18n/locales/pl.json +25 -2
- package/i18n/locales/tr.json +25 -2
- package/i18n/locales/uk.json +25 -2
- 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
|
|
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(
|
|
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)
|
|
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
|
-
|
|
88
|
-
for (const type of gestures)
|
|
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
|
-
|
|
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
|
|
119
|
-
if (!
|
|
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
|
|
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`
|