@cat-factory/app 0.30.6 → 0.32.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 (32) hide show
  1. package/app/components/brainstorm/BrainstormWindow.vue +617 -0
  2. package/app/components/followUp/FollowUpWindow.vue +257 -0
  3. package/app/components/kaizen/KaizenPanel.vue +208 -0
  4. package/app/components/kaizen/KaizenStepStatus.vue +94 -0
  5. package/app/components/layout/NotificationsInbox.vue +13 -0
  6. package/app/components/layout/SideBar.vue +12 -0
  7. package/app/components/panels/AgentStepDetail.vue +6 -0
  8. package/app/components/panels/StepResultViewHost.vue +7 -0
  9. package/app/components/pipeline/PipelineBuilder.vue +21 -0
  10. package/app/components/pipeline/PipelineProgress.vue +59 -1
  11. package/app/components/settings/WorkspaceSettingsPanel.vue +20 -0
  12. package/app/components/slack/SlackPanel.vue +1 -0
  13. package/app/composables/api/followUps.ts +52 -0
  14. package/app/composables/api/kaizen.ts +16 -0
  15. package/app/composables/api/reviews.ts +68 -0
  16. package/app/composables/useApi.ts +4 -0
  17. package/app/composables/useResultView.ts +3 -1
  18. package/app/composables/useWorkspaceStream.ts +11 -0
  19. package/app/pages/index.vue +2 -0
  20. package/app/stores/brainstorm.ts +210 -0
  21. package/app/stores/followUps.ts +73 -0
  22. package/app/stores/kaizen.ts +101 -0
  23. package/app/stores/pipelines.ts +25 -0
  24. package/app/stores/ui.ts +64 -1
  25. package/app/stores/workspaceSettings.ts +1 -0
  26. package/app/types/brainstorm.ts +55 -0
  27. package/app/types/domain.ts +64 -0
  28. package/app/types/execution.ts +41 -0
  29. package/app/types/notifications.ts +1 -0
  30. package/app/utils/catalog.spec.ts +2 -0
  31. package/app/utils/catalog.ts +68 -3
  32. package/package.json +1 -1
