@cat-factory/app 0.74.0 → 0.74.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.
@@ -18,20 +18,46 @@ const props = defineProps<{
18
18
  * in-repo and a pasted URL / bare issue number resolves to the exact issue.
19
19
  */
20
20
  scopeBlockId?: string
21
+ /**
22
+ * Controlled source: when provided the parent owns the selected tracker (via
23
+ * `v-model:source`); omitted, the picker manages it internally (the add-task case).
24
+ */
25
+ source?: TaskSourceKind
26
+ /**
27
+ * Always render the source selector, even with a single offered tracker — so the
28
+ * user can see *which* tracker is being searched (the "create task from issue"
29
+ * surface, where the source is otherwise invisible). Off by default: the inline
30
+ * add-task picker stays compact and only shows a selector when there's a choice.
31
+ */
32
+ alwaysShowSource?: boolean
33
+ }>()
34
+ const emit = defineEmits<{
35
+ pick: [item: PendingContext]
36
+ 'update:source': [value: TaskSourceKind]
21
37
  }>()
22
- const emit = defineEmits<{ pick: [item: PendingContext] }>()
23
38
 
24
39
  const { t } = useI18n()
25
40
  const tasks = useTasksStore()
26
41
 
27
42
  const chosen = computed(() => new Set(props.chosenKeys ?? []))
28
43
 
29
- // Source: default to the first offered tracker; a selector appears only when more
30
- // than one is offered (the common case is a single source).
31
- const source = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
44
+ // Source: default to the first offered tracker. Controlled when the parent passes
45
+ // `source` (write-through to `update:source`), else internal. A selector appears when
46
+ // more than one source is offered, or whenever the parent asks (`alwaysShowSource`).
47
+ const internalSource = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
48
+ const source = computed<TaskSourceKind | undefined>({
49
+ get: () => props.source ?? internalSource.value,
50
+ set: (v) => {
51
+ internalSource.value = v
52
+ if (v) emit('update:source', v)
53
+ },
54
+ })
32
55
  const sourceItems = computed(() =>
33
56
  tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
34
57
  )
58
+ const showSourceSelect = computed(
59
+ () => sourceItems.value.length > 1 || (props.alwaysShowSource && sourceItems.value.length > 0),
60
+ )
35
61
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
36
62
  const searchable = computed(() => descriptor.value?.searchable ?? false)
37
63
 
@@ -42,8 +68,10 @@ const searchError = ref<string | null>(null)
42
68
 
43
69
  // Debounced search: free text hits the tracker; a query that's clearly a URL/key
44
70
  // is left to the explicit "by reference" row below (search won't surface it).
71
+ // Re-scope when `scopeBlockId` changes too (a GitHub search is scoped to the block's
72
+ // repo, so switching the target container re-runs against the new repo).
45
73
  let timer: ReturnType<typeof setTimeout> | undefined
