@cat-factory/app 0.6.0 → 0.7.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.
- package/LICENSE +21 -21
- package/app/components/board/ContextPicker.vue +367 -367
- package/app/components/gates/GateResultView.vue +109 -4
- package/app/components/layout/SideBar.vue +11 -0
- package/app/components/observability/StepMetricsBar.vue +102 -102
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
- package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -124
- package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
- package/app/composables/useBlockQueries.ts +154 -154
- package/app/composables/useContextLinking.ts +65 -65
- package/app/composables/useFrameResize.ts +54 -54
- package/app/pages/index.vue +2 -0
- package/app/stores/documents.ts +176 -176
- package/app/stores/services.ts +87 -87
- package/app/stores/tracker.ts +39 -27
- package/app/stores/ui.ts +12 -0
- package/app/types/documents.ts +104 -104
- package/app/types/domain.ts +5 -1
- package/app/types/execution.ts +18 -0
- package/app/types/github.ts +173 -173
- package/app/types/services.ts +27 -27
- package/app/types/tasks.ts +82 -82
- package/app/types/tracker.ts +27 -18
- package/app/utils/agentOutput.spec.ts +128 -128
- package/app/utils/agentOutput.ts +173 -173
- package/app/utils/observability.ts +52 -52
- package/package.json +6 -1
|
@@ -1,367 +1,367 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// A self-contained picker for attaching external context (imported docs +
|
|
3
|
-
// tracker issues) to a task. It surfaces the existing integrations — Confluence /
|
|
4
|
-
// Notion / GitHub repo docs (documents) and Jira / GitHub issues (tasks) — behind
|
|
5
|
-
// one control: pick a source, then either search its catalogue by title/content
|
|
6
|
-
// (when the source is searchable) or paste a page/issue URL or id, and pick from
|
|
7
|
-
// what's already been imported. Chosen items are collected into a v-model list of
|
|
8
|
-
// `PendingContext`; the parent links them once the block exists (see
|
|
9
|
-
// useContextLinking). Connection/availability gating mirrors the sidebar: a
|
|
10
|
-
// source that isn't connected shows a connect affordance instead of an input.
|
|
11
|
-
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
12
|
-
import type {
|
|
13
|
-
DocumentSearchResult,
|
|
14
|
-
DocumentSourceKind,
|
|
15
|
-
TaskSearchResult,
|
|
16
|
-
TaskSourceKind,
|
|
17
|
-
} from '~/types/domain'
|
|
18
|
-
|
|
19
|
-
const model = defineModel<PendingContext[]>({ required: true })
|
|
20
|
-
|
|
21
|
-
const documents = useDocumentsStore()
|
|
22
|
-
const tasks = useTasksStore()
|
|
23
|
-
const ui = useUiStore()
|
|
24
|
-
const toast = useToast()
|
|
25
|
-
|
|
26
|
-
interface SourceOption {
|
|
27
|
-
kind: 'document' | 'task'
|
|
28
|
-
source: DocumentSourceKind | TaskSourceKind
|
|
29
|
-
label: string
|
|
30
|
-
icon: string
|
|
31
|
-
searchable: boolean
|
|
32
|
-
connected: boolean
|
|
33
|
-
refLabel: string
|
|
34
|
-
refPlaceholder: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Every configured source across both integrations, tagged by kind. Documents
|
|
38
|
-
// first, then trackers — the order the sidebar uses.
|
|
39
|
-
const sources = computed<SourceOption[]>(() => {
|
|
40
|
-
const docs: SourceOption[] = documents.available
|
|
41
|
-
? documents.sources.map((s) => ({
|
|
42
|
-
kind: 'document',
|
|
43
|
-
source: s.source,
|
|
44
|
-
label: s.label,
|
|
45
|
-
icon: s.icon,
|
|
46
|
-
searchable: s.searchable ?? false,
|
|
47
|
-
connected: documents.isConnected(s.source),
|
|
48
|
-
refLabel: s.refLabel,
|
|
49
|
-
refPlaceholder: s.refPlaceholder,
|
|
50
|
-
}))
|
|
51
|
-
: []
|
|
52
|
-
const issues: SourceOption[] = tasks.available
|
|
53
|
-
? tasks.sources.map((s) => ({
|
|
54
|
-
kind: 'task',
|
|
55
|
-
source: s.source,
|
|
56
|
-
label: s.label,
|
|
57
|
-
icon: s.icon,
|
|
58
|
-
searchable: s.searchable ?? false,
|
|
59
|
-
connected: tasks.isConnected(s.source),
|
|
60
|
-
refLabel: s.refLabel,
|
|
61
|
-
refPlaceholder: s.refPlaceholder,
|
|
62
|
-
}))
|
|
63
|
-
: []
|
|
64
|
-
return [...docs, ...issues]
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const selectedKey = ref('')
|
|
68
|
-
const selected = computed(() =>
|
|
69
|
-
sources.value.find((s) => `${s.kind}:${s.source}` === selectedKey.value),
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
// Default the selection to the first connected source (else the first source),
|
|
73
|
-
// once the source list resolves.
|
|
74
|
-
watch(
|
|
75
|
-
sources,
|
|
76
|
-
(list) => {
|
|
77
|
-
if (selected.value || list.length === 0) return
|
|
78
|
-
selectedKey.value = `${(list.find((s) => s.connected) ?? list[0]!).kind}:${(list.find((s) => s.connected) ?? list[0]!).source}`
|
|
79
|
-
},
|
|
80
|
-
{ immediate: true },
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
const sourceMenu = computed<DropdownMenuItem[][]>(() => [
|
|
84
|
-
sources.value.map((s) => ({
|
|
85
|
-
label: s.connected ? s.label : `${s.label} (not connected)`,
|
|
86
|
-
icon: s.icon,
|
|
87
|
-
onSelect: () => {
|
|
88
|
-
selectedKey.value = `${s.kind}:${s.source}`
|
|
89
|
-
query.value = ''
|
|
90
|
-
results.value = []
|
|
91
|
-
},
|
|
92
|
-
})),
|
|
93
|
-
])
|
|
94
|
-
|
|
95
|
-
// The "set up a new integration" menu: every configured source, so the user can
|
|
96
|
-
// connect (or reconnect) one without leaving the add-task popup. Unconnected
|
|
97
|
-
// sources come first — those are the ones you'd typically be setting up here.
|
|
98
|
-
const connectMenu = computed<DropdownMenuItem[][]>(() => [
|
|
99
|
-
[...sources.value]
|
|
100
|
-
.sort((a, b) => Number(a.connected) - Number(b.connected))
|
|
101
|
-
.map((s) => ({
|
|
102
|
-
label: s.connected ? `${s.label} (reconnect)` : `Connect ${s.label}`,
|
|
103
|
-
icon: s.icon,
|
|
104
|
-
onSelect: () => connect(s),
|
|
105
|
-
})),
|
|
106
|
-
])
|
|
107
|
-
|
|
108
|
-
// ---- search / import-by-ref ----------------------------------------------
|
|
109
|
-
|
|
110
|
-
const query = ref('')
|
|
111
|
-
const results = ref<(DocumentSearchResult | TaskSearchResult)[]>([])
|
|
112
|
-
const searching = ref(false)
|
|
113
|
-
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
114
|
-
|
|
115
|
-
watch([query, selectedKey], () => {
|
|
116
|
-
results.value = []
|
|
117
|
-
if (searchTimer) clearTimeout(searchTimer)
|
|
118
|
-
const src = selected.value
|
|
119
|
-
const q = query.value.trim()
|
|
120
|
-
if (!src || !src.searchable || !src.connected || q.length < 2) return
|
|
121
|
-
searchTimer = setTimeout(() => void runSearch(src, q), 300)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
async function runSearch(src: SourceOption, q: string) {
|
|
125
|
-
searching.value = true
|
|
126
|
-
try {
|
|
127
|
-
results.value =
|
|
128
|
-
src.kind === 'document'
|
|
129
|
-
? await documents.search(src.source as DocumentSourceKind, q)
|
|
130
|
-
: await tasks.search(src.source as TaskSourceKind, q)
|
|
131
|
-
} catch {
|
|
132
|
-
// A search failure (e.g. the source can't search, or a transient API error)
|
|
133
|
-
// just yields no results — paste-a-URL still works.
|
|
134
|
-
results.value = []
|
|
135
|
-
} finally {
|
|
136
|
-
searching.value = false
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const selectedKeys = computed(() => new Set(model.value.map(contextKey)))
|
|
141
|
-
|
|
142
|
-
function toggle(item: PendingContext) {
|
|
143
|
-
const key = contextKey(item)
|
|
144
|
-
if (selectedKeys.value.has(key)) {
|
|
145
|
-
model.value = model.value.filter((c) => contextKey(c) !== key)
|
|
146
|
-
} else {
|
|
147
|
-
model.value = [...model.value, item]
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Attach the raw input as a page/issue ref (URL or id) — imported on commit. */
|
|
152
|
-
function addByRef() {
|
|
153
|
-
const src = selected.value
|
|
154
|
-
const ref = query.value.trim()
|
|
155
|
-
if (!src || !ref) return
|
|
156
|
-
toggle({
|
|
157
|
-
kind: src.kind,
|
|
158
|
-
source: src.source,
|
|
159
|
-
externalId: ref,
|
|
160
|
-
title: ref,
|
|
161
|
-
subtitle: `${src.label} · imports on add`,
|
|
162
|
-
icon: src.icon,
|
|
163
|
-
needsImport: true,
|
|
164
|
-
})
|
|
165
|
-
query.value = ''
|
|
166
|
-
results.value = []
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function pickResult(src: SourceOption, r: DocumentSearchResult | TaskSearchResult) {
|
|
170
|
-
toggle({
|
|
171
|
-
kind: src.kind,
|
|
172
|
-
source: src.source,
|
|
173
|
-
externalId: r.externalId,
|
|
174
|
-
title: r.title,
|
|
175
|
-
subtitle: 'status' in r && r.status ? r.status : src.label,
|
|
176
|
-
icon: src.icon,
|
|
177
|
-
needsImport: true,
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Already-imported items for the selected source, for quick re-attaching without
|
|
182
|
-
// a round-trip. Excludes anything already pending.
|
|
183
|
-
const imported = computed<PendingContext[]>(() => {
|
|
184
|
-
const src = selected.value
|
|
185
|
-
if (!src) return []
|
|
186
|
-
const items: PendingContext[] =
|
|
187
|
-
src.kind === 'document'
|
|
188
|
-
? documents.documents
|
|
189
|
-
.filter((d) => d.source === src.source)
|
|
190
|
-
.map((d) => ({
|
|
191
|
-
kind: 'document' as const,
|
|
192
|
-
source: d.source,
|
|
193
|
-
externalId: d.externalId,
|
|
194
|
-
title: d.title,
|
|
195
|
-
subtitle: src.label,
|
|
196
|
-
icon: src.icon,
|
|
197
|
-
needsImport: false,
|
|
198
|
-
}))
|
|
199
|
-
: tasks.tasks
|
|
200
|
-
.filter((t) => t.source === src.source)
|
|
201
|
-
.map((t) => ({
|
|
202
|
-
kind: 'task' as const,
|
|
203
|
-
source: t.source,
|
|
204
|
-
externalId: t.externalId,
|
|
205
|
-
title: `${t.externalId} · ${t.title}`,
|
|
206
|
-
subtitle: t.status || src.label,
|
|
207
|
-
icon: src.icon,
|
|
208
|
-
needsImport: false,
|
|
209
|
-
}))
|
|
210
|
-
return items.filter((i) => !selectedKeys.value.has(contextKey(i)))
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
function connect(src: SourceOption) {
|
|
214
|
-
if (src.kind === 'document') ui.openDocumentConnect(src.source as DocumentSourceKind)
|
|
215
|
-
else ui.openTaskConnect(src.source as TaskSourceKind)
|
|
216
|
-
toast.add({
|
|
217
|
-
title: `Connect ${src.label} — it'll be ready here once connected`,
|
|
218
|
-
icon: 'i-lucide-plug',
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
</script>
|
|
222
|
-
|
|
223
|
-
<template>
|
|
224
|
-
<div v-if="sources.length" class="space-y-2">
|
|
225
|
-
<div class="flex items-center gap-2">
|
|
226
|
-
<UDropdownMenu :items="sourceMenu" class="shrink-0">
|
|
227
|
-
<UButton
|
|
228
|
-
color="neutral"
|
|
229
|
-
variant="subtle"
|
|
230
|
-
size="sm"
|
|
231
|
-
:icon="selected?.icon ?? 'i-lucide-link'"
|
|
232
|
-
trailing-icon="i-lucide-chevron-down"
|
|
233
|
-
>
|
|
234
|
-
{{ selected?.label ?? 'Source' }}
|
|
235
|
-
</UButton>
|
|
236
|
-
</UDropdownMenu>
|
|
237
|
-
|
|
238
|
-
<UInput
|
|
239
|
-
v-if="selected?.connected"
|
|
240
|
-
v-model="query"
|
|
241
|
-
:placeholder="
|
|
242
|
-
selected?.searchable
|
|
243
|
-
? `Search ${selected?.label} or paste a URL…`
|
|
244
|
-
: selected?.refPlaceholder
|
|
245
|
-
"
|
|
246
|
-
class="flex-1"
|
|
247
|
-
:loading="searching"
|
|
248
|
-
icon="i-lucide-search"
|
|
249
|
-
@keydown.enter.prevent="addByRef"
|
|
250
|
-
/>
|
|
251
|
-
|
|
252
|
-
<UDropdownMenu :items="connectMenu" class="ml-auto shrink-0">
|
|
253
|
-
<UButton
|
|
254
|
-
color="neutral"
|
|
255
|
-
variant="ghost"
|
|
256
|
-
size="sm"
|
|
257
|
-
icon="i-lucide-plus"
|
|
258
|
-
trailing-icon="i-lucide-chevron-down"
|
|
259
|
-
title="Connect an integration"
|
|
260
|
-
>
|
|
261
|
-
Connect a source
|
|
262
|
-
</UButton>
|
|
263
|
-
</UDropdownMenu>
|
|
264
|
-
</div>
|
|
265
|
-
|
|
266
|
-
<!-- not-connected affordance -->
|
|
267
|
-
<div
|
|
268
|
-
v-if="selected && !selected.connected"
|
|
269
|
-
class="flex items-center justify-between rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-400"
|
|
270
|
-
>
|
|
271
|
-
<span>{{ selected.label }} isn't connected yet.</span>
|
|
272
|
-
<UButton
|
|
273
|
-
color="neutral"
|
|
274
|
-
variant="soft"
|
|
275
|
-
size="xs"
|
|
276
|
-
icon="i-lucide-plug"
|
|
277
|
-
@click="connect(selected)"
|
|
278
|
-
>
|
|
279
|
-
Connect
|
|
280
|
-
</UButton>
|
|
281
|
-
</div>
|
|
282
|
-
|
|
283
|
-
<!-- search results + paste-by-URL -->
|
|
284
|
-
<div v-if="selected?.connected && query.trim()" class="space-y-1">
|
|
285
|
-
<button
|
|
286
|
-
type="button"
|
|
287
|
-
class="flex w-full items-center gap-1.5 rounded-md border border-dashed border-slate-700 bg-slate-900/40 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
288
|
-
@click="addByRef"
|
|
289
|
-
>
|
|
290
|
-
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
291
|
-
<span class="truncate">Link “{{ query.trim() }}” by URL or id</span>
|
|
292
|
-
</button>
|
|
293
|
-
<button
|
|
294
|
-
v-for="r in results"
|
|
295
|
-
:key="`${r.source}:${r.externalId}`"
|
|
296
|
-
type="button"
|
|
297
|
-
class="w-full rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
298
|
-
@click="pickResult(selected!, r)"
|
|
299
|
-
>
|
|
300
|
-
<span class="flex items-center gap-1.5">
|
|
301
|
-
<UIcon
|
|
302
|
-
:name="selected?.icon ?? 'i-lucide-file-text'"
|
|
303
|
-
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
304
|
-
/>
|
|
305
|
-
<span class="truncate">{{ r.title }}</span>
|
|
306
|
-
<UBadge
|
|
307
|
-
v-if="'status' in r && r.status"
|
|
308
|
-
color="neutral"
|
|
309
|
-
variant="soft"
|
|
310
|
-
size="xs"
|
|
311
|
-
class="ml-auto shrink-0"
|
|
312
|
-
>
|
|
313
|
-
{{ r.status }}
|
|
314
|
-
</UBadge>
|
|
315
|
-
</span>
|
|
316
|
-
<span v-if="r.excerpt" class="mt-0.5 block truncate pl-5 text-[11px] text-slate-500">
|
|
317
|
-
{{ r.excerpt }}
|
|
318
|
-
</span>
|
|
319
|
-
</button>
|
|
320
|
-
<p
|
|
321
|
-
v-if="selected?.searchable && !searching && !results.length"
|
|
322
|
-
class="px-1 text-[11px] text-slate-500"
|
|
323
|
-
>
|
|
324
|
-
No matches — or paste the exact URL/id above.
|
|
325
|
-
</p>
|
|
326
|
-
</div>
|
|
327
|
-
|
|
328
|
-
<!-- already-imported quick pick -->
|
|
329
|
-
<div v-if="imported.length" class="space-y-1">
|
|
330
|
-
<span class="px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
331
|
-
Already imported
|
|
332
|
-
</span>
|
|
333
|
-
<button
|
|
334
|
-
v-for="item in imported"
|
|
335
|
-
:key="contextKey(item)"
|
|
336
|
-
type="button"
|
|
337
|
-
class="flex w-full items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
338
|
-
@click="toggle(item)"
|
|
339
|
-
>
|
|
340
|
-
<UIcon
|
|
341
|
-
:name="item.icon ?? 'i-lucide-file-text'"
|
|
342
|
-
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
343
|
-
/>
|
|
344
|
-
<span class="truncate">{{ item.title }}</span>
|
|
345
|
-
</button>
|
|
346
|
-
</div>
|
|
347
|
-
|
|
348
|
-
<!-- chosen items -->
|
|
349
|
-
<div v-if="model.length" class="flex flex-wrap gap-1.5">
|
|
350
|
-
<span
|
|
351
|
-
v-for="item in model"
|
|
352
|
-
:key="contextKey(item)"
|
|
353
|
-
class="flex max-w-full items-center gap-1 rounded-full border border-indigo-500/60 bg-indigo-500/10 px-2 py-0.5 text-[11px] text-slate-200"
|
|
354
|
-
>
|
|
355
|
-
<UIcon :name="item.icon ?? 'i-lucide-link'" class="h-3 w-3 shrink-0 text-indigo-400" />
|
|
356
|
-
<span class="truncate">{{ item.title }}</span>
|
|
357
|
-
<button
|
|
358
|
-
type="button"
|
|
359
|
-
class="shrink-0 text-slate-400 hover:text-slate-200"
|
|
360
|
-
@click="toggle(item)"
|
|
361
|
-
>
|
|
362
|
-
<UIcon name="i-lucide-x" class="h-3 w-3" />
|
|
363
|
-
</button>
|
|
364
|
-
</span>
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
367
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A self-contained picker for attaching external context (imported docs +
|
|
3
|
+
// tracker issues) to a task. It surfaces the existing integrations — Confluence /
|
|
4
|
+
// Notion / GitHub repo docs (documents) and Jira / GitHub issues (tasks) — behind
|
|
5
|
+
// one control: pick a source, then either search its catalogue by title/content
|
|
6
|
+
// (when the source is searchable) or paste a page/issue URL or id, and pick from
|
|
7
|
+
// what's already been imported. Chosen items are collected into a v-model list of
|
|
8
|
+
// `PendingContext`; the parent links them once the block exists (see
|
|
9
|
+
// useContextLinking). Connection/availability gating mirrors the sidebar: a
|
|
10
|
+
// source that isn't connected shows a connect affordance instead of an input.
|
|
11
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
12
|
+
import type {
|
|
13
|
+
DocumentSearchResult,
|
|
14
|
+
DocumentSourceKind,
|
|
15
|
+
TaskSearchResult,
|
|
16
|
+
TaskSourceKind,
|
|
17
|
+
} from '~/types/domain'
|
|
18
|
+
|
|
19
|
+
const model = defineModel<PendingContext[]>({ required: true })
|
|
20
|
+
|
|
21
|
+
const documents = useDocumentsStore()
|
|
22
|
+
const tasks = useTasksStore()
|
|
23
|
+
const ui = useUiStore()
|
|
24
|
+
const toast = useToast()
|
|
25
|
+
|
|
26
|
+
interface SourceOption {
|
|
27
|
+
kind: 'document' | 'task'
|
|
28
|
+
source: DocumentSourceKind | TaskSourceKind
|
|
29
|
+
label: string
|
|
30
|
+
icon: string
|
|
31
|
+
searchable: boolean
|
|
32
|
+
connected: boolean
|
|
33
|
+
refLabel: string
|
|
34
|
+
refPlaceholder: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Every configured source across both integrations, tagged by kind. Documents
|
|
38
|
+
// first, then trackers — the order the sidebar uses.
|
|
39
|
+
const sources = computed<SourceOption[]>(() => {
|
|
40
|
+
const docs: SourceOption[] = documents.available
|
|
41
|
+
? documents.sources.map((s) => ({
|
|
42
|
+
kind: 'document',
|
|
43
|
+
source: s.source,
|
|
44
|
+
label: s.label,
|
|
45
|
+
icon: s.icon,
|
|
46
|
+
searchable: s.searchable ?? false,
|
|
47
|
+
connected: documents.isConnected(s.source),
|
|
48
|
+
refLabel: s.refLabel,
|
|
49
|
+
refPlaceholder: s.refPlaceholder,
|
|
50
|
+
}))
|
|
51
|
+
: []
|
|
52
|
+
const issues: SourceOption[] = tasks.available
|
|
53
|
+
? tasks.sources.map((s) => ({
|
|
54
|
+
kind: 'task',
|
|
55
|
+
source: s.source,
|
|
56
|
+
label: s.label,
|
|
57
|
+
icon: s.icon,
|
|
58
|
+
searchable: s.searchable ?? false,
|
|
59
|
+
connected: tasks.isConnected(s.source),
|
|
60
|
+
refLabel: s.refLabel,
|
|
61
|
+
refPlaceholder: s.refPlaceholder,
|
|
62
|
+
}))
|
|
63
|
+
: []
|
|
64
|
+
return [...docs, ...issues]
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const selectedKey = ref('')
|
|
68
|
+
const selected = computed(() =>
|
|
69
|
+
sources.value.find((s) => `${s.kind}:${s.source}` === selectedKey.value),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// Default the selection to the first connected source (else the first source),
|
|
73
|
+
// once the source list resolves.
|
|
74
|
+
watch(
|
|
75
|
+
sources,
|
|
76
|
+
(list) => {
|
|
77
|
+
if (selected.value || list.length === 0) return
|
|
78
|
+
selectedKey.value = `${(list.find((s) => s.connected) ?? list[0]!).kind}:${(list.find((s) => s.connected) ?? list[0]!).source}`
|
|
79
|
+
},
|
|
80
|
+
{ immediate: true },
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const sourceMenu = computed<DropdownMenuItem[][]>(() => [
|
|
84
|
+
sources.value.map((s) => ({
|
|
85
|
+
label: s.connected ? s.label : `${s.label} (not connected)`,
|
|
86
|
+
icon: s.icon,
|
|
87
|
+
onSelect: () => {
|
|
88
|
+
selectedKey.value = `${s.kind}:${s.source}`
|
|
89
|
+
query.value = ''
|
|
90
|
+
results.value = []
|
|
91
|
+
},
|
|
92
|
+
})),
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
// The "set up a new integration" menu: every configured source, so the user can
|
|
96
|
+
// connect (or reconnect) one without leaving the add-task popup. Unconnected
|
|
97
|
+
// sources come first — those are the ones you'd typically be setting up here.
|
|
98
|
+
const connectMenu = computed<DropdownMenuItem[][]>(() => [
|
|
99
|
+
[...sources.value]
|
|
100
|
+
.sort((a, b) => Number(a.connected) - Number(b.connected))
|
|
101
|
+
.map((s) => ({
|
|
102
|
+
label: s.connected ? `${s.label} (reconnect)` : `Connect ${s.label}`,
|
|
103
|
+
icon: s.icon,
|
|
104
|
+
onSelect: () => connect(s),
|
|
105
|
+
})),
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
// ---- search / import-by-ref ----------------------------------------------
|
|
109
|
+
|
|
110
|
+
const query = ref('')
|
|
111
|
+
const results = ref<(DocumentSearchResult | TaskSearchResult)[]>([])
|
|
112
|
+
const searching = ref(false)
|
|
113
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
114
|
+
|
|
115
|
+
watch([query, selectedKey], () => {
|
|
116
|
+
results.value = []
|
|
117
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
118
|
+
const src = selected.value
|
|
119
|
+
const q = query.value.trim()
|
|
120
|
+
if (!src || !src.searchable || !src.connected || q.length < 2) return
|
|
121
|
+
searchTimer = setTimeout(() => void runSearch(src, q), 300)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
async function runSearch(src: SourceOption, q: string) {
|
|
125
|
+
searching.value = true
|
|
126
|
+
try {
|
|
127
|
+
results.value =
|
|
128
|
+
src.kind === 'document'
|
|
129
|
+
? await documents.search(src.source as DocumentSourceKind, q)
|
|
130
|
+
: await tasks.search(src.source as TaskSourceKind, q)
|
|
131
|
+
} catch {
|
|
132
|
+
// A search failure (e.g. the source can't search, or a transient API error)
|
|
133
|
+
// just yields no results — paste-a-URL still works.
|
|
134
|
+
results.value = []
|
|
135
|
+
} finally {
|
|
136
|
+
searching.value = false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const selectedKeys = computed(() => new Set(model.value.map(contextKey)))
|
|
141
|
+
|
|
142
|
+
function toggle(item: PendingContext) {
|
|
143
|
+
const key = contextKey(item)
|
|
144
|
+
if (selectedKeys.value.has(key)) {
|
|
145
|
+
model.value = model.value.filter((c) => contextKey(c) !== key)
|
|
146
|
+
} else {
|
|
147
|
+
model.value = [...model.value, item]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Attach the raw input as a page/issue ref (URL or id) — imported on commit. */
|
|
152
|
+
function addByRef() {
|
|
153
|
+
const src = selected.value
|
|
154
|
+
const ref = query.value.trim()
|
|
155
|
+
if (!src || !ref) return
|
|
156
|
+
toggle({
|
|
157
|
+
kind: src.kind,
|
|
158
|
+
source: src.source,
|
|
159
|
+
externalId: ref,
|
|
160
|
+
title: ref,
|
|
161
|
+
subtitle: `${src.label} · imports on add`,
|
|
162
|
+
icon: src.icon,
|
|
163
|
+
needsImport: true,
|
|
164
|
+
})
|
|
165
|
+
query.value = ''
|
|
166
|
+
results.value = []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pickResult(src: SourceOption, r: DocumentSearchResult | TaskSearchResult) {
|
|
170
|
+
toggle({
|
|
171
|
+
kind: src.kind,
|
|
172
|
+
source: src.source,
|
|
173
|
+
externalId: r.externalId,
|
|
174
|
+
title: r.title,
|
|
175
|
+
subtitle: 'status' in r && r.status ? r.status : src.label,
|
|
176
|
+
icon: src.icon,
|
|
177
|
+
needsImport: true,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Already-imported items for the selected source, for quick re-attaching without
|
|
182
|
+
// a round-trip. Excludes anything already pending.
|
|
183
|
+
const imported = computed<PendingContext[]>(() => {
|
|
184
|
+
const src = selected.value
|
|
185
|
+
if (!src) return []
|
|
186
|
+
const items: PendingContext[] =
|
|
187
|
+
src.kind === 'document'
|
|
188
|
+
? documents.documents
|
|
189
|
+
.filter((d) => d.source === src.source)
|
|
190
|
+
.map((d) => ({
|
|
191
|
+
kind: 'document' as const,
|
|
192
|
+
source: d.source,
|
|
193
|
+
externalId: d.externalId,
|
|
194
|
+
title: d.title,
|
|
195
|
+
subtitle: src.label,
|
|
196
|
+
icon: src.icon,
|
|
197
|
+
needsImport: false,
|
|
198
|
+
}))
|
|
199
|
+
: tasks.tasks
|
|
200
|
+
.filter((t) => t.source === src.source)
|
|
201
|
+
.map((t) => ({
|
|
202
|
+
kind: 'task' as const,
|
|
203
|
+
source: t.source,
|
|
204
|
+
externalId: t.externalId,
|
|
205
|
+
title: `${t.externalId} · ${t.title}`,
|
|
206
|
+
subtitle: t.status || src.label,
|
|
207
|
+
icon: src.icon,
|
|
208
|
+
needsImport: false,
|
|
209
|
+
}))
|
|
210
|
+
return items.filter((i) => !selectedKeys.value.has(contextKey(i)))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
function connect(src: SourceOption) {
|
|
214
|
+
if (src.kind === 'document') ui.openDocumentConnect(src.source as DocumentSourceKind)
|
|
215
|
+
else ui.openTaskConnect(src.source as TaskSourceKind)
|
|
216
|
+
toast.add({
|
|
217
|
+
title: `Connect ${src.label} — it'll be ready here once connected`,
|
|
218
|
+
icon: 'i-lucide-plug',
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
</script>
|
|
222
|
+
|
|
223
|
+
<template>
|
|
224
|
+
<div v-if="sources.length" class="space-y-2">
|
|
225
|
+
<div class="flex items-center gap-2">
|
|
226
|
+
<UDropdownMenu :items="sourceMenu" class="shrink-0">
|
|
227
|
+
<UButton
|
|
228
|
+
color="neutral"
|
|
229
|
+
variant="subtle"
|
|
230
|
+
size="sm"
|
|
231
|
+
:icon="selected?.icon ?? 'i-lucide-link'"
|
|
232
|
+
trailing-icon="i-lucide-chevron-down"
|
|
233
|
+
>
|
|
234
|
+
{{ selected?.label ?? 'Source' }}
|
|
235
|
+
</UButton>
|
|
236
|
+
</UDropdownMenu>
|
|
237
|
+
|
|
238
|
+
<UInput
|
|
239
|
+
v-if="selected?.connected"
|
|
240
|
+
v-model="query"
|
|
241
|
+
:placeholder="
|
|
242
|
+
selected?.searchable
|
|
243
|
+
? `Search ${selected?.label} or paste a URL…`
|
|
244
|
+
: selected?.refPlaceholder
|
|
245
|
+
"
|
|
246
|
+
class="flex-1"
|
|
247
|
+
:loading="searching"
|
|
248
|
+
icon="i-lucide-search"
|
|
249
|
+
@keydown.enter.prevent="addByRef"
|
|
250
|
+
/>
|
|
251
|
+
|
|
252
|
+
<UDropdownMenu :items="connectMenu" class="ml-auto shrink-0">
|
|
253
|
+
<UButton
|
|
254
|
+
color="neutral"
|
|
255
|
+
variant="ghost"
|
|
256
|
+
size="sm"
|
|
257
|
+
icon="i-lucide-plus"
|
|
258
|
+
trailing-icon="i-lucide-chevron-down"
|
|
259
|
+
title="Connect an integration"
|
|
260
|
+
>
|
|
261
|
+
Connect a source
|
|
262
|
+
</UButton>
|
|
263
|
+
</UDropdownMenu>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- not-connected affordance -->
|
|
267
|
+
<div
|
|
268
|
+
v-if="selected && !selected.connected"
|
|
269
|
+
class="flex items-center justify-between rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-400"
|
|
270
|
+
>
|
|
271
|
+
<span>{{ selected.label }} isn't connected yet.</span>
|
|
272
|
+
<UButton
|
|
273
|
+
color="neutral"
|
|
274
|
+
variant="soft"
|
|
275
|
+
size="xs"
|
|
276
|
+
icon="i-lucide-plug"
|
|
277
|
+
@click="connect(selected)"
|
|
278
|
+
>
|
|
279
|
+
Connect
|
|
280
|
+
</UButton>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<!-- search results + paste-by-URL -->
|
|
284
|
+
<div v-if="selected?.connected && query.trim()" class="space-y-1">
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
class="flex w-full items-center gap-1.5 rounded-md border border-dashed border-slate-700 bg-slate-900/40 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
288
|
+
@click="addByRef"
|
|
289
|
+
>
|
|
290
|
+
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
291
|
+
<span class="truncate">Link “{{ query.trim() }}” by URL or id</span>
|
|
292
|
+
</button>
|
|
293
|
+
<button
|
|
294
|
+
v-for="r in results"
|
|
295
|
+
:key="`${r.source}:${r.externalId}`"
|
|
296
|
+
type="button"
|
|
297
|
+
class="w-full rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
298
|
+
@click="pickResult(selected!, r)"
|
|
299
|
+
>
|
|
300
|
+
<span class="flex items-center gap-1.5">
|
|
301
|
+
<UIcon
|
|
302
|
+
:name="selected?.icon ?? 'i-lucide-file-text'"
|
|
303
|
+
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
304
|
+
/>
|
|
305
|
+
<span class="truncate">{{ r.title }}</span>
|
|
306
|
+
<UBadge
|
|
307
|
+
v-if="'status' in r && r.status"
|
|
308
|
+
color="neutral"
|
|
309
|
+
variant="soft"
|
|
310
|
+
size="xs"
|
|
311
|
+
class="ml-auto shrink-0"
|
|
312
|
+
>
|
|
313
|
+
{{ r.status }}
|
|
314
|
+
</UBadge>
|
|
315
|
+
</span>
|
|
316
|
+
<span v-if="r.excerpt" class="mt-0.5 block truncate pl-5 text-[11px] text-slate-500">
|
|
317
|
+
{{ r.excerpt }}
|
|
318
|
+
</span>
|
|
319
|
+
</button>
|
|
320
|
+
<p
|
|
321
|
+
v-if="selected?.searchable && !searching && !results.length"
|
|
322
|
+
class="px-1 text-[11px] text-slate-500"
|
|
323
|
+
>
|
|
324
|
+
No matches — or paste the exact URL/id above.
|
|
325
|
+
</p>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<!-- already-imported quick pick -->
|
|
329
|
+
<div v-if="imported.length" class="space-y-1">
|
|
330
|
+
<span class="px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
|
|
331
|
+
Already imported
|
|
332
|
+
</span>
|
|
333
|
+
<button
|
|
334
|
+
v-for="item in imported"
|
|
335
|
+
:key="contextKey(item)"
|
|
336
|
+
type="button"
|
|
337
|
+
class="flex w-full items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
|
|
338
|
+
@click="toggle(item)"
|
|
339
|
+
>
|
|
340
|
+
<UIcon
|
|
341
|
+
:name="item.icon ?? 'i-lucide-file-text'"
|
|
342
|
+
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
343
|
+
/>
|
|
344
|
+
<span class="truncate">{{ item.title }}</span>
|
|
345
|
+
</button>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<!-- chosen items -->
|
|
349
|
+
<div v-if="model.length" class="flex flex-wrap gap-1.5">
|
|
350
|
+
<span
|
|
351
|
+
v-for="item in model"
|
|
352
|
+
:key="contextKey(item)"
|
|
353
|
+
class="flex max-w-full items-center gap-1 rounded-full border border-indigo-500/60 bg-indigo-500/10 px-2 py-0.5 text-[11px] text-slate-200"
|
|
354
|
+
>
|
|
355
|
+
<UIcon :name="item.icon ?? 'i-lucide-link'" class="h-3 w-3 shrink-0 text-indigo-400" />
|
|
356
|
+
<span class="truncate">{{ item.title }}</span>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
class="shrink-0 text-slate-400 hover:text-slate-200"
|
|
360
|
+
@click="toggle(item)"
|
|
361
|
+
>
|
|
362
|
+
<UIcon name="i-lucide-x" class="h-3 w-3" />
|
|
363
|
+
</button>
|
|
364
|
+
</span>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</template>
|