@cat-factory/app 0.282.2 → 0.284.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.
Files changed (57) hide show
  1. package/README.md +19 -0
  2. package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
  3. package/app/components/board/BoardCanvas.logic.ts +92 -0
  4. package/app/components/board/BoardCanvas.vue +14 -27
  5. package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
  6. package/app/components/panels/AgentStepDetail.vue +27 -1
  7. package/app/components/panels/ResultWindowShell.vue +19 -0
  8. package/app/components/panels/RunDetailLoadState.vue +41 -0
  9. package/app/components/panels/inspector/TaskEstimateBadge.vue +63 -7
  10. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  11. package/app/components/pipeline/PipelineProgress.vue +7 -3
  12. package/app/composables/api/execution.ts +12 -0
  13. package/app/composables/useBlockDrag.ts +51 -5
  14. package/app/composables/usePipelineDraftWarnings.ts +6 -4
  15. package/app/composables/usePipelineHealth.spec.ts +13 -1
  16. package/app/composables/usePipelineHealth.ts +8 -9
  17. package/app/composables/useSingleFlight.spec.ts +42 -0
  18. package/app/composables/useSingleFlight.ts +37 -0
  19. package/app/composables/useStepApproval.ts +19 -0
  20. package/app/composables/useStepTimer.ts +70 -14
  21. package/app/composables/useUpsertList.spec.ts +73 -0
  22. package/app/composables/useUpsertList.ts +52 -6
  23. package/app/composables/useViewport.ts +13 -3
  24. package/app/stores/consensus.ts +8 -1
  25. package/app/stores/docInterview.ts +10 -1
  26. package/app/stores/execution/reconcile.ts +182 -0
  27. package/app/stores/execution/wholeRunReads.ts +139 -0
  28. package/app/stores/execution.spec.ts +297 -1
  29. package/app/stores/execution.ts +57 -110
  30. package/app/stores/kaizen.spec.ts +77 -14
  31. package/app/stores/kaizen.ts +75 -17
  32. package/app/stores/notifications.spec.ts +65 -0
  33. package/app/stores/notifications.ts +29 -0
  34. package/app/stores/observability/agentContext.ts +128 -0
  35. package/app/stores/observability/toolCalls.ts +30 -2
  36. package/app/stores/observability.spec.ts +98 -0
  37. package/app/stores/observability.ts +51 -79
  38. package/app/stores/requirements/settlement.ts +55 -0
  39. package/app/stores/requirements.ts +25 -23
  40. package/app/stores/workspace/hydrate.ts +11 -0
  41. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  42. package/app/utils/catalog.spec.ts +1 -0
  43. package/app/utils/catalog.ts +18 -0
  44. package/app/utils/estimateGating.spec.ts +22 -0
  45. package/app/utils/estimateGating.ts +32 -0
  46. package/app/utils/pipelineRender.ts +2 -0
  47. package/i18n/locales/de.json +14 -2
  48. package/i18n/locales/en.json +14 -2
  49. package/i18n/locales/es.json +14 -2
  50. package/i18n/locales/fr.json +14 -2
  51. package/i18n/locales/he.json +14 -2
  52. package/i18n/locales/it.json +14 -2
  53. package/i18n/locales/ja.json +14 -2
  54. package/i18n/locales/pl.json +14 -2
  55. package/i18n/locales/tr.json +14 -2
  56. package/i18n/locales/uk.json +14 -2
  57. package/package.json +2 -2
@@ -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
- draggingId.value = null
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. */
@@ -1,6 +1,7 @@
1
1
  import { computed, type ComputedRef } from 'vue'
2
2
  import {
3
3
  pipelineEnvironmentProblems,
4
+ producesTaskEstimate,
4
5
  purposeAllowsAgentCategory,
5
6
  type PipelineEnvironmentProblemReason,
6
7
  } from '@cat-factory/contracts'
