@cat-factory/app 0.15.0 → 0.16.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.
@@ -5,13 +5,14 @@
5
5
  // pipeline on it explicitly (and can keep editing it until they do).
6
6
  //
7
7
  // The form also shows ungated "Context documents" / "Context issues" sections
8
- // (mirroring the task inspector): pick already-imported docs/issues or open the
9
- // import flow to attach as agent context. When the relevant integration isn't
10
- // connected the Attach button is disabled with a hint. Linking needs the block id,
8
+ // (mirroring the task inspector): an inline search picker (ContextDocumentPicker /
9
+ // ContextIssuePicker) finds already-imported items, search hits, or a pasted ref to
10
+ // attach as agent context. When the relevant integration isn't connected the Attach
11
+ // button is disabled with a hint. Linking needs the block id,
11
12
  // so chosen items are staged locally and import-and-linked once the task is created
12
13
  // (see useContextLinking) — the same context the agents see for every step of the run.
13
- import type { DropdownMenuItem } from '@nuxt/ui'
14
14
  import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
15
+ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
15
16
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
16
17
 
17
18
  const ui = useUiStore()
@@ -200,41 +201,13 @@ function removePending(item: PendingContext) {
200
201
  pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
201
202
  }
202
203
 
203
- // Attach menus: already-imported items not yet chosen, plus the import entry — the
204
- // same affordances the inspector offers. Picking one stages it locally (it links
205
- // after the task is created).
206
- const docAttachMenu = computed<DropdownMenuItem[][]>(() => {
207
- const chosen = new Set(pendingContext.value.map(contextKey))
208
- const items: DropdownMenuItem[] = documents.documents
209
- .filter(
210
- (d) =>
211
- !chosen.has(contextKey({ kind: 'document', source: d.source, externalId: d.externalId })),
212
- )
213
- .map((d) => ({
214
- label: d.title,
215
- icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
216
- onSelect: () =>
217
- addPending({
218
- kind: 'document',
219
- source: d.source,
220
- externalId: d.externalId,
221
- title: d.title,
222
- icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
223
- needsImport: false,
224
- }),
225
- }))
226
- items.push({
227
- label: 'Import a page…',
228
- icon: 'i-lucide-file-down',
229
- onSelect: () => ui.openDocumentImport(null),
230
- })
231
- return [items]
232
- })
233
-
234
- // Context issues are picked through an inline search picker (ContextIssuePicker)
235
- // rather than a dropdown that opens a second modal — stacked page-level modals
236
- // don't interact here, which is why the old "Import an issue…" path appeared to
237
- // do nothing. The "Attach" button toggles the picker open.
204
+ // Context documents and issues are both picked through an inline search picker
205
+ // (ContextDocumentPicker / ContextIssuePicker) rather than a dropdown that opens a
206
+ // second modal stacked page-level modals don't interact here, which is why the
207
+ // old "Import a page…" / "Import an issue…" entries appeared to open something but
208
+ // nothing was clickable. The "Attach" button toggles the relevant picker open.
209
+ const showDocPicker = ref(false)
210
+ const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
238
211
  const showIssuePicker = ref(false)
239
212
  const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
240
213
 
