@cat-factory/app 0.14.0 → 0.15.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.
@@ -12,6 +12,7 @@
12
12
  // (see useContextLinking) — the same context the agents see for every step of the run.
13
13
  import type { DropdownMenuItem } from '@nuxt/ui'
14
14
  import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
15
+ import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
15
16
 
16
17
  const ui = useUiStore()
17
18
  const board = useBoardStore()
@@ -230,32 +231,12 @@ const docAttachMenu = computed<DropdownMenuItem[][]>(() => {
230
231
  return [items]
231
232
  })
232
233
 
233
- const issueAttachMenu = computed<DropdownMenuItem[][]>(() => {
234
- const chosen = new Set(pendingContext.value.map(contextKey))
235
- const items: DropdownMenuItem[] = tasks.tasks
236
- .filter(
237
- (t) => !chosen.has(contextKey({ kind: 'task', source: t.source, externalId: t.externalId })),
238
- )
239
- .map((t) => ({
240
- label: `${t.externalId} · ${t.title}`,
241
- icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
242
- onSelect: () =>
243
- addPending({
244
- kind: 'task',
245
- source: t.source,
246
- externalId: t.externalId,
247
- title: `${t.externalId} · ${t.title}`,
248
- icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
249
- needsImport: false,
250
- }),
251
- }))
252
- items.push({
253
- label: 'Import an issue…',
254
- icon: 'i-lucide-file-down',
255
- onSelect: () => ui.openTaskImport(),
256
- })
257
- return [items]
258
- })
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.
238
+ const showIssuePicker = ref(false)
239
+ const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
259
240
 
260
241
  // Reset the form whenever the modal opens for a (new) container, and refresh the
261
242
  // imported docs/issues so the quick-pick list is current.
@@ -274,6 +255,7 @@ watch(open, (isOpen) => {
274
255
  pipelineId.value = ''
275
256
  agentConfigValues.value = {}
276
257
  pendingContext.value = []
258
+ showIssuePicker.value = false
277
259
  documents.loadDocuments().catch(() => {})
278
260
  tasks.loadTasks().catch(() => {})
279
261
  })
