@cat-factory/app 0.14.0 → 0.16.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.
|
@@ -5,13 +5,15 @@
|
|
|
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):
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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'
|
|
16
|
+
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
15
17
|
|
|
16
18
|
const ui = useUiStore()
|
|
17
19
|
const board = useBoardStore()
|
|
@@ -199,63 +201,15 @@ function removePending(item: PendingContext) {
|
|
|
199
201
|
pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
)
|
|
212
|
-
.map((d) => ({
|
|
213
|
-
label: d.title,
|
|
214
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
215
|
-
onSelect: () =>
|
|
216
|
-
addPending({
|
|
217
|
-
kind: 'document',
|
|
218
|
-
source: d.source,
|
|
219
|
-
externalId: d.externalId,
|
|
220
|
-
title: d.title,
|
|
221
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
222
|
-
needsImport: false,
|
|
223
|
-
}),
|
|
224
|
-
}))
|
|
225
|
-
items.push({
|
|
226
|
-
label: 'Import a page…',
|
|
227
|
-
icon: 'i-lucide-file-down',
|
|
228
|
-
onSelect: () => ui.openDocumentImport(null),
|
|
229
|
-
})
|
|
230
|
-
return [items]
|
|
231
|
-
})
|
|
232
|
-
|
|
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
|
-
})
|
|
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))
|
|
211
|
+
const showIssuePicker = ref(false)
|
|
212
|
+
const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
|
|
259
213
|
|
|
260
214
|
// Reset the form whenever the modal opens for a (new) container, and refresh the
|
|
261
215
|
// imported docs/issues so the quick-pick list is current.
|
|
@@ -274,6 +228,8 @@ watch(open, (isOpen) => {
|
|
|
274
228
|
pipelineId.value = ''
|
|
275
229
|
agentConfigValues.value = {}
|
|
276
230
|
pendingContext.value = []
|
|
231
|
+
showDocPicker.value = false
|
|
232
|
+
showIssuePicker.value = false
|
|
277
233
|
documents.loadDocuments().catch(() => {})
|
|
278
234
|
tasks.loadTasks().catch(() => {})
|
|
279
235
|
})
|
|
@@ -526,15 +482,16 @@ async function add() {
|
|
|
526
482
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
527
483
|
Context documents
|
|
528
484
|
</span>
|
|
529
|
-
<
|
|
485
|
+
<UButton
|
|
530
486
|
v-if="docsConnected"
|
|
531
|
-
|
|
532
|
-
|
|
487
|
+
color="neutral"
|
|
488
|
+
variant="soft"
|
|
489
|
+
size="xs"
|
|
490
|
+
:icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
|
|
491
|
+
@click="showDocPicker = !showDocPicker"
|
|
533
492
|
>
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
</UButton>
|
|
537
|
-
</UDropdownMenu>
|
|
493
|
+
{{ showDocPicker ? 'Done' : 'Attach' }}
|
|
494
|
+
</UButton>
|
|
538
495
|
<UButton
|
|
539
496
|
v-else
|
|
540
497
|
color="neutral"
|
|
@@ -551,6 +508,11 @@ async function add() {
|
|
|
551
508
|
Attach
|
|
552
509
|
</UButton>
|
|
553
510
|
</div>
|
|
511
|
+
<ContextDocumentPicker
|
|
512
|
+
v-if="showDocPicker && docsConnected"
|
|
513
|
+
:chosen-keys="chosenDocKeys"
|
|
514
|
+
@pick="addPending"
|
|
515
|
+
/>
|
|
554
516
|
<div v-if="pendingDocs.length" class="space-y-1">
|
|
555
517
|
<div
|
|
556
518
|
v-for="item in pendingDocs"
|
|
@@ -591,15 +553,16 @@ async function add() {
|
|
|
591
553
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
592
554
|
Context issues
|
|
593
555
|
</span>
|
|
594
|
-
<
|
|
556
|
+
<UButton
|
|
595
557
|
v-if="issuesConnected"
|
|
596
|
-
|
|
597
|
-
|
|
558
|
+
color="neutral"
|
|
559
|
+
variant="soft"
|
|
560
|
+
size="xs"
|
|
561
|
+
:icon="showIssuePicker ? 'i-lucide-x' : 'i-lucide-plus'"
|
|
562
|
+
@click="showIssuePicker = !showIssuePicker"
|
|
598
563
|
>
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
</UButton>
|
|
602
|
-
</UDropdownMenu>
|
|
564
|
+
{{ showIssuePicker ? 'Done' : 'Attach' }}
|
|
565
|
+
</UButton>
|
|
603
566
|
<UButton
|
|
604
567
|
v-else
|
|
605
568
|
color="neutral"
|
|
@@ -616,6 +579,11 @@ async function add() {
|
|
|
616
579
|
Attach
|
|
617
580
|
</UButton>
|
|
618
581
|
</div>
|
|
582
|
+
<ContextIssuePicker
|
|
583
|
+
v-if="showIssuePicker && issuesConnected"
|
|
584
|
+
:chosen-keys="chosenIssueKeys"
|
|
585
|
+
@pick="addPending"
|
|
586
|
+
/>
|
|
619
587
|
<div v-if="pendingIssues.length" class="space-y-1">
|
|
620
588
|
<div
|
|
621
589
|
v-for="item in pendingIssues"
|
|
@@ -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,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
|
-
|
|
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="
|
|
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
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.16.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",
|