@cat-factory/app 0.298.0 → 0.299.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 CHANGED
@@ -1091,9 +1091,23 @@ event left to restore it.
1091
1091
  swaps that in. A missing trigger and an in-place patch are both SILENT, so
1092
1092
  `stores/execution.spec.ts` pins each write shape twice: once on the array, once through the
1093
1093
  `getInstance` chain a window actually reads.
1094
+ - **A record keyed by block id is written PER KEY, never replaced.** The review-family caches
1095
+ (`requirements`, `clarity`, `brainstorm`, `consensus`, `docInterview`, `initiative`) are deep
1096
+ reactive refs holding a `Record<blockId, T>`, so `x.value = { ...x.value, [id]: v }` is a write
1097
+ to the REF: a dependency every reader shares, whatever key it reads. One review event therefore
1098
+ woke every card on the board. `x.value[id] = v` keeps the invalidation on the key that changed,
1099
+ and Vue still tracks a key read before it exists, so a first write reaches its waiting reader.
1100
+ The exception is a HYDRATE, which replaces the record wholesale on purpose: a snapshot is
1101
+ authoritative for EXISTENCE, so anything it omits has been deleted. This is the opposite rule
1102
+ from `execution.instances` above, and for the opposite reason: that one is a `shallowRef`.
1094
1103
  - **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
1095
1104
  `stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
1096
- for echoes): drive the two orderings and assert the fresher one wins.
1105
+ for echoes): drive the two orderings and assert the fresher one wins. The per-key rule above has
1106
+ one table for the whole family, `stores/perKeyWrites.spec.ts`: a store joining it is a row there,
1107
+ and each row counts a `computed`'s evaluations across an event for a DIFFERENT block, plus the
1108
+ first write to a key a reader read while it was absent. What the record is keyed BY is per store
1109
+ (`stores/requirements.spec.ts` also covers the stage read, whose pending-recommendation half has
1110
+ to answer off the block's own review object rather than a computed over the record).
1097
1111
 
1098
1112
  ## Internationalization (i18n) authoring
1099
1113
 
@@ -80,8 +80,13 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
80
80
  return allSettled(session) && answeredCount(session) === 0
81
81
  }
82
82
 
83
+ /**
84
+ * Write one block+stage session into the cache, IN PLACE: per-key, never a whole-record clone.
85
+ * `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
86
+ * an UNCHANGED key; assigning the key retriggers only the session that actually changed.
87
+ */
83
88
  function store(session: BrainstormSession) {
84
- sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
89
+ sessions.value[key(session.blockId, session.stage)] = session
85
90
  }
86
91
 
87
92
  /** Patch the cache from a live `brainstorm` stream event (newest wins per block+stage). */
