@cat-factory/app 0.283.0 → 0.285.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
- package/app/components/board/BoardCanvas.logic.ts +92 -0
- package/app/components/board/BoardCanvas.vue +14 -27
- package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
- package/app/components/followUp/FollowUpWindow.vue +49 -9
- package/app/components/panels/AgentStepDetail.vue +27 -1
- package/app/components/panels/ResultWindowShell.vue +19 -0
- package/app/components/panels/RunDetailLoadState.vue +41 -0
- package/app/components/panels/inspector/TaskExecution.vue +3 -3
- package/app/components/pipeline/PipelineProgress.vue +7 -3
- package/app/components/settings/KubernetesEngineForm.vue +1 -1
- package/app/components/settings/KubernetesEnvironmentForm.vue +1 -1
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/api/followUps.ts +11 -3
- package/app/composables/useBlockDrag.ts +51 -5
- package/app/composables/useSingleFlight.spec.ts +42 -0
- package/app/composables/useSingleFlight.ts +37 -0
- package/app/composables/useStepApproval.ts +19 -0
- package/app/composables/useStepTimer.ts +70 -14
- package/app/composables/useUpsertList.spec.ts +73 -0
- package/app/composables/useUpsertList.ts +52 -6
- package/app/composables/useViewport.ts +13 -3
- package/app/stores/consensus.ts +8 -1
- package/app/stores/docInterview.ts +10 -1
- package/app/stores/execution/reconcile.ts +182 -0
- package/app/stores/execution/wholeRunReads.ts +139 -0
- package/app/stores/execution.spec.ts +297 -1
- package/app/stores/execution.ts +57 -110
- package/app/stores/followUps.spec.ts +94 -0
- package/app/stores/followUps.ts +17 -3
- package/app/stores/kaizen.spec.ts +77 -14
- package/app/stores/kaizen.ts +75 -17
- package/app/stores/notifications.spec.ts +65 -0
- package/app/stores/notifications.ts +29 -0
- package/app/stores/observability/agentContext.ts +128 -0
- package/app/stores/observability/toolCalls.ts +30 -2
- package/app/stores/observability.spec.ts +98 -0
- package/app/stores/observability.ts +51 -79
- package/app/stores/requirements/settlement.ts +55 -0
- package/app/stores/requirements.ts +25 -23
- package/app/stores/ui/k3sDeepLink.spec.ts +5 -5
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/app/types/execution.ts +1 -0
- package/i18n/locales/de.json +10 -1
- package/i18n/locales/en.json +10 -1
- package/i18n/locales/es.json +10 -1
- package/i18n/locales/fr.json +10 -1
- package/i18n/locales/he.json +10 -1
- package/i18n/locales/it.json +10 -1
- package/i18n/locales/ja.json +10 -1
- package/i18n/locales/pl.json +10 -1
- package/i18n/locales/tr.json +10 -1
- package/i18n/locales/uk.json +10 -1
- package/package.json +6 -6
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getFollowUpsContract,
|
|
6
6
|
queueFollowUpContract,
|
|
7
7
|
} from '@cat-factory/contracts'
|
|
8
|
+
import type { FollowUpResolution } from '~/types/execution'
|
|
8
9
|
import type { ApiContext } from './context'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -34,12 +35,19 @@ export function followUpsApi({ send, ws }: ApiContext) {
|
|
|
34
35
|
pathParams: { executionId, itemId },
|
|
35
36
|
}),
|
|
36
37
|
|
|
37
|
-
// Answer a question item
|
|
38
|
-
|
|
38
|
+
// Answer a question item: 'answered' folds it into the Coder's next pass, 'closed' records a
|
|
39
|
+
// ruling that buys no pass (the Coder still sees it, as something already settled).
|
|
40
|
+
answerFollowUp: (
|
|
41
|
+
workspaceId: string,
|
|
42
|
+
executionId: string,
|
|
43
|
+
itemId: string,
|
|
44
|
+
answer: string,
|
|
45
|
+
resolution: FollowUpResolution = 'answered',
|
|
46
|
+
) =>
|
|
39
47
|
send(answerFollowUpContract, {
|
|
40
48
|
pathPrefix: ws(workspaceId),
|
|
41
49
|
pathParams: { executionId, itemId },
|
|
42
|
-
body: { answer },
|
|
50
|
+
body: { answer, resolution },
|
|
43
51
|
}),
|
|
44
52
|
|
|
45
53
|
// Dismiss a follow-up / question item without acting on it.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
|
+
import { tryOnScopeDispose } from '@vueuse/core'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
|
|
4
5
|
// Only one block is ever dragged at a time, so the dragged id is a module-level
|
|
@@ -27,6 +28,18 @@ export function useBlockDrag() {
|
|
|
27
28
|
const ui = useUiStore()
|
|
28
29
|
const access = useWorkspaceAccess()
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Tear down the in-flight drag's window listeners.
|
|
33
|
+
*
|
|
34
|
+
* They used to be removed inside `onUp` alone, which covers only the drag that ENDS. A touch
|
|
35
|
+
* interruption (an incoming call, a system gesture) fires `pointercancel` and no `pointerup`,
|
|
36
|
+
* and unmounting the dragging component fires neither, so both stranded a `pointermove` and a
|
|
37
|
+
* `pointerup` on `window` plus a `draggingId` that never cleared, leaving the card dimmed and
|
|
38
|
+
* every frame's z-index elevated for the rest of the session.
|
|
39
|
+
*/
|
|
40
|
+
let endDrag: (() => void) | null = null
|
|
41
|
+
tryOnScopeDispose(() => endDrag?.())
|
|
42
|
+
|
|
30
43
|
function startDrag(
|
|
31
44
|
block: Block,
|
|
32
45
|
e: PointerEvent,
|
|
@@ -65,9 +78,32 @@ export function useBlockDrag() {
|
|
|
65
78
|
// undoes. The `draggingId` state the card dims itself with is the whole feedback.
|
|
66
79
|
if (positioned) board.previewMove(block.id, last)
|
|
67
80
|
}
|
|
81
|
+
// What is currently bound to `window`, so the teardown below needs no forward reference to
|
|
82
|
+
// the handlers that call it.
|
|
83
|
+
const bound: Array<[string, (ev: PointerEvent) => void]> = []
|
|
84
|
+
/**
|
|
85
|
+
* Stop listening and clear the drag state, WITHOUT committing anything. The shared exit for
|
|
86
|
+
* every way a drag ends: the drop commits first and then calls this, and a cancel (a
|
|
87
|
+
* `pointercancel`, or the component unmounting mid-drag) calls it alone.
|
|
88
|
+
*/
|
|
89
|
+
const detach = () => {
|
|
90
|
+
for (const [type, handler] of bound) {
|
|
91
|
+
window.removeEventListener(type, handler as EventListener)
|
|
92
|
+
}
|
|
93
|
+
bound.length = 0
|
|
94
|
+
endDrag = null
|
|
95
|
+
draggingId.value = null
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A drag the pointer never finished. Nothing is persisted, so the local preview has to go
|
|
99
|
+
* back where it started: leaving it would show a position the server does not hold and the
|
|
100
|
+
* next refresh would silently snap the block back.
|
|
101
|
+
*/
|
|
102
|
+
const onCancel = () => {
|
|
103
|
+
if (moved && positioned) board.previewMove(block.id, orig)
|
|
104
|
+
detach()
|
|
105
|
+
}
|
|
68
106
|
const onUp = (ev: PointerEvent) => {
|
|
69
|
-
window.removeEventListener('pointermove', onMove)
|
|
70
|
-
window.removeEventListener('pointerup', onUp)
|
|
71
107
|
if (moved) {
|
|
72
108
|
// A successful reparent persists the move itself; otherwise commit the final
|
|
73
109
|
// position in place. Either way it's a single write, not one per frame. Run
|
|
@@ -76,10 +112,20 @@ export function useBlockDrag() {
|
|
|
76
112
|
const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY, positioned)
|
|
77
113
|
if (!reparented && positioned) void board.moveBlock(block.id, last)
|
|
78
114
|
}
|
|
79
|
-
|
|
115
|
+
detach()
|
|
116
|
+
}
|
|
117
|
+
// A second drag can only start after the first released or cancelled, but a stale listener
|
|
118
|
+
// set would silently drive it; end whatever is still attached before attaching this one.
|
|
119
|
+
endDrag?.()
|
|
120
|
+
endDrag = onCancel
|
|
121
|
+
for (const binding of [
|
|
122
|
+
['pointermove', onMove],
|
|
123
|
+
['pointerup', onUp],
|
|
124
|
+
['pointercancel', onCancel],
|
|
125
|
+
] as Array<[string, (ev: PointerEvent) => void]>) {
|
|
126
|
+
bound.push(binding)
|
|
127
|
+
window.addEventListener(binding[0], binding[1] as EventListener)
|
|
80
128
|
}
|
|
81
|
-
window.addEventListener('pointermove', onMove)
|
|
82
|
-
window.addEventListener('pointerup', onUp)
|
|
83
129
|
}
|
|
84
130
|
|
|
85
131
|
/** Returns true when the block was dropped into a *different* container. */
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
3
|
+
|
|
4
|
+
describe('useSingleFlight', () => {
|
|
5
|
+
it('joins concurrent callers of one key onto a single call', async () => {
|
|
6
|
+
const fn = vi.fn(() => Promise.resolve('answer'))
|
|
7
|
+
const flight = useSingleFlight<string, string>()
|
|
8
|
+
|
|
9
|
+
const [a, b] = await Promise.all([flight.run('k', fn), flight.run('k', fn)])
|
|
10
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
11
|
+
expect([a, b]).toEqual(['answer', 'answer'])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('keeps different keys apart', async () => {
|
|
15
|
+
const fn = vi.fn((k: string) => Promise.resolve(k))
|
|
16
|
+
const flight = useSingleFlight<string, string>()
|
|
17
|
+
|
|
18
|
+
await Promise.all([flight.run('a', () => fn('a')), flight.run('b', () => fn('b'))])
|
|
19
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('coalesces rather than caches: a later call runs again', async () => {
|
|
23
|
+
const fn = vi.fn(() => Promise.resolve('answer'))
|
|
24
|
+
const flight = useSingleFlight<string, string>()
|
|
25
|
+
|
|
26
|
+
await flight.run('k', fn)
|
|
27
|
+
expect(flight.isRunning('k')).toBe(false)
|
|
28
|
+
await flight.run('k', fn)
|
|
29
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('gives every joiner the same failure, and lets the next caller retry', async () => {
|
|
33
|
+
const fn = vi.fn(() => Promise.reject(new Error('boom')))
|
|
34
|
+
const flight = useSingleFlight<string, string>()
|
|
35
|
+
|
|
36
|
+
const results = await Promise.allSettled([flight.run('k', fn), flight.run('k', fn)])
|
|
37
|
+
expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected'])
|
|
38
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
39
|
+
|
|
40
|
+
await expect(flight.run('k', () => Promise.resolve('ok'))).resolves.toBe('ok')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce concurrent calls that ask for the SAME thing onto one request.
|
|
3
|
+
*
|
|
4
|
+
* Panel loads are the case this exists for: two openers routinely fire in the same tick (a window
|
|
5
|
+
* and the shell it mounts in, a deep link plus the click that follows it, a tab restoring its own
|
|
6
|
+
* state), and each fired its own fetch of the heaviest read on that surface. The second answer is
|
|
7
|
+
* byte-for-byte the first, so the only thing the duplicate adds is load and a second chance to
|
|
8
|
+
* land out of order.
|
|
9
|
+
*
|
|
10
|
+
* The in-flight entry is dropped when the promise SETTLES, so a later call re-fetches: this
|
|
11
|
+
* coalesces concurrent work, it does not cache the answer. A rejection propagates to every joiner,
|
|
12
|
+
* which is what makes joining equivalent to having asked.
|
|
13
|
+
*
|
|
14
|
+
* It does NOT replace a store's load-ordering ticket. Coalescing removes the duplicates a single
|
|
15
|
+
* key can produce; a ticket settles which of two loads issued at different times may commit.
|
|
16
|
+
*/
|
|
17
|
+
export function useSingleFlight<K, T>() {
|
|
18
|
+
const inFlight = new Map<K, Promise<T>>()
|
|
19
|
+
|
|
20
|
+
/** Run `fn` for `key`, or join the call already running for it. */
|
|
21
|
+
function run(key: K, fn: () => Promise<T>): Promise<T> {
|
|
22
|
+
const pending = inFlight.get(key)
|
|
23
|
+
if (pending) return pending
|
|
24
|
+
const promise = fn().finally(() => {
|
|
25
|
+
inFlight.delete(key)
|
|
26
|
+
})
|
|
27
|
+
inFlight.set(key, promise)
|
|
28
|
+
return promise
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a call for `key` is currently running. */
|
|
32
|
+
function isRunning(key: K): boolean {
|
|
33
|
+
return inFlight.has(key)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { run, isRunning }
|
|
37
|
+
}
|
|
@@ -23,6 +23,11 @@ export function useStepApproval(opts: {
|
|
|
23
23
|
approvalId: () => string | null
|
|
24
24
|
approvalPending: () => boolean
|
|
25
25
|
companionExceeded: () => boolean
|
|
26
|
+
/**
|
|
27
|
+
* Whether the cached run carries the step's captured prose rather than the board snapshot's
|
|
28
|
+
* projection of it (`ExecutionInstance.projected`). Gates {@link canEditProposal}: see there.
|
|
29
|
+
*/
|
|
30
|
+
runIsWhole: () => boolean
|
|
26
31
|
close: () => void
|
|
27
32
|
}) {
|
|
28
33
|
const execution = useExecutionStore()
|
|
@@ -138,7 +143,20 @@ export function useStepApproval(opts: {
|
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Whether "approve with corrections" can be ENTERED. The editor seeds itself from the step's own
|
|
148
|
+
* prose, and the board snapshot WITHHOLDS that prose: an instance is a lean projection until the
|
|
149
|
+
* overlay's whole-run read lands (`projectExecutionForBoard`, `ExecutionStore.ensureFull`). Under
|
|
150
|
+
* a projection `step.output` is `undefined`, so entering edit mode would seed an EMPTY draft, and
|
|
151
|
+
* approving it would replace the agent's proposal with nothing. The reader states the fetch's
|
|
152
|
+
* pending/failed state on its own (`RunDetailLoadState`), so this withholds the verb rather than
|
|
153
|
+
* explaining itself twice.
|
|
154
|
+
*/
|
|
155
|
+
const canEditProposal = computed(() => opts.runIsWhole())
|
|
156
|
+
|
|
141
157
|
function startEditing() {
|
|
158
|
+
// Refused rather than clamped: seeding an empty draft is the data loss this guards.
|
|
159
|
+
if (!canEditProposal.value) return
|
|
142
160
|
draftProposal.value = opts.step()?.output ?? ''
|
|
143
161
|
editing.value = true
|
|
144
162
|
// Editing and the review/reject path are mutually exclusive — clear the other.
|
|
@@ -226,6 +244,7 @@ export function useStepApproval(opts: {
|
|
|
226
244
|
draftProposal,
|
|
227
245
|
rejectArmed,
|
|
228
246
|
canRequestChanges,
|
|
247
|
+
canEditProposal,
|
|
229
248
|
quorum,
|
|
230
249
|
viewerHasApproved,
|
|
231
250
|
approvalWouldClearGate,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
1
|
+
import { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
2
3
|
import type { PipelineStep } from '~/types/execution'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -55,21 +56,74 @@ export function stepActivityAgoMs(step: PipelineStep | null, nowMs: number): num
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* One wall-clock ticker per interval, shared by every caller and running only while at least one
|
|
60
|
+
* of them WANTS it. Keyed by interval because the surfaces genuinely differ (a 1s elapsed clock,
|
|
61
|
+
* the outcome card's 30s one) and two intervals cannot share a timer.
|
|
62
|
+
*
|
|
63
|
+
* Both halves were per-caller before, and both cost: `useStepTimer` creates a tick per invocation
|
|
64
|
+
* against its own one-interval intent, so N mounted `StepRunMeta`s meant N independent 1s timers;
|
|
65
|
+
* and the timer ran for the component's whole mounted lifetime whether or not anything was
|
|
66
|
+
* running, so a board of finished runs woke the main thread once a second to recompute labels that
|
|
67
|
+
* are frozen by definition.
|
|
68
|
+
*/
|
|
69
|
+
const tickers = new Map<
|
|
70
|
+
number,
|
|
71
|
+
{ now: Ref<number>; users: number; timer?: ReturnType<typeof setInterval> }
|
|
72
|
+
>()
|
|
73
|
+
|
|
74
|
+
function tickerFor(intervalMs: number) {
|
|
75
|
+
let ticker = tickers.get(intervalMs)
|
|
76
|
+
if (!ticker) {
|
|
77
|
+
ticker = { now: ref(0), users: 0 }
|
|
78
|
+
tickers.set(intervalMs, ticker)
|
|
79
|
+
}
|
|
80
|
+
return ticker
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function acquireTicker(intervalMs: number) {
|
|
84
|
+
const ticker = tickerFor(intervalMs)
|
|
85
|
+
if (++ticker.users === 1) {
|
|
86
|
+
// Stamp on the way in: a caller that subscribes between ticks must not read the stale
|
|
87
|
+
// value the last one left behind (or the 0 of a ticker nothing has ever run).
|
|
88
|
+
ticker.now.value = Date.now()
|
|
89
|
+
ticker.timer = setInterval(() => (ticker.now.value = Date.now()), intervalMs)
|
|
90
|
+
}
|
|
91
|
+
return ticker.now
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function releaseTicker(intervalMs: number) {
|
|
95
|
+
const ticker = tickers.get(intervalMs)
|
|
96
|
+
if (!ticker || ticker.users === 0) return
|
|
97
|
+
if (--ticker.users === 0) {
|
|
98
|
+
clearInterval(ticker.timer)
|
|
99
|
+
ticker.timer = undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A shared wall-clock tick for surfaces that render live durations (the pipeline timeline, the
|
|
105
|
+
* inspector run list, a step's elapsed clock). Reads `0` until something is subscribed, so the
|
|
61
106
|
* first paint never reads a stale time.
|
|
107
|
+
*
|
|
108
|
+
* `active` gates the SUBSCRIPTION: pass it when the surface only needs a clock some of the time
|
|
109
|
+
* (a step's timer needs one exactly while the step runs), and the shared timer stops as soon as
|
|
110
|
+
* the last interested caller stops asking. Omitted means "for as long as this component is
|
|
111
|
+
* mounted", which is what a surface rendering many steps at once wants.
|
|
62
112
|
*/
|
|
63
|
-
export function useNowTick(intervalMs = 1000) {
|
|
64
|
-
const now =
|
|
65
|
-
let
|
|
113
|
+
export function useNowTick(intervalMs = 1000, active?: () => boolean) {
|
|
114
|
+
const now = tickerFor(intervalMs).now
|
|
115
|
+
let subscribed = false
|
|
116
|
+
function want(on: boolean) {
|
|
117
|
+
if (on === subscribed) return
|
|
118
|
+
subscribed = on
|
|
119
|
+
if (on) acquireTicker(intervalMs)
|
|
120
|
+
else releaseTicker(intervalMs)
|
|
121
|
+
}
|
|
66
122
|
onMounted(() => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
})
|
|
70
|
-
onUnmounted(() => {
|
|
71
|
-
if (timer) clearInterval(timer)
|
|
123
|
+
if (active) watchEffect(() => want(active()))
|
|
124
|
+
else want(true)
|
|
72
125
|
})
|
|
126
|
+
onUnmounted(() => want(false))
|
|
73
127
|
return now
|
|
74
128
|
}
|
|
75
129
|
|
|
@@ -84,12 +138,14 @@ export function useStepTimer(opts: {
|
|
|
84
138
|
runFailed: () => boolean
|
|
85
139
|
failureAt: () => number | null | undefined
|
|
86
140
|
}) {
|
|
87
|
-
const nowTick = useNowTick()
|
|
88
|
-
|
|
89
141
|
// A step that is finished, failed, or parked on a human is not actively
|
|
90
142
|
// executing — no ticking clock or spinner. `pausedAt` is the "waiting on input" freeze.
|
|
91
143
|
const isRunning = computed(() => stepIsRunning(opts.step(), opts.runFailed()))
|
|
92
144
|
|
|
145
|
+
// Subscribe to the SHARED 1s clock, and only while this step is actually running: every value
|
|
146
|
+
// below freezes at the step's own end stamp otherwise, so a tick would recompute nothing.
|
|
147
|
+
const nowTick = useNowTick(1000, () => isRunning.value)
|
|
148
|
+
|
|
93
149
|
/** Elapsed/total execution time in ms — null until the step has started. */
|
|
94
150
|
const durationMs = computed(() =>
|
|
95
151
|
stepDurationMs(opts.step(), nowTick.value, opts.runFailed(), opts.failureAt()),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
3
4
|
|
|
4
5
|
interface Item {
|
|
@@ -63,6 +64,78 @@ describe('useUpsertList', () => {
|
|
|
63
64
|
expect(items.value).toHaveLength(2)
|
|
64
65
|
})
|
|
65
66
|
|
|
67
|
+
// Lookups run off a lazily-rebuilt key -> position map. Every write that MOVES an existing
|
|
68
|
+
// position has to invalidate it, and so does a caller replacing `items` wholesale (which the
|
|
69
|
+
// returned ref deliberately allows). A stale index answers with the wrong row, so these assert
|
|
70
|
+
// the identity of what comes back, not just that something did.
|
|
71
|
+
describe('key index coherence', () => {
|
|
72
|
+
it('answers correctly after a prepend has shifted every later position', () => {
|
|
73
|
+
const { upsert, get, indexOf } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
|
|
74
|
+
upsert({ id: 'a', v: 1 })
|
|
75
|
+
expect(indexOf('a')).toBe(0)
|
|
76
|
+
upsert({ id: 'b', v: 2 })
|
|
77
|
+
expect(indexOf('a')).toBe(1)
|
|
78
|
+
expect(get('a')).toEqual({ id: 'a', v: 1 })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('answers correctly after a removal has shifted every later position', () => {
|
|
82
|
+
const { upsert, remove, get, indexOf } = useUpsertList<Item>({ key: (x) => x.id })
|
|
83
|
+
upsert({ id: 'a', v: 1 })
|
|
84
|
+
upsert({ id: 'b', v: 2 })
|
|
85
|
+
upsert({ id: 'c', v: 3 })
|
|
86
|
+
expect(indexOf('c')).toBe(2)
|
|
87
|
+
remove('a')
|
|
88
|
+
expect(indexOf('c')).toBe(1)
|
|
89
|
+
expect(get('b')).toEqual({ id: 'b', v: 2 })
|
|
90
|
+
expect(get('a')).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('answers correctly after the caller replaces the list wholesale', () => {
|
|
94
|
+
const { items, upsert, get } = useUpsertList<Item>({ key: (x) => x.id })
|
|
95
|
+
upsert({ id: 'a', v: 1 })
|
|
96
|
+
items.value = [
|
|
97
|
+
{ id: 'b', v: 2 },
|
|
98
|
+
{ id: 'a', v: 7 },
|
|
99
|
+
]
|
|
100
|
+
expect(get('a')).toEqual({ id: 'a', v: 7 })
|
|
101
|
+
expect(get('b')).toEqual({ id: 'b', v: 2 })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// The index is a plain Map, so a reader answered out of an ALREADY-FRESH one depends on
|
|
105
|
+
// nothing the write it is waiting for touches: an append leaves `items.value` the same array,
|
|
106
|
+
// so a `computed` that missed on a key would never re-run. That is invisible in the store
|
|
107
|
+
// that has one reader per key today and a bug the moment a second appears, which is why it is
|
|
108
|
+
// pinned on the composable rather than on any caller.
|
|
109
|
+
it('re-runs a computed that MISSED on a key when that key is later appended', () => {
|
|
110
|
+
const { upsert, get } = useUpsertList<Item>({ key: (x) => x.id })
|
|
111
|
+
upsert({ id: 'a', v: 1 })
|
|
112
|
+
const wanted = computed(() => get('b'))
|
|
113
|
+
expect(wanted.value).toBeUndefined()
|
|
114
|
+
|
|
115
|
+
upsert({ id: 'b', v: 2 })
|
|
116
|
+
expect(wanted.value).toEqual({ id: 'b', v: 2 })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('re-runs a computed whose item moved under a prepend', () => {
|
|
120
|
+
const { upsert, indexOf } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
|
|
121
|
+
upsert({ id: 'a', v: 1 })
|
|
122
|
+
const position = computed(() => indexOf('a'))
|
|
123
|
+
expect(position.value).toBe(0)
|
|
124
|
+
|
|
125
|
+
upsert({ id: 'b', v: 2 })
|
|
126
|
+
expect(position.value).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('answers correctly after hydrate replaces the list', () => {
|
|
130
|
+
const { upsert, hydrate, get } = useUpsertList<Item>({ key: (x) => x.id })
|
|
131
|
+
upsert({ id: 'a', v: 1 })
|
|
132
|
+
expect(get('a')).toEqual({ id: 'a', v: 1 })
|
|
133
|
+
hydrate([{ id: 'b', v: 2 }])
|
|
134
|
+
expect(get('a')).toBeUndefined()
|
|
135
|
+
expect(get('b')).toEqual({ id: 'b', v: 2 })
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
66
139
|
it('seeds from initial without aliasing the caller array', () => {
|
|
67
140
|
const seed: Item[] = [{ id: 'a', v: 1 }]
|
|
68
141
|
const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, initial: seed })
|
|
@@ -28,20 +28,66 @@ export function useUpsertList<T>(opts: {
|
|
|
28
28
|
} {
|
|
29
29
|
const items = ref<T[]>(opts.initial ? [...opts.initial] : []) as Ref<T[]>
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* key -> position, rebuilt LAZILY.
|
|
33
|
+
*
|
|
34
|
+
* Every operation here was a `findIndex`, so a store's live-event path scanned its whole list
|
|
35
|
+
* per event and each `get` scanned it again. The map is invalidated rather than maintained
|
|
36
|
+
* because the two structural writes that move existing positions (a prepend, a removal) shift
|
|
37
|
+
* every later index, and a burst of them must not pay a rebuild each: whoever reads next pays
|
|
38
|
+
* for one. `indexedFor` also catches a caller REPLACING `items` wholesale, which the returned
|
|
39
|
+
* ref deliberately allows.
|
|
40
|
+
*/
|
|
41
|
+
let index = new Map<unknown, number>()
|
|
42
|
+
let indexedFor: T[] | null = null
|
|
43
|
+
|
|
44
|
+
function reindex(): Map<unknown, number> {
|
|
45
|
+
// Track the array's LENGTH on every path, the fresh-index fast path included. The Map is
|
|
46
|
+
// plain, so a reader answered out of an already-fresh index would otherwise depend on nothing
|
|
47
|
+
// but the `items` ref, and an in-place append leaves that ref's value the same array: a
|
|
48
|
+
// computed that MISSED on a key would never re-run when the item it was waiting for arrives.
|
|
49
|
+
// `length` is the dependency the `findIndex` this replaced established, and it moves on every
|
|
50
|
+
// write that can turn a miss into a hit (push, unshift, splice) or shift a hit's position.
|
|
51
|
+
// A replace in place moves neither, and a reader that resolved an item already tracks its
|
|
52
|
+
// own index through the `items.value[i]` read below.
|
|
53
|
+
void items.value.length
|
|
54
|
+
if (indexedFor === items.value) return index
|
|
55
|
+
index = new Map(items.value.map((item, i) => [opts.key(item), i]))
|
|
56
|
+
indexedFor = items.value
|
|
57
|
+
return index
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mark the index stale after a write that moved existing positions. */
|
|
61
|
+
function invalidate() {
|
|
62
|
+
indexedFor = null
|
|
63
|
+
}
|
|
64
|
+
|
|
31
65
|
function indexOf(keyValue: unknown): number {
|
|
32
|
-
return
|
|
66
|
+
return reindex().get(keyValue) ?? -1
|
|
33
67
|
}
|
|
34
68
|
|
|
35
69
|
function upsert(item: T) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
70
|
+
const key = opts.key(item)
|
|
71
|
+
const i = indexOf(key)
|
|
72
|
+
if (i >= 0) {
|
|
73
|
+
// A replace in place moves nothing, so the index stays correct.
|
|
74
|
+
items.value[i] = item
|
|
75
|
+
} else if (opts.prepend) {
|
|
76
|
+
items.value.unshift(item)
|
|
77
|
+
invalidate()
|
|
78
|
+
} else {
|
|
79
|
+
// An append is the one structural write that moves nothing already indexed.
|
|
80
|
+
items.value.push(item)
|
|
81
|
+
if (indexedFor === items.value) index.set(key, items.value.length - 1)
|
|
82
|
+
}
|
|
40
83
|
}
|
|
41
84
|
|
|
42
85
|
function remove(keyValue: unknown) {
|
|
43
86
|
const i = indexOf(keyValue)
|
|
44
|
-
if (i >= 0)
|
|
87
|
+
if (i >= 0) {
|
|
88
|
+
items.value.splice(i, 1)
|
|
89
|
+
invalidate()
|
|
90
|
+
}
|
|
45
91
|
}
|
|
46
92
|
|
|
47
93
|
function get(keyValue: unknown): T | undefined {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
breakpointsTailwind,
|
|
3
|
+
createSharedComposable,
|
|
4
|
+
useBreakpoints,
|
|
5
|
+
useMediaQuery,
|
|
6
|
+
} from '@vueuse/core'
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Single source of truth for responsive / input-modality decisions across the SPA.
|
|
@@ -18,11 +23,16 @@ import { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core
|
|
|
18
23
|
* but which can still be finger-panned. Use it for behaviour that must work the
|
|
19
24
|
* moment a finger is on the glass (the board's one-finger pan); use `isTouch` for
|
|
20
25
|
* the dominant-modality choices (hit-target sizing).
|
|
26
|
+
*
|
|
27
|
+
* SHARED, as the "single source of truth" above says: every caller gets the same three refs and
|
|
28
|
+
* the same three media-query listeners. Plain per-call composition attached a fresh listener set
|
|
29
|
+
* per calling component, so the layout shell, the board canvas and every responsive panel each
|
|
30
|
+
* registered their own copies of queries that can only ever agree.
|
|
21
31
|
*/
|
|
22
|
-
export
|
|
32
|
+
export const useViewport = createSharedComposable(() => {
|
|
23
33
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
24
34
|
const isCompact = breakpoints.smaller('lg')
|
|
25
35
|
const isTouch = useMediaQuery('(pointer: coarse)')
|
|
26
36
|
const hasTouch = useMediaQuery('(any-pointer: coarse)')
|
|
27
37
|
return { isCompact, isTouch, hasTouch }
|
|
28
|
-
}
|
|
38
|
+
})
|
package/app/stores/consensus.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type { ConsensusSession } from '~/types/consensus'
|
|
4
4
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Consensus session state. A consensus-enabled step runs a multi-model process (panel /
|
|
@@ -18,6 +19,8 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
18
19
|
const sessions = ref<Record<string, ConsensusSession | null>>({})
|
|
19
20
|
/** Block ids whose session is currently being fetched. */
|
|
20
21
|
const loading = ref<Set<string>>(new Set())
|
|
22
|
+
/** One in-flight fetch per block: the window and its opener both load on open. */
|
|
23
|
+
const loads = useSingleFlight<string, void>()
|
|
21
24
|
|
|
22
25
|
function sessionFor(blockId: string): ConsensusSession | null {
|
|
23
26
|
return sessions.value[blockId] ?? null
|
|
@@ -40,7 +43,11 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/** Load the latest session for a block (window open / reload). Best-effort. */
|
|
43
|
-
|
|
46
|
+
function load(blockId: string): Promise<void> {
|
|
47
|
+
return loads.run(blockId, () => fetchSession(blockId))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchSession(blockId: string): Promise<void> {
|
|
44
51
|
const wsId = workspace.workspaceId
|
|
45
52
|
if (!wsId) return
|
|
46
53
|
loading.value = new Set(loading.value).add(blockId)
|
|
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type { DocInterviewSession } from '~/types/domain'
|
|
4
4
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Interactive document-interview sessions (WS5), keyed by their anchor BLOCK id. Loaded on
|
|
@@ -18,6 +19,8 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
|
|
|
18
19
|
const byBlock = ref<Record<string, DocInterviewSession>>({})
|
|
19
20
|
/** True while a window action (continue/proceed) is resuming the run. */
|
|
20
21
|
const resuming = ref(false)
|
|
22
|
+
/** One in-flight fetch per block: the window and its opener both load on open. */
|
|
23
|
+
const loads = useSingleFlight<string, void>()
|
|
21
24
|
|
|
22
25
|
function forBlock(blockId: string): DocInterviewSession | null {
|
|
23
26
|
return byBlock.value[blockId] ?? null
|
|
@@ -31,7 +34,13 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
|
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/** Re-fetch one block's session (the interview window's load path). */
|
|
34
|
-
|
|
37
|
+
function load(blockId: string): Promise<void> {
|
|
38
|
+
return loads.run(blockId, () => fetchSession(blockId))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Out-of-order results need no ticket here: `upsert` is monotonic by the session's own
|
|
42
|
+
// `updatedAt`, so a slow fetch resolving after a live push (or after a newer load) is dropped.
|
|
43
|
+
async function fetchSession(blockId: string) {
|
|
35
44
|
if (!workspace.workspaceId) return
|
|
36
45
|
const session = await api.getDocInterview(workspace.workspaceId, blockId)
|
|
37
46
|
if (session) upsert(session)
|