@cat-factory/app 0.28.0 → 0.28.2

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.
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { DocumentSourceKind } from '~/types/domain'
3
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
3
4
 
4
5
  // Import pages from a connected document source and pick one to expand into
5
6
  // board structure. A source selector lets the user choose which connected source
@@ -74,6 +75,15 @@ function preview(externalId: string) {
74
75
 
75
76
  <template>
76
77
  <UModal v-model:open="open" title="Import from a document source">
78
+ <template #title>
79
+ <IntegrationBackTitle
80
+ title="Import from a document source"
81
+ @back="
82
+ open = false
83
+ ui.openIntegrations()
84
+ "
85
+ />
86
+ </template>
77
87
  <template #body>
78
88
  <div v-if="!documents.anyConnected" class="space-y-3 text-center">
79
89
  <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
@@ -4,6 +4,8 @@
4
4
  // same modal serves Confluence, Notion and any future source. Secret credentials
5
5
  // are write-only — the backend never returns them, so on reload we show
6
6
  // "Connected" with empty fields.
7
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
8
+
7
9
  const ui = useUiStore()
8
10
  const documents = useDocumentsStore()
9
11
  const toast = useToast()
@@ -77,6 +79,15 @@ async function disconnect() {
77
79
 
78
80
  <template>
79
81
  <UModal v-model:open="open" :title="descriptor?.label ?? 'Connect source'">
82
+ <template #title>
83
+ <IntegrationBackTitle
84
+ :title="descriptor?.label ?? 'Connect source'"
85
+ @back="
86
+ open = false
87
+ ui.openIntegrations()
88
+ "
89
+ />
90
+ </template>
80
91
  <template #body>
81
92
  <div v-if="descriptor" class="space-y-4">
82
93
  <p class="text-sm text-slate-400">
@@ -10,6 +10,7 @@ import type { GitHubPullRequest, GitHubRepo } from '~/types/domain'
10
10
  // tag, so it silently renders as an empty element. Importing it directly binds
11
11
  // the tag unambiguously.
12
12
  import GitHubConnect from './GitHubConnect.vue'
13
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
13
14
 
14
15
  const ui = useUiStore()
15
16
  const github = useGitHubStore()
@@ -199,6 +200,15 @@ async function merge(pr: GitHubPullRequest) {
199
200
 
200
201
  <template>
201
202
  <UModal v-model:open="open" title="GitHub" :ui="{ content: 'max-w-2xl' }">
203
+ <template #title>
204
+ <IntegrationBackTitle
205
+ title="GitHub"
206
+ @back="
207
+ open = false
208
+ ui.openIntegrations()
209
+ "
210
+ />
211
+ </template>
202
212
  <template #body>
203
213
  <div class="space-y-5">
204
214
  <!-- not connected: connect -->
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ // Title content for an integration sub-panel's modal header. Renders the panel
3
+ // title with a leading "back" control that returns to the Integrations hub, shown
4
+ // only when the panel was actually reached from there (`ui.cameFromIntegrations`).
5
+ // Panels opened from the command bar, sidebar, a banner or an inspector link don't
6
+ // grow a dead Back. Dropped into a UModal's #title slot, so it inherits the modal's
7
+ // title styling; it emits `back` and the host panel closes itself + reopens the hub.
8
+ defineProps<{ title?: string }>()
9
+ const emit = defineEmits<{ back: [] }>()
10
+ const ui = useUiStore()
11
+ </script>
12
+
13
+ <template>
14
+ <span class="flex items-center gap-1.5">
15
+ <UButton
16
+ v-if="ui.cameFromIntegrations"
17
+ icon="i-lucide-arrow-left"
18
+ color="neutral"
19
+ variant="ghost"
20
+ size="xs"
21
+ class="-ml-1.5 shrink-0"
22
+ aria-label="Back to Integrations"
23
+ @click.stop="emit('back')"
24
+ />
25
+ <span class="min-w-0 truncate">{{ title }}</span>
26
+ </span>
27
+ </template>
@@ -64,9 +64,10 @@ interface IntegrationGroup {
64
64
  }
65
65
 
66
66
  // Run an integration's open handler, then dismiss the hub so its panel takes over.
67
+ // `openFromIntegrations` also marks that the panel was reached from here, so the panel
68
+ // renders a "Back to Integrations" control (see IntegrationBackTitle).
67
69
  function go(fn: () => void) {
68
- fn()
69
- ui.closeIntegrations()
70
+ ui.openFromIntegrations(fn)
70
71
  }
71
72
 
72
73
  const groups = computed<IntegrationGroup[]>(() => {
@@ -7,6 +7,7 @@
7
7
  // (Claude, GLM, ChatGPT/Codex) are connected per-user in the Personal subscriptions section.
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import type { SubscriptionVendor } from '~/types/domain'
10
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
 
11
12
  const ui = useUiStore()
12
13
  const workspace = useWorkspaceStore()
@@ -116,6 +117,15 @@ function vendorLabel(v: SubscriptionVendor): string {
116
117
 
117
118
  <template>
118
119
  <UModal v-model:open="open" title="LLM Vendors" :ui="{ content: 'max-w-2xl' }">
120
+ <template #title>
121
+ <IntegrationBackTitle
122
+ title="LLM Vendors"
123
+ @back="
124
+ open = false
125
+ ui.openIntegrations()
126
+ "
127
+ />
128
+ </template>
119
129
  <template #body>
120
130
  <UTabs
121
131
  v-model="activeTab"
@@ -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
  >
@@ -7,6 +7,7 @@
7
7
  // automatically in the per-workspace model picker. One endpoint per runner type.
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import { LOCAL_RUNNER_DEFAULTS, LOCAL_RUNNER_LABELS, type LocalRunner } from '~/types/localModels'
10
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
 
11
12
  const ui = useUiStore()
12
13
  const store = useLocalModelsStore()
@@ -152,6 +153,15 @@ async function remove(p: LocalRunner) {
152
153
 
153
154
  <template>
154
155
  <UModal v-model:open="open" title="My local runners" :ui="{ content: 'max-w-2xl' }">
156
+ <template #title>
157
+ <IntegrationBackTitle
158
+ title="My local runners"
159
+ @back="
160
+ open = false
161
+ ui.openIntegrations()
162
+ "
163
+ />
164
+ </template>
155
165
  <template #body>
156
166
  <div class="space-y-4">
157
167
  <p class="text-xs text-slate-400">
@@ -6,6 +6,7 @@
6
6
  // block-id entry here. Opened from the Integrations hub.
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { ObservabilityProviderKind } from '~/types/releaseHealth'
9
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
9
10
 
10
11
  const ui = useUiStore()
11
12
  const store = useReleaseHealthStore()
@@ -128,6 +129,15 @@ const connectedLabel = computed(() => {
128
129
 
129
130
  <template>
130
131
  <UModal v-model:open="open" title="Post-release health" :ui="{ content: 'max-w-lg' }">
132
+ <template #title>
133
+ <IntegrationBackTitle
134
+ title="Post-release health"
135
+ @back="
136
+ open = false
137
+ ui.openIntegrations()
138
+ "
139
+ />
140
+ </template>
131
141
  <template #body>
132
142
  <div class="space-y-4">
133
143
  <p class="text-sm text-slate-400">
@@ -9,6 +9,7 @@
9
9
  // entry point for the key; this panel just makes OpenRouter self-sufficient.
10
10
  import { computed, ref, watch } from 'vue'
11
11
  import type { OpenRouterModelMeta } from '~/types/openrouter'
12
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
12
13
 
13
14
  const ui = useUiStore()
14
15
  const workspace = useWorkspaceStore()
@@ -193,6 +194,15 @@ function manageKeys() {
193
194
 
194
195
  <template>
195
196
  <UModal v-model:open="open" title="OpenRouter" :ui="{ content: 'max-w-2xl' }">
197
+ <template #title>
198
+ <IntegrationBackTitle
199
+ title="OpenRouter"
200
+ @back="
201
+ open = false
202
+ ui.openIntegrations()
203
+ "
204
+ />
205
+ </template>
196
206
  <template #body>
197
207
  <div class="space-y-4">
198
208
  <p class="text-xs text-slate-400">
@@ -9,6 +9,7 @@
9
9
  // with a `default` is optional — left blank it falls back to that default.
10
10
  import { computed, ref, watch } from 'vue'
11
11
  import type { ProviderConnectionKind } from '~/types/providerConnections'
12
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
12
13
 
13
14
  const ui = useUiStore()
14
15
  const store = useProviderConnectionsStore()
@@ -213,6 +214,15 @@ function fieldHelp(key: string): string | undefined {
213
214
 
214
215
  <template>
215
216
  <UModal v-model:open="open" :title="meta?.title ?? 'Provider'" :ui="{ content: 'max-w-xl' }">
217
+ <template #title>
218
+ <IntegrationBackTitle
219
+ :title="meta?.title ?? 'Provider'"
220
+ @back="
221
+ open = false
222
+ ui.openIntegrations()
223
+ "
224
+ />
225
+ </template>
216
226
  <template #body>
217
227
  <div v-if="descriptor" class="space-y-4">
218
228
  <p class="text-xs text-slate-400">{{ meta?.blurb }}</p>
@@ -6,6 +6,7 @@
6
6
  // access); the secret is write-only server-side and never shown again.
7
7
  import { computed, ref, watch } from 'vue'
8
8
  import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
9
10
 
10
11
  const ui = useUiStore()
11
12
  const store = useUserSecretsStore()
@@ -123,6 +124,15 @@ async function remove() {
123
124
 
124
125
  <template>
125
126
  <UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
127
+ <template #title>
128
+ <IntegrationBackTitle
129
+ title="My GitHub token"
130
+ @back="
131
+ open = false
132
+ ui.openIntegrations()
133
+ "
134
+ />
135
+ </template>
126
136
  <template #body>
127
137
  <div class="space-y-4">
128
138
  <p class="text-xs text-slate-400">
@@ -12,6 +12,7 @@ import type { CreateTaskType, TaskLimitMode, WorkspaceSettings } from '~/types/d
12
12
  import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
15
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
16
 
16
17
  const ui = useUiStore()
17
18
  const store = useWorkspaceSettingsStore()
@@ -160,6 +161,15 @@ async function saveBudget() {
160
161
 
161
162
  <template>
162
163
  <UModal v-model:open="open" title="Workspace settings" :ui="{ content: 'max-w-3xl' }">
164
+ <template #title>
165
+ <IntegrationBackTitle
166
+ title="Workspace settings"
167
+ @back="
168
+ open = false
169
+ ui.openIntegrations()
170
+ "
171
+ />
172
+ </template>
163
173
  <template #body>
164
174
  <UTabs
165
175
  v-model="activeTab"
@@ -7,6 +7,7 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { NotificationType } from '~/types/notifications'
9
9
  import type { SlackMemberMappingEntry, SlackMemberRole, SlackRoute } from '~/types/slack'
10
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
 
11
12
  const ui = useUiStore()
12
13
  const slack = useSlackStore()
@@ -141,6 +142,15 @@ async function saveMapping() {
141
142
 
142
143
  <template>
143
144
  <UModal v-model:open="open" title="Slack notifications" :ui="{ content: 'max-w-2xl' }">
145
+ <template #title>
146
+ <IntegrationBackTitle
147
+ title="Slack notifications"
148
+ @back="
149
+ open = false
150
+ ui.openIntegrations()
151
+ "
152
+ />
153
+ </template>
144
154
  <template #body>
145
155
  <div class="space-y-5">
146
156
  <p class="text-xs text-slate-400">
@@ -8,6 +8,7 @@
8
8
  // each row opens the issue on GitHub.
9
9
  import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
10
  import type { AddTaskPrefill } from '~/stores/ui'
11
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
12
 
12
13
  const ui = useUiStore()
13
14
  const tasks = useTasksStore()
@@ -195,6 +196,15 @@ async function doSpawnEpic() {
195
196
 
196
197
  <template>
197
198
  <UModal v-model:open="open" :title="title">
199
+ <template #title>
200
+ <IntegrationBackTitle
201
+ :title="title"
202
+ @back="
203
+ open = false
204
+ ui.openIntegrations()
205
+ "
206
+ />
207
+ </template>
198
208
  <template #body>
199
209
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
200
210
  <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
@@ -11,6 +11,8 @@
11
11
  // connected Jira without disconnecting it. The toggle only applies once a source is
12
12
  // available (Jira connected / the GitHub App installed) — there is nothing to offer
13
13
  // before that.
14
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
+
14
16
  const ui = useUiStore()
15
17
  const tasks = useTasksStore()
16
18
  const toast = useToast()
@@ -104,6 +106,15 @@ async function toggleEnabled(enabled: boolean) {
104
106
 
105
107
  <template>
106
108
  <UModal v-model:open="open" :title="descriptor?.label ?? 'Task source'">
109
+ <template #title>
110
+ <IntegrationBackTitle
111
+ :title="descriptor?.label ?? 'Task source'"
112
+ @back="
113
+ open = false
114
+ ui.openIntegrations()
115
+ "
116
+ />
117
+ </template>
107
118
  <template #body>
108
119
  <div v-if="descriptor" class="space-y-4">
109
120
  <p class="text-sm text-slate-400">
@@ -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 {
package/app/stores/ui.ts CHANGED
@@ -85,6 +85,11 @@ export const useUiStore = defineStore('ui', () => {
85
85
  // local runners, OpenRouter). Replaces the per-integration navbar buttons; each
86
86
  // row inside it opens that integration's own panel via the handlers below.
87
87
  const integrationsOpen = ref(false)
88
+ // True while an integration's own panel is showing AND it was reached from the hub
89
+ // (not the command bar, sidebar, a banner or an inspector link). Drives the "Back to
90
+ // Integrations" control those panels render: it only offers a return path when there
91
+ // is one. Every direct `open*` below resets it; `openFromIntegrations` sets it.
92
+ const cameFromIntegrations = ref(false)
88
93
 
89
94
  // Workspace-settings modal: a single tabbed window gathering the workspace-wide
90
95
  // config (workspace / merge thresholds / issue writeback / service best practices).
@@ -231,6 +236,7 @@ export const useUiStore = defineStore('ui', () => {
231
236
  }
232
237
 
233
238
  function openDocumentConnect(source: DocumentSourceKind) {
239
+ cameFromIntegrations.value = false
234
240
  documentConnect.value = { source }
235
241
  }
236
242
  function closeDocumentConnect() {
@@ -240,6 +246,7 @@ export const useUiStore = defineStore('ui', () => {
240
246
  targetFrameId: string | null = null,
241
247
  source: DocumentSourceKind | null = null,
242
248
  ) {
249
+ cameFromIntegrations.value = false
243
250
  documentImport.value = { source, targetFrameId }
244
251
  }
245
252
  function closeDocumentImport() {
@@ -256,12 +263,14 @@ export const useUiStore = defineStore('ui', () => {
256
263
  spawnPreview.value = null
257
264
  }
258
265
  function openTaskConnect(source: TaskSourceKind) {
266
+ cameFromIntegrations.value = false
259
267
  taskConnect.value = { source }
260
268
  }
261
269
  function closeTaskConnect() {
262
270
  taskConnect.value = null
263
271
  }
264
272
  function openTaskImport(source: TaskSourceKind | null = null, containerId: string | null = null) {
273
+ cameFromIntegrations.value = false
265
274
  taskImport.value = { source, containerId }
266
275
  }
267
276
  function closeTaskImport() {
@@ -294,12 +303,14 @@ export const useUiStore = defineStore('ui', () => {
294
303
  addServiceOpen.value = false
295
304
  }
296
305
  function openGitHub() {
306
+ cameFromIntegrations.value = false
297
307
  githubOpen.value = true
298
308
  }
299
309
  function closeGitHub() {
300
310
  githubOpen.value = false
301
311
  }
302
312
  function openSlack() {
313
+ cameFromIntegrations.value = false
303
314
  slackOpen.value = true
304
315
  }
305
316
  function closeSlack() {
@@ -321,12 +332,24 @@ export const useUiStore = defineStore('ui', () => {
321
332
  commandBarOpen.value = !commandBarOpen.value
322
333
  }
323
334
  function openIntegrations() {
335
+ // Reaching the hub itself (fresh, or via a panel's Back control) clears the
336
+ // came-from marker — we're at the hub, not inside a hub-spawned panel.
337
+ cameFromIntegrations.value = false
324
338
  integrationsOpen.value = true
325
339
  }
326
340
  function closeIntegrations() {
327
341
  integrationsOpen.value = false
328
342
  }
343
+ // Open an integration's own panel FROM the hub: run its open handler (which resets
344
+ // `cameFromIntegrations`), then mark that we came from the hub and dismiss it. The
345
+ // panel reads `cameFromIntegrations` to show its Back control.
346
+ function openFromIntegrations(open: () => void) {
347
+ open()
348
+ cameFromIntegrations.value = true
349
+ integrationsOpen.value = false
350
+ }
329
351
  function openWorkspaceSettings(tab = 'workspace') {
352
+ cameFromIntegrations.value = false
330
353
  workspaceSettingsTab.value = tab
331
354
  workspaceSettingsOpen.value = true
332
355
  }
@@ -337,12 +360,14 @@ export const useUiStore = defineStore('ui', () => {
337
360
  workspaceSettingsTab.value = tab
338
361
  }
339
362
  function openObservabilityConnection() {
363
+ cameFromIntegrations.value = false
340
364
  observabilityConnectionOpen.value = true
341
365
  }
342
366
  function closeObservabilityConnection() {
343
367
  observabilityConnectionOpen.value = false
344
368
  }
345
369
  function openProviderConnection(kind: 'environment' | 'runner-pool') {
370
+ cameFromIntegrations.value = false
346
371
  providerConnectionKind.value = kind
347
372
  }
348
373
  function closeProviderConnection() {
@@ -355,12 +380,14 @@ export const useUiStore = defineStore('ui', () => {
355
380
  modelConfigOpen.value = false
356
381
  }
357
382
  function openVendorCredentials() {
383
+ cameFromIntegrations.value = false
358
384
  vendorCredentialsOpen.value = true
359
385
  }
360
386
  function closeVendorCredentials() {
361
387
  vendorCredentialsOpen.value = false
362
388
  }
363
389
  function openLocalModels() {
390
+ cameFromIntegrations.value = false
364
391
  localModelsOpen.value = true
365
392
  }
366
393
  function closeLocalModels() {
@@ -373,12 +400,14 @@ export const useUiStore = defineStore('ui', () => {
373
400
  sandboxOpen.value = false
374
401
  }
375
402
  function openUserSecrets() {
403
+ cameFromIntegrations.value = false
376
404
  userSecretsOpen.value = true
377
405
  }
378
406
  function closeUserSecrets() {
379
407
  userSecretsOpen.value = false
380
408
  }
381
409
  function openOpenRouter() {
410
+ cameFromIntegrations.value = false
382
411
  openRouterOpen.value = true
383
412
  }
384
413
  function closeOpenRouter() {
@@ -465,6 +494,7 @@ export const useUiStore = defineStore('ui', () => {
465
494
  fragmentLibraryOpen,
466
495
  commandBarOpen,
467
496
  integrationsOpen,
497
+ cameFromIntegrations,
468
498
  workspaceSettingsOpen,
469
499
  workspaceSettingsTab,
470
500
  observabilityConnectionOpen,
@@ -524,6 +554,7 @@ export const useUiStore = defineStore('ui', () => {
524
554
  toggleCommandBar,
525
555
  openIntegrations,
526
556
  closeIntegrations,
557
+ openFromIntegrations,
527
558
  openWorkspaceSettings,
528
559
  closeWorkspaceSettings,
529
560
  setWorkspaceSettingsTab,
@@ -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.2",
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",