46
- watch([query, source], () => {
74
+ watch([query, source, () => props.scopeBlockId], () => {
47
75
  if (timer) clearTimeout(timer)
48
76
  results.value = []
49
77
  searchError.value = null
@@ -108,8 +136,10 @@ const refRow = computed(() => {
108
136
  searchRows.value.some((r) => r.externalId === q) ||
109
137
  chosen.value.has(keyFor(q))
110
138
  if (known) return null
111
- // Only worth offering when it looks like a reference, not a search phrase.
112
- const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
139
+ // Only worth offering when it looks like a reference, not a search phrase: a URL,
140
+ // an owner/repo#n or #n GitHub ref, or a Jira/Linear-style key (PROJ-123, ENG-42).
141
+ const looksLikeRef =
142
+ q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q) || /^[a-z][a-z0-9]*-\d+$/i.test(q)
113
143
  return looksLikeRef ? q : null
114
144
  })
115
145
 
@@ -173,7 +203,7 @@ onMounted(() => {
173
203
  <template>
174
204
  <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
175
205
  <USelect
176
- v-if="sourceItems.length > 1"
206
+ v-if="showSourceSelect"
177
207
  v-model="source"
178
208
  :items="sourceItems"
179
209
  size="xs"
@@ -1,13 +1,14 @@
1
1
  <script setup lang="ts">
2
- // Import an issue from a connected task source (by key or URL). An imported issue
3
- // can be attached to an existing task for context from the inspector (see
4
- // TaskContextIssues.vue), or turned into a new board task here: pick a container
5
- // (service frame or module), then click a search hit to open the prefilled
6
- // add-task form (title seeded, issue staged as linked context) where the user
7
- // confirms the pipeline / presets before creating it. A separate icon button on
8
- // each row opens the issue on GitHub.
9
- import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
- import type { AddTaskPrefill } from '~/stores/ui'
2
+ // Create a board task from a connected tracker's issue. Pick a container (service
3
+ // frame or module), then use the inline picker below to find an issue (search by
4
+ // title, pick an already-imported one, or paste a URL/key) choosing one opens the
5
+ // prefilled add-task form (title seeded, issue staged as linked context) where the
6
+ // user confirms the pipeline / presets before it's created. This is the same picker
7
+ // the add-task form uses for "context issues", so the two behave identically. A
8
+ // pasted parent/epic reference can instead be spawned as a whole linked task group.
9
+ import type { TaskSourceKind } from '~/types/domain'
10
+ import type { PendingContext } from '~/composables/useContextLinking'
11
+ import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
11
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
12
13
 
13
14
  const { t } = useI18n()
@@ -24,6 +25,9 @@ const open = computed({
24
25
  })
25
26
  const back = useIntegrationBack(open)
26
27
 
28
+ // The tracker being browsed. Owned here (not the picker) so the epic action and the
29
+ // ref-input placeholder share the same selected source; passed to the picker via
30
+ // `v-model:source` with `always-show-source` so it's always visible + selectable.
27
31
  const source = ref<TaskSourceKind | undefined>(undefined)
28
32
  const ref_ = ref('')
29
33
  const importing = ref(false)
@@ -34,60 +38,11 @@ const title = computed(() =>
34
38
  ui.taskImport?.containerId ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
35
39
  )
36
40
 
37
- const sourceItems = computed(() =>
38
- tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
39
- )
40
41
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
41
- const searchable = computed(() => descriptor.value?.searchable ?? false)
42
42
 
43
- // The container (service frame or module) a new task is created in. Also the repo
44
- // scope for the issue search — declared up here so the search watch can read it.
43
+ // The container (service frame or module) a new task is created in.
45
44
  const containerId = ref<string | undefined>(undefined)
46
45
 
47
- // Browse the tracker by free text so an issue can be turned into a task without
48
- // knowing its key. Debounced; a created/imported hit also lands in the list below.
49
- const searchQuery = ref('')
50
- const searchResults = ref<TaskSearchResult[]>([])
51
- const searching = ref(false)
52
- const searchError = ref<string | null>(null)
53
-
54
- let searchTimer: ReturnType<typeof setTimeout> | undefined
55
- // Re-run when the chosen container changes too: a GitHub search is scoped to the
56
- // selected service's repo, so switching containers re-scopes the results.
57
- watch([searchQuery, source, () => containerId.value], () => {
58
- if (searchTimer) clearTimeout(searchTimer)
59
- searchResults.value = []
60
- searchError.value = null
61
- const q = searchQuery.value.trim()
62
- if (!q || !searchable.value) return
63
- searchTimer = setTimeout(runSearch, 300)
64
- })
65
-
66
- async function runSearch() {
67
- const q = searchQuery.value.trim()
68
- if (!q || !source.value) return
69
- searching.value = true
70
- searchError.value = null
71
- try {
72
- // Scope to the selected container's repo so hits stay in-repo and a pasted
73
- // URL / bare issue number resolves to the exact issue.
74
- searchResults.value = await tasks.search(source.value, q, containerId.value)
75
- } catch (e) {
76
- searchResults.value = []
77
- searchError.value = e instanceof Error ? e.message : String(e)
78
- } finally {
79
- searching.value = false
80
- }
81
- }
82
-
83
- // Search hits not yet imported (imported ones already render in the list below).
84
- const importedIds = computed(
85
- () => new Set(tasks.tasks.filter((t) => t.source === source.value).map((t) => t.externalId)),
86
- )
87
- const freshHits = computed(() =>
88
- searchResults.value.filter((r) => !importedIds.value.has(r.externalId)),
89
- )
90
-
91
46
  // Containers a new task can be created in: every service frame and module on the
92
47
  // board. Modules are labelled with their parent frame so the choice is unambiguous.
93
48
  const containerItems = computed(() =>
@@ -104,69 +59,28 @@ const containerItems = computed(() =>
104
59
  watch(open, (isOpen) => {
105
60
  if (isOpen) {
106
61
  ref_.value = ''
107
- searchQuery.value = ''
108
- searchResults.value = []
109
- searchError.value = null
110
62
  source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
111
- // Opened from a service frame → preselect it as the create-in target (and the
112
- // search's repo scope); otherwise fall back to the first container on the board.
63
+ // Opened from a service frame → preselect it as the create-in target; otherwise
64
+ // fall back to the first container on the board.
113
65
  containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
114
66
  tasks.loadTasks().catch(() => {})
115
67
  }
116
68
  })
117
69
 
118
- // Selecting an issue hands off to the add-task form, prefilled with the issue title
119
- // and the issue staged as linked context (so agents see its description + comments).
120
- // The user still confirms pipeline / preset there before the task is created. The
121
- // issue body is carried when already in hand (imported issues); for a search hit it's
122
- // resolved in the add-task form (by importing). Either way the form shows it read-only
123
- // and folds it into the new task's description, so the original description is visible
124
- // and included — the user adds their own notes on top.
125
- function selectIssue(
126
- issue: { externalId: string; title: string; status?: string; description?: string },
127
- needsImport: boolean,
128
- ) {
129
- if (!source.value || !containerId.value) return
130
- const prefill: AddTaskPrefill = {
131
- title: issue.title,
132
- context: [
133
- {
134
- kind: 'task',
135
- source: source.value,
136
- externalId: issue.externalId,
137
- title: `${issue.externalId} · ${issue.title}`,
138
- subtitle: issue.status || undefined,
139
- icon: descriptor.value?.icon,
140
- description: issue.description || undefined,
141
- needsImport,
142
- },
143
- ],
144
- }
70
+ // Choosing an issue in the picker hands off to the add-task form, prefilled with the
71
+ // issue title and the issue staged as linked context (so agents see its description +
72
+ // comments). The user still confirms pipeline / preset there before the task is
73
+ // created. A search hit / pasted ref carries `needsImport`, so the add-task form
74
+ // resolves its body (by importing) and folds it into the new task's description; an
75
+ // already-imported issue carries its body directly.
76
+ function createFromPick(item: PendingContext) {
77
+ if (!containerId.value) return
78
+ // The picker titles rows as "EXTERNALID · Title"; seed the task with the clean
79
+ // title. A pasted ref has no title (title === the raw ref), so leave it blank for
80
+ // the user to name in the form.
81
+ const seededTitle = item.title === item.externalId ? '' : item.title.replace(/^[^·]+·\s*/, '')
145
82
  ui.closeTaskImport()
146
- ui.openAddTask(containerId.value, prefill)
147
- }
148
-
149
- async function doImport() {
150
- const value = ref_.value.trim()
151
- if (!value || !source.value) return
152
- importing.value = true
153
- try {
154
- const task = await tasks.importTask(source.value, value)
155
- ref_.value = ''
156
- toast.add({
157
- title: t('tasks.import.imported', { title: task.title }),
158
- icon: 'i-lucide-file-down',
159
- })
160
- } catch (e) {
161
- toast.add({
162
- title: t('tasks.import.importFailed'),
163
- description: e instanceof Error ? e.message : String(e),
164
- icon: 'i-lucide-triangle-alert',
165
- color: 'error',
166
- })
167
- } finally {
168
- importing.value = false
169
- }
83
+ ui.openAddTask(containerId.value, { title: seededTitle, context: [item] })
170
84
  }
171
85
 
172
86
  // Spawn the referenced issue as an EPIC: an epic node + a task per child issue (into the
@@ -227,54 +141,15 @@ async function doSpawnEpic() {
227
141
  </div>
228
142
  </div>
229
143
 
144
+ <!-- No service frame yet → nowhere to create a task. -->
145
+ <p v-else-if="!containerItems.length" class="text-center text-xs text-slate-500">
146
+ {{ t('tasks.import.needFrameFirst') }}
147
+ </p>
148
+
230
149
  <!-- Main form -->
231
150
  <div v-else class="space-y-4">
232
- <UFormField v-if="sourceItems.length > 1" :label="t('tasks.import.source')">
233
- <USelect v-model="source" :items="sourceItems" class="w-full" />
234
- </UFormField>
235
-
236
- <div class="flex items-end gap-2">
237
- <UFormField :label="descriptor?.refLabel ?? t('tasks.import.refLabel')" class="flex-1">
238
- <UInput
239
- v-model="ref_"
240
- :placeholder="descriptor?.refPlaceholder"
241
- class="w-full"
242
- @keydown.enter="doImport"
243
- />
244
- </UFormField>
245
- <UButton
246
- color="primary"
247
- icon="i-lucide-file-down"
248
- :loading="importing"
249
- :disabled="!ref_.trim()"
250
- @click="doImport"
251
- >
252
- {{ t('tasks.import.import') }}
253
- </UButton>
254
- <UButton
255
- color="primary"
256
- variant="soft"
257
- icon="i-lucide-layers"
258
- :loading="importing"
259
- :disabled="!ref_.trim() || !containerId"
260
- :title="
261
- containerId
262
- ? t('tasks.import.asEpicTitleReady')
263
- : t('tasks.import.asEpicTitleNeedsContainer')
264
- "
265
- @click="doSpawnEpic"
266
- >
267
- {{ t('tasks.import.asEpic') }}
268
- </UButton>
269
- </div>
270
-
271
- <!-- Container for epic children when spawning from a pasted ref (the shared
272
- "Create tasks in" selector below covers the search-results case). -->
273
- <UFormField
274
- v-if="containerItems.length && !freshHits.length"
275
- :label="t('tasks.import.epicChildrenContainer')"
276
- class="w-72"
277
- >
151
+ <!-- Where the new task lands (preselected when opened from a service frame). -->
152
+ <UFormField :label="t('tasks.import.createTasksIn')">
278
153
  <USelect
279
154
  v-model="containerId"
280
155
  :items="containerItems"
@@ -283,94 +158,52 @@ async function doSpawnEpic() {
283
158
  />
284
159
  </UFormField>
285
160
 
286
- <!-- Browse: search the tracker by title so an issue can be turned into a
287
- task without knowing its key. -->
288
- <UFormField v-if="searchable" :label="t('tasks.import.searchIssues')">
289
- <UInput
290
- v-model="searchQuery"
291
- :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
292
- :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
293
- :placeholder="t('tasks.import.searchPlaceholder')"
294
- class="w-full"
161
+ <!-- Find an issue and create a task from it. Same picker the add-task form
162
+ uses for context issues: search by title, pick an already-imported one,
163
+ or paste a URL/key — choosing one opens the prefilled add-task form. The
164
+ search is scoped to the chosen container's repo (so a GitHub search stays
165
+ in that service's repo and a pasted URL / bare number resolves there), and
166
+ the source selector is always shown so it's clear which tracker is in use. -->
167
+ <UFormField :label="t('tasks.import.searchIssues')">
168
+ <ContextIssuePicker
169
+ v-model:source="source"
170
+ :scope-block-id="containerId"
171
+ always-show-source
172
+ @pick="createFromPick"
295
173
  />
296
174
  </UFormField>
297
175
 
298
- <!-- Shared target container for every "Create task" action below. -->
299
- <UFormField
300
- v-if="containerItems.length && freshHits.length"
301
- :label="t('tasks.import.createTasksIn')"
302
- class="w-72"
303
- >
304
- <USelect
305
- v-model="containerId"
306
- :items="containerItems"
307
- :placeholder="t('tasks.import.pickContainer')"
308
- class="w-full"
309
- />
310
- </UFormField>
311
- <p
312
- v-else-if="!containerItems.length && freshHits.length"
313
- class="text-[11px] text-slate-500"
314
- >
315
- {{ t('tasks.import.needFrameFirst') }}
316
- </p>
317
-
318
- <!-- Search results (not yet imported): click a hit to create a task from it
319
- (opens the prefilled add-task form); the icon button views it on GitHub. -->
320
- <div v-if="searchError" class="text-[11px] text-amber-400">
321
- {{ t('tasks.import.searchFailed', { error: searchError }) }}
322
- </div>
323
- <div v-if="freshHits.length" class="space-y-2">
324
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
325
- {{ t('tasks.import.searchResults') }}
326
- </h3>
327
- <div
328
- v-for="hit in freshHits"
329
- :key="`hit:${hit.source}:${hit.externalId}`"
330
- 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"
331
- >
332
- <button
333
- type="button"
334
- class="min-w-0 flex-1 text-start disabled:cursor-not-allowed disabled:opacity-60"
335
- :disabled="!containerId"
176
+ <!-- Secondary: spawn a parent/epic issue as a whole linked task group. -->
177
+ <div class="space-y-2 border-t border-slate-800 pt-3">
178
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
179
+ {{ t('tasks.import.asEpic') }}
180
+ </span>
181
+ <div class="flex items-end gap-2">
182
+ <UFormField :label="descriptor?.refLabel ?? t('tasks.import.refLabel')" class="flex-1">
183
+ <UInput
184
+ v-model="ref_"
185
+ :placeholder="descriptor?.refPlaceholder"
186
+ class="w-full"
187
+ @keydown.enter="doSpawnEpic"
188
+ />
189
+ </UFormField>
190
+ <UButton
191
+ color="primary"
192
+ variant="soft"
193
+ icon="i-lucide-layers"
194
+ :loading="importing"
195
+ :disabled="!ref_.trim() || !containerId"
336
196
  :title="
337
197
  containerId
338
- ? t('tasks.import.createFromIssue')
339
- : t('tasks.import.pickContainerFirst')
198
+ ? t('tasks.import.asEpicTitleReady')
199
+ : t('tasks.import.asEpicTitleNeedsContainer')
340
200
  "
341
- @click="selectIssue(hit, true)"
201
+ @click="doSpawnEpic"
342
202
  >
343
- <span class="block truncate text-sm font-medium text-white">
344
- {{ hit.externalId }} · {{ hit.title }}
345
- </span>
346
- <span v-if="hit.excerpt" class="mt-0.5 line-clamp-2 block text-xs text-slate-500">
347
- {{ hit.excerpt }}
348
- </span>
349
- </button>
350
- <div class="flex shrink-0 items-center gap-2">
351
- <UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
352
- {{ hit.status }}
353
- </UBadge>
354
- <UButton
355
- color="neutral"
356
- variant="ghost"
357
- size="xs"
358
- icon="i-lucide-external-link"
359
- :to="hit.url"
360
- target="_blank"
361
- rel="noopener"
362
- :aria-label="t('tasks.import.viewOnGitHub', { id: hit.externalId })"
363
- />
364
- </div>
203
+ {{ t('tasks.import.asEpic') }}
204
+ </UButton>
365
205
  </div>
366
206
  </div>
367
-
368
- <p
369
- v-if="!freshHits.length && !searchQuery.trim()"
370
- class="text-center text-xs text-slate-500"
371
- >
372
- {{ t('tasks.import.emptyHint') }}
373
- </p>
374
207
  </div>
375
208
  </template>
376
209
  </UModal>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.74.0",
3
+ "version": "0.74.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",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.81.0"
37
+ "@cat-factory/contracts": "0.81.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",