@cat-factory/app 0.31.0 → 0.32.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.
@@ -0,0 +1,257 @@
1
+ <script setup lang="ts">
2
+ // Follow-up companion window — the dedicated surface for the future-looking Coder's
3
+ // surfaced items, opened via the universal result-view host (`ui.openFollowUps`). It reads
4
+ // the live items straight off the run's Coder step (`step.followUps`, kept fresh by the
5
+ // execution stream — a synchronous window, no `onOpen` loader) and lets a human decide each:
6
+ // file a follow-up as a tracker issue, send it back to the Coder, answer a question, or
7
+ // dismiss it. The pipeline's following steps stay blocked until every item is decided.
8
+ import { computed, reactive } from 'vue'
9
+ import { useResultView } from '~/composables/useResultView'
10
+ import { useExecutionStore } from '~/stores/execution'
11
+ import { useBoardStore } from '~/stores/board'
12
+ import { useFollowUpsStore } from '~/stores/followUps'
13
+ import type { FollowUpItem } from '~/types/execution'
14
+ import { FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
15
+
16
+ const execution = useExecutionStore()
17
+ const board = useBoardStore()
18
+ const followUps = useFollowUpsStore()
19
+
20
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('follow-ups')
21
+
22
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
23
+ const instance = computed(() =>
24
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
25
+ )
26
+ const step = computed(() => {
27
+ if (instance.value === null || stepIndex.value === null) return null
28
+ return instance.value.steps[stepIndex.value] ?? null
29
+ })
30
+ const items = computed<FollowUpItem[]>(() => step.value?.followUps?.items ?? [])
31
+ const pendingCount = computed(() => items.value.filter((i) => i.status === 'pending').length)
32
+ const loops = computed(() => step.value?.followUps?.loops ?? 0)
33
+ const maxLoops = computed(() => step.value?.followUps?.maxLoops ?? 0)
34
+
35
+ // Draft answers per question item (keyed by item id), so typing doesn't clobber on re-render.
36
+ const drafts = reactive<Record<string, string>>({})
37
+
38
+ function execId(): string | null {
39
+ return instanceId.value
40
+ }
41
+
42
+ async function onFile(item: FollowUpItem) {
43
+ const id = execId()
44
+ if (id) await followUps.fileItem(id, item.id).catch(() => {})
45
+ }
46
+ async function onQueue(item: FollowUpItem) {
47
+ const id = execId()
48
+ if (id) await followUps.queueItem(id, item.id).catch(() => {})
49
+ }
50
+ async function onAnswer(item: FollowUpItem) {
51
+ const id = execId()
52
+ const answer = (drafts[item.id] ?? '').trim()
53
+ if (id && answer) await followUps.answerItem(id, item.id, answer).catch(() => {})
54
+ }
55
+ async function onDismiss(item: FollowUpItem) {
56
+ const id = execId()
57
+ if (id) await followUps.dismissItem(id, item.id).catch(() => {})
58
+ }
59
+
60
+ const STATUS_META: Record<
61
+ FollowUpItem['status'],
62
+ { label: string; badge: 'neutral' | 'info' | 'success' | 'warning'; text: string }
63
+ > = {
64
+ pending: { label: 'Needs a decision', badge: 'warning', text: 'text-amber-300' },
65
+ filed: { label: 'Filed as issue', badge: 'success', text: 'text-emerald-300' },
66
+ queued: { label: 'Sent to Coder', badge: 'info', text: 'text-sky-300' },
67
+ answered: { label: 'Answered', badge: 'info', text: 'text-sky-300' },
68
+ dismissed: { label: 'Dismissed', badge: 'neutral', text: 'text-slate-400' },
69
+ }
70
+ </script>
71
+
72
+ <template>
73
+ <Teleport to="body">
74
+ <div
75
+ v-if="open"
76
+ class="fixed inset-0 z-50 flex items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
77
+ @click.self="close"
78
+ >
79
+ <div
80
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
81
+ >
82
+ <!-- Header -->
83
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
84
+ <span
85
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-pink-500/15 text-pink-300"
86
+ >
87
+ <UIcon :name="FOLLOW_UP_COMPANION_META.icon" class="h-4 w-4" />
88
+ </span>
89
+ <div class="min-w-0 flex-1">
90
+ <h2 class="truncate text-sm font-semibold text-slate-100">
91
+ {{ FOLLOW_UP_COMPANION_META.label }}{{ block ? ` — ${block.title}` : '' }}
92
+ </h2>
93
+ <p class="truncate text-[11px] text-slate-400">
94
+ Forward-looking follow-ups & questions the Coder surfaced. The pipeline continues once
95
+ every item is decided.
96
+ </p>
97
+ </div>
98
+ <UBadge :color="pendingCount > 0 ? 'warning' : 'success'" variant="subtle" size="sm">
99
+ {{ pendingCount > 0 ? `${pendingCount} to decide` : 'All decided' }}
100
+ </UBadge>
101
+ <button
102
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
103
+ @click="close"
104
+ >
105
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
106
+ </button>
107
+ </header>
108
+
109
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
110
+ <!-- Empty -->
111
+ <div
112
+ v-if="items.length === 0"
113
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
114
+ >
115
+ <UIcon :name="FOLLOW_UP_COMPANION_META.icon" class="h-8 w-8 opacity-40" />
116
+ <p class="text-sm">No follow-ups yet.</p>
117
+ <p class="max-w-sm text-[11px] text-slate-500">
118
+ As the Coder works it streams loose ends, side-tasks and questions here. They appear
119
+ live — you can act on them before the Coder even finishes.
120
+ </p>
121
+ </div>
122
+
123
+ <div v-else class="space-y-3">
124
+ <p
125
+ v-if="followUps.error"
126
+ class="rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
127
+ >
128
+ {{ followUps.error }}
129
+ </p>
130
+
131
+ <article
132
+ v-for="item in items"
133
+ :key="item.id"
134
+ class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3"
135
+ :class="item.status === 'pending' ? 'border-amber-500/40' : ''"
136
+ >
137
+ <div class="flex items-start gap-2">
138
+ <UIcon
139
+ :name="item.kind === 'question' ? 'i-lucide-circle-help' : 'i-lucide-compass'"
140
+ class="mt-0.5 h-4 w-4 shrink-0"
141
+ :class="item.kind === 'question' ? 'text-sky-300' : 'text-pink-300'"
142
+ />
143
+ <div class="min-w-0 flex-1">
144
+ <div class="flex items-center gap-2">
145
+ <h3 class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-100">
146
+ {{ item.title }}
147
+ </h3>
148
+ <UBadge :color="STATUS_META[item.status].badge" variant="subtle" size="sm">
149
+ {{ STATUS_META[item.status].label }}
150
+ </UBadge>
151
+ </div>
152
+ <p v-if="item.detail" class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300">
153
+ {{ item.detail }}
154
+ </p>
155
+ <p v-if="item.suggestedAction" class="mt-1 text-[11px] text-slate-400">
156
+ <span class="text-slate-500">Suggested:</span> {{ item.suggestedAction }}
157
+ </p>
158
+ <p v-if="item.status === 'filed' && item.ticketUrl" class="mt-1 text-[11px]">
159
+ <a
160
+ :href="item.ticketUrl"
161
+ target="_blank"
162
+ rel="noopener"
163
+ class="text-emerald-300 hover:underline"
164
+ >
165
+ {{ item.ticketExternalId ?? 'View issue' }}
166
+ </a>
167
+ </p>
168
+ <p
169
+ v-if="item.status === 'answered' && item.answer"
170
+ class="mt-1 text-[11px] text-slate-300"
171
+ >
172
+ <span class="text-slate-500">Your answer:</span> {{ item.answer }}
173
+ </p>
174
+
175
+ <!-- Actions (only while the item is still undecided) -->
176
+ <div v-if="item.status === 'pending'" class="mt-2.5">
177
+ <!-- A question: answer it -->
178
+ <div v-if="item.kind === 'question'" class="space-y-2">
179
+ <textarea
180
+ v-model="drafts[item.id]"
181
+ rows="2"
182
+ placeholder="Answer this question — it's folded into the Coder's next pass…"
183
+ class="w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-sky-500 focus:outline-none"
184
+ />
185
+ <div class="flex items-center gap-2">
186
+ <UButton
187
+ size="xs"
188
+ color="primary"
189
+ :loading="followUps.isActing(item.id)"
190
+ :disabled="!(drafts[item.id] ?? '').trim()"
191
+ @click="onAnswer(item)"
192
+ >
193
+ Answer & send back
194
+ </UButton>
195
+ <UButton
196
+ size="xs"
197
+ color="neutral"
198
+ variant="ghost"
199
+ :loading="followUps.isActing(item.id)"
200
+ @click="onDismiss(item)"
201
+ >
202
+ Dismiss
203
+ </UButton>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- A follow-up: file / send back / dismiss -->
208
+ <div v-else class="flex flex-wrap items-center gap-2">
209
+ <UButton
210
+ size="xs"
211
+ color="primary"
212
+ icon="i-lucide-ticket"
213
+ :loading="followUps.isActing(item.id)"
214
+ @click="onFile(item)"
215
+ >
216
+ File as issue
217
+ </UButton>
218
+ <UButton
219
+ size="xs"
220
+ color="info"
221
+ variant="soft"
222
+ icon="i-lucide-corner-up-left"
223
+ :loading="followUps.isActing(item.id)"
224
+ @click="onQueue(item)"
225
+ >
226
+ Send to Coder
227
+ </UButton>
228
+ <UButton
229
+ size="xs"
230
+ color="neutral"
231
+ variant="ghost"
232
+ :loading="followUps.isActing(item.id)"
233
+ @click="onDismiss(item)"
234
+ >
235
+ Dismiss
236
+ </UButton>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </article>
242
+ </div>
243
+ </div>
244
+
245
+ <footer
246
+ class="flex items-center justify-between border-t border-slate-800 px-5 py-2.5 text-[11px] text-slate-400"
247
+ >
248
+ <span>
249
+ {{ items.length }} item{{ items.length === 1 ? '' : 's' }} ·
250
+ {{ pendingCount }} undecided
251
+ </span>
252
+ <span v-if="maxLoops > 0">Coder loops used: {{ loops }} / {{ maxLoops }}</span>
253
+ </footer>
254
+ </div>
255
+ </div>
256
+ </Teleport>
257
+ </template>
@@ -97,8 +97,8 @@ function statusLabel(g: KaizenGrading): string {
97
97
  <span class="text-xs font-normal text-slate-500">({{ kaizen.verifiedCount }})</span>
98
98
  </h2>
99
99
  <p class="mb-3 text-[11px] text-slate-500">
100
- A prompt + agent + model combination that graded 4 or 5 with no recommendations
101
- five times in a row. These are no longer graded.
100
+ A prompt + agent + model combination that graded 4 or 5 with no recommendations five
101
+ times in a row. These are no longer graded.
102
102
  </p>
103
103
  <ul class="space-y-2">
104
104
  <li
@@ -36,6 +36,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
36
36
  // Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
37
37
  // marks it read (the gate is resolved in that window — confirm / request a fix — not here).
38
38
  human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
39
+ // Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
40
+ // just marks it read (items are decided in that window — file / send back / answer — not here).
41
+ followup_pending: { icon: 'i-lucide-compass', color: 'warning', action: 'Mark read' },
39
42
  }
40
43
 
41
44
  /** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
@@ -81,9 +84,19 @@ function reveal(n: Notification) {
81
84
  else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
82
85
  else if (n.type === 'decision_required') revealDecision(n)
83
86
  else if (n.type === 'human_test_ready') revealHumanTest(n)
87
+ else if (n.type === 'followup_pending') revealFollowUps(n)
84
88
  else ui.select(n.blockId)
85
89
  }
86
90
 
91
+ /**
92
+ * Open the Follow-up companion window for a run whose Coder parked on undecided items.
93
+ * Falls back to focusing the block when the run isn't loaded.
94
+ */
95
+ function revealFollowUps(n: Notification) {
96
+ if (n.executionId && execution.getInstance(n.executionId)) ui.openFollowUps(n.executionId)
97
+ else if (n.blockId) ui.select(n.blockId)
98
+ }
99
+
87
100
  /**
88
101
  * Open the human-testing window for a parked `human-test` gate: find the run's parked
89
102
  * human-test step and open it through the universal step dispatch (its archetype declares
@@ -13,18 +13,22 @@
13
13
  import { computed, type Component } from 'vue'
14
14
  import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
15
  import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
+ import BrainstormWindow from '~/components/brainstorm/BrainstormWindow.vue'
16
17
  import TestReportWindow from '~/components/testing/TestReportWindow.vue'
17
18
  import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
18
19
  import GateResultView from '~/components/gates/GateResultView.vue'
19
20
  import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
20
21
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
21
22
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
23
+ import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
22
24
 
23
25
  const ui = useUiStore()
24
26
 
25
27
  const STEP_RESULT_VIEWS: Record<string, Component> = {
26
28
  'requirements-review': RequirementsReviewWindow,
27
29
  'clarity-review': ClarityReviewWindow,
30
+ // Shared by both brainstorm stages (requirements + architecture); the window reads the stage.
31
+ brainstorm: BrainstormWindow,
28
32
  tester: TestReportWindow,
29
33
  // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
30
34
  'human-test': HumanTestWindow,
@@ -38,6 +42,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
38
42
  // The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
39
43
  // Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
40
44
  'service-spec': ServiceSpecWindow,
45
+ // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
46
+ // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
47
+ 'follow-ups': FollowUpWindow,
41
48
  }
42
49
 
43
50
  const active = computed<Component | null>(() => {
@@ -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>
@@ -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
+ }
@@ -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,6 +6,7 @@ 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'
@@ -85,6 +86,7 @@ export function useApi() {
85
86
  ...documentsApi(ctx),
86
87
  ...tasksApi(ctx),
87
88
  ...reviewsApi(ctx),
89
+ ...followUpsApi(ctx),
88
90
  ...humanTestApi(ctx),
89
91
  ...kaizenApi(ctx),
90
92
  ...specApi(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,7 @@ export function useWorkspaceStream() {
23
23
  const requirements = useRequirementsStore()
24
24
  const consensus = useConsensusStore()
25
25
  const clarity = useClarityStore()
26
+ const brainstorm = useBrainstormStore()
26
27
  const kaizen = useKaizenStore()
27
28
  const api = useApi()
28
29
  const apiBase = useRuntimeConfig().public.apiBase
@@ -88,6 +89,10 @@ export function useWorkspaceStream() {
88
89
  // cache so an open review window / inspector reflects it live ("incorporating…" → the
89
90
  // next cycle / converged). The summons back, when needed, arrives as a `notification`.
90
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)
91
96
  } else if (event.type === 'kaizen') {
92
97
  // A post-run Kaizen grading was scheduled, started or completed — fold it into the
93
98
  // run cache (so an open run window shows scheduled→running→complete live) and the