@cat-factory/app 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -230,6 +230,15 @@ watch(open, (isOpen) => {
230
230
  pendingContext.value = []
231
231
  showDocPicker.value = false
232
232
  showIssuePicker.value = false
233
+ // Seed from a prefill when opened from another surface (e.g. "create task from
234
+ // issue" sets the title + stages the issue as linked context). Pipeline / preset
235
+ // are intentionally left at their defaults so the user confirms them here.
236
+ const prefill = ui.addTaskPrefill
237
+ if (prefill) {
238
+ if (prefill.title) title.value = prefill.title
239
+ if (prefill.description) description.value = prefill.description
240
+ if (prefill.context?.length) pendingContext.value = [...prefill.context]
241
+ }
233
242
  documents.loadDocuments().catch(() => {})
234
243
  tasks.loadTasks().catch(() => {})
235
244
  })
@@ -582,6 +591,7 @@ async function add() {
582
591
  <ContextIssuePicker
583
592
  v-if="showIssuePicker && issuesConnected"
584
593
  :chosen-keys="chosenIssueKeys"
594
+ :scope-block-id="ui.addTaskContainerId ?? undefined"
585
595
  @pick="addPending"
586
596
  />
587
597
  <div v-if="pendingIssues.length" class="space-y-1">
@@ -16,6 +16,7 @@ const props = defineProps<{ id: string }>()
16
16
  const board = useBoardStore()
17
17
  const execution = useExecutionStore()
18
18
  const ui = useUiStore()
19
+ const tasks = useTasksStore()
19
20
  const agentRuns = useAgentRunsStore()
20
21
  const services = useServicesStore()
21
22
  const reviews = useReviewStage()
@@ -108,6 +109,13 @@ function addTask() {
108
109
  ui.openAddTask(props.id)
109
110
  }
110
111
 
112
+ // Open the tracker-issue modal scoped to THIS service: the create-in target and the
113
+ // repo-scoped issue search are both pinned to this frame (see TaskImportModal).
114
+ function createTaskFromIssue() {
115
+ ui.expandFrame(props.id)
116
+ ui.openTaskImport(null, props.id)
117
+ }
118
+
111
119
  function addRecurring() {
112
120
  ui.openAddRecurring(props.id)
113
121
  }
@@ -360,6 +368,16 @@ const ITEM_ICON: Record<string, string> = {
360
368
  title="Add task"
361
369
  @click.stop="addTask"
362
370
  />
371
+ <UButton
372
+ v-if="tasks.anyOffered"
373
+ class="nodrag"
374
+ size="xs"
375
+ variant="ghost"
376
+ color="neutral"
377
+ icon="i-lucide-ticket"
378
+ title="Create task from issue"
379
+ @click.stop="createTaskFromIssue"
380
+ />
363
381
  <UButton
364
382
  class="nodrag"
365
383
  size="xs"
@@ -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>
@@ -11,6 +11,12 @@ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
11
11
  const props = defineProps<{
12
12
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
13
13
  chosenKeys?: string[]
14
+ /**
15
+ * The block the picker is attaching context to (a service frame or a task/module
16
+ * under one). Scopes a GitHub search to that service's linked repo, so hits stay
17
+ * in-repo and a pasted URL / bare issue number resolves to the exact issue.
18
+ */
19
+ scopeBlockId?: string
14
20
  }>()
15
21
  const emit = defineEmits<{ pick: [item: PendingContext] }>()
16
22
 
@@ -50,7 +56,7 @@ async function runSearch() {
50
56
  searching.value = true
51
57
  searchError.value = null
52
58
  try {
53
- results.value = await tasks.search(source.value, q)
59
+ results.value = await tasks.search(source.value, q, props.scopeBlockId)
54
60
  } catch (e) {
55
61
  results.value = []
56
62
  searchError.value = e instanceof Error ? e.message : String(e)
@@ -2,10 +2,12 @@
2
2
  // Import an issue from a connected task source (by key or URL) and review the
3
3
  // issues already imported into the workspace. An imported issue can be attached
4
4
  // to an existing task for context from the inspector (see TaskContextIssues.vue),
5
- // or turned directly into a new board task here pick a container (service frame
6
- // or module) and "Create task", which seeds a leaf block from the issue and links
7
- // the issue to it for context.
8
- import type { Block, TaskSearchResult, TaskSourceKind } from '~/types/domain'
5
+ // or turned into a new board task here: pick a container (service frame or module),
6
+ // then click an issue to open the prefilled add-task form (title seeded, issue
7
+ // staged as linked context) where the user confirms the pipeline / presets before
8
+ // creating it. A separate icon button on each row opens the issue on GitHub.
9
+ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
+ import type { AddTaskPrefill } from '~/stores/ui'
9
11
 
10
12
  const ui = useUiStore()
11
13
  const tasks = useTasksStore()
@@ -23,12 +25,22 @@ const source = ref<TaskSourceKind | undefined>(undefined)
23
25
  const ref_ = ref('')
24
26
  const importing = ref(false)
25
27
 
28
+ // When opened from a service frame the modal is the "create a task from an issue"
29
+ // surface; opened standalone it's the general tracker-issue browser/importer.
30
+ const title = computed(() =>
31
+ ui.taskImport?.containerId ? 'Create task from issue' : 'Tracker issues',
32
+ )
33
+
26
34
  const sourceItems = computed(() =>
27
35
  tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
28
36
  )
29
37
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
30
38
  const searchable = computed(() => descriptor.value?.searchable ?? false)
31
39
 
40
+ // The container (service frame or module) a new task is created in. Also the repo
41
+ // scope for the issue search — declared up here so the search watch can read it.
42
+ const containerId = ref<string | undefined>(undefined)
43
+
32
44
  // Browse the tracker by free text so an issue can be turned into a task without
33
45
  // knowing its key. Debounced; a created/imported hit also lands in the list below.
34
46
  const searchQuery = ref('')
@@ -37,7 +49,9 @@ const searching = ref(false)
37
49
  const searchError = ref<string | null>(null)
38
50
 
39
51
  let searchTimer: ReturnType<typeof setTimeout> | undefined
40
- watch([searchQuery, source], () => {
52
+ // Re-run when the chosen container changes too: a GitHub search is scoped to the
53
+ // selected service's repo, so switching containers re-scopes the results.
54
+ watch([searchQuery, source, () => containerId.value], () => {
41
55
  if (searchTimer) clearTimeout(searchTimer)
42
56
  searchResults.value = []
43
57
  searchError.value = null
@@ -52,7 +66,9 @@ async function runSearch() {
52
66
  searching.value = true
53
67
  searchError.value = null
54
68
  try {
55
- searchResults.value = await tasks.search(source.value, q)
69
+ // Scope to the selected container's repo so hits stay in-repo and a pasted
70
+ // URL / bare issue number resolves to the exact issue.
71
+ searchResults.value = await tasks.search(source.value, q, containerId.value)
56
72
  } catch (e) {
57
73
  searchResults.value = []
58
74
  searchError.value = e instanceof Error ? e.message : String(e)
@@ -75,7 +91,6 @@ const sourceTasks = computed(() =>
75
91
 
76
92
  // Containers a new task can be created in: every service frame and module on the
77
93
  // board. Modules are labelled with their parent frame so the choice is unambiguous.
78
- const containerId = ref<string | undefined>(undefined)
79
94
  const containerItems = computed(() =>
80
95
  board.blocks
81
96
  .filter((b) => b.level === 'frame' || b.level === 'module')
@@ -87,9 +102,6 @@ const containerItems = computed(() =>
87
102
  value: b.id,
88
103
  })),
89
104
  )
90
- // The issue currently being turned into a task (its row shows a spinner).
91
- const creatingId = ref<string | null>(null)
92
-
93
105
  watch(open, (isOpen) => {
94
106
  if (isOpen) {
95
107
  ref_.value = ''
@@ -97,33 +109,38 @@ watch(open, (isOpen) => {
97
109
  searchResults.value = []
98
110
  searchError.value = null
99
111
  source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
100
- containerId.value = containerItems.value[0]?.value
101
- creatingId.value = null
112
+ // Opened from a service frame → preselect it as the create-in target (and the
113
+ // search's repo scope); otherwise fall back to the first container on the board.
114
+ containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
102
115
  tasks.loadTasks().catch(() => {})
103
116
  }
104
117
  })
105
118
 
106
- // Create a board task from an issue, seeding its title/description from the issue
107
- // and linking it back for writeback. A search hit isn't projected locally yet, so
108
- // `needsImport` fetches + persists it first (create-block requires it imported).
109
- async function createTask(externalId: string, needsImport = false) {
119
+ // Selecting an issue hands off to the add-task form, prefilled with the issue title
120
+ // and the issue staged as linked context (so agents see its description + comments).
121
+ // The user still confirms pipeline / preset there before the task is created — we do
122
+ // NOT dump the issue body into the description; the link is enough.
123
+ function selectIssue(
124
+ issue: { externalId: string; title: string; status?: string },
125
+ needsImport: boolean,
126
+ ) {
110
127
  if (!source.value || !containerId.value) return
111
- creatingId.value = externalId
112
- try {
113
- if (needsImport) await tasks.importTask(source.value, externalId)
114
- const { block } = await tasks.createTaskFromIssue(source.value, externalId, containerId.value)
115
- board.upsert(block as Block)
116
- toast.add({ title: `Created task "${block.title}"`, icon: 'i-lucide-square-check' })
117
- } catch (e) {
118
- toast.add({
119
- title: 'Could not create task',
120
- description: e instanceof Error ? e.message : String(e),
121
- icon: 'i-lucide-triangle-alert',
122
- color: 'error',
123
- })
124
- } finally {
125
- creatingId.value = null
128
+ const prefill: AddTaskPrefill = {
129
+ title: issue.title,
130
+ context: [
131
+ {
132
+ kind: 'task',
133
+ source: source.value,
134
+ externalId: issue.externalId,
135
+ title: `${issue.externalId} · ${issue.title}`,
136
+ subtitle: issue.status || undefined,
137
+ icon: descriptor.value?.icon,
138
+ needsImport,
139
+ },
140
+ ],
126
141
  }
142
+ ui.closeTaskImport()
143
+ ui.openAddTask(containerId.value, prefill)
127
144
  }
128
145
 
129
146
  async function doImport() {
@@ -148,7 +165,7 @@ async function doImport() {
148
165
  </script>
149
166
 
150
167
  <template>
151
- <UModal v-model:open="open" title="Tracker issues">
168
+ <UModal v-model:open="open" :title="title">
152
169
  <template #body>
153
170
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
154
171
  <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
@@ -201,7 +218,7 @@ async function doImport() {
201
218
  v-model="searchQuery"
202
219
  :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
203
220
  :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
204
- placeholder="Search by title…"
221
+ placeholder="Search by title, paste an issue URL, or type an issue number…"
205
222
  class="w-full"
206
223
  />
207
224
  </UFormField>
@@ -226,7 +243,8 @@ async function doImport() {
226
243
  Add a service frame to the board first to create tasks from issues.
227
244
  </p>
228
245
 
229
- <!-- Search results (not yet imported): create a task directly from a hit. -->
246
+ <!-- Search results (not yet imported): click a hit to create a task from it
247
+ (opens the prefilled add-task form); the icon button views it on GitHub. -->
230
248
  <div v-if="searchError" class="text-[11px] text-amber-400">
231
249
  Search failed: {{ searchError }}
232
250
  </div>
@@ -237,38 +255,36 @@ async function doImport() {
237
255
  <div
238
256
  v-for="hit in freshHits"
239
257
  :key="`hit:${hit.source}:${hit.externalId}`"
240
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
258
+ class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
241
259
  >
242
- <div class="flex items-start justify-between gap-2">
243
- <div class="min-w-0">
244
- <a
245
- :href="hit.url"
246
- target="_blank"
247
- rel="noopener"
248
- class="truncate text-sm font-medium text-white hover:underline"
249
- >
250
- {{ hit.externalId }} · {{ hit.title }}
251
- </a>
252
- <p v-if="hit.excerpt" class="mt-0.5 line-clamp-2 text-xs text-slate-500">
253
- {{ hit.excerpt }}
254
- </p>
255
- </div>
256
- <div class="flex shrink-0 items-center gap-2">
257
- <UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
258
- {{ hit.status }}
259
- </UBadge>
260
- <UButton
261
- color="primary"
262
- variant="soft"
263
- size="xs"
264
- icon="i-lucide-square-check"
265
- :loading="creatingId === hit.externalId"
266
- :disabled="!containerId || creatingId !== null"
267
- @click="createTask(hit.externalId, true)"
268
- >
269
- Create task
270
- </UButton>
271
- </div>
260
+ <button
261
+ type="button"
262
+ class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
263
+ :disabled="!containerId"
264
+ :title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
265
+ @click="selectIssue(hit, true)"
266
+ >
267
+ <span class="block truncate text-sm font-medium text-white">
268
+ {{ hit.externalId }} · {{ hit.title }}
269
+ </span>
270
+ <span v-if="hit.excerpt" class="mt-0.5 line-clamp-2 block text-xs text-slate-500">
271
+ {{ hit.excerpt }}
272
+ </span>
273
+ </button>
274
+ <div class="flex shrink-0 items-center gap-2">
275
+ <UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
276
+ {{ hit.status }}
277
+ </UBadge>
278
+ <UButton
279
+ color="neutral"
280
+ variant="ghost"
281
+ size="xs"
282
+ icon="i-lucide-external-link"
283
+ :to="hit.url"
284
+ target="_blank"
285
+ rel="noopener"
286
+ :aria-label="`View ${hit.externalId} on GitHub`"
287
+ />
272
288
  </div>
273
289
  </div>
274
290
  </div>
@@ -281,36 +297,36 @@ async function doImport() {
281
297
  <div
282
298
  v-for="task in sourceTasks"
283
299
  :key="`${task.source}:${task.externalId}`"
284
- class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
300
+ class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
285
301
  >
286
- <div class="flex items-start justify-between gap-2">
287
- <div class="min-w-0">
288
- <a
289
- :href="task.url"
290
- target="_blank"
291
- rel="noopener"
292
- class="truncate text-sm font-medium text-white hover:underline"
293
- >
294
- {{ task.externalId }} · {{ task.title }}
295
- </a>
296
- <p class="mt-0.5 line-clamp-2 text-xs text-slate-500">{{ task.excerpt }}</p>
297
- </div>
298
- <div class="flex shrink-0 items-center gap-2">
299
- <UBadge color="neutral" variant="soft" size="xs">
300
- {{ task.status }}
301
- </UBadge>
302
- <UButton
303
- color="primary"
304
- variant="soft"
305
- size="xs"
306
- icon="i-lucide-square-check"
307
- :loading="creatingId === task.externalId"
308
- :disabled="!containerId || creatingId !== null"
309
- @click="createTask(task.externalId)"
310
- >
311
- Create task
312
- </UButton>
313
- </div>
302
+ <button
303
+ type="button"
304
+ class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
305
+ :disabled="!containerId"
306
+ :title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
307
+ @click="selectIssue(task, false)"
308
+ >
309
+ <span class="block truncate text-sm font-medium text-white">
310
+ {{ task.externalId }} · {{ task.title }}
311
+ </span>
312
+ <span class="mt-0.5 line-clamp-2 block text-xs text-slate-500">{{
313
+ task.excerpt
314
+ }}</span>
315
+ </button>
316
+ <div class="flex shrink-0 items-center gap-2">
317
+ <UBadge color="neutral" variant="soft" size="xs">
318
+ {{ task.status }}
319
+ </UBadge>
320
+ <UButton
321
+ color="neutral"
322
+ variant="ghost"
323
+ size="xs"
324
+ icon="i-lucide-external-link"
325
+ :to="task.url"
326
+ target="_blank"
327
+ rel="noopener"
328
+ :aria-label="`View ${task.externalId} on GitHub`"
329
+ />
314
330
  </div>
315
331
  </div>
316
332
  </div>
@@ -57,10 +57,15 @@ export function tasksApi({ http, ws }: ApiContext) {
57
57
  body,
58
58
  }),
59
59
 
60
- searchTaskSource: (workspaceId: string, source: TaskSourceKind, query: string) =>
60
+ searchTaskSource: (
61
+ workspaceId: string,
62
+ source: TaskSourceKind,
63
+ query: string,
64
+ blockId?: string,
65
+ ) =>
61
66
  http<{ results: TaskSearchResult[] }>(`${ws(workspaceId)}/task-sources/${source}/search`, {
62
67
  method: 'POST',
63
- body: { query },
68
+ body: { query, ...(blockId ? { blockId } : {}) },
64
69
  }),
65
70
 
66
71
  linkTask: (
@@ -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
  })
@@ -170,9 +170,18 @@ export const useTasksStore = defineStore('tasks', () => {
170
170
  }
171
171
  }
172
172
 
173
- /** Search a connected tracker's issues by free text (title/content). */
174
- async function search(source: TaskSourceKind, query: string): Promise<TaskSearchResult[]> {
175
- const { results } = await api.searchTaskSource(workspace.requireId(), source, query)
173
+ /**
174
+ * Search a connected tracker's issues by free text (title/content). `blockId`
175
+ * (a service frame or a task/module under one) scopes a GitHub search to that
176
+ * service's linked repo — so hits stay in-repo and a pasted URL / bare issue
177
+ * number resolves to the exact issue. Omitted → an unscoped workspace search.
178
+ */
179
+ async function search(
180
+ source: TaskSourceKind,
181
+ query: string,
182
+ blockId?: string,
183
+ ): Promise<TaskSearchResult[]> {
184
+ const { results } = await api.searchTaskSource(workspace.requireId(), source, query, blockId)
176
185
  return results
177
186
  }
178
187
 
package/app/stores/ui.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
3
  import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
4
+ import type { PendingContext } from '~/composables/useContextLinking'
4
5
  import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
5
6
  import { useExecutionStore } from '~/stores/execution'
6
7
  import { agentKindMeta } from '~/utils/catalog'
7
8
 
9
+ /** Values used to seed the add-task form when it is opened from another surface. */
10
+ export interface AddTaskPrefill {
11
+ title?: string
12
+ description?: string
13
+ /** Context items staged on the new task (e.g. the source issue), linked once created. */
14
+ context?: PendingContext[]
15
+ }
16
+
8
17
  /** Transient UI state: selection, panels, zoom level. */
9
18
  export const useUiStore = defineStore('ui', () => {
10
19
  const selectedBlockId = ref<string | null>(null)
@@ -33,12 +42,19 @@ export const useUiStore = defineStore('ui', () => {
33
42
  // the modal pick a connected one (there is no spawn target — issues are linked
34
43
  // to a block for context, not expanded into structure).
35
44
  const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
36
- const taskImport = ref<{ source: TaskSourceKind | null } | null>(null)
45
+ // `containerId` (a service frame) scopes the modal: it preselects that frame as
46
+ // the create-in target AND scopes the issue search to the frame's linked repo.
47
+ // Null → the unscoped "import an issue" surface (workspace-wide search).
48
+ const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
37
49
 
38
50
  // Add-task modal: the container (service frame or module) a new task is being
39
51
  // added to, or null when closed. The user types the title + description; nothing
40
52
  // is launched until they explicitly start the created task.
41
53
  const addTaskContainerId = ref<string | null>(null)
54
+ // Optional values to seed the add-task form with when it is opened from another
55
+ // surface (e.g. "create task from issue" prefills the title + stages the issue as
56
+ // linked context). The user still confirms pipeline / preset before adding.
57
+ const addTaskPrefill = ref<AddTaskPrefill | null>(null)
42
58
 
43
59
  // Add-recurring-pipeline modal: the service frame a new recurring pipeline is
44
60
  // being added to, or null when closed (mirrors the add-task flow — a button on
@@ -88,6 +104,16 @@ export const useUiStore = defineStore('ui', () => {
88
104
  // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
89
105
  const openRouterOpen = ref(false)
90
106
 
107
+ // AI-onboarding surfaces (driven by `useAiReadiness`). `aiProviderSetupOpen` is the
108
+ // "no usable AI source" dialog; `aiPresetMismatchOpen` is the "default preset points at
109
+ // unavailable models" dialog. The `*Dismissed` flags are per-session: they suppress the
110
+ // auto-open (and let the banner be dismissed) without permanently hiding the prompt — it
111
+ // re-evaluates on the next load. Both clear themselves once the underlying gap is closed.
112
+ const aiProviderSetupOpen = ref(false)
113
+ const aiPresetMismatchOpen = ref(false)
114
+ const aiSetupDismissed = ref(false)
115
+ const aiPresetDismissed = ref(false)
116
+
91
117
  // Dedicated result-view overlay: a step whose agent kind declares a bespoke
92
118
  // visualization (via the archetype's `resultView`) opens here instead of the generic
93
119
  // prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
@@ -227,17 +253,19 @@ export const useUiStore = defineStore('ui', () => {
227
253
  function closeTaskConnect() {
228
254
  taskConnect.value = null
229
255
  }
230
- function openTaskImport(source: TaskSourceKind | null = null) {
231
- taskImport.value = { source }
256
+ function openTaskImport(source: TaskSourceKind | null = null, containerId: string | null = null) {
257
+ taskImport.value = { source, containerId }
232
258
  }
233
259
  function closeTaskImport() {
234
260
  taskImport.value = null
235
261
  }
236
- function openAddTask(containerId: string) {
262
+ function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
263
+ addTaskPrefill.value = prefill
237
264
  addTaskContainerId.value = containerId
238
265
  }
239
266
  function closeAddTask() {
240
267
  addTaskContainerId.value = null
268
+ addTaskPrefill.value = null
241
269
  }
242
270
  function openAddRecurring(frameId: string) {
243
271
  addRecurringFrameId.value = frameId
@@ -330,6 +358,39 @@ export const useUiStore = defineStore('ui', () => {
330
358
  function closeOpenRouter() {
331
359
  openRouterOpen.value = false
332
360
  }
361
+ function openAiProviderSetup() {
362
+ aiProviderSetupOpen.value = true
363
+ }
364
+ function closeAiProviderSetup() {
365
+ aiProviderSetupOpen.value = false
366
+ }
367
+ function openAiPresetMismatch() {
368
+ aiPresetMismatchOpen.value = true
369
+ }
370
+ function closeAiPresetMismatch() {
371
+ aiPresetMismatchOpen.value = false
372
+ }
373
+ // Banner dismissal is distinct from closing the dialog: closing the dialog leaves the
374
+ // banner so the user can reopen it; dismissing the banner hides the whole prompt for
375
+ // the session (it re-evaluates on the next load).
376
+ function dismissAiSetup() {
377
+ aiProviderSetupOpen.value = false
378
+ aiSetupDismissed.value = true
379
+ }
380
+ function dismissAiPresetMismatch() {
381
+ aiPresetMismatchOpen.value = false
382
+ aiPresetDismissed.value = true
383
+ }
384
+ // Clear the per-session AI-onboarding state (open dialogs + dismissed flags). Called on
385
+ // workspace switch: dismissals are per-session-per-workspace, so a prompt dismissed in one
386
+ // workspace must not suppress the (independent) prompt for another workspace that also
387
+ // lacks a usable AI source / has a broken default preset.
388
+ function resetAiOnboarding() {
389
+ aiProviderSetupOpen.value = false
390
+ aiPresetMismatchOpen.value = false
391
+ aiSetupDismissed.value = false
392
+ aiPresetDismissed.value = false
393
+ }
333
394
  function openRequirementReview(blockId: string) {
334
395
  resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
335
396
  }
@@ -369,6 +430,7 @@ export const useUiStore = defineStore('ui', () => {
369
430
  taskConnect,
370
431
  taskImport,
371
432
  addTaskContainerId,
433
+ addTaskPrefill,
372
434
  addRecurringFrameId,
373
435
  bootstrapOpen,
374
436
  addServiceOpen,
@@ -384,6 +446,10 @@ export const useUiStore = defineStore('ui', () => {
384
446
  vendorCredentialsOpen,
385
447
  localModelsOpen,
386
448
  openRouterOpen,
449
+ aiProviderSetupOpen,
450
+ aiPresetMismatchOpen,
451
+ aiSetupDismissed,
452
+ aiPresetDismissed,
387
453
  resultView,
388
454
  closeResultView,
389
455
  stepDetail,
@@ -442,6 +508,13 @@ export const useUiStore = defineStore('ui', () => {
442
508
  closeLocalModels,
443
509
  openOpenRouter,
444
510
  closeOpenRouter,
511
+ openAiProviderSetup,
512
+ closeAiProviderSetup,
513
+ openAiPresetMismatch,
514
+ closeAiPresetMismatch,
515
+ dismissAiSetup,
516
+ dismissAiPresetMismatch,
517
+ resetAiOnboarding,
445
518
  openRequirementReview,
446
519
  openClarityReview,
447
520
  openServiceSpec,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
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",