@@ -255,6 +228,7 @@ watch(open, (isOpen) => {
255
228
  pipelineId.value = ''
256
229
  agentConfigValues.value = {}
257
230
  pendingContext.value = []
231
+ showDocPicker.value = false
258
232
  showIssuePicker.value = false
259
233
  documents.loadDocuments().catch(() => {})
260
234
  tasks.loadTasks().catch(() => {})
@@ -508,15 +482,16 @@ async function add() {
508
482
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
509
483
  Context documents
510
484
  </span>
511
- <UDropdownMenu
485
+ <UButton
512
486
  v-if="docsConnected"
513
- :items="docAttachMenu"
514
- :content="{ side: 'bottom', align: 'end' }"
487
+ color="neutral"
488
+ variant="soft"
489
+ size="xs"
490
+ :icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
491
+ @click="showDocPicker = !showDocPicker"
515
492
  >
516
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">
517
- Attach
518
- </UButton>
519
- </UDropdownMenu>
493
+ {{ showDocPicker ? 'Done' : 'Attach' }}
494
+ </UButton>
520
495
  <UButton
521
496
  v-else
522
497
  color="neutral"
@@ -533,6 +508,11 @@ async function add() {
533
508
  Attach
534
509
  </UButton>
535
510
  </div>
511
+ <ContextDocumentPicker
512
+ v-if="showDocPicker && docsConnected"
513
+ :chosen-keys="chosenDocKeys"
514
+ @pick="addPending"
515
+ />
536
516
  <div v-if="pendingDocs.length" class="space-y-1">
537
517
  <div
538
518
  v-for="item in pendingDocs"
@@ -0,0 +1,245 @@
1
+ <script setup lang="ts">
2
+ // Inline picker for attaching a document (Confluence / Notion / GitHub page) as
3
+ // task context. It searches the connected source by free text, lists
4
+ // already-imported documents for quick re-use, and accepts a pasted URL/ID as a
5
+ // reference — all inline, with NO second modal (stacked page-level modals don't
6
+ // interact here, which is why the old "Import a page…" path appeared to open
7
+ // something but nothing was clickable). It only *stages* a choice: the caller
8
+ // collects PendingContext items and links them once the block exists (see
9
+ // useContextLinking). A search hit / pasted ref carries `needsImport: true` so
10
+ // it's fetched + persisted before linking. Mirrors ContextIssuePicker.
11
+ import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
12
+
13
+ const props = defineProps<{
14
+ /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
15
+ chosenKeys?: string[]
16
+ }>()
17
+ const emit = defineEmits<{ pick: [item: PendingContext] }>()
18
+
19
+ const documents = useDocumentsStore()
20
+
21
+ const chosen = computed(() => new Set(props.chosenKeys ?? []))
22
+
23
+ // Source: default to the first connected source; a selector appears only when
24
+ // more than one is connected (the common case is a single source).
25
+ const source = ref<DocumentSourceKind | undefined>(documents.connectedSources[0]?.source)
26
+ const sourceItems = computed(() =>
27
+ documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
28
+ )
29
+ const descriptor = computed(() =>
30
+ source.value ? documents.descriptorFor(source.value) : undefined,
31
+ )
32
+ const searchable = computed(() => descriptor.value?.searchable ?? false)
33
+
34
+ const query = ref('')
35
+ const results = ref<DocumentSearchResult[]>([])
36
+ const searching = ref(false)
37
+ const searchError = ref<string | null>(null)
38
+
39
+ // Debounced search: free text hits the source; a query that's clearly a URL/ID
40
+ // is left to the explicit "by reference" row below (search won't surface it).
41
+ let timer: ReturnType<typeof setTimeout> | undefined
42
+ watch([query, source], () => {
43
+ if (timer) clearTimeout(timer)
44
+ results.value = []
45
+ searchError.value = null
46
+ const q = query.value.trim()
47
+ if (!q || !searchable.value) return
48
+ timer = setTimeout(runSearch, 300)
49
+ })
50
+
51
+ async function runSearch() {
52
+ const q = query.value.trim()
53
+ if (!q || !source.value) return
54
+ searching.value = true
55
+ searchError.value = null
56
+ try {
57
+ results.value = await documents.search(source.value, q)
58
+ } catch (e) {
59
+ results.value = []
60
+ searchError.value = e instanceof Error ? e.message : String(e)
61
+ } finally {
62
+ searching.value = false
63
+ }
64
+ }
65
+
66
+ const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-file-text')
67
+
68
+ function keyFor(externalId: string): string {
69
+ return source.value
70
+ ? contextKey({ kind: 'document', source: source.value, externalId })
71
+ : `document::${externalId}`
72
+ }
73
+
74
+ // Already-imported documents for this source, filtered by the query and never
75
+ // re-offering one the caller already staged.
76
+ const importedRows = computed(() => {
77
+ if (!source.value) return []
78
+ const q = query.value.trim().toLowerCase()
79
+ return documents.documents
80
+ .filter((d) => d.source === source.value)
81
+ .filter((d) => !chosen.value.has(keyFor(d.externalId)))
82
+ .filter((d) => !q || d.title.toLowerCase().includes(q) || d.excerpt.toLowerCase().includes(q))
83
+ })
84
+
85
+ // Search hits not already imported (those show in importedRows) and not staged.
86
+ const searchRows = computed(() => {
87
+ if (!source.value) return []
88
+ const importedIds = new Set(
89
+ documents.documents.filter((d) => d.source === source.value).map((d) => d.externalId),
90
+ )
91
+ return results.value
92
+ .filter((r) => !importedIds.has(r.externalId))
93
+ .filter((r) => !chosen.value.has(keyFor(r.externalId)))
94
+ })
95
+
96
+ // A pasted URL / ID the search won't match: offer it as an explicit reference.
97
+ // When the source isn't searchable, any non-empty query is treated as a ref (the
98
+ // only way to attach a page there, mirroring the import modal's single input).
99
+ const refRow = computed(() => {
100
+ const q = query.value.trim()
101
+ if (!q || !source.value) return null
102
+ const known =
103
+ importedRows.value.some((d) => d.externalId === q) ||
104
+ searchRows.value.some((r) => r.externalId === q) ||
105
+ chosen.value.has(keyFor(q))
106
+ if (known) return null
107
+ if (!searchable.value) return q
108
+ // Only worth offering when it looks like a reference, not a search phrase.
109
+ const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
110
+ return looksLikeRef ? q : null
111
+ })
112
+
113
+ const empty = computed(
114
+ () =>
115
+ !searching.value &&
116
+ !searchError.value &&
117
+ importedRows.value.length === 0 &&
118
+ searchRows.value.length === 0 &&
119
+ refRow.value === null,
120
+ )
121
+
122
+ function pickImported(externalId: string, title: string, excerpt: string) {
123
+ if (!source.value) return
124
+ emit('pick', {
125
+ kind: 'document',
126
+ source: source.value,
127
+ externalId,
128
+ title,
129
+ subtitle: excerpt || undefined,
130
+ icon: icon.value,
131
+ needsImport: false,
132
+ })
133
+ }
134
+
135
+ function pickSearch(r: DocumentSearchResult) {
136
+ emit('pick', {
137
+ kind: 'document',
138
+ source: r.source,
139
+ externalId: r.externalId,
140
+ title: r.title,
141
+ subtitle: r.excerpt || undefined,
142
+ icon: icon.value,
143
+ needsImport: true,
144
+ })
145
+ }
146
+
147
+ function pickRef(q: string) {
148
+ if (!source.value) return
149
+ emit('pick', {
150
+ kind: 'document',
151
+ source: source.value,
152
+ externalId: q,
153
+ title: q,
154
+ subtitle: descriptor.value?.label,
155
+ icon: icon.value,
156
+ needsImport: true,
157
+ })
158
+ query.value = ''
159
+ }
160
+
161
+ onMounted(() => {
162
+ // Keep the quick-pick list current (cheap; the store dedupes).
163
+ documents.loadDocuments().catch(() => {})
164
+ })
165
+ </script>
166
+
167
+ <template>
168
+ <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
169
+ <USelect
170
+ v-if="sourceItems.length > 1"
171
+ v-model="source"
172
+ :items="sourceItems"
173
+ size="xs"
174
+ class="w-full"
175
+ />
176
+
177
+ <UInput
178
+ v-model="query"
179
+ :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
180
+ :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
181
+ size="sm"
182
+ class="w-full"
183
+ :placeholder="
184
+ searchable
185
+ ? 'Search pages or paste a URL/ID…'
186
+ : (descriptor?.refPlaceholder ?? 'Paste a page URL or ID…')
187
+ "
188
+ @keydown.enter="refRow && pickRef(refRow)"
189
+ />
190
+
191
+ <p v-if="searchError" class="px-1 text-[11px] text-amber-400">
192
+ Search failed: {{ searchError }}
193
+ </p>
194
+
195
+ <div class="max-h-56 space-y-0.5 overflow-y-auto">
196
+ <!-- Already-imported documents (linked directly, no re-fetch). -->
197
+ <button
198
+ v-for="d in importedRows"
199
+ :key="`imp:${d.externalId}`"
200
+ type="button"
201
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
202
+ @click="pickImported(d.externalId, d.title, d.excerpt)"
203
+ >
204
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
205
+ <span class="truncate">{{ d.title }}</span>
206
+ <UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">imported</UBadge>
207
+ </button>
208
+
209
+ <!-- Source search hits (imported on add). -->
210
+ <button
211
+ v-for="r in searchRows"
212
+ :key="`hit:${r.externalId}`"
213
+ type="button"
214
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
215
+ @click="pickSearch(r)"
216
+ >
217
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
218
+ <span class="truncate">{{ r.title }}</span>
219
+ </button>
220
+
221
+ <!-- Explicit URL/ID reference (imported on add). -->
222
+ <button
223
+ v-if="refRow"
224
+ type="button"
225
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
226
+ @click="pickRef(refRow)"
227
+ >
228
+ <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
229
+ <span class="truncate"
230
+ >Attach <span class="text-slate-200">{{ refRow }}</span> by reference</span
231
+ >
232
+ </button>
233
+
234
+ <p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
235
+ {{
236
+ query.trim()
237
+ ? 'No matching pages.'
238
+ : searchable
239
+ ? 'Search by title, or pick an imported document.'
240
+ : 'Paste a page URL or ID to attach it.'
241
+ }}
242
+ </p>
243
+ </div>
244
+ </div>
245
+ </template>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ // Persistent prompt that AI isn't ready, mirroring GitHubPatBanner. Two states, in priority
3
+ // order: (1) no usable model source at all → "Configure AI" reopens the onboarding dialog;
4
+ // (2) usable models exist but the default model preset points at unavailable ones → a milder
5
+ // warning reopening the preset-mismatch dialog. Dismissible per session (the dismissed flags
6
+ // live on the ui store, so the auto-open watcher and this banner share one source of truth).
7
+ import { computed } from 'vue'
8
+
9
+ const ui = useUiStore()
10
+ const { ready, hasUsableModel, defaultPresetBroken } = useAiReadiness()
11
+
12
+ const showSetup = computed(() => ready.value && !hasUsableModel.value && !ui.aiSetupDismissed)
13
+ const showPreset = computed(() => ready.value && defaultPresetBroken.value && !ui.aiPresetDismissed)
14
+ // The no-AI prompt owns the screen when nothing works; the preset prompt is secondary.
15
+ const show = computed(() => showSetup.value || showPreset.value)
16
+ </script>
17
+
18
+ <template>
19
+ <Transition name="fade">
20
+ <div v-if="show" class="absolute inset-x-0 top-0 z-40 flex justify-center px-4 pt-4">
21
+ <!-- (1) No usable AI source -->
22
+ <div
23
+ v-if="showSetup"
24
+ class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
25
+ role="alert"
26
+ >
27
+ <div class="flex items-start gap-4">
28
+ <UIcon name="i-lucide-cpu" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
29
+ <div class="min-w-0 flex-1">
30
+ <div class="flex items-start justify-between gap-3">
31
+ <h2 class="text-lg font-semibold text-amber-100">No AI model configured</h2>
32
+ <UButton
33
+ color="neutral"
34
+ variant="ghost"
35
+ size="xs"
36
+ icon="i-lucide-x"
37
+ aria-label="Dismiss"
38
+ @click="ui.dismissAiSetup()"
39
+ />
40
+ </div>
41
+ <p class="mt-1 text-sm text-amber-200/90">
42
+ Agents need a model to run. AI works out of the box only on a Cloudflare deployment
43
+ with Workers AI enabled — otherwise connect a provider key, subscription, proxy, or
44
+ local runner to continue.
45
+ </p>
46
+ <div class="mt-4">
47
+ <UButton
48
+ color="warning"
49
+ variant="solid"
50
+ icon="i-lucide-settings"
51
+ @click="ui.openAiProviderSetup()"
52
+ >
53
+ Configure AI
54
+ </UButton>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- (2) Default preset references unavailable models -->
61
+ <div
62
+ v-else
63
+ class="w-full max-w-3xl rounded-2xl border border-amber-500/50 bg-amber-950/90 p-4 shadow-xl backdrop-blur"
64
+ role="alert"
65
+ >
66
+ <div class="flex items-start gap-3">
67
+ <UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-7 w-7 shrink-0 text-amber-400" />
68
+ <div class="min-w-0 flex-1">
69
+ <div class="flex items-start justify-between gap-3">
70
+ <h2 class="text-sm font-semibold text-amber-100">
71
+ Default model preset uses unavailable models
72
+ </h2>
73
+ <UButton
74
+ color="neutral"
75
+ variant="ghost"
76
+ size="xs"
77
+ icon="i-lucide-x"
78
+ aria-label="Dismiss"
79
+ @click="ui.dismissAiPresetMismatch()"
80
+ />
81
+ </div>
82
+ <p class="mt-1 text-[13px] text-amber-200/90">
83
+ Tasks using the default preset would fail. Edit the preset or configure the missing
84
+ provider.
85
+ </p>
86
+ <div class="mt-3">
87
+ <UButton
88
+ size="sm"
89
+ color="warning"
90
+ variant="solid"
91
+ icon="i-lucide-cpu"
92
+ @click="ui.openAiPresetMismatch()"
93
+ >
94
+ Review preset
95
+ </UButton>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </Transition>
102
+ </template>
103
+
104
+ <style scoped>
105
+ .fade-enter-active,
106
+ .fade-leave-active {
107
+ transition: opacity 0.2s ease;
108
+ }
109
+ .fade-enter-from,
110
+ .fade-leave-to {
111
+ opacity: 0;
112
+ }
113
+ </style>
@@ -8,9 +8,12 @@ const props = defineProps<{ block: Block }>()
8
8
  const board = useBoardStore()
9
9
  const mergePresets = useMergePresetsStore()
10
10
  const modelPresets = useModelPresetsStore()
11
+ const models = useModelsStore()
11
12
  const pipelines = usePipelinesStore()
12
13
  const accounts = useAccountsStore()
13
14
  const tracker = useTrackerStore()
15
+ const ui = useUiStore()
16
+ const { ready, unavailableInPreset } = useAiReadiness()
14
17
 
15
18
  // ---- responsible product person --------------------------------------------
16
19
  // The account member (a `product` role-holder) accountable for this task; they are
@@ -73,6 +76,17 @@ function setPreset(id: string) {
73
76
  // (a running step keeps the model it was dispatched with). A model pinned directly on
74
77
  // the task still overrides the preset.
75
78
  const selectedModelPreset = computed(() => modelPresets.resolve(props.block.modelPresetId))
79
+
80
+ // Model ids in the chosen preset that aren't usable under the current configuration — the
81
+ // task would fail when a step dispatches onto one. Labelled for the inline warning below.
82
+ // Gated on `ready`: until the per-workspace catalog has loaded, `isUsableId` reports every
83
+ // model unusable, which would surface a spurious "this task would fail" warning (e.g. while
84
+ // the catalog fetch is in flight, or if it failed) — so only flag once the catalog is known.
85
+ const unavailablePresetModels = computed(() =>
86
+ ready.value
87
+ ? unavailableInPreset(selectedModelPreset.value).map((id) => models.labelForId(id))
88
+ : [],
89
+ )
76
90
  const modelPresetMenu = computed(() => [
77
91
  [
78
92
  {
@@ -243,6 +257,38 @@ const resolveOnMergeLabel = computed(() =>
243
257
  <div v-else class="text-[11px] text-slate-500">
244
258
  No preset configured — agents run on the deployment's default routing.
245
259
  </div>
260
+ <div
261
+ v-if="unavailablePresetModels.length"
262
+ class="mt-2 rounded-md border border-amber-500/40 bg-amber-950/40 p-2 text-[11px] text-amber-200/90"
263
+ >
264
+ <div class="flex items-start gap-1.5">
265
+ <UIcon
266
+ name="i-lucide-triangle-alert"
267
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400"
268
+ />
269
+ <div class="min-w-0">
270
+ <p>
271
+ Not available under the current configuration:
272
+ <span class="text-amber-100">{{ unavailablePresetModels.join(', ') }}</span
273
+ >. This task would fail on those steps.
274
+ </p>
275
+ <div class="mt-1.5 flex flex-wrap gap-2">
276
+ <button
277
+ class="font-medium text-amber-100 underline-offset-2 hover:underline"
278
+ @click="ui.openModelConfig()"
279
+ >
280
+ Edit presets
281
+ </button>
282
+ <button
283
+ class="font-medium text-amber-100 underline-offset-2 hover:underline"
284
+ @click="ui.openVendorCredentials()"
285
+ >
286
+ Configure vendors
287
+ </button>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ </div>
246
292
  <p class="mt-1 text-[11px] text-slate-500">
247
293
  Changing this affects only steps that haven't started yet.
248
294
  </p>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ // Shown when the workspace HAS usable AI models but its DEFAULT model preset still points
3
+ // at one or more that aren't usable under the current configuration (e.g. the built-in
4
+ // "Kimi K2.7" default with no Kimi source connected). Tasks fall back to the default
5
+ // preset, so they would dispatch onto an unavailable model and fail. This dialog names the
6
+ // offending models and offers to edit/switch the preset or configure more vendors. It
7
+ // auto-opens once per session (driven from pages/index.vue) and clears once the preset is
8
+ // fixed (or all its models become available).
9
+ import { computed } from 'vue'
10
+
11
+ const ui = useUiStore()
12
+ const models = useModelsStore()
13
+ const modelPresets = useModelPresetsStore()
14
+ const { defaultPresetUnavailable } = useAiReadiness()
15
+
16
+ const open = computed({
17
+ get: () => ui.aiPresetMismatchOpen,
18
+ set: (v: boolean) => (v ? ui.openAiPresetMismatch() : ui.closeAiPresetMismatch()),
19
+ })
20
+
21
+ const presetName = computed(() => modelPresets.defaultPreset?.name ?? 'default preset')
22
+
23
+ /** Readable labels for the unavailable model ids (catalog label, else the raw id). */
24
+ const unavailableLabels = computed(() =>
25
+ defaultPresetUnavailable.value.map((id) => models.labelForId(id)),
26
+ )
27
+
28
+ function go(action: () => void) {
29
+ ui.closeAiPresetMismatch()
30
+ action()
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <UModal v-model:open="open" title="Preset uses unavailable models" :ui="{ content: 'max-w-xl' }">
36
+ <template #body>
37
+ <div class="space-y-5">
38
+ <p class="text-sm text-slate-300">
39
+ The workspace default model preset
40
+ <span class="font-medium text-slate-100">“{{ presetName }}”</span>
41
+ assigns models that aren't available under the current configuration. Tasks that use this
42
+ preset would fail when they reach those steps.
43
+ </p>
44
+
45
+ <div class="rounded-lg border border-slate-700 bg-slate-900/50 p-3">
46
+ <p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
47
+ Unavailable
48
+ </p>
49
+ <div class="flex flex-wrap gap-1.5">
50
+ <UBadge
51
+ v-for="label in unavailableLabels"
52
+ :key="label"
53
+ color="warning"
54
+ variant="subtle"
55
+ size="sm"
56
+ >
57
+ {{ label }}
58
+ </UBadge>
59
+ </div>
60
+ </div>
61
+
62
+ <p class="text-[13px] text-slate-400">
63
+ Either repoint the preset at models you have configured, or add the missing provider.
64
+ </p>
65
+
66
+ <div class="flex flex-wrap justify-end gap-2">
67
+ <UButton color="neutral" variant="ghost" size="sm" @click="open = false"> Later </UButton>
68
+ <UButton
69
+ color="neutral"
70
+ variant="subtle"
71
+ size="sm"
72
+ icon="i-lucide-key-round"
73
+ @click="go(ui.openVendorCredentials)"
74
+ >
75
+ Configure vendors
76
+ </UButton>
77
+ <UButton color="primary" size="sm" icon="i-lucide-cpu" @click="go(ui.openModelConfig)">
78
+ Edit presets
79
+ </UButton>
80
+ </div>
81
+ </div>
82
+ </template>
83
+ </UModal>
84
+ </template>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ // Shown when the workspace has NO usable AI model source. cat-factory only ships a working
3
+ // model out of the box on a Cloudflare deployment with Workers AI enabled; every other
4
+ // deployment needs the user to onboard at least one source. This dialog explains that and
5
+ // routes to each configuration surface (reusing the existing credential panels rather than
6
+ // duplicating them). It auto-opens once per session (driven from pages/index.vue) and, like
7
+ // the banner, disappears automatically the moment a usable source exists.
8
+ import { computed } from 'vue'
9
+
10
+ const ui = useUiStore()
11
+
12
+ const open = computed({
13
+ get: () => ui.aiProviderSetupOpen,
14
+ set: (v: boolean) => (v ? ui.openAiProviderSetup() : ui.closeAiProviderSetup()),
15
+ })
16
+
17
+ // Each route closes this dialog first so we never stack two modals, then opens the target
18
+ // panel. All of these panels are mounted in pages/index.vue alongside this one.
19
+ function go(action: () => void) {
20
+ ui.closeAiProviderSetup()
21
+ action()
22
+ }
23
+
24
+ interface Route {
25
+ icon: string
26
+ title: string
27
+ body: string
28
+ cta: string
29
+ onSelect: () => void
30
+ }
31
+
32
+ const routes = computed<Route[]>(() => [
33
+ {
34
+ icon: 'i-lucide-key-round',
35
+ title: 'Provider keys & subscriptions',
36
+ body: 'Add a direct provider API key (OpenAI, Anthropic, Qwen, …) or connect a commercial coding-plan subscription (Kimi, DeepSeek) or a personal one (Claude, GLM, Codex).',
37
+ cta: 'Open LLM vendors',
38
+ onSelect: () => go(ui.openVendorCredentials),
39
+ },
40
+ {
41
+ icon: 'i-lucide-route',
42
+ title: 'OpenRouter gateway',
43
+ body: 'Enable models through the OpenRouter gateway with a single key — browse and turn on the models you want.',
44
+ cta: 'Browse OpenRouter models',
45
+ onSelect: () => go(ui.openOpenRouter),
46
+ },
47
+ {
48
+ icon: 'i-lucide-server',
49
+ title: 'My local runners',
50
+ body: 'Point cat-factory at a model you run yourself (Ollama, LM Studio, llama.cpp, vLLM, …). No API key, no spend.',
51
+ cta: 'Configure local runners',
52
+ onSelect: () => go(ui.openLocalModels),
53
+ },
54
+ ])
55
+ </script>
56
+
57
+ <template>
58
+ <UModal v-model:open="open" title="Set up an AI model provider" :ui="{ content: 'max-w-2xl' }">
59
+ <template #body>
60
+ <div class="space-y-5">
61
+ <div
62
+ class="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-950/40 p-4"
63
+ >
64
+ <UIcon name="i-lucide-cpu" class="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
65
+ <div class="min-w-0 text-sm text-amber-100/90">
66
+ <p class="font-medium text-amber-100">
67
+ No AI model is available on this workspace yet.
68
+ </p>
69
+ <p class="mt-1">
70
+ Agents need a model to run. AI works out of the box only on a Cloudflare deployment
71
+ with Workers AI enabled — otherwise connect at least one source below.
72
+ </p>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="space-y-3">
77
+ <div
78
+ v-for="r in routes"
79
+ :key="r.title"
80
+ class="flex items-start gap-3 rounded-xl border border-slate-700 bg-slate-900/50 p-4"
81
+ >
82
+ <UIcon :name="r.icon" class="mt-0.5 h-5 w-5 shrink-0 text-indigo-300" />
83
+ <div class="min-w-0 flex-1">
84
+ <p class="text-sm font-semibold text-slate-100">{{ r.title }}</p>
85
+ <p class="mt-0.5 text-[13px] leading-relaxed text-slate-400">{{ r.body }}</p>
86
+ </div>
87
+ <UButton
88
+ size="sm"
89
+ color="primary"
90
+ variant="subtle"
91
+ class="shrink-0"
92
+ @click="r.onSelect()"
93
+ >
94
+ {{ r.cta }}
95
+ </UButton>
96
+ </div>
97
+ </div>
98
+
99
+ <p class="text-[11px] leading-relaxed text-slate-500">
100
+ AWS Bedrock and Cloudflare Workers AI are enabled by the deployment operator via
101
+ environment configuration, not from this screen.
102
+ </p>
103
+ </div>
104
+ </template>
105
+ </UModal>
106
+ </template>
@@ -0,0 +1,62 @@
1
+ import type { ModelPreset } from '~/types/model-presets'
2
+
3
+ /**
4
+ * AI-configuration readiness for the active workspace. cat-factory only ships a usable
5
+ * AI model out of the box on a Cloudflare deployment with Workers AI enabled; every other
6
+ * deployment needs the user to onboard at least one source (a provider key, a pooled /
7
+ * personal subscription, a proxy, Bedrock, or a local runner). This composable turns the
8
+ * per-workspace model catalog (whose `available` flag already reflects all of those) plus
9
+ * the workspace's model presets into the two signals the onboarding surfaces consume:
10
+ *
11
+ * - `hasUsableModel` — is ANY AI source configured at all? (false ⇒ the no-AI prompt)
12
+ * - `defaultPresetBroken` — the workspace has usable models, but its DEFAULT model preset
13
+ * still points at one or more that aren't usable (⇒ the preset-mismatch prompt). Gated on
14
+ * `hasUsableModel` so the no-AI prompt owns the "nothing works" case on its own.
15
+ *
16
+ * Read-only over the existing stores; the catalog is loaded elsewhere (on workspace-ready
17
+ * and after credential edits), so `ready` simply reports whether that load has landed for
18
+ * the active workspace.
19
+ */
20
+ export function useAiReadiness() {
21
+ const models = useModelsStore()
22
+ const modelPresets = useModelPresetsStore()
23
+ const workspace = useWorkspaceStore()
24
+
25
+ /** The per-workspace catalog has loaded for the workspace currently open. */
26
+ const ready = computed(
27
+ () =>
28
+ models.loaded &&
29
+ workspace.workspaceId != null &&
30
+ models.loadedWorkspaceId === workspace.workspaceId,
31
+ )
32
+
33
+ const hasUsableModel = computed(() => models.hasUsableModel)
34
+
35
+ /** Every distinct catalog model id a preset assigns (base + overrides). */
36
+ function presetModelIds(preset: ModelPreset | null): string[] {
37
+ if (!preset) return []
38
+ return [...new Set([preset.baseModelId, ...Object.values(preset.overrides)])]
39
+ }
40
+
41
+ /** The preset's assigned model ids that aren't usable under the current configuration. */
42
+ function unavailableInPreset(preset: ModelPreset | null): string[] {
43
+ return presetModelIds(preset).filter((id) => !models.isUsableId(id))
44
+ }
45
+
46
+ /** Unusable model ids in the workspace DEFAULT preset (what tasks fall back to). */
47
+ const defaultPresetUnavailable = computed(() => unavailableInPreset(modelPresets.defaultPreset))
48
+
49
+ /** Usable models exist, but the default preset still references unusable ones. */
50
+ const defaultPresetBroken = computed(
51
+ () => hasUsableModel.value && defaultPresetUnavailable.value.length > 0,
52
+ )
53
+
54
+ return {
55
+ ready,
56
+ hasUsableModel,
57
+ presetModelIds,
58
+ unavailableInPreset,
59
+ defaultPresetUnavailable,
60
+ defaultPresetBroken,
61
+ }
62
+ }
@@ -4,6 +4,7 @@ import SideBar from '~/components/layout/SideBar.vue'
4
4
  import BoardToolbar from '~/components/layout/BoardToolbar.vue'
5
5
  import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
6
6
  import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
7
+ import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
7
8
  import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
8
9
  import InspectorPanel from '~/components/panels/InspectorPanel.vue'
9
10
  import DecisionModal from '~/components/panels/DecisionModal.vue'
@@ -33,13 +34,72 @@ import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsP
33
34
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
34
35
  import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
35
36
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
37
+ import AiProviderOnboardingModal from '~/components/providers/AiProviderOnboardingModal.vue'
38
+ import AiPresetMismatchDialog from '~/components/providers/AiPresetMismatchDialog.vue'
36
39
 
37
40
  const workspace = useWorkspaceStore()
38
41
  const github = useGitHubStore()
42
+ const models = useModelsStore()
43
+ const ui = useUiStore()
44
+ const aiReadiness = useAiReadiness()
39
45
 
40
46
  // Load the board from the backend before rendering it.
41
47
  onMounted(() => workspace.init())
42
48
 
49
+ // Per-session guards so each AI-onboarding dialog auto-opens at most once (later opens are
50
+ // user-driven from the banner). Reset on workspace switch by the catalog watcher below.
51
+ const autoOpenedSetup = ref(false)
52
+ const autoOpenedPreset = ref(false)
53
+
54
+ // Load the per-workspace model catalog as soon as a board is active (re-loaded per board —
55
+ // availability reflects that workspace's keys/subscriptions). This populates the AI-readiness
56
+ // signals regardless of which lazy picker happens to mount, so the onboarding prompts below
57
+ // can fire. Credential edits re-fetch via `models.refresh()` in the provider panels.
58
+ watch(
59
+ () => workspace.workspaceId,
60
+ (id, prev) => {
61
+ if (id) void models.ensureLoaded(id)
62
+ // Switching workspaces resets the per-session AI-onboarding state: dismissals and the
63
+ // auto-open guards are scoped to one workspace, so a prompt dismissed in workspace A must
64
+ // not suppress the (independent) prompt for workspace B that also lacks a usable source.
65
+ if (prev !== undefined && id !== prev) {
66
+ autoOpenedSetup.value = false
67
+ autoOpenedPreset.value = false
68
+ ui.resetAiOnboarding()
69
+ }
70
+ },
71
+ { immediate: true },
72
+ )
73
+
74
+ // Auto-open the right AI-onboarding dialog once per session: the no-source prompt takes
75
+ // precedence over the preset-mismatch prompt. Honour the per-session dismissed flags so a
76
+ // user who closed the banner isn't re-interrupted, and only auto-open once each (later opens
77
+ // are user-driven from the banner). The prompts clear themselves once the gap is closed.
78
+ watch(
79
+ () => [
80
+ aiReadiness.ready.value,
81
+ aiReadiness.hasUsableModel.value,
82
+ aiReadiness.defaultPresetBroken.value,
83
+ ],
84
+ () => {
85
+ if (!aiReadiness.ready.value) return
86
+ if (!aiReadiness.hasUsableModel.value) {
87
+ if (!autoOpenedSetup.value && !ui.aiSetupDismissed) {
88
+ autoOpenedSetup.value = true
89
+ ui.openAiProviderSetup()
90
+ }
91
+ return
92
+ }
93
+ if (aiReadiness.defaultPresetBroken.value) {
94
+ if (!autoOpenedPreset.value && !ui.aiPresetDismissed) {
95
+ autoOpenedPreset.value = true
96
+ ui.openAiPresetMismatch()
97
+ }
98
+ }
99
+ },
100
+ { immediate: true },
101
+ )
102
+
43
103
  // Probe the GitHub integration as soon as a board is active (re-probe per board —
44
104
  // connections are per workspace). The result drives the onboarding gate below
45
105
  // before the board mounts, so an unconnected user can't slip past it. SideBar
@@ -75,6 +135,8 @@ watch(
75
135
  <div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
76
136
  <!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
77
137
  <GitHubPatBanner />
138
+ <!-- AI-readiness prompt (no usable model source, or default preset uses unavailable models). -->
139
+ <AiProvidersBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
78
140
 
79
141
  <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
80
142
  <div
@@ -124,6 +186,8 @@ watch(
124
186
  <OpenRouterCatalogPanel />
125
187
  <VendorCredentialsModal />
126
188
  <PersonalCredentialModal />
189
+ <AiProviderOnboardingModal />
190
+ <AiPresetMismatchDialog />
127
191
  </template>
128
192
 
129
193
  <!-- Backend unreachable / bootstrap failed -->
@@ -116,6 +116,28 @@ export const useModelsStore = defineStore('models', () => {
116
116
  return id ? byId.value.get(id) : undefined
117
117
  }
118
118
 
119
+ /** Readable label for a catalog model id: the catalog label, else the raw id. */
120
+ function labelForId(id: string): string {
121
+ return byId.value.get(id)?.label ?? id
122
+ }
123
+
124
+ /**
125
+ * Whether the workspace has at least one usable AI model source right now. The
126
+ * per-workspace catalog already resolves `available` from the configured keys /
127
+ * subscriptions / Cloudflare opt-in / local runners, so this is the single signal for
128
+ * "is any AI configured at all". Only meaningful once the per-workspace catalog has
129
+ * loaded (`loadedWorkspaceId` set); the deployment catalog leaves `available` undefined.
130
+ */
131
+ const hasUsableModel = computed(() => models.value.some((m) => m.available === true))
132
+
133
+ /**
134
+ * Whether a specific catalog model id is usable under the current configuration. An id
135
+ * absent from the catalog (e.g. a model that was removed) counts as not usable.
136
+ */
137
+ function isUsableId(id: string): boolean {
138
+ return byId.value.get(id)?.available === true
139
+ }
140
+
119
141
  /**
120
142
  * Friendly label for a recorded `provider:model` identifier (as carried on a
121
143
  * pipeline step). Matches it against the catalog's effective refs; falls back
@@ -130,5 +152,17 @@ export const useModelsStore = defineStore('models', () => {
130
152
  return hit ? `${hit.label} · ${hit.providerLabel}` : model || ref
131
153
  }
132
154
 
133
- return { models, loaded, ensureLoaded, refresh, byId, getModel, labelForRef }
155
+ return {
156
+ models,
157
+ loaded,
158
+ loadedWorkspaceId,
159
+ ensureLoaded,
160
+ refresh,
161
+ byId,
162
+ getModel,
163
+ labelForId,
164
+ hasUsableModel,
165
+ isUsableId,
166
+ labelForRef,
167
+ }
134
168
  })
package/app/stores/ui.ts CHANGED
@@ -88,6 +88,16 @@ export const useUiStore = defineStore('ui', () => {
88
88
  // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
89
89
  const openRouterOpen = ref(false)
90
90
 
91
+ // AI-onboarding surfaces (driven by `useAiReadiness`). `aiProviderSetupOpen` is the
92
+ // "no usable AI source" dialog; `aiPresetMismatchOpen` is the "default preset points at
93
+ // unavailable models" dialog. The `*Dismissed` flags are per-session: they suppress the
94
+ // auto-open (and let the banner be dismissed) without permanently hiding the prompt — it
95
+ // re-evaluates on the next load. Both clear themselves once the underlying gap is closed.
96
+ const aiProviderSetupOpen = ref(false)
97
+ const aiPresetMismatchOpen = ref(false)
98
+ const aiSetupDismissed = ref(false)
99
+ const aiPresetDismissed = ref(false)
100
+
91
101
  // Dedicated result-view overlay: a step whose agent kind declares a bespoke
92
102
  // visualization (via the archetype's `resultView`) opens here instead of the generic
93
103
  // prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
@@ -330,6 +340,39 @@ export const useUiStore = defineStore('ui', () => {
330
340
  function closeOpenRouter() {
331
341
  openRouterOpen.value = false
332
342
  }
343
+ function openAiProviderSetup() {
344
+ aiProviderSetupOpen.value = true
345
+ }
346
+ function closeAiProviderSetup() {
347
+ aiProviderSetupOpen.value = false
348
+ }
349
+ function openAiPresetMismatch() {
350
+ aiPresetMismatchOpen.value = true
351
+ }
352
+ function closeAiPresetMismatch() {
353
+ aiPresetMismatchOpen.value = false
354
+ }
355
+ // Banner dismissal is distinct from closing the dialog: closing the dialog leaves the
356
+ // banner so the user can reopen it; dismissing the banner hides the whole prompt for
357
+ // the session (it re-evaluates on the next load).
358
+ function dismissAiSetup() {
359
+ aiProviderSetupOpen.value = false
360
+ aiSetupDismissed.value = true
361
+ }
362
+ function dismissAiPresetMismatch() {
363
+ aiPresetMismatchOpen.value = false
364
+ aiPresetDismissed.value = true
365
+ }
366
+ // Clear the per-session AI-onboarding state (open dialogs + dismissed flags). Called on
367
+ // workspace switch: dismissals are per-session-per-workspace, so a prompt dismissed in one
368
+ // workspace must not suppress the (independent) prompt for another workspace that also
369
+ // lacks a usable AI source / has a broken default preset.
370
+ function resetAiOnboarding() {
371
+ aiProviderSetupOpen.value = false
372
+ aiPresetMismatchOpen.value = false
373
+ aiSetupDismissed.value = false
374
+ aiPresetDismissed.value = false
375
+ }
333
376
  function openRequirementReview(blockId: string) {
334
377
  resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
335
378
  }
@@ -384,6 +427,10 @@ export const useUiStore = defineStore('ui', () => {
384
427
  vendorCredentialsOpen,
385
428
  localModelsOpen,
386
429
  openRouterOpen,
430
+ aiProviderSetupOpen,
431
+ aiPresetMismatchOpen,
432
+ aiSetupDismissed,
433
+ aiPresetDismissed,
387
434
  resultView,
388
435
  closeResultView,
389
436
  stepDetail,
@@ -442,6 +489,13 @@ export const useUiStore = defineStore('ui', () => {
442
489
  closeLocalModels,
443
490
  openOpenRouter,
444
491
  closeOpenRouter,
492
+ openAiProviderSetup,
493
+ closeAiProviderSetup,
494
+ openAiPresetMismatch,
495
+ closeAiPresetMismatch,
496
+ dismissAiSetup,
497
+ dismissAiPresetMismatch,
498
+ resetAiOnboarding,
445
499
  openRequirementReview,
446
500
  openClarityReview,
447
501
  openServiceSpec,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",