@@ -60,13 +61,14 @@ export function usePipelineDraftWarnings(
60
61
 
61
62
  const enabled = (i: number) => pipelines.draftEnabled[i] !== false
62
63
 
63
- // A gated step with no task-estimator before it (mirrors `assertValidGating`, which rejects the
64
- // save and the start). Both the step's own estimate gate (`draftGating`) and the Tester QC
65
- // companion's (`draftTesterQuality[i].gating`) count.
64
+ // A gated step with no estimate PRODUCER before it (mirrors `assertValidGating`, which rejects
65
+ // the save and the start). Both the step's own estimate gate (`draftGating`) and the Tester QC
66
+ // companion's (`draftTesterQuality[i].gating`) count, and either producer satisfies it: the
67
+ // estimator forecasts the estimate up front, the reassessor measures it once the change lands.
66
68
  const gatingNeedsEstimator = computed(() => {
67
69
  const kinds = pipelines.draft
68
70
  const hasEstimatorBefore = (i: number) =>
69
- kinds.slice(0, i).some((k, j) => k === 'task-estimator' && enabled(j))
71
+ kinds.slice(0, i).some((k, j) => producesTaskEstimate(k) && enabled(j))
70
72
  return kinds.some((_, i) => {
71
73
  if (!enabled(i)) return false
72
74
  const gated =
@@ -133,7 +133,7 @@ describe('usePipelineHealth', () => {
133
133
  expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
134
134
  })
135
135
 
136
- it('flags an estimate-gated companion with no task-estimator before it (shape)', () => {
136
+ it('flags an estimate-gated companion with no estimate producer before it (shape)', () => {
137
137
  const gated = builtin(['coder', 'reviewer'], {
138
138
  gating: [null, { enabled: true, minComplexity: 0.5, onMissingEstimate: 'run' }],
139
139
  })
@@ -142,6 +142,18 @@ describe('usePipelineHealth', () => {
142
142
  expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
143
143
  })
144
144
 
145
+ // The estimate has two producers, at opposite ends of a run, and this advisory reads the SAME
146
+ // `producesTaskEstimate` predicate the engine's `assertValidGating` does. A copy that knew only
147
+ // the estimator would call a perfectly saveable pipeline invalid, in the surface that auto-opens
148
+ // a modal over the board.
149
+ it('accepts a gated step preceded by a task-reassessor instead of an estimator', () => {
150
+ const measured = builtin(['coder', 'task-reassessor', 'human-review'], {
151
+ gating: [null, null, { enabled: true, minRisk: 0.6, onMissingEstimate: 'run' }],
152
+ })
153
+ const { hasIssues } = scan([measured])
154
+ expect(hasIssues.value).toBe(false)
155
+ })
156
+
145
157
  // The regression this pins: the advisory carried its own "only a companion may be gated" rule,
146
158
  // so when the engine generalised gating to `BUILTIN_GATABLE_KINDS` the shipped `pl_simple`
147
159
  // ("Adaptive build" — an estimate-gated `architect`) was reported invalid in EVERY workspace.
@@ -1,13 +1,10 @@
1
1
  import { computed } from 'vue'
2
2
  import type { Pipeline } from '~/types/domain'
3
3
  import type { StepGating } from '~/types/consensus'
4
- import { isBuiltinGatableKind } from '@cat-factory/contracts'
4
+ import { isBuiltinGatableKind, producesTaskEstimate } from '@cat-factory/contracts'
5
5
  import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
6
6
  import { usePipelinesStore } from '~/stores/pipelines'
7
7
 
8
- /** Estimate-gating consults a `task-estimator` step (mirrors the backend constant). */
9
- const TASK_ESTIMATOR_KIND = 'task-estimator'
10
-
11
8
  export type PipelineProblemType = 'unknown-kind' | 'shape' | 'outdated' | 'retired'
12
9
 
13
10
  export interface PipelineProblem {
@@ -137,9 +134,11 @@ function skipAxisProblem(
137
134
  }
138
135
 
139
136
  /**
140
- * Estimate gating: the shared skip-axis rules, plus the two specific to an estimate at least one
141
- * axis threshold (with none the step would ALWAYS skip) and an enabled task-estimator earlier in
142
- * the chain (or the gate has nothing to consult). Mirrors `assertValidGating`.
137
+ * Estimate gating: the shared skip-axis rules, plus the two specific to an estimate: at least one
138
+ * axis threshold (with none the step would ALWAYS skip) and an enabled step that PRODUCES an
139
+ * estimate earlier in the chain (or the gate has nothing to consult). Mirrors `assertValidGating`,
140
+ * through the same `producesTaskEstimate` predicate, so neither surface can drift from the other
141
+ * about which kinds count.
143
142
  */
144
143
  function gatingProblem(p: Pipeline): string | null {
145
144
  const gating = p.gating
@@ -161,9 +160,9 @@ function gatingProblem(p: Pipeline): string | null {
161
160
  }
162
161
  const hasEstimator = kinds
163
162
  .slice(0, i)
164
- .some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
163
+ .some((k, j) => producesTaskEstimate(k) && isEnabledAt(p, j))
165
164
  if (!hasEstimator) {
166
- return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
165
+ return `Step '${kind}' is gated on the estimate but no step that produces one runs before it.`
167
166
  }
168
167
  }
169
168
  return null
@@ -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
- * A shared 1s wall-clock tick for surfaces that render many steps' live durations
59
- * at once (the pipeline timeline, the inspector run list). One interval drives every
60
- * step's elapsed label instead of a per-step timer. Stays `0` until mounted so the
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 = ref(0)
65
- let timer: ReturnType<typeof setInterval> | undefined
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
- now.value = Date.now()
68
- timer = setInterval(() => (now.value = Date.now()), intervalMs)
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 items.value.findIndex((x) => opts.key(x) === keyValue)
66
+ return reindex().get(keyValue) ?? -1
33
67
  }
34
68
 
35
69
  function upsert(item: T) {
36
- const i = indexOf(opts.key(item))
37
- if (i >= 0) items.value[i] = item
38
- else if (opts.prepend) items.value.unshift(item)
39
- else items.value.push(item)
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) items.value.splice(i, 1)
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 { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core'
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 function useViewport() {
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
+ })
@@ -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
- async function load(blockId: string): Promise<void> {
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
- async function load(blockId: string) {
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)