@@ -381,6 +381,27 @@ async function clone(p: Pipeline) {
381
381
  "
382
382
  @click="pipelines.toggleDraftConsensus(unit.index)"
383
383
  />
384
+ <!-- Follow-up companion: the future-looking Coder surfaces loose ends /
385
+ side-tasks / questions mid-run (coder steps only). Enabled by default. -->
386
+ <UButton
387
+ v-if="unit.kind === 'coder'"
388
+ :icon="
389
+ pipelines.draftFollowUps[unit.index] === false
390
+ ? 'i-lucide-circle-slash'
391
+ : 'i-lucide-compass'
392
+ "
393
+ :color="
394
+ pipelines.draftFollowUps[unit.index] === false ? 'neutral' : 'secondary'
395
+ "
396
+ variant="ghost"
397
+ size="xs"
398
+ :title="
399
+ pipelines.draftFollowUps[unit.index] === false
400
+ ? 'Follow-up companion disabled — click to enable (Coder surfaces loose ends / questions)'
401
+ : 'Follow-up companion enabled — Coder surfaces loose ends / side-tasks / questions; click to disable'
402
+ "
403
+ @click="pipelines.toggleDraftFollowUps(unit.index)"
404
+ />
384
405
  <UButton
385
406
  icon="i-lucide-chevron-up"
386
407
  color="neutral"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { AgentState, ExecutionInstance } from '~/types/domain'
3
- import { agentKindMeta } from '~/utils/catalog'
3
+ import type { PipelineStep } from '~/types/execution'
4
+ import { agentKindMeta, FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
4
5
  import {
5
6
  subtaskIconClass,
6
7
  gateCompanionFor,
@@ -43,6 +44,18 @@ function openStep(i: number) {
43
44
  ui.openStepDetail(props.instance.id, i)
44
45
  }
45
46
 
47
+ // Follow-up companion (the future-looking Coder): how many surfaced items still need a
48
+ // decision, and the chip's roll-up label. The chip blinks while any item is pending.
49
+ function followUpPending(step: PipelineStep): number {
50
+ return (step.followUps?.items ?? []).filter((it) => it.status === 'pending').length
51
+ }
52
+ function followUpLabel(step: PipelineStep): string {
53
+ const items = step.followUps?.items ?? []
54
+ if (items.length === 0) return 'Watching…'
55
+ const pending = followUpPending(step)
56
+ return pending > 0 ? `${pending} to decide` : 'All decided'
57
+ }
58
+
46
59
  // --- restart from a step -----------------------------------------------------
47
60
  // Re-run the pipeline from a chosen step onward: the server resets that step +
48
61
  // every later step's iteration counters and re-drives a fresh run, keeping the
@@ -420,6 +433,37 @@ const ITEM_ICON: Record<string, string> = {
420
433
  </span>
421
434
  </div>
422
435
 
436
+ <!-- Follow-up companion (future-looking Coder): a blinking chip that lights up the
437
+ moment the Coder streams an item; click to triage. Blinks while any item is
438
+ undecided (the gate holds the pipeline until they're all decided). -->
439
+ <button
440
+ v-if="s.followUps?.enabled"
441
+ type="button"
442
+ class="mt-3 flex w-full items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-left transition hover:border-pink-400/60"
443
+ :class="
444
+ followUpPending(s) > 0
445
+ ? 'border-pink-500/50 bg-pink-500/10 followup-blink'
446
+ : 'border-slate-700/70 bg-slate-900/40'
447
+ "
448
+ @click="ui.openFollowUps(instance.id, i)"
449
+ >
450
+ <span
451
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-pink-500/40 bg-pink-500/15"
452
+ >
453
+ <UIcon :name="FOLLOW_UP_COMPANION_META.icon" class="h-3 w-3 text-pink-300" />
454
+ </span>
455
+ <span class="min-w-0 flex-1 truncate text-[12px] text-slate-300">
456
+ {{ FOLLOW_UP_COMPANION_META.label }}
457
+ <span class="text-slate-500">(companion)</span>
458
+ </span>
459
+ <span
460
+ class="shrink-0 text-[11px] font-medium"
461
+ :class="followUpPending(s) > 0 ? 'text-pink-300' : 'text-slate-400'"
462
+ >
463
+ {{ followUpLabel(s) }}
464
+ </span>
465
+ </button>
466
+
423
467
  <!-- reviewer gate folding/re-reviewing in the background: a working indicator,
424
468
  NOT a "Review & approve" gate (the human is summoned only if needed) -->
425
469
  <div
@@ -483,4 +527,18 @@ const ITEM_ICON: Record<string, string> = {
483
527
  .step-active {
484
528
  animation: step-pulse 1.6s ease-in-out infinite;
485
529
  }
530
+ /* The Follow-up companion chip blinks (pink) while it has undecided items, drawing the eye
531
+ to forward-looking work surfaced mid-run. */
532
+ @keyframes followup-blink {
533
+ 0%,
534
+ 100% {
535
+ box-shadow: 0 0 0 0 rgba(244, 114, 182, 0.5);
536
+ }
537
+ 50% {
538
+ box-shadow: 0 0 0 5px rgba(244, 114, 182, 0);
539
+ }
540
+ }
541
+ .followup-blink {
542
+ animation: followup-blink 1.4s ease-in-out infinite;
543
+ }
486
544
  </style>
@@ -68,6 +68,7 @@ const draft = reactive({
68
68
  taskLimitShared: 5 as number,
69
69
  perType: {} as Record<CreateTaskType, number>,
70
70
  storeAgentContext: true,
71
+ kaizenEnabled: true,
71
72
  // Budget: empty string ⇒ "use the built-in default" (null on the wire).
72
73
  spendCurrency: '',
73
74
  spendMonthlyLimit: '',
@@ -81,6 +82,7 @@ function hydrate() {
81
82
  const pt = s.taskLimitPerType ?? {}
82
83
  for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
83
84
  draft.storeAgentContext = s.storeAgentContext
85
+ draft.kaizenEnabled = s.kaizenEnabled
84
86
  draft.spendCurrency = s.spendCurrency ?? ''
85
87
  draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
86
88
  }
@@ -107,6 +109,7 @@ async function save() {
107
109
  )
108
110
  : null,
109
111
  storeAgentContext: draft.storeAgentContext,
112
+ kaizenEnabled: draft.kaizenEnabled,
110
113
  })
111
114
  toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
112
115
  } catch (e) {
@@ -237,6 +240,23 @@ async function saveBudget() {
237
240
  </label>
238
241
  </section>
239
242
 
243
+ <!-- Kaizen agent -->
244
+ <section class="space-y-2">
245
+ <h3 class="text-sm font-semibold text-slate-200">Kaizen agent</h3>
246
+ <p class="text-[11px] text-slate-400">
247
+ After each run completes, the Kaizen agent grades how every agent step went — smooth
248
+ and efficient vs confused and chaotic — and recommends prompt/model improvements. A
249
+ prompt + agent + model combination that grades highly with no recommendations five
250
+ times in a row is marked verified and is no longer graded. Grading runs in the
251
+ background and is shown inside run details and the Kaizen screen. Set the grader's
252
+ model in Model Configuration (the “Kaizen” agent).
253
+ </p>
254
+ <label class="flex items-center gap-2">
255
+ <USwitch v-model="draft.kaizenEnabled" size="sm" />
256
+ <span class="text-sm text-slate-200">Grade agent runs with Kaizen</span>
257
+ </label>
258
+ </section>
259
+
240
260
  <div class="flex justify-end">
241
261
  <UButton
242
262
  color="primary"
@@ -45,6 +45,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
45
45
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
46
46
  decision_required: { enabled: false, channel: '' },
47
47
  human_test_ready: { enabled: false, channel: '' },
48
+ followup_pending: { enabled: false, channel: '' },
48
49
  })
49
50
  const mentionsEnabled = ref(false)
50
51
  const mapping = ref<SlackMemberMappingEntry[]>([])
@@ -0,0 +1,52 @@
1
+ import type { FollowUpsStepState } from '~/types/execution'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The Follow-up companion: the Coder surfaces forward-looking items (loose ends /
6
+ * side-tasks / questions) live on its run step; these endpoints decide each one. Every
7
+ * call returns the updated live state; when the run is parked on the follow-up gate and
8
+ * the last item is decided, the backend drives the run forward (loop the Coder for the
9
+ * queued / answered items, else advance).
10
+ */
11
+ export function followUpsApi({ http, ws }: ApiContext) {
12
+ const base = (workspaceId: string, executionId: string) =>
13
+ `${ws(workspaceId)}/executions/${encodeURIComponent(executionId)}/follow-ups`
14
+
15
+ return {
16
+ // The live follow-up state for a run (null when the companion is off / nothing surfaced).
17
+ getFollowUps: (workspaceId: string, executionId: string) =>
18
+ http<FollowUpsStepState | null>(base(workspaceId, executionId)),
19
+
20
+ // File a follow-up as a tracker issue (GitHub Issues / Jira).
21
+ fileFollowUp: (workspaceId: string, executionId: string, itemId: string) =>
22
+ http<FollowUpsStepState>(
23
+ `${base(workspaceId, executionId)}/${encodeURIComponent(itemId)}/file`,
24
+ {
25
+ method: 'POST',
26
+ },
27
+ ),
28
+
29
+ // Send a follow-up back to the Coder (queued for its next pass).
30
+ queueFollowUp: (workspaceId: string, executionId: string, itemId: string) =>
31
+ http<FollowUpsStepState>(
32
+ `${base(workspaceId, executionId)}/${encodeURIComponent(itemId)}/queue`,
33
+ {
34
+ method: 'POST',
35
+ },
36
+ ),
37
+
38
+ // Answer a question item (folded into the Coder's next pass).
39
+ answerFollowUp: (workspaceId: string, executionId: string, itemId: string, answer: string) =>
40
+ http<FollowUpsStepState>(
41
+ `${base(workspaceId, executionId)}/${encodeURIComponent(itemId)}/answer`,
42
+ { method: 'POST', body: { answer } },
43
+ ),
44
+
45
+ // Dismiss a follow-up / question item without acting on it.
46
+ dismissFollowUp: (workspaceId: string, executionId: string, itemId: string) =>
47
+ http<FollowUpsStepState>(
48
+ `${base(workspaceId, executionId)}/${encodeURIComponent(itemId)}/dismiss`,
49
+ { method: 'POST' },
50
+ ),
51
+ }
52
+ }
@@ -0,0 +1,16 @@
1
+ import type { KaizenGrading, KaizenOverview } from '~/types/domain'
2
+ import type { ApiContext } from './context'
3
+
4
+ /** Kaizen (post-run grading) read endpoints: the screen overview + a run's gradings. */
5
+ export function kaizenApi({ http, ws }: ApiContext) {
6
+ return {
7
+ // The Kaizen screen: recent grading history + the verified-combo library.
8
+ getKaizenOverview: (workspaceId: string) => http<KaizenOverview>(`${ws(workspaceId)}/kaizen`),
9
+
10
+ // The gradings recorded for one run (the run-window status surface).
11
+ getKaizenForExecution: (workspaceId: string, executionId: string) =>
12
+ http<{ gradings: KaizenGrading[] }>(
13
+ `${ws(workspaceId)}/executions/${encodeURIComponent(executionId)}/kaizen`,
14
+ ),
15
+ }
16
+ }
@@ -1,4 +1,9 @@
1
1
  import type { ClarityReview, ResolveClarityExceededChoice } from '~/types/clarity'
2
+ import type {
3
+ BrainstormSession,
4
+ BrainstormStage,
5
+ ResolveBrainstormExceededChoice,
6
+ } from '~/types/brainstorm'
2
7
  import type { ConsensusSession } from '~/types/consensus'
3
8
  import type {
4
9
  RequirementReview,
@@ -176,5 +181,68 @@ export function reviewsApi({ http, ws }: ApiContext) {
176
181
  `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/clarity-review/resolve-exceeded`,
177
182
  { method: 'POST', body: { choice } },
178
183
  ),
184
+
185
+ // ---- brainstorm (structured-dialogue agent, stage-scoped) ------------
186
+ // The current session for a block + stage (null when none has been run). A 503 means
187
+ // the feature is unconfigured (the panel hides on any error here).
188
+ getBrainstorm: (workspaceId: string, blockId: string, stage: BrainstormStage) =>
189
+ http<BrainstormSession | null>(
190
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/brainstorm/${stage}`,
191
+ ),
192
+
193
+ replyBrainstormItem: (workspaceId: string, sessionId: string, itemId: string, reply: string) =>
194
+ http<BrainstormSession>(
195
+ `${ws(workspaceId)}/brainstorm-sessions/${encodeURIComponent(sessionId)}/items/${encodeURIComponent(itemId)}/reply`,
196
+ { method: 'POST', body: { reply } },
197
+ ),
198
+
199
+ setBrainstormItemStatus: (
200
+ workspaceId: string,
201
+ sessionId: string,
202
+ itemId: string,
203
+ status: ReviewItemStatus,
204
+ ) =>
205
+ http<BrainstormSession>(
206
+ `${ws(workspaceId)}/brainstorm-sessions/${encodeURIComponent(sessionId)}/items/${encodeURIComponent(itemId)}`,
207
+ { method: 'PATCH', body: { status } },
208
+ ),
209
+
210
+ // Incorporate the picks ASYNCHRONOUSLY (the durable driver folds + re-runs).
211
+ incorporateBrainstorm: (
212
+ workspaceId: string,
213
+ blockId: string,
214
+ stage: BrainstormStage,
215
+ feedback?: string,
216
+ ) =>
217
+ http<BrainstormSession>(
218
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/brainstorm/${stage}/incorporate`,
219
+ { method: 'POST', body: feedback ? { feedback } : {} },
220
+ ),
221
+
222
+ // Re-run the brainstorm against the converged direction (one more pass).
223
+ reReviewBrainstorm: (workspaceId: string, blockId: string, stage: BrainstormStage) =>
224
+ http<BrainstormSession>(
225
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/brainstorm/${stage}/re-review`,
226
+ { method: 'POST' },
227
+ ),
228
+
229
+ // Proceed: settle the brainstorm and advance the parked run (all options dismissed).
230
+ proceedBrainstorm: (workspaceId: string, blockId: string, stage: BrainstormStage) =>
231
+ http<BrainstormSession>(
232
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/brainstorm/${stage}/proceed`,
233
+ { method: 'POST' },
234
+ ),
235
+
236
+ // Resolve a session that hit its iteration cap: extra-round / proceed / stop-reset.
237
+ resolveBrainstormExceeded: (
238
+ workspaceId: string,
239
+ blockId: string,
240
+ stage: BrainstormStage,
241
+ choice: ResolveBrainstormExceededChoice,
242
+ ) =>
243
+ http<BrainstormSession>(
244
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/brainstorm/${stage}/resolve-exceeded`,
245
+ { method: 'POST', body: { choice } },
246
+ ),
179
247
  }
180
248
  }
@@ -6,9 +6,11 @@ import { bootstrapApi } from './api/bootstrap'
6
6
  import { boardApi } from './api/board'
7
7
  import { documentsApi } from './api/documents'
8
8
  import { executionApi } from './api/execution'
9
+ import { followUpsApi } from './api/followUps'
9
10
  import { fragmentsApi } from './api/fragments'
10
11
  import { githubApi } from './api/github'
11
12
  import { humanTestApi } from './api/humanTest'
13
+ import { kaizenApi } from './api/kaizen'
12
14
  import { modelsApi } from './api/models'
13
15
  import { notificationsApi } from './api/notifications'
14
16
  import { presetsApi } from './api/presets'
@@ -84,7 +86,9 @@ export function useApi() {
84
86
  ...documentsApi(ctx),
85
87
  ...tasksApi(ctx),
86
88
  ...reviewsApi(ctx),
89
+ ...followUpsApi(ctx),
87
90
  ...humanTestApi(ctx),
91
+ ...kaizenApi(ctx),
88
92
  ...specApi(ctx),
89
93
  ...notificationsApi(ctx),
90
94
  ...presetsApi(ctx),
@@ -22,6 +22,8 @@ export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string
22
22
  const blockId = computed(() => (open.value ? ui.resultView!.blockId : null))
23
23
  const instanceId = computed(() => (open.value ? ui.resultView!.instanceId : null))
24
24
  const stepIndex = computed(() => (open.value ? ui.resultView!.stepIndex : null))
25
+ // Set only for the brainstorm window (its two stages share one view id).
26
+ const stage = computed(() => (open.value ? (ui.resultView!.stage ?? null) : null))
25
27
 
26
28
  function close() {
27
29
  ui.closeResultView()
@@ -44,5 +46,5 @@ export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string
44
46
  )
45
47
  }
46
48
 
47
- return { open, blockId, instanceId, stepIndex, close }
49
+ return { open, blockId, instanceId, stepIndex, stage, close }
48
50
  }
@@ -23,6 +23,8 @@ export function useWorkspaceStream() {
23
23
  const requirements = useRequirementsStore()
24
24
  const consensus = useConsensusStore()
25
25
  const clarity = useClarityStore()
26
+ const brainstorm = useBrainstormStore()
27
+ const kaizen = useKaizenStore()
26
28
  const api = useApi()
27
29
  const apiBase = useRuntimeConfig().public.apiBase
28
30
 
@@ -87,6 +89,15 @@ export function useWorkspaceStream() {
87
89
  // cache so an open review window / inspector reflects it live ("incorporating…" → the
88
90
  // next cycle / converged). The summons back, when needed, arrives as a `notification`.
89
91
  clarity.upsert(event.review)
92
+ } else if (event.type === 'brainstorm') {
93
+ // The async incorporate + re-run cycle changed a brainstorm session's status — patch the
94
+ // cache so an open brainstorm window / inspector reflects it live.
95
+ brainstorm.upsert(event.session)
96
+ } else if (event.type === 'kaizen') {
97
+ // A post-run Kaizen grading was scheduled, started or completed — fold it into the
98
+ // run cache (so an open run window shows scheduled→running→complete live) and the
99
+ // Kaizen screen history. Never surfaced on the board.
100
+ kaizen.upsert(event.grading)
90
101
  }
91
102
  }
92
103
 
@@ -11,6 +11,7 @@ import DecisionModal from '~/components/panels/DecisionModal.vue'
11
11
  import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
12
12
  import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
13
13
  import ObservabilityPanel from '~/components/panels/ObservabilityPanel.vue'
14
+ import KaizenPanel from '~/components/kaizen/KaizenPanel.vue'
14
15
  import BlockFocusView from '~/components/focus/BlockFocusView.vue'
15
16
  import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
16
17
  import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
@@ -171,6 +172,7 @@ watch(
171
172
  <AgentStepDetail />
172
173
  <StepResultViewHost />
173
174
  <ObservabilityPanel />
175
+ <KaizenPanel />
174
176
  <DocumentSourceConnectModal />
175
177
  <DocumentImportModal />
176
178
  <SpawnPreviewModal />
@@ -0,0 +1,210 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type {
4
+ BrainstormSession,
5
+ BrainstormStage,
6
+ ResolveBrainstormExceededChoice,
7
+ ReviewItemStatus,
8
+ } from '~/types/brainstorm'
9
+ import { useWorkspaceStore } from '~/stores/workspace'
10
+
11
+ /**
12
+ * Brainstorm (structured-dialogue) state. On the pipeline path a brainstorm runs as an opt-in
13
+ * gate step: the run parks while the human picks / steers / dismisses the proposed options, then
14
+ * asks to incorporate. Incorporation + the re-run run ASYNCHRONOUSLY in the durable driver — the
15
+ * call returns at once (status `incorporating`) and the user goes back to the board; they are
16
+ * summoned again (a notification) only if the re-run yields options or hits the cap. The store is
17
+ * patched both from call responses and from live `brainstorm` stream events (see `upsert`).
18
+ *
19
+ * A block may have one live session per STAGE (`requirements` / `architecture`), so the cache is
20
+ * keyed by `${blockId}:${stage}`. `available` mirrors the backend's opt-in gate (a 503 hides the
21
+ * UI). Per-workspace; nothing is persisted client-side.
22
+ */
23
+ export const useBrainstormStore = defineStore('brainstorm', () => {
24
+ const api = useApi()
25
+ const workspace = useWorkspaceStore()
26
+
27
+ const key = (blockId: string, stage: BrainstormStage) => `${blockId}:${stage}`
28
+
29
+ /** null = unknown (not probed), true/false = feature on/off. */
30
+ const available = ref<boolean | null>(null)
31
+ /** The current session per `${blockId}:${stage}` (null = fetched, none exists). */
32
+ const sessions = ref<Record<string, BrainstormSession | null>>({})
33
+ /** `${blockId}:${stage}` keys whose agent is currently running (run / re-run). */
34
+ const running = ref<Set<string>>(new Set())
35
+ /** Session ids currently incorporating their picks. */
36
+ const incorporating = ref<Set<string>>(new Set())
37
+ /** `${blockId}:${stage}` keys whose current session is being fetched (the initial `load`). */
38
+ const loadingByKey = ref<Set<string>>(new Set())
39
+ const inFlight = new Map<string, Promise<void>>()
40
+
41
+ function sessionFor(blockId: string, stage: BrainstormStage): BrainstormSession | null {
42
+ return sessions.value[key(blockId, stage)] ?? null
43
+ }
44
+ /** The async background stage a session is in, or null (so the board can show "working"). */
45
+ function backgroundStage(
46
+ blockId: string,
47
+ stage: BrainstormStage,
48
+ ): 'incorporating' | 'reviewing' | null {
49
+ const status = sessions.value[key(blockId, stage)]?.status
50
+ return status === 'incorporating' || status === 'reviewing' ? status : null
51
+ }
52
+ function isRunning(blockId: string, stage: BrainstormStage): boolean {
53
+ return running.value.has(key(blockId, stage))
54
+ }
55
+ function isLoading(blockId: string, stage: BrainstormStage): boolean {
56
+ return loadingByKey.value.has(key(blockId, stage))
57
+ }
58
+ function isIncorporating(sessionId: string): boolean {
59
+ return incorporating.value.has(sessionId)
60
+ }
61
+
62
+ /** Options still needing a human (status `open`). */
63
+ function openCount(session: BrainstormSession): number {
64
+ return session.items.filter((i) => i.status === 'open').length
65
+ }
66
+ /** Options the human chose (a reply recorded), which the companion folds in. */
67
+ function answeredCount(session: BrainstormSession): number {
68
+ return session.items.filter((i) => i.status === 'answered' || i.status === 'resolved').length
69
+ }
70
+ /** Every option is settled (chosen or dismissed) — none still open. */
71
+ function allSettled(session: BrainstormSession): boolean {
72
+ return openCount(session) === 0
73
+ }
74
+ /** Incorporation is possible: all options settled AND at least one was chosen. */
75
+ function canIncorporate(session: BrainstormSession): boolean {
76
+ return allSettled(session) && answeredCount(session) > 0
77
+ }
78
+ /** Proceed (skip the companion) is possible: all options settled but none chosen. */
79
+ function canProceed(session: BrainstormSession): boolean {
80
+ return allSettled(session) && answeredCount(session) === 0
81
+ }
82
+
83
+ function store(session: BrainstormSession) {
84
+ sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
85
+ }
86
+
87
+ function withFlag(set: typeof running, k: string, on: boolean) {
88
+ const next = new Set(set.value)
89
+ if (on) next.add(k)
90
+ else next.delete(k)
91
+ set.value = next
92
+ }
93
+
94
+ /** Fetch the current session for a block + stage (probing the feature's availability). */
95
+ async function load(blockId: string, stage: BrainstormStage) {
96
+ if (!workspace.workspaceId) return
97
+ const k = key(blockId, stage)
98
+ const pending = inFlight.get(k)
99
+ if (pending) return pending
100
+ const promise = (async () => {
101
+ withFlag(loadingByKey, k, true)
102
+ try {
103
+ const session = await api.getBrainstorm(workspace.requireId(), blockId, stage)
104
+ available.value = true
105
+ sessions.value = { ...sessions.value, [k]: session }
106
+ } catch {
107
+ available.value = false
108
+ } finally {
109
+ withFlag(loadingByKey, k, false)
110
+ inFlight.delete(k)
111
+ }
112
+ })()
113
+ inFlight.set(k, promise)
114
+ return promise
115
+ }
116
+
117
+ /** Record a human's choice on one option. */
118
+ async function reply(session: BrainstormSession, itemId: string, text: string) {
119
+ store(await api.replyBrainstormItem(workspace.requireId(), session.id, itemId, text))
120
+ }
121
+
122
+ /** Set an option's status (dismiss / reopen). */
123
+ async function setItemStatus(
124
+ session: BrainstormSession,
125
+ itemId: string,
126
+ status: ReviewItemStatus,
127
+ ) {
128
+ store(await api.setBrainstormItemStatus(workspace.requireId(), session.id, itemId, status))
129
+ }
130
+
131
+ /**
132
+ * Ask the driver to incorporate the picks ASYNCHRONOUSLY. Optional `feedback` is the "do it
133
+ * differently" direction when redoing a merge. Returns at once with the `incorporating`
134
+ * session (the fold + re-run happen in the background).
135
+ */
136
+ async function incorporate(session: BrainstormSession, feedback?: string) {
137
+ withFlag(incorporating, session.id, true)
138
+ try {
139
+ const updated = await api.incorporateBrainstorm(
140
+ workspace.requireId(),
141
+ session.blockId,
142
+ session.stage,
143
+ feedback,
144
+ )
145
+ store(updated)
146
+ return updated
147
+ } finally {
148
+ withFlag(incorporating, session.id, false)
149
+ }
150
+ }
151
+
152
+ /** Re-run the brainstorm against the converged direction (one more pass; may converge/advance). */
153
+ async function reReview(blockId: string, stage: BrainstormStage): Promise<BrainstormSession> {
154
+ withFlag(running, key(blockId, stage), true)
155
+ try {
156
+ const updated = await api.reReviewBrainstorm(workspace.requireId(), blockId, stage)
157
+ store(updated)
158
+ return updated
159
+ } finally {
160
+ withFlag(running, key(blockId, stage), false)
161
+ }
162
+ }
163
+
164
+ /** Proceed: settle the brainstorm and advance the parked run. */
165
+ async function proceed(blockId: string, stage: BrainstormStage): Promise<BrainstormSession> {
166
+ const updated = await api.proceedBrainstorm(workspace.requireId(), blockId, stage)
167
+ store(updated)
168
+ return updated
169
+ }
170
+
171
+ /** Resolve a capped session: extra-round / proceed / stop-reset. */
172
+ async function resolveExceeded(
173
+ blockId: string,
174
+ stage: BrainstormStage,
175
+ choice: ResolveBrainstormExceededChoice,
176
+ ): Promise<BrainstormSession> {
177
+ const updated = await api.resolveBrainstormExceeded(
178
+ workspace.requireId(),
179
+ blockId,
180
+ stage,
181
+ choice,
182
+ )
183
+ store(updated)
184
+ return updated
185
+ }
186
+
187
+ return {
188
+ available,
189
+ sessions,
190
+ sessionFor,
191
+ backgroundStage,
192
+ isRunning,
193
+ isLoading,
194
+ isIncorporating,
195
+ openCount,
196
+ answeredCount,
197
+ allSettled,
198
+ canIncorporate,
199
+ canProceed,
200
+ load,
201
+ reply,
202
+ setItemStatus,
203
+ incorporate,
204
+ reReview,
205
+ proceed,
206
+ resolveExceeded,
207
+ // Patch the cache from a live `brainstorm` stream event.
208
+ upsert: store,
209
+ }
210
+ })