@@ -122,7 +127,9 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
122
127
  try {
123
128
  const session = await api.getBrainstorm(workspace.requireId(), blockId, stage)
124
129
  available.value = true
125
- sessions.value = { ...sessions.value, [k]: session }
130
+ // By key like `store()`, and directly because a load resolving to "none exists" caches a
131
+ // null the getter reads as "fetched, absent".
132
+ sessions.value[k] = session
126
133
  } catch {
127
134
  available.value = false
128
135
  } finally {
@@ -83,8 +83,13 @@ export const useClarityStore = defineStore('clarity', () => {
83
83
  return allSettled(review) && answeredCount(review) === 0
84
84
  }
85
85
 
86
+ /**
87
+ * Write one block's review into the cache, IN PLACE: per-key, never a whole-record clone.
88
+ * `reviews` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
89
+ * an UNCHANGED block; assigning the key retriggers only the block that actually changed.
90
+ */
86
91
  function store(review: ClarityReview) {
87
- reviews.value = { ...reviews.value, [review.blockId]: review }
92
+ reviews.value[review.blockId] = review
88
93
  }
89
94
 
90
95
  /** Patch the cache from a live `clarity` stream event (newest wins per block). */
@@ -125,7 +130,9 @@ export const useClarityStore = defineStore('clarity', () => {
125
130
  try {
126
131
  const review = await api.getClarityReview(workspace.requireId(), blockId)
127
132
  available.value = true
128
- reviews.value = { ...reviews.value, [blockId]: review }
133
+ // By key like `store()`, and directly because a load resolving to "none exists" caches a
134
+ // null the getter reads as "fetched, absent".
135
+ reviews.value[blockId] = review
129
136
  } catch {
130
137
  // 503 (feature off) or any error → hide the UI entry points.
131
138
  available.value = false
@@ -30,8 +30,13 @@ export const useConsensusStore = defineStore('consensus', () => {
30
30
  return loading.value.has(blockId)
31
31
  }
32
32
 
33
+ /**
34
+ * Write one block's session into the cache, IN PLACE: per-key, never a whole-record clone.
35
+ * `sessions` is a deep reactive ref, so replacing the record retriggered every consumer keyed on
36
+ * an UNCHANGED block; assigning the key retriggers only the block that actually changed.
37
+ */
33
38
  function store(session: ConsensusSession) {
34
- sessions.value = { ...sessions.value, [session.blockId]: session }
39
+ sessions.value[session.blockId] = session
35
40
  }
36
41
 
37
42
  /** Patch the cache from a live `consensus` stream event (newest wins per block). */
@@ -62,7 +67,7 @@ export const useConsensusStore = defineStore('consensus', () => {
62
67
  if (session) {
63
68
  if (!existing || session.updatedAt >= existing.updatedAt) store(session)
64
69
  } else if (existing === undefined) {
65
- sessions.value = { ...sessions.value, [blockId]: null }
70
+ sessions.value[blockId] = null
66
71
  }
67
72
  } catch {
68
73
  // Consensus off / no session — leave the cache as-is; the window shows its empty state.
@@ -30,7 +30,9 @@ export const useDocInterviewStore = defineStore('docInterview', () => {
30
30
  function upsert(session: DocInterviewSession) {
31
31
  const existing = byBlock.value[session.blockId]
32
32
  if (existing && existing.updatedAt > session.updatedAt) return
33
- byBlock.value = { ...byBlock.value, [session.blockId]: session }
33
+ // Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
34
+ // record retriggered every consumer keyed on an UNCHANGED block.
35
+ byBlock.value[session.blockId] = session
34
36
  }
35
37
 
36
38
  /** Re-fetch one block's session (the interview window's load path). */
@@ -96,7 +96,10 @@ export const useInitiativesStore = defineStore('initiatives', () => {
96
96
  function upsert(initiative: Initiative) {
97
97
  const existing = byBlock.value[initiative.blockId]
98
98
  if (existing && existing.rev > initiative.rev) return
99
- byBlock.value = { ...byBlock.value, [initiative.blockId]: initiative }
99
+ // Per-key, never a whole-record clone: `byBlock` is a deep reactive ref, so replacing the
100
+ // record retriggered every consumer keyed on an UNCHANGED block. {@link hydrate} still
101
+ // replaces it wholesale, because a snapshot is authoritative for EXISTENCE.
102
+ byBlock.value[initiative.blockId] = initiative
100
103
  }
101
104
 
102
105
  /**
@@ -16,8 +16,8 @@ import { useWorkspaceStore } from '~/stores/workspace'
16
16
  * Open, human-actionable notifications surfaced on the board (a PR awaiting a
17
17
  * merge decision, a completed pipeline awaiting confirmation, CI that gave up).
18
18
  * Hydrated from the workspace snapshot and patched live by the `notification`
19
- * WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox + a
20
- * per-block badge from `open` / `byBlock`.
19
+ * WorkspaceEvent (see `useWorkspaceStream`). The board renders an inbox from `open`, and the
20
+ * per-block review-wait stamps the swimlanes need from {@link reviewDebtByBlock}.
21
21
  */
22
22
  export const useNotificationsStore = defineStore('notifications', () => {
23
23
  const api = useApi()
@@ -110,16 +110,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
110
110
  upsertOpen(notification)
111
111
  }
112
112
 
113
- /** Open notifications for a given block (for the board card badge). */
114
- const byBlock = computed<Record<string, Notification[]>>(() => {
115
- const map: Record<string, Notification[]> = {}
116
- for (const n of open.value) {
117
- if (!n.blockId) continue
118
- ;(map[n.blockId] ??= []).push(n)
119
- }
120
- return map
121
- })
122
-
123
113
  /**
124
114
  * Per-block "waiting since", derived from the open review-wait cards by the same
125
115
  * `collectReviewDebt` the backend's friction check uses. It is the fallback source for the park
@@ -213,7 +203,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
213
203
  hydrate,
214
204
  hydrateBaseline,
215
205
  upsert,
216
- byBlock,
217
206
  reviewDebtByBlock,
218
207
  count,
219
208
  act,
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computed } from 'vue'
3
+ import type { BrainstormSession } from '~/types/brainstorm'
4
+ import type { ClarityReview } from '~/types/clarity'
5
+ import type { ConsensusSession } from '~/types/consensus'
6
+ import type { DocInterviewSession, Initiative } from '~/types/domain'
7
+ import type { RequirementReview } from '~/types/requirements'
8
+ import { useBrainstormStore } from '~/stores/brainstorm'
9
+ import { useClarityStore } from '~/stores/clarity'
10
+ import { useConsensusStore } from '~/stores/consensus'
11
+ import { useDocInterviewStore } from '~/stores/docInterview'
12
+ import { useInitiativesStore } from '~/stores/initiative'
13
+ import { useRequirementsStore } from '~/stores/requirements'
14
+
15
+ // The review-family stores all hold a `Record<blockId, T>` in a DEEP reactive ref and all patch it
16
+ // from a live stream event. `x.value = { ...x.value, [id]: v }` is a write to the REF, a dependency
17
+ // every reader shares whatever key it reads, so one event woke every card on the board;
18
+ // `x.value[id] = v` keeps the invalidation on the key that changed. The rule is stated once in
19
+ // `frontend/app/README.md` ("A record keyed by block id is written PER KEY, never replaced").
20
+ //
21
+ // One table rather than six near-identical specs, because the property is one property and a store
22
+ // that JOINS this family should have exactly one obvious place to be added. Each row asserts the
23
+ // two halves that can regress independently: an event for another block does not invalidate this
24
+ // block's reader, and a FIRST write still reaches a reader that read the key while it was absent
25
+ // (Vue tracks a missing-key read, which is what makes the in-place write safe at all).
26
+
27
+ interface StoreCase {
28
+ name: string
29
+ /** Patch the store from a live event for `blockId`, tagged with `mark` so the read can see it. */
30
+ write: (blockId: string, mark: string) => void
31
+ /** What a card renders off that block: the tag, or null when the store holds nothing. */
32
+ read: (blockId: string) => string | null
33
+ }
34
+
35
+ const cases: StoreCase[] = [
36
+ {
37
+ name: 'requirements',
38
+ write: (blockId, mark) => {
39
+ useRequirementsStore().upsert({
40
+ id: mark,
41
+ blockId,
42
+ status: 'ready',
43
+ iteration: 1,
44
+ maxIterations: 3,
45
+ items: [],
46
+ updatedAt: 1,
47
+ } as unknown as RequirementReview)
48
+ },
49
+ read: (blockId) => useRequirementsStore().reviewFor(blockId)?.id ?? null,
50
+ },
51
+ {
52
+ name: 'clarity',
53
+ write: (blockId, mark) => {
54
+ useClarityStore().upsert({
55
+ id: mark,
56
+ blockId,
57
+ status: 'ready',
58
+ items: [],
59
+ updatedAt: 1,
60
+ } as unknown as ClarityReview)
61
+ },
62
+ read: (blockId) => useClarityStore().reviewFor(blockId)?.id ?? null,
63
+ },
64
+ {
65
+ name: 'brainstorm',
66
+ // Keyed by block+STAGE, so a block legitimately holds one live session per stage; the
67
+ // invalidation still has to land on the one composite key that changed.
68
+ write: (blockId, mark) => {
69
+ useBrainstormStore().upsert({
70
+ id: mark,
71
+ blockId,
72
+ stage: 'requirements',
73
+ status: 'ready',
74
+ options: [],
75
+ updatedAt: 1,
76
+ } as unknown as BrainstormSession)
77
+ },
78
+ read: (blockId) => useBrainstormStore().sessionFor(blockId, 'requirements')?.id ?? null,
79
+ },
80
+ {
81
+ name: 'consensus',
82
+ write: (blockId, mark) => {
83
+ useConsensusStore().upsert({
84
+ id: mark,
85
+ blockId,
86
+ status: 'complete',
87
+ participants: [],
88
+ rounds: [],
89
+ synthesis: null,
90
+ createdAt: 1,
91
+ updatedAt: 1,
92
+ } as unknown as ConsensusSession)
93
+ },
94
+ read: (blockId) => useConsensusStore().sessionFor(blockId)?.id ?? null,
95
+ },
96
+ {
97
+ name: 'docInterview',
98
+ write: (blockId, mark) => {
99
+ useDocInterviewStore().upsert({
100
+ id: mark,
101
+ blockId,
102
+ status: 'awaiting_answers',
103
+ round: 1,
104
+ maxRounds: 3,
105
+ qa: [],
106
+ createdAt: 1,
107
+ updatedAt: 1,
108
+ } as unknown as DocInterviewSession)
109
+ },
110
+ read: (blockId) => useDocInterviewStore().forBlock(blockId)?.id ?? null,
111
+ },
112
+ {
113
+ name: 'initiative',
114
+ write: (blockId, mark) => {
115
+ useInitiativesStore().upsert({
116
+ id: mark,
117
+ blockId,
118
+ slug: 'i',
119
+ title: 'I',
120
+ rev: 1,
121
+ } as unknown as Initiative)
122
+ },
123
+ read: (blockId) => useInitiativesStore().forBlock(blockId)?.id ?? null,
124
+ },
125
+ ]
126
+
127
+ describe.each(cases)('$name store per-key writes', ({ write, read }) => {
128
+ it('an event for one block does not invalidate a consumer reading another', () => {
129
+ write('blk-a', 'a1')
130
+
131
+ let evaluations = 0
132
+ const forA = computed(() => {
133
+ evaluations++
134
+ return read('blk-a')
135
+ })
136
+ expect(forA.value).toBe('a1')
137
+ expect(evaluations).toBe(1)
138
+
139
+ // A brand-new key, then a rewrite of that existing one: neither is about `blk-a`.
140
+ write('blk-b', 'b1')
141
+ expect(forA.value).toBe('a1')
142
+ write('blk-b', 'b2')
143
+ expect(forA.value).toBe('a1')
144
+ expect(evaluations).toBe(1)
145
+
146
+ // The block's OWN event still reaches it.
147
+ write('blk-a', 'a2')
148
+ expect(forA.value).toBe('a2')
149
+ expect(evaluations).toBe(2)
150
+ })
151
+
152
+ it('a FIRST write reaches a reader that read the key while it was absent', () => {
153
+ // The half an in-place write could plausibly lose: the reader tracked a key that did not
154
+ // exist. Vue tracks a missing-key read, so ADDING the key notifies it. Without this the
155
+ // window a card opens before its first event would render its empty state forever.
156
+ let evaluations = 0
157
+ const forC = computed(() => {
158
+ evaluations++
159
+ return read('blk-c')
160
+ })
161
+ expect(forC.value).toBeNull()
162
+ expect(evaluations).toBe(1)
163
+
164
+ write('blk-c', 'c1')
165
+ expect(forC.value).toBe('c1')
166
+ expect(evaluations).toBe(2)
167
+ })
168
+ })
@@ -20,6 +20,31 @@ export interface RecommendationCommandContext {
20
20
  hasPendingRecommendations: (blockId: string) => boolean
21
21
  }
22
22
 
23
+ /**
24
+ * Whether the Requirement Writer is still producing recommendations for THIS review: a `pending`
25
+ * placeholder exists. Server-derived, so the "Recommending…" state survives the window closing
26
+ * and a page reload; the client-local `recommending` set only covers the request round-trip.
27
+ *
28
+ * Memoised on the review OBJECT, like the settlement tallies next door, and for the same reason:
29
+ * the store REPLACES the review on every write, so identity self-invalidates and a superseded
30
+ * review is collected along with its answer.
31
+ *
32
+ * Per REVIEW rather than per record, which is the whole point. `backgroundStage` asks this on the
33
+ * per-CARD path, and a `computed` over the `reviews` record tracks the ref plus every key in it,
34
+ * so one review event would re-evaluate every card's stage, the fan-out the per-key write exists
35
+ * to remove. Reading one key depends on one key.
36
+ */
37
+ const pendingByReview = new WeakMap<RequirementReview, boolean>()
38
+
39
+ export function awaitsRecommendations(review: RequirementReview): boolean {
40
+ let pending = pendingByReview.get(review)
41
+ if (pending === undefined) {
42
+ pending = (review.recommendations ?? []).some((r) => r.status === 'pending')
43
+ pendingByReview.set(review, pending)
44
+ }
45
+ return pending
46
+ }
47
+
23
48
  /** Ask for, accept, reject and re-request the Requirement Writer's suggested answers. */
24
49
  export function createRecommendationCommands(ctx: RecommendationCommandContext) {
25
50
  const { api, workspace, recommending, withFlag, store, hasPendingRecommendations } = ctx
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest'
2
- import type { RequirementReview } from '~/types/requirements'
2
+ import { computed } from 'vue'
3
+ import type { RequirementRecommendation, RequirementReview } from '~/types/requirements'
3
4
  import { useRequirementsStore } from '~/stores/requirements'
4
5
  import { useWorkspaceStore } from '~/stores/workspace'
5
6
 
@@ -18,6 +19,20 @@ function review(over: Partial<RequirementReview> = {}): RequirementReview {
18
19
  } as RequirementReview
19
20
  }
20
21
 
22
+ /** A `pending` Writer placeholder: the state `backgroundStage` reads as "recommending". */
23
+ function pendingRecommendation(id: string): RequirementRecommendation {
24
+ return {
25
+ id,
26
+ sourceFinding: { title: 'f', detail: 'd', itemId: 'i1' },
27
+ recommendedText: '',
28
+ status: 'pending',
29
+ note: null,
30
+ groundedInFragment: null,
31
+ createdAt: 1,
32
+ updatedAt: 1,
33
+ } as RequirementRecommendation
34
+ }
35
+
21
36
  describe('requirements store load() loading flag', () => {
22
37
  beforeEach(() => {
23
38
  // The store resolves its workspace id from the workspace store at call time.
@@ -113,3 +128,76 @@ describe('requirements store live-event upsert guard', () => {
113
128
  expect(store.reviewFor('b1')?.id).toBe('rr2')
114
129
  })
115
130
  })
131
+
132
+ describe('requirements store per-key writes', () => {
133
+ it('an event for one block does not invalidate a consumer reading another', () => {
134
+ // Every card on the board reads its OWN block's review (the "Recommending…"/gate badge), so
135
+ // one review event used to wake every card: the store replaced the whole record, which is a
136
+ // write to the ref itself and therefore a dependency every reader shares. Writing the key
137
+ // keeps the invalidation on the block that changed.
138
+ const store = useRequirementsStore()
139
+ store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
140
+
141
+ let evaluations = 0
142
+ const forA = computed(() => {
143
+ evaluations++
144
+ return store.reviewFor('blk-a')?.id ?? null
145
+ })
146
+ expect(forA.value).toBe('rr-a')
147
+ expect(evaluations).toBe(1)
148
+
149
+ // A brand-new key, then a rewrite of an existing one: neither is about `blk-a`.
150
+ store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 1 }))
151
+ expect(forA.value).toBe('rr-a')
152
+ store.upsert(review({ id: 'rr-b', blockId: 'blk-b', updatedAt: 2 }))
153
+ expect(forA.value).toBe('rr-a')
154
+ expect(evaluations).toBe(1)
155
+
156
+ // The block's OWN event still reaches it.
157
+ store.upsert(review({ id: 'rr-a2', blockId: 'blk-a', updatedAt: 2 }))
158
+ expect(forA.value).toBe('rr-a2')
159
+ expect(evaluations).toBe(2)
160
+ })
161
+
162
+ it('the per-card STAGE read depends on one block too, pending recommendations included', () => {
163
+ // `backgroundStage` is the read every card actually makes (TaskCard/BlockNode via
164
+ // `useReviewStage`), and it is the one the per-key write alone does not fix: while the pending
165
+ // -recommendation answer came from a `computed` over the whole `reviews` record, that computed
166
+ // tracked every key, so one event still re-evaluated the stage of every card on the board.
167
+ // Answering off the block's own review object is what closes it.
168
+ const store = useRequirementsStore()
169
+ store.upsert(review({ id: 'rr-a', blockId: 'blk-a', updatedAt: 1 }))
170
+
171
+ let evaluations = 0
172
+ const stageForA = computed(() => {
173
+ evaluations++
174
+ return store.backgroundStage('blk-a')
175
+ })
176
+ expect(stageForA.value).toBeNull()
177
+ expect(evaluations).toBe(1)
178
+
179
+ // Another block starts recommending: not this card's business.
180
+ store.upsert(
181
+ review({
182
+ id: 'rr-b',
183
+ blockId: 'blk-b',
184
+ updatedAt: 1,
185
+ recommendations: [pendingRecommendation('rec-1')],
186
+ }),
187
+ )
188
+ expect(stageForA.value).toBeNull()
189
+ expect(evaluations).toBe(1)
190
+
191
+ // This block's own placeholder still surfaces the working state.
192
+ store.upsert(
193
+ review({
194
+ id: 'rr-a2',
195
+ blockId: 'blk-a',
196
+ updatedAt: 2,
197
+ recommendations: [pendingRecommendation('rec-2')],
198
+ }),
199
+ )
200
+ expect(stageForA.value).toBe('recommending')
201
+ expect(evaluations).toBe(2)
202
+ })
203
+ })
@@ -1,5 +1,5 @@
1
1
  import { defineStore } from 'pinia'
2
- import { computed, ref } from 'vue'
2
+ import { ref } from 'vue'
3
3
  import type {
4
4
  RequirementReview,
5
5
  ResolveRequirementsExceededChoice,
@@ -16,7 +16,10 @@ import {
16
16
  canProceed,
17
17
  openCount,
18
18
  } from '~/stores/requirements/settlement'
19
- import { createRecommendationCommands } from '~/stores/requirements/recommendations'
19
+ import {
20
+ awaitsRecommendations,
21
+ createRecommendationCommands,
22
+ } from '~/stores/requirements/recommendations'
20
23
 
21
24
  /**
22
25
  * Requirements-review state. On the pipeline path the reviewer runs as the first gate
@@ -55,24 +58,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
55
58
  function reviewFor(blockId: string): RequirementReview | null {
56
59
  return reviews.value[blockId] ?? null
57
60
  }
58
- /** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
59
- * placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
60
- * and a page reload — the client-local `recommending` set only covers the request round-trip. */
61
61
  /**
62
- * Blocks whose stored review still carries a `pending` recommendation placeholder, derived once
63
- * per change to `reviews` rather than per call. {@link backgroundStage} asks this on the per-CARD
64
- * path, so as a function it re-scanned one review's recommendation list for every card on the
65
- * board on every event.
62
+ * Whether the Requirement Writer is still producing recommendations for a block. Reads ONE key
63
+ * and answers off the review object itself ({@link awaitsRecommendations} memoises the scan on
64
+ * that object), so a card asking about its own block depends only on its own block. A `computed`
65
+ * over the whole record would be the fan-out again: it tracks every key, so one review event
66
+ * would re-evaluate the stage of every card on the board.
66
67
  */
67
- const blocksAwaitingRecommendations = computed(() => {
68
- const blocks = new Set<string>()
69
- for (const [blockId, review] of Object.entries(reviews.value)) {
70
- if ((review?.recommendations ?? []).some((r) => r.status === 'pending')) blocks.add(blockId)
71
- }
72
- return blocks
73
- })
74
68
  function hasPendingRecommendations(blockId: string): boolean {
75
- return blocksAwaitingRecommendations.value.has(blockId)
69
+ const review = reviews.value[blockId]
70
+ return review ? awaitsRecommendations(review) : false
76
71
  }
77
72
  /**
78
73
  * The async background stage a block's review is in, or null. While the driver folds the
@@ -96,8 +91,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
96
91
  return incorporating.value.has(reviewId)
97
92
  }
98
93
 
94
+ /**
95
+ * Write one block's review into the cache, IN PLACE.
96
+ *
97
+ * Per-key, never a whole-record clone: `reviews` is a deep reactive ref, so a consumer reading
98
+ * `reviews[someBlockId]` depends on THAT key. Replacing the record retriggered every one of
99
+ * them (a card, a badge, an inspector panel for an untouched block) on every event; assigning
100
+ * the key retriggers only the consumers of the block that actually changed.
101
+ */
99
102
  function store(review: RequirementReview) {
100
- reviews.value = { ...reviews.value, [review.blockId]: review }
103
+ reviews.value[review.blockId] = review
101
104
  }
102
105
 
103
106
  /** Patch the cache from a live `requirements` stream event (newest wins per block). */
@@ -139,7 +142,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
139
142
  try {
140
143
  const review = await api.getRequirementReview(workspace.requireId(), blockId)
141
144
  available.value = true
142
- reviews.value = { ...reviews.value, [blockId]: review }
145
+ // Written by key like `store()`, and directly because a load resolving to "none exists"
146
+ // caches a null the getter reads as "fetched, absent".
147
+ reviews.value[blockId] = review
143
148
  } catch {
144
149
  // 503 (feature off) or any error → hide the UI entry points.
145
150
  available.value = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.298.0",
3
+ "version": "0.299.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  "@modular-vue/nuxt": "^0.4.1",
26
26
  "@modular-vue/runtime": "^1.4.1",
27
27
  "@modular-vue/vue": "^1.4.1",
28
- "@nuxt/ui": "^4.11.0",
28
+ "@nuxt/ui": "^4.11.1",
29
29
  "@nuxtjs/i18n": "^10.6.0",
30
30
  "@pinia/nuxt": "^1.0.2",
31
31
  "@toad-contracts/core": "0.4.0",