@cat-factory/app 0.28.0 → 0.28.1

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.
@@ -214,10 +214,24 @@ async function setStatus(item: RequirementReviewItem, itemStatus: ReviewItemStat
214
214
  const recommending = computed(() =>
215
215
  blockId.value ? requirements.isRecommending(blockId.value) : false,
216
216
  )
217
- // Recommendations still awaiting a human decision (the ones to surface for review).
218
- const pendingRecommendations = computed<RequirementRecommendation[]>(() =>
217
+ // Recommendations the Writer has produced that still await a human decision (`ready`).
218
+ const readyRecommendations = computed<RequirementRecommendation[]>(() =>
219
219
  (review.value?.recommendations ?? []).filter((r) => r.status === 'ready'),
220
220
  )
221
+ // Placeholders the Requirement Writer is still producing in the background (`pending`).
222
+ const generatingRecommendations = computed<RequirementRecommendation[]>(() =>
223
+ (review.value?.recommendations ?? []).filter((r) => r.status === 'pending'),
224
+ )
225
+ // "ready / total" progress for the in-flight batch (null when nothing is generating). Scoped to
226
+ // the current wave via `createdAt` (all placeholders in one request share the timestamp), so
227
+ // stale `ready` recommendations the human hasn't acted on from an earlier batch don't inflate it.
228
+ const recommendationProgress = computed(() => {
229
+ const generating = generatingRecommendations.value
230
+ if (generating.length === 0) return null
231
+ const batchTimes = new Set(generating.map((r) => r.createdAt))
232
+ const ready = readyRecommendations.value.filter((r) => batchTimes.has(r.createdAt)).length
233
+ return { ready, total: ready + generating.length }
234
+ })
221
235
  function isMarkedForRecommend(item: RequirementReviewItem): boolean {
222
236
  return markedForRecommend.value.has(item.id)
223
237
  }
@@ -228,18 +242,37 @@ function toggleRecommend(item: RequirementReviewItem) {
228
242
  markedForRecommend.value = next
229
243
  }
230
244
 
231
- // Fire the Writer over the whole marked batch at once (grounded on the project's
232
- // best-practice standards, specs/tech-specs and web search).
245
+ // Fire the Writer over the whole marked batch (grounded on the project's best-practice
246
+ // standards, specs/tech-specs and web search). ASYNCHRONOUS: it returns at once with `pending`
247
+ // placeholders that fill in live; the user can close the window and is notified when the batch
248
+ // is ready. Flush any typed-but-unblurred answers first so nothing the human entered is lost.
233
249
  async function requestRecommendations() {
234
250
  if (!blockId.value || markedForRecommend.value.size === 0) return
235
251
  const ids = [...markedForRecommend.value]
236
252
  try {
237
- await requirements.requestRecommendations(blockId.value, ids)
253
+ await flushDrafts()
254
+ const updated = await requirements.requestRecommendations(blockId.value, ids)
238
255
  markedForRecommend.value = new Set()
239
- toast.add({
240
- title: `Requesting ${ids.length} recommendation${ids.length === 1 ? '' : 's'}…`,
241
- icon: 'i-lucide-sparkles',
242
- })
256
+ const n = ids.length
257
+ const plural = n === 1 ? '' : 's'
258
+ // On a parked run the request returns at once with `pending` placeholders the durable driver
259
+ // fills in the background; off-path (no active pipeline) there is no driver, so the Writer
260
+ // ran inline and the recommendations are already settled. Tell the human which actually
261
+ // happened rather than always promising a background callback.
262
+ const stillGenerating = (updated?.recommendations ?? []).some((r) => r.status === 'pending')
263
+ toast.add(
264
+ stillGenerating
265
+ ? {
266
+ title: `Preparing ${n} recommendation${plural} in the background`,
267
+ description:
268
+ "Your answers are saved — close this if you like; we'll notify you when they're ready.",
269
+ icon: 'i-lucide-sparkles',
270
+ }
271
+ : {
272
+ title: `${n} recommendation${plural} ready`,
273
+ icon: 'i-lucide-sparkles',
274
+ },
275
+ )
243
276
  } catch (e) {
244
277
  notifyError('Could not request recommendations', e)
245
278
  }
@@ -567,18 +600,47 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
567
600
  </div>
568
601
  </div>
569
602
 
570
- <!-- Requirement-Writer recommendations awaiting a human decision -->
603
+ <!-- Requirement-Writer recommendations: awaiting a human decision (`ready`) and/or
604
+ still generating in the background (`pending`) -->
571
605
  <section
572
- v-if="pendingRecommendations.length"
606
+ v-if="readyRecommendations.length || generatingRecommendations.length"
573
607
  class="mt-6 border-t border-slate-800 pt-5"
574
608
  >
575
- <div class="mb-3 flex items-center gap-1.5 text-[11px] text-indigo-300">
609
+ <div class="mb-3 flex items-center gap-2 text-[11px] text-indigo-300">
576
610
  <UIcon name="i-lucide-wand-2" class="h-3.5 w-3.5" />
577
611
  <span class="font-semibold uppercase tracking-wide">Recommended answers</span>
612
+ <span
613
+ v-if="recommendationProgress"
614
+ class="ml-auto flex items-center gap-1.5 normal-case text-indigo-300/80"
615
+ >
616
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
617
+ {{ recommendationProgress.ready }} / {{ recommendationProgress.total }} ready
618
+ </span>
578
619
  </div>
620
+
621
+ <!-- still-generating placeholders (one per requested finding) -->
622
+ <div v-if="generatingRecommendations.length" class="mb-3 flex flex-col gap-3">
623
+ <div
624
+ v-for="rec in generatingRecommendations"
625
+ :key="rec.id"
626
+ class="flex items-start gap-2 rounded-lg border border-dashed border-indigo-900/50 bg-indigo-950/10 p-3"
627
+ >
628
+ <UIcon
629
+ name="i-lucide-loader-circle"
630
+ class="mt-0.5 h-4 w-4 shrink-0 animate-spin text-indigo-300"
631
+ />
632
+ <div class="min-w-0">
633
+ <span class="text-sm font-medium text-white">{{
634
+ rec.sourceFinding.title
635
+ }}</span>
636
+ <p class="text-xs text-indigo-300/70">Generating a grounded suggestion…</p>
637
+ </div>
638
+ </div>
639
+ </div>
640
+
579
641
  <div class="flex flex-col gap-3">
580
642
  <div
581
- v-for="rec in pendingRecommendations"
643
+ v-for="rec in readyRecommendations"
582
644
  :key="rec.id"
583
645
  class="rounded-lg border border-indigo-900/50 bg-indigo-950/20 p-3"
584
646
  >
@@ -83,11 +83,17 @@ export function reviewsApi({ http, ws }: ApiContext) {
83
83
  ),
84
84
 
85
85
  // Ask the Requirement Writer to recommend grounded answers for a batch of findings (by
86
- // item id). Returns the review with `ready` recommendations for the human to act on.
87
- requestRecommendations: (workspaceId: string, blockId: string, itemIds: string[]) =>
86
+ // item id). Returns the review with `pending` placeholder recommendations; they fill in
87
+ // (`ready`) asynchronously via the `requirements` stream as the Writer produces each.
88
+ requestRecommendations: (
89
+ workspaceId: string,
90
+ blockId: string,
91
+ itemIds: string[],
92
+ note?: string,
93
+ ) =>
88
94
  http<RequirementReview | null>(
89
95
  `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/requirement-review/recommend`,
90
- { method: 'POST', body: { itemIds } },
96
+ { method: 'POST', body: { itemIds, ...(note ? { note } : {}) } },
91
97
  ),
92
98
 
93
99
  // Accept a recommendation (becomes the finding's answer), reject it, or re-request it
@@ -44,14 +44,21 @@ export const useRequirementsStore = defineStore('requirements', () => {
44
44
  function reviewFor(blockId: string): RequirementReview | null {
45
45
  return reviews.value[blockId] ?? null
46
46
  }
47
+ /** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
48
+ * placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
49
+ * and a page reload — the client-local `recommending` set only covers the request round-trip. */
50
+ function hasPendingRecommendations(blockId: string): boolean {
51
+ return (reviews.value[blockId]?.recommendations ?? []).some((r) => r.status === 'pending')
52
+ }
47
53
  /**
48
54
  * The async background stage a block's review is in, or null. While the driver folds the
49
- * answers (`incorporating`) then re-reviews the document (`reviewing`), NO human action is
50
- * needed so the board suppresses the "Approval needed" gate and shows this working state
51
- * instead, with copy that names which of the two stages is running.
55
+ * answers (`incorporating`) then re-reviews the document (`reviewing`), or the Requirement
56
+ * Writer is producing recommendations (`recommending`), NO human action is needed so the
57
+ * board suppresses the "Approval needed" gate and shows this working state instead, with copy
58
+ * that names which stage is running.
52
59
  */
53
60
  function backgroundStage(blockId: string): 'incorporating' | 'reviewing' | 'recommending' | null {
54
- if (recommending.value.has(blockId)) return 'recommending'
61
+ if (recommending.value.has(blockId) || hasPendingRecommendations(blockId)) return 'recommending'
55
62
  const status = reviews.value[blockId]?.status
56
63
  return status === 'incorporating' || status === 'reviewing' ? status : null
57
64
  }
@@ -176,19 +183,26 @@ export const useRequirementsStore = defineStore('requirements', () => {
176
183
  }
177
184
 
178
185
  function isRecommending(blockId: string): boolean {
179
- return recommending.value.has(blockId)
186
+ return recommending.value.has(blockId) || hasPendingRecommendations(blockId)
180
187
  }
181
188
 
182
189
  /**
183
190
  * Ask the Requirement Writer to recommend answers for a batch of findings (by item id).
184
- * Runs the Writer inline (grounded on best-practice fragments spec/tech-spec → web) and
185
- * returns the review with `ready` recommendations to accept/reject. Shows a `recommending`
186
- * background stage on the board while it runs.
191
+ * ASYNCHRONOUS: returns at once with `pending` placeholder recommendations (the Writer runs
192
+ * per finding in the durable driver), which fill in (`ready`) via live `requirements` stream
193
+ * events; a notification calls the user back when the batch is ready. The board shows the
194
+ * `recommending` background stage while any placeholder is pending. Optional `note` steers the
195
+ * whole batch.
187
196
  */
188
- async function requestRecommendations(blockId: string, itemIds: string[]) {
197
+ async function requestRecommendations(blockId: string, itemIds: string[], note?: string) {
189
198
  withFlag(recommending, blockId, true)
190
199
  try {
191
- const updated = await api.requestRecommendations(workspace.requireId(), blockId, itemIds)
200
+ const updated = await api.requestRecommendations(
201
+ workspace.requireId(),
202
+ blockId,
203
+ itemIds,
204
+ note,
205
+ )
192
206
  if (updated) store(updated)
193
207
  return updated
194
208
  } finally {
@@ -50,8 +50,12 @@ export type RequirementReviewStatus =
50
50
  /** How a human resolves a review that hit its iteration cap. */
51
51
  export type ResolveRequirementsExceededChoice = 'extra-round' | 'proceed' | 'stop-reset'
52
52
 
53
- /** Lifecycle of a Requirement-Writer recommendation. */
54
- export type RecommendationStatus = 'ready' | 'accepted' | 'rejected'
53
+ /**
54
+ * Lifecycle of a Requirement-Writer recommendation. `pending` is a placeholder created the
55
+ * moment the human requests it — the Writer is still producing the suggestion in the background
56
+ * (the async story); it fills in to `ready` via the `requirements` stream.
57
+ */
58
+ export type RecommendationStatus = 'pending' | 'ready' | 'accepted' | 'rejected'
55
59
 
56
60
  /**
57
61
  * A Requirement-Writer suggestion for one finding. First-class on the review (survives the
@@ -60,7 +64,7 @@ export type RecommendationStatus = 'ready' | 'accepted' | 'rejected'
60
64
  */
61
65
  export interface RequirementRecommendation {
62
66
  id: string
63
- sourceFinding: { title: string; detail: string }
67
+ sourceFinding: { title: string; detail: string; itemId?: string }
64
68
  recommendedText: string
65
69
  status: RecommendationStatus
66
70
  note: string | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
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",