@@ -591,15 +573,16 @@ async function add() {
591
573
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
592
574
  Context issues
593
575
  </span>
594
- <UDropdownMenu
576
+ <UButton
595
577
  v-if="issuesConnected"
596
- :items="issueAttachMenu"
597
- :content="{ side: 'bottom', align: 'end' }"
578
+ color="neutral"
579
+ variant="soft"
580
+ size="xs"
581
+ :icon="showIssuePicker ? 'i-lucide-x' : 'i-lucide-plus'"
582
+ @click="showIssuePicker = !showIssuePicker"
598
583
  >
599
- <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">
600
- Attach
601
- </UButton>
602
- </UDropdownMenu>
584
+ {{ showIssuePicker ? 'Done' : 'Attach' }}
585
+ </UButton>
603
586
  <UButton
604
587
  v-else
605
588
  color="neutral"
@@ -616,6 +599,11 @@ async function add() {
616
599
  Attach
617
600
  </UButton>
618
601
  </div>
602
+ <ContextIssuePicker
603
+ v-if="showIssuePicker && issuesConnected"
604
+ :chosen-keys="chosenIssueKeys"
605
+ @pick="addPending"
606
+ />
619
607
  <div v-if="pendingIssues.length" class="space-y-1">
620
608
  <div
621
609
  v-for="item in pendingIssues"
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ // Inline picker for attaching a tracker issue as task context. It searches the
3
+ // connected tracker (GitHub Issues / Jira) by free text, lists already-imported
4
+ // issues for quick re-use, and accepts a pasted URL/key as a reference — all
5
+ // inline, with NO second modal (stacked page-level modals don't interact here).
6
+ // It only *stages* a choice: the caller collects PendingContext items and links
7
+ // them once the block exists (see useContextLinking). A search hit / pasted ref
8
+ // carries `needsImport: true` so it's fetched + persisted before linking.
9
+ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
+
11
+ const props = defineProps<{
12
+ /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
13
+ chosenKeys?: string[]
14
+ }>()
15
+ const emit = defineEmits<{ pick: [item: PendingContext] }>()
16
+
17
+ const tasks = useTasksStore()
18
+
19
+ const chosen = computed(() => new Set(props.chosenKeys ?? []))
20
+
21
+ // Source: default to the first offered tracker; a selector appears only when more
22
+ // than one is offered (the common case is a single source).
23
+ const source = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
24
+ const sourceItems = computed(() =>
25
+ tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
26
+ )
27
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
28
+ const searchable = computed(() => descriptor.value?.searchable ?? false)
29
+
30
+ const query = ref('')
31
+ const results = ref<TaskSearchResult[]>([])
32
+ const searching = ref(false)
33
+ const searchError = ref<string | null>(null)
34
+
35
+ // Debounced search: free text hits the tracker; a query that's clearly a URL/key
36
+ // is left to the explicit "by reference" row below (search won't surface it).
37
+ let timer: ReturnType<typeof setTimeout> | undefined
38
+ watch([query, source], () => {
39
+ if (timer) clearTimeout(timer)
40
+ results.value = []
41
+ searchError.value = null
42
+ const q = query.value.trim()
43
+ if (!q || !searchable.value) return
44
+ timer = setTimeout(runSearch, 300)
45
+ })
46
+
47
+ async function runSearch() {
48
+ const q = query.value.trim()
49
+ if (!q || !source.value) return
50
+ searching.value = true
51
+ searchError.value = null
52
+ try {
53
+ results.value = await tasks.search(source.value, q)
54
+ } catch (e) {
55
+ results.value = []
56
+ searchError.value = e instanceof Error ? e.message : String(e)
57
+ } finally {
58
+ searching.value = false
59
+ }
60
+ }
61
+
62
+ const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-square-check')
63
+
64
+ function keyFor(externalId: string): string {
65
+ return source.value
66
+ ? contextKey({ kind: 'task', source: source.value, externalId })
67
+ : `task::${externalId}`
68
+ }
69
+
70
+ // Already-imported issues for this source, filtered by the query and never
71
+ // re-offering one the caller already staged.
72
+ const importedRows = computed(() => {
73
+ if (!source.value) return []
74
+ const q = query.value.trim().toLowerCase()
75
+ return tasks.tasks
76
+ .filter((t) => t.source === source.value)
77
+ .filter((t) => !chosen.value.has(keyFor(t.externalId)))
78
+ .filter(
79
+ (t) => !q || t.externalId.toLowerCase().includes(q) || t.title.toLowerCase().includes(q),
80
+ )
81
+ })
82
+
83
+ // Search hits not already imported (those show in importedRows) and not staged.
84
+ const searchRows = computed(() => {
85
+ if (!source.value) return []
86
+ const importedIds = new Set(
87
+ tasks.tasks.filter((t) => t.source === source.value).map((t) => t.externalId),
88
+ )
89
+ return results.value
90
+ .filter((r) => !importedIds.has(r.externalId))
91
+ .filter((r) => !chosen.value.has(keyFor(r.externalId)))
92
+ })
93
+
94
+ // A pasted URL / key the search won't match: offer it as an explicit reference.
95
+ const refRow = computed(() => {
96
+ const q = query.value.trim()
97
+ if (!q || !source.value) return null
98
+ const known =
99
+ importedRows.value.some((t) => t.externalId === q) ||
100
+ searchRows.value.some((r) => r.externalId === q) ||
101
+ chosen.value.has(keyFor(q))
102
+ if (known) return null
103
+ // Only worth offering when it looks like a reference, not a search phrase.
104
+ const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
105
+ return looksLikeRef ? q : null
106
+ })
107
+
108
+ const empty = computed(
109
+ () =>
110
+ !searching.value &&
111
+ !searchError.value &&
112
+ importedRows.value.length === 0 &&
113
+ searchRows.value.length === 0 &&
114
+ refRow.value === null,
115
+ )
116
+
117
+ function pickImported(externalId: string, title: string, status: string) {
118
+ if (!source.value) return
119
+ emit('pick', {
120
+ kind: 'task',
121
+ source: source.value,
122
+ externalId,
123
+ title: `${externalId} · ${title}`,
124
+ subtitle: status || undefined,
125
+ icon: icon.value,
126
+ needsImport: false,
127
+ })
128
+ }
129
+
130
+ function pickSearch(r: TaskSearchResult) {
131
+ emit('pick', {
132
+ kind: 'task',
133
+ source: r.source,
134
+ externalId: r.externalId,
135
+ title: `${r.externalId} · ${r.title}`,
136
+ subtitle: r.status || undefined,
137
+ icon: icon.value,
138
+ needsImport: true,
139
+ })
140
+ }
141
+
142
+ function pickRef(q: string) {
143
+ if (!source.value) return
144
+ emit('pick', {
145
+ kind: 'task',
146
+ source: source.value,
147
+ externalId: q,
148
+ title: q,
149
+ subtitle: descriptor.value?.label,
150
+ icon: icon.value,
151
+ needsImport: true,
152
+ })
153
+ query.value = ''
154
+ }
155
+
156
+ onMounted(() => {
157
+ // Keep the quick-pick list current (cheap; the store dedupes).
158
+ tasks.loadTasks().catch(() => {})
159
+ })
160
+ </script>
161
+
162
+ <template>
163
+ <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
164
+ <USelect
165
+ v-if="sourceItems.length > 1"
166
+ v-model="source"
167
+ :items="sourceItems"
168
+ size="xs"
169
+ class="w-full"
170
+ />
171
+
172
+ <UInput
173
+ v-model="query"
174
+ :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
175
+ :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
176
+ size="sm"
177
+ class="w-full"
178
+ :placeholder="
179
+ searchable
180
+ ? 'Search issues or paste a URL/key…'
181
+ : (descriptor?.refPlaceholder ?? 'Paste an issue URL or key…')
182
+ "
183
+ @keydown.enter="refRow && pickRef(refRow)"
184
+ />
185
+
186
+ <p v-if="searchError" class="px-1 text-[11px] text-amber-400">
187
+ Search failed: {{ searchError }}
188
+ </p>
189
+
190
+ <div class="max-h-56 space-y-0.5 overflow-y-auto">
191
+ <!-- Already-imported issues (linked directly, no re-fetch). -->
192
+ <button
193
+ v-for="t in importedRows"
194
+ :key="`imp:${t.externalId}`"
195
+ type="button"
196
+ 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"
197
+ @click="pickImported(t.externalId, t.title, t.status)"
198
+ >
199
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
200
+ <span class="truncate">{{ t.externalId }} · {{ t.title }}</span>
201
+ <UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">imported</UBadge>
202
+ </button>
203
+
204
+ <!-- Tracker search hits (imported on add). -->
205
+ <button
206
+ v-for="r in searchRows"
207
+ :key="`hit:${r.externalId}`"
208
+ type="button"
209
+ 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"
210
+ @click="pickSearch(r)"
211
+ >
212
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
213
+ <span class="truncate">{{ r.externalId }} · {{ r.title }}</span>
214
+ <UBadge v-if="r.status" color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">
215
+ {{ r.status }}
216
+ </UBadge>
217
+ </button>
218
+
219
+ <!-- Explicit URL/key reference (imported on add). -->
220
+ <button
221
+ v-if="refRow"
222
+ type="button"
223
+ 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"
224
+ @click="pickRef(refRow)"
225
+ >
226
+ <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
227
+ <span class="truncate"
228
+ >Attach <span class="text-slate-200">{{ refRow }}</span> by reference</span
229
+ >
230
+ </button>
231
+
232
+ <p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
233
+ {{
234
+ query.trim()
235
+ ? 'No matching issues.'
236
+ : searchable
237
+ ? 'Search by title, or pick an imported issue.'
238
+ : 'Paste an issue URL or key to attach it.'
239
+ }}
240
+ </p>
241
+ </div>
242
+ </div>
243
+ </template>
@@ -5,7 +5,7 @@
5
5
  // or turned directly into a new board task here — pick a container (service frame
6
6
  // or module) and "Create task", which seeds a leaf block from the issue and links
7
7
  // the issue to it for context.
8
- import type { Block, TaskSourceKind } from '~/types/domain'
8
+ import type { Block, TaskSearchResult, TaskSourceKind } from '~/types/domain'
9
9
 
10
10
  const ui = useUiStore()
11
11
  const tasks = useTasksStore()
@@ -27,6 +27,47 @@ const sourceItems = computed(() =>
27
27
  tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
28
28
  )
29
29
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
30
+ const searchable = computed(() => descriptor.value?.searchable ?? false)
31
+
32
+ // Browse the tracker by free text so an issue can be turned into a task without
33
+ // knowing its key. Debounced; a created/imported hit also lands in the list below.
34
+ const searchQuery = ref('')
35
+ const searchResults = ref<TaskSearchResult[]>([])
36
+ const searching = ref(false)
37
+ const searchError = ref<string | null>(null)
38
+
39
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
40
+ watch([searchQuery, source], () => {
41
+ if (searchTimer) clearTimeout(searchTimer)
42
+ searchResults.value = []
43
+ searchError.value = null
44
+ const q = searchQuery.value.trim()
45
+ if (!q || !searchable.value) return
46
+ searchTimer = setTimeout(runSearch, 300)
47
+ })
48
+
49
+ async function runSearch() {
50
+ const q = searchQuery.value.trim()
51
+ if (!q || !source.value) return
52
+ searching.value = true
53
+ searchError.value = null
54
+ try {
55
+ searchResults.value = await tasks.search(source.value, q)
56
+ } catch (e) {
57
+ searchResults.value = []
58
+ searchError.value = e instanceof Error ? e.message : String(e)
59
+ } finally {
60
+ searching.value = false
61
+ }
62
+ }
63
+
64
+ // Search hits not yet imported (imported ones already render in the list below).
65
+ const importedIds = computed(
66
+ () => new Set(tasks.tasks.filter((t) => t.source === source.value).map((t) => t.externalId)),
67
+ )
68
+ const freshHits = computed(() =>
69
+ searchResults.value.filter((r) => !importedIds.value.has(r.externalId)),
70
+ )
30
71
 
31
72
  const sourceTasks = computed(() =>
32
73
  source.value ? tasks.tasks.filter((t) => t.source === source.value) : [],
@@ -52,6 +93,9 @@ const creatingId = ref<string | null>(null)
52
93
  watch(open, (isOpen) => {
53
94
  if (isOpen) {
54
95
  ref_.value = ''
96
+ searchQuery.value = ''
97
+ searchResults.value = []
98
+ searchError.value = null
55
99
  source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
56
100
  containerId.value = containerItems.value[0]?.value
57
101
  creatingId.value = null
@@ -59,10 +103,14 @@ watch(open, (isOpen) => {
59
103
  }
60
104
  })
61
105
 
62
- async function createTask(externalId: string) {
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) {
63
110
  if (!source.value || !containerId.value) return
64
111
  creatingId.value = externalId
65
112
  try {
113
+ if (needsImport) await tasks.importTask(source.value, externalId)
66
114
  const { block } = await tasks.createTaskFromIssue(source.value, externalId, containerId.value)
67
115
  board.upsert(block as Block)
68
116
  toast.add({ title: `Created task "${block.title}"`, icon: 'i-lucide-square-check' })
@@ -100,7 +148,7 @@ async function doImport() {
100
148
  </script>
101
149
 
102
150
  <template>
103
- <UModal v-model:open="open" title="Import from a task source">
151
+ <UModal v-model:open="open" title="Tracker issues">
104
152
  <template #body>
105
153
  <!-- Empty state: no source offered (none connected/installed, or all disabled) -->
106
154
  <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
@@ -146,21 +194,90 @@ async function doImport() {
146
194
  </UButton>
147
195
  </div>
148
196
 
197
+ <!-- Browse: search the tracker by title so an issue can be turned into a
198
+ task without knowing its key. -->
199
+ <UFormField v-if="searchable" label="Search issues">
200
+ <UInput
201
+ v-model="searchQuery"
202
+ :icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
203
+ :ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
204
+ placeholder="Search by title…"
205
+ class="w-full"
206
+ />
207
+ </UFormField>
208
+
209
+ <!-- Shared target container for every "Create task" action below. -->
210
+ <UFormField
211
+ v-if="containerItems.length && (freshHits.length || sourceTasks.length)"
212
+ label="Create tasks in"
213
+ class="w-72"
214
+ >
215
+ <USelect
216
+ v-model="containerId"
217
+ :items="containerItems"
218
+ placeholder="Pick a frame or module"
219
+ class="w-full"
220
+ />
221
+ </UFormField>
222
+ <p
223
+ v-else-if="!containerItems.length && (freshHits.length || sourceTasks.length)"
224
+ class="text-[11px] text-slate-500"
225
+ >
226
+ Add a service frame to the board first to create tasks from issues.
227
+ </p>
228
+
229
+ <!-- Search results (not yet imported): create a task directly from a hit. -->
230
+ <div v-if="searchError" class="text-[11px] text-amber-400">
231
+ Search failed: {{ searchError }}
232
+ </div>
233
+ <div v-if="freshHits.length" class="space-y-2">
234
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
235
+ Search results
236
+ </h3>
237
+ <div
238
+ v-for="hit in freshHits"
239
+ :key="`hit:${hit.source}:${hit.externalId}`"
240
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
241
+ >
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>
272
+ </div>
273
+ </div>
274
+ </div>
275
+
149
276
  <!-- List of already-imported issues -->
150
277
  <div v-if="sourceTasks.length" class="space-y-2">
151
- <div class="flex items-end justify-between gap-3">
152
- <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
153
- Imported issues
154
- </h3>
155
- <UFormField v-if="containerItems.length" label="Create tasks in" size="xs" class="w-56">
156
- <USelect
157
- v-model="containerId"
158
- :items="containerItems"
159
- placeholder="Pick a frame or module"
160
- class="w-full"
161
- />
162
- </UFormField>
163
- </div>
278
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
279
+ Imported issues
280
+ </h3>
164
281
  <div
165
282
  v-for="task in sourceTasks"
166
283
  :key="`${task.source}:${task.externalId}`"
@@ -196,11 +313,13 @@ async function doImport() {
196
313
  </div>
197
314
  </div>
198
315
  </div>
199
- <p v-if="!containerItems.length" class="text-[11px] text-slate-500">
200
- Add a service frame to the board first to create tasks from issues.
201
- </p>
202
316
  </div>
203
- <p v-else class="text-center text-xs text-slate-500">No issues imported yet.</p>
317
+ <p
318
+ v-else-if="!freshHits.length && !searchQuery.trim()"
319
+ class="text-center text-xs text-slate-500"
320
+ >
321
+ No issues imported yet. Search above, or paste an issue URL/key to import one.
322
+ </p>
204
323
  </div>
205
324
  </template>
206
325
  </UModal>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",