@cat-factory/app 0.15.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,14 @@
|
|
|
5
5
|
// pipeline on it explicitly (and can keep editing it until they do).
|
|
6
6
|
//
|
|
7
7
|
// The form also shows ungated "Context documents" / "Context issues" sections
|
|
8
|
-
// (mirroring the task inspector):
|
|
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'
|
|
15
16
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
16
17
|
|
|
17
18
|
const ui = useUiStore()
|
|
@@ -200,41 +201,13 @@ function removePending(item: PendingContext) {
|
|
|
200
201
|
pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
(d) =>
|
|
211
|
-
!chosen.has(contextKey({ kind: 'document', source: d.source, externalId: d.externalId })),
|
|
212
|
-
)
|
|
213
|
-
.map((d) => ({
|
|
214
|
-
label: d.title,
|
|
215
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
216
|
-
onSelect: () =>
|
|
217
|
-
addPending({
|
|
218
|
-
kind: 'document',
|
|
219
|
-
source: d.source,
|
|
220
|
-
externalId: d.externalId,
|
|
221
|
-
title: d.title,
|
|
222
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
223
|
-
needsImport: false,
|
|
224
|
-
}),
|
|
225
|
-
}))
|
|
226
|
-
items.push({
|
|
227
|
-
label: 'Import a page…',
|
|
228
|
-
icon: 'i-lucide-file-down',
|
|
229
|
-
onSelect: () => ui.openDocumentImport(null),
|
|
230
|
-
})
|
|
231
|
-
return [items]
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
// Context issues are picked through an inline search picker (ContextIssuePicker)
|
|
235
|
-
// rather than a dropdown that opens a second modal — stacked page-level modals
|
|
236
|
-
// don't interact here, which is why the old "Import an issue…" path appeared to
|
|
237
|
-
// do nothing. The "Attach" button toggles the picker open.
|
|
204
|
+
// Context documents and issues are both picked through an inline search picker
|
|
205
|
+
// (ContextDocumentPicker / ContextIssuePicker) rather than a dropdown that opens a
|
|
206
|
+
// second modal — stacked page-level modals don't interact here, which is why the
|
|
207
|
+
// old "Import a page…" / "Import an issue…" entries appeared to open something but
|
|
208
|
+
// nothing was clickable. The "Attach" button toggles the relevant picker open.
|
|
209
|
+
const showDocPicker = ref(false)
|
|
210
|
+
const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
|
|
238
211
|
const showIssuePicker = ref(false)
|
|
239
212
|
const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
|
|
240
213
|
|
|
@@ -255,6 +228,7 @@ watch(open, (isOpen) => {
|
|
|
255
228
|
pipelineId.value = ''
|
|
256
229
|
agentConfigValues.value = {}
|
|
257
230
|
pendingContext.value = []
|
|
231
|
+
showDocPicker.value = false
|
|
258
232
|
showIssuePicker.value = false
|
|
259
233
|
documents.loadDocuments().catch(() => {})
|
|
260
234
|
tasks.loadTasks().catch(() => {})
|
|
@@ -508,15 +482,16 @@ async function add() {
|
|
|
508
482
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
509
483
|
Context documents
|
|
510
484
|
</span>
|
|
511
|
-
<
|
|
485
|
+
<UButton
|
|
512
486
|
v-if="docsConnected"
|
|
513
|
-
|
|
514
|
-
|
|
487
|
+
color="neutral"
|
|
488
|
+
variant="soft"
|
|
489
|
+
size="xs"
|
|
490
|
+
:icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
|
|
491
|
+
@click="showDocPicker = !showDocPicker"
|
|
515
492
|
>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
</UButton>
|
|
519
|
-
</UDropdownMenu>
|
|
493
|
+
{{ showDocPicker ? 'Done' : 'Attach' }}
|
|
494
|
+
</UButton>
|
|
520
495
|
<UButton
|
|
521
496
|
v-else
|
|
522
497
|
color="neutral"
|
|
@@ -533,6 +508,11 @@ async function add() {
|
|
|
533
508
|
Attach
|
|
534
509
|
</UButton>
|
|
535
510
|
</div>
|
|
511
|
+
<ContextDocumentPicker
|
|
512
|
+
v-if="showDocPicker && docsConnected"
|
|
513
|
+
:chosen-keys="chosenDocKeys"
|
|
514
|
+
@pick="addPending"
|
|
515
|
+
/>
|
|
536
516
|
<div v-if="pendingDocs.length" class="space-y-1">
|
|
537
517
|
<div
|
|
538
518
|
v-for="item in pendingDocs"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Inline picker for attaching a document (Confluence / Notion / GitHub page) as
|
|
3
|
+
// task context. It searches the connected source by free text, lists
|
|
4
|
+
// already-imported documents for quick re-use, and accepts a pasted URL/ID as a
|
|
5
|
+
// reference — all inline, with NO second modal (stacked page-level modals don't
|
|
6
|
+
// interact here, which is why the old "Import a page…" path appeared to open
|
|
7
|
+
// something but nothing was clickable). It only *stages* a choice: the caller
|
|
8
|
+
// collects PendingContext items and links them once the block exists (see
|
|
9
|
+
// useContextLinking). A search hit / pasted ref carries `needsImport: true` so
|
|
10
|
+
// it's fetched + persisted before linking. Mirrors ContextIssuePicker.
|
|
11
|
+
import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
15
|
+
chosenKeys?: string[]
|
|
16
|
+
}>()
|
|
17
|
+
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
18
|
+
|
|
19
|
+
const documents = useDocumentsStore()
|
|
20
|
+
|
|
21
|
+
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
22
|
+
|
|
23
|
+
// Source: default to the first connected source; a selector appears only when
|
|
24
|
+
// more than one is connected (the common case is a single source).
|
|
25
|
+
const source = ref<DocumentSourceKind | undefined>(documents.connectedSources[0]?.source)
|
|
26
|
+
const sourceItems = computed(() =>
|
|
27
|
+
documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
|
+
)
|
|
29
|
+
const descriptor = computed(() =>
|
|
30
|
+
source.value ? documents.descriptorFor(source.value) : undefined,
|
|
31
|
+
)
|
|
32
|
+
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
33
|
+
|
|
34
|
+
const query = ref('')
|
|
35
|
+
const results = ref<DocumentSearchResult[]>([])
|
|
36
|
+
const searching = ref(false)
|
|
37
|
+
const searchError = ref<string | null>(null)
|
|
38
|
+
|
|
39
|
+
// Debounced search: free text hits the source; a query that's clearly a URL/ID
|
|
40
|
+
// is left to the explicit "by reference" row below (search won't surface it).
|
|
41
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
42
|
+
watch([query, source], () => {
|
|
43
|
+
if (timer) clearTimeout(timer)
|
|
44
|
+
results.value = []
|
|
45
|
+
searchError.value = null
|
|
46
|
+
const q = query.value.trim()
|
|
47
|
+
if (!q || !searchable.value) return
|
|
48
|
+
timer = setTimeout(runSearch, 300)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
async function runSearch() {
|
|
52
|
+
const q = query.value.trim()
|
|
53
|
+
if (!q || !source.value) return
|
|
54
|
+
searching.value = true
|
|
55
|
+
searchError.value = null
|
|
56
|
+
try {
|
|
57
|
+
results.value = await documents.search(source.value, q)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
results.value = []
|
|
60
|
+
searchError.value = e instanceof Error ? e.message : String(e)
|
|
61
|
+
} finally {
|
|
62
|
+
searching.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-file-text')
|
|
67
|
+
|
|
68
|
+
function keyFor(externalId: string): string {
|
|
69
|
+
return source.value
|
|
70
|
+
? contextKey({ kind: 'document', source: source.value, externalId })
|
|
71
|
+
: `document::${externalId}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Already-imported documents for this source, filtered by the query and never
|
|
75
|
+
// re-offering one the caller already staged.
|
|
76
|
+
const importedRows = computed(() => {
|
|
77
|
+
if (!source.value) return []
|
|
78
|
+
const q = query.value.trim().toLowerCase()
|
|
79
|
+
return documents.documents
|
|
80
|
+
.filter((d) => d.source === source.value)
|
|
81
|
+
.filter((d) => !chosen.value.has(keyFor(d.externalId)))
|
|
82
|
+
.filter((d) => !q || d.title.toLowerCase().includes(q) || d.excerpt.toLowerCase().includes(q))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Search hits not already imported (those show in importedRows) and not staged.
|
|
86
|
+
const searchRows = computed(() => {
|
|
87
|
+
if (!source.value) return []
|
|
88
|
+
const importedIds = new Set(
|
|
89
|
+
documents.documents.filter((d) => d.source === source.value).map((d) => d.externalId),
|
|
90
|
+
)
|
|
91
|
+
return results.value
|
|
92
|
+
.filter((r) => !importedIds.has(r.externalId))
|
|
93
|
+
.filter((r) => !chosen.value.has(keyFor(r.externalId)))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// A pasted URL / ID the search won't match: offer it as an explicit reference.
|
|
97
|
+
// When the source isn't searchable, any non-empty query is treated as a ref (the
|
|
98
|
+
// only way to attach a page there, mirroring the import modal's single input).
|
|
99
|
+
const refRow = computed(() => {
|
|
100
|
+
const q = query.value.trim()
|
|
101
|
+
if (!q || !source.value) return null
|
|
102
|
+
const known =
|
|
103
|
+
importedRows.value.some((d) => d.externalId === q) ||
|
|
104
|
+
searchRows.value.some((r) => r.externalId === q) ||
|
|
105
|
+
chosen.value.has(keyFor(q))
|
|
106
|
+
if (known) return null
|
|
107
|
+
if (!searchable.value) return q
|
|
108
|
+
// Only worth offering when it looks like a reference, not a search phrase.
|
|
109
|
+
const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
|
|
110
|
+
return looksLikeRef ? q : null
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const empty = computed(
|
|
114
|
+
() =>
|
|
115
|
+
!searching.value &&
|
|
116
|
+
!searchError.value &&
|
|
117
|
+
importedRows.value.length === 0 &&
|
|
118
|
+
searchRows.value.length === 0 &&
|
|
119
|
+
refRow.value === null,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
function pickImported(externalId: string, title: string, excerpt: string) {
|
|
123
|
+
if (!source.value) return
|
|
124
|
+
emit('pick', {
|
|
125
|
+
kind: 'document',
|
|
126
|
+
source: source.value,
|
|
127
|
+
externalId,
|
|
128
|
+
title,
|
|
129
|
+
subtitle: excerpt || undefined,
|
|
130
|
+
icon: icon.value,
|
|
131
|
+
needsImport: false,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickSearch(r: DocumentSearchResult) {
|
|
136
|
+
emit('pick', {
|
|
137
|
+
kind: 'document',
|
|
138
|
+
source: r.source,
|
|
139
|
+
externalId: r.externalId,
|
|
140
|
+
title: r.title,
|
|
141
|
+
subtitle: r.excerpt || undefined,
|
|
142
|
+
icon: icon.value,
|
|
143
|
+
needsImport: true,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pickRef(q: string) {
|
|
148
|
+
if (!source.value) return
|
|
149
|
+
emit('pick', {
|
|
150
|
+
kind: 'document',
|
|
151
|
+
source: source.value,
|
|
152
|
+
externalId: q,
|
|
153
|
+
title: q,
|
|
154
|
+
subtitle: descriptor.value?.label,
|
|
155
|
+
icon: icon.value,
|
|
156
|
+
needsImport: true,
|
|
157
|
+
})
|
|
158
|
+
query.value = ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onMounted(() => {
|
|
162
|
+
// Keep the quick-pick list current (cheap; the store dedupes).
|
|
163
|
+
documents.loadDocuments().catch(() => {})
|
|
164
|
+
})
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<template>
|
|
168
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
169
|
+
<USelect
|
|
170
|
+
v-if="sourceItems.length > 1"
|
|
171
|
+
v-model="source"
|
|
172
|
+
:items="sourceItems"
|
|
173
|
+
size="xs"
|
|
174
|
+
class="w-full"
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
<UInput
|
|
178
|
+
v-model="query"
|
|
179
|
+
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
180
|
+
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
181
|
+
size="sm"
|
|
182
|
+
class="w-full"
|
|
183
|
+
:placeholder="
|
|
184
|
+
searchable
|
|
185
|
+
? 'Search pages or paste a URL/ID…'
|
|
186
|
+
: (descriptor?.refPlaceholder ?? 'Paste a page URL or ID…')
|
|
187
|
+
"
|
|
188
|
+
@keydown.enter="refRow && pickRef(refRow)"
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
192
|
+
Search failed: {{ searchError }}
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
196
|
+
<!-- Already-imported documents (linked directly, no re-fetch). -->
|
|
197
|
+
<button
|
|
198
|
+
v-for="d in importedRows"
|
|
199
|
+
:key="`imp:${d.externalId}`"
|
|
200
|
+
type="button"
|
|
201
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
202
|
+
@click="pickImported(d.externalId, d.title, d.excerpt)"
|
|
203
|
+
>
|
|
204
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
205
|
+
<span class="truncate">{{ d.title }}</span>
|
|
206
|
+
<UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">imported</UBadge>
|
|
207
|
+
</button>
|
|
208
|
+
|
|
209
|
+
<!-- Source search hits (imported on add). -->
|
|
210
|
+
<button
|
|
211
|
+
v-for="r in searchRows"
|
|
212
|
+
:key="`hit:${r.externalId}`"
|
|
213
|
+
type="button"
|
|
214
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
215
|
+
@click="pickSearch(r)"
|
|
216
|
+
>
|
|
217
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
218
|
+
<span class="truncate">{{ r.title }}</span>
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
<!-- Explicit URL/ID reference (imported on add). -->
|
|
222
|
+
<button
|
|
223
|
+
v-if="refRow"
|
|
224
|
+
type="button"
|
|
225
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
226
|
+
@click="pickRef(refRow)"
|
|
227
|
+
>
|
|
228
|
+
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
229
|
+
<span class="truncate"
|
|
230
|
+
>Attach <span class="text-slate-200">{{ refRow }}</span> by reference</span
|
|
231
|
+
>
|
|
232
|
+
</button>
|
|
233
|
+
|
|
234
|
+
<p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
|
|
235
|
+
{{
|
|
236
|
+
query.trim()
|
|
237
|
+
? 'No matching pages.'
|
|
238
|
+
: searchable
|
|
239
|
+
? 'Search by title, or pick an imported document.'
|
|
240
|
+
: 'Paste a page URL or ID to attach it.'
|
|
241
|
+
}}
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
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",
|