@cat-factory/app 0.16.1 → 0.17.1
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/app/components/board/AddTaskModal.vue +10 -0
- package/app/components/board/nodes/BlockNode.vue +18 -0
- package/app/components/tasks/ContextIssuePicker.vue +7 -1
- package/app/components/tasks/TaskImportModal.vue +110 -94
- package/app/composables/api/tasks.ts +7 -2
- package/app/composables/useTaskExpansion.ts +32 -6
- package/app/stores/tasks.ts +12 -3
- package/app/stores/ui.ts +23 -4
- package/package.json +1 -1
|
@@ -230,6 +230,15 @@ watch(open, (isOpen) => {
|
|
|
230
230
|
pendingContext.value = []
|
|
231
231
|
showDocPicker.value = false
|
|
232
232
|
showIssuePicker.value = false
|
|
233
|
+
// Seed from a prefill when opened from another surface (e.g. "create task from
|
|
234
|
+
// issue" sets the title + stages the issue as linked context). Pipeline / preset
|
|
235
|
+
// are intentionally left at their defaults so the user confirms them here.
|
|
236
|
+
const prefill = ui.addTaskPrefill
|
|
237
|
+
if (prefill) {
|
|
238
|
+
if (prefill.title) title.value = prefill.title
|
|
239
|
+
if (prefill.description) description.value = prefill.description
|
|
240
|
+
if (prefill.context?.length) pendingContext.value = [...prefill.context]
|
|
241
|
+
}
|
|
233
242
|
documents.loadDocuments().catch(() => {})
|
|
234
243
|
tasks.loadTasks().catch(() => {})
|
|
235
244
|
})
|
|
@@ -582,6 +591,7 @@ async function add() {
|
|
|
582
591
|
<ContextIssuePicker
|
|
583
592
|
v-if="showIssuePicker && issuesConnected"
|
|
584
593
|
:chosen-keys="chosenIssueKeys"
|
|
594
|
+
:scope-block-id="ui.addTaskContainerId ?? undefined"
|
|
585
595
|
@pick="addPending"
|
|
586
596
|
/>
|
|
587
597
|
<div v-if="pendingIssues.length" class="space-y-1">
|
|
@@ -16,6 +16,7 @@ const props = defineProps<{ id: string }>()
|
|
|
16
16
|
const board = useBoardStore()
|
|
17
17
|
const execution = useExecutionStore()
|
|
18
18
|
const ui = useUiStore()
|
|
19
|
+
const tasks = useTasksStore()
|
|
19
20
|
const agentRuns = useAgentRunsStore()
|
|
20
21
|
const services = useServicesStore()
|
|
21
22
|
const reviews = useReviewStage()
|
|
@@ -108,6 +109,13 @@ function addTask() {
|
|
|
108
109
|
ui.openAddTask(props.id)
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// Open the tracker-issue modal scoped to THIS service: the create-in target and the
|
|
113
|
+
// repo-scoped issue search are both pinned to this frame (see TaskImportModal).
|
|
114
|
+
function createTaskFromIssue() {
|
|
115
|
+
ui.expandFrame(props.id)
|
|
116
|
+
ui.openTaskImport(null, props.id)
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
function addRecurring() {
|
|
112
120
|
ui.openAddRecurring(props.id)
|
|
113
121
|
}
|
|
@@ -360,6 +368,16 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
360
368
|
title="Add task"
|
|
361
369
|
@click.stop="addTask"
|
|
362
370
|
/>
|
|
371
|
+
<UButton
|
|
372
|
+
v-if="tasks.anyOffered"
|
|
373
|
+
class="nodrag"
|
|
374
|
+
size="xs"
|
|
375
|
+
variant="ghost"
|
|
376
|
+
color="neutral"
|
|
377
|
+
icon="i-lucide-ticket"
|
|
378
|
+
title="Create task from issue"
|
|
379
|
+
@click.stop="createTaskFromIssue"
|
|
380
|
+
/>
|
|
363
381
|
<UButton
|
|
364
382
|
class="nodrag"
|
|
365
383
|
size="xs"
|
|
@@ -11,6 +11,12 @@ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
|
11
11
|
const props = defineProps<{
|
|
12
12
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
13
13
|
chosenKeys?: string[]
|
|
14
|
+
/**
|
|
15
|
+
* The block the picker is attaching context to (a service frame or a task/module
|
|
16
|
+
* under one). Scopes a GitHub search to that service's linked repo, so hits stay
|
|
17
|
+
* in-repo and a pasted URL / bare issue number resolves to the exact issue.
|
|
18
|
+
*/
|
|
19
|
+
scopeBlockId?: string
|
|
14
20
|
}>()
|
|
15
21
|
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
16
22
|
|
|
@@ -50,7 +56,7 @@ async function runSearch() {
|
|
|
50
56
|
searching.value = true
|
|
51
57
|
searchError.value = null
|
|
52
58
|
try {
|
|
53
|
-
results.value = await tasks.search(source.value, q)
|
|
59
|
+
results.value = await tasks.search(source.value, q, props.scopeBlockId)
|
|
54
60
|
} catch (e) {
|
|
55
61
|
results.value = []
|
|
56
62
|
searchError.value = e instanceof Error ? e.message : String(e)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// Import an issue from a connected task source (by key or URL) and review the
|
|
3
3
|
// issues already imported into the workspace. An imported issue can be attached
|
|
4
4
|
// to an existing task for context from the inspector (see TaskContextIssues.vue),
|
|
5
|
-
// or turned
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
|
|
5
|
+
// or turned into a new board task here: pick a container (service frame or module),
|
|
6
|
+
// then click an issue to open the prefilled add-task form (title seeded, issue
|
|
7
|
+
// staged as linked context) where the user confirms the pipeline / presets before
|
|
8
|
+
// creating it. A separate icon button on each row opens the issue on GitHub.
|
|
9
|
+
import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
|
+
import type { AddTaskPrefill } from '~/stores/ui'
|
|
9
11
|
|
|
10
12
|
const ui = useUiStore()
|
|
11
13
|
const tasks = useTasksStore()
|
|
@@ -23,12 +25,22 @@ const source = ref<TaskSourceKind | undefined>(undefined)
|
|
|
23
25
|
const ref_ = ref('')
|
|
24
26
|
const importing = ref(false)
|
|
25
27
|
|
|
28
|
+
// When opened from a service frame the modal is the "create a task from an issue"
|
|
29
|
+
// surface; opened standalone it's the general tracker-issue browser/importer.
|
|
30
|
+
const title = computed(() =>
|
|
31
|
+
ui.taskImport?.containerId ? 'Create task from issue' : 'Tracker issues',
|
|
32
|
+
)
|
|
33
|
+
|
|
26
34
|
const sourceItems = computed(() =>
|
|
27
35
|
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
36
|
)
|
|
29
37
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
30
38
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
31
39
|
|
|
40
|
+
// The container (service frame or module) a new task is created in. Also the repo
|
|
41
|
+
// scope for the issue search — declared up here so the search watch can read it.
|
|
42
|
+
const containerId = ref<string | undefined>(undefined)
|
|
43
|
+
|
|
32
44
|
// Browse the tracker by free text so an issue can be turned into a task without
|
|
33
45
|
// knowing its key. Debounced; a created/imported hit also lands in the list below.
|
|
34
46
|
const searchQuery = ref('')
|
|
@@ -37,7 +49,9 @@ const searching = ref(false)
|
|
|
37
49
|
const searchError = ref<string | null>(null)
|
|
38
50
|
|
|
39
51
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
40
|
-
|
|
52
|
+
// Re-run when the chosen container changes too: a GitHub search is scoped to the
|
|
53
|
+
// selected service's repo, so switching containers re-scopes the results.
|
|
54
|
+
watch([searchQuery, source, () => containerId.value], () => {
|
|
41
55
|
if (searchTimer) clearTimeout(searchTimer)
|
|
42
56
|
searchResults.value = []
|
|
43
57
|
searchError.value = null
|
|
@@ -52,7 +66,9 @@ async function runSearch() {
|
|
|
52
66
|
searching.value = true
|
|
53
67
|
searchError.value = null
|
|
54
68
|
try {
|
|
55
|
-
|
|
69
|
+
// Scope to the selected container's repo so hits stay in-repo and a pasted
|
|
70
|
+
// URL / bare issue number resolves to the exact issue.
|
|
71
|
+
searchResults.value = await tasks.search(source.value, q, containerId.value)
|
|
56
72
|
} catch (e) {
|
|
57
73
|
searchResults.value = []
|
|
58
74
|
searchError.value = e instanceof Error ? e.message : String(e)
|
|
@@ -75,7 +91,6 @@ const sourceTasks = computed(() =>
|
|
|
75
91
|
|
|
76
92
|
// Containers a new task can be created in: every service frame and module on the
|
|
77
93
|
// board. Modules are labelled with their parent frame so the choice is unambiguous.
|
|
78
|
-
const containerId = ref<string | undefined>(undefined)
|
|
79
94
|
const containerItems = computed(() =>
|
|
80
95
|
board.blocks
|
|
81
96
|
.filter((b) => b.level === 'frame' || b.level === 'module')
|
|
@@ -87,9 +102,6 @@ const containerItems = computed(() =>
|
|
|
87
102
|
value: b.id,
|
|
88
103
|
})),
|
|
89
104
|
)
|
|
90
|
-
// The issue currently being turned into a task (its row shows a spinner).
|
|
91
|
-
const creatingId = ref<string | null>(null)
|
|
92
|
-
|
|
93
105
|
watch(open, (isOpen) => {
|
|
94
106
|
if (isOpen) {
|
|
95
107
|
ref_.value = ''
|
|
@@ -97,33 +109,38 @@ watch(open, (isOpen) => {
|
|
|
97
109
|
searchResults.value = []
|
|
98
110
|
searchError.value = null
|
|
99
111
|
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
// Opened from a service frame → preselect it as the create-in target (and the
|
|
113
|
+
// search's repo scope); otherwise fall back to the first container on the board.
|
|
114
|
+
containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
|
|
102
115
|
tasks.loadTasks().catch(() => {})
|
|
103
116
|
}
|
|
104
117
|
})
|
|
105
118
|
|
|
106
|
-
//
|
|
107
|
-
// and
|
|
108
|
-
//
|
|
109
|
-
|
|
119
|
+
// Selecting an issue hands off to the add-task form, prefilled with the issue title
|
|
120
|
+
// and the issue staged as linked context (so agents see its description + comments).
|
|
121
|
+
// The user still confirms pipeline / preset there before the task is created — we do
|
|
122
|
+
// NOT dump the issue body into the description; the link is enough.
|
|
123
|
+
function selectIssue(
|
|
124
|
+
issue: { externalId: string; title: string; status?: string },
|
|
125
|
+
needsImport: boolean,
|
|
126
|
+
) {
|
|
110
127
|
if (!source.value || !containerId.value) return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
} finally {
|
|
125
|
-
creatingId.value = null
|
|
128
|
+
const prefill: AddTaskPrefill = {
|
|
129
|
+
title: issue.title,
|
|
130
|
+
context: [
|
|
131
|
+
{
|
|
132
|
+
kind: 'task',
|
|
133
|
+
source: source.value,
|
|
134
|
+
externalId: issue.externalId,
|
|
135
|
+
title: `${issue.externalId} · ${issue.title}`,
|
|
136
|
+
subtitle: issue.status || undefined,
|
|
137
|
+
icon: descriptor.value?.icon,
|
|
138
|
+
needsImport,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
126
141
|
}
|
|
142
|
+
ui.closeTaskImport()
|
|
143
|
+
ui.openAddTask(containerId.value, prefill)
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
async function doImport() {
|
|
@@ -148,7 +165,7 @@ async function doImport() {
|
|
|
148
165
|
</script>
|
|
149
166
|
|
|
150
167
|
<template>
|
|
151
|
-
<UModal v-model:open="open" title="
|
|
168
|
+
<UModal v-model:open="open" :title="title">
|
|
152
169
|
<template #body>
|
|
153
170
|
<!-- Empty state: no source offered (none connected/installed, or all disabled) -->
|
|
154
171
|
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
@@ -201,7 +218,7 @@ async function doImport() {
|
|
|
201
218
|
v-model="searchQuery"
|
|
202
219
|
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
203
220
|
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
204
|
-
placeholder="Search by title…"
|
|
221
|
+
placeholder="Search by title, paste an issue URL, or type an issue number…"
|
|
205
222
|
class="w-full"
|
|
206
223
|
/>
|
|
207
224
|
</UFormField>
|
|
@@ -226,7 +243,8 @@ async function doImport() {
|
|
|
226
243
|
Add a service frame to the board first to create tasks from issues.
|
|
227
244
|
</p>
|
|
228
245
|
|
|
229
|
-
<!-- Search results (not yet imported):
|
|
246
|
+
<!-- Search results (not yet imported): click a hit to create a task from it
|
|
247
|
+
(opens the prefilled add-task form); the icon button views it on GitHub. -->
|
|
230
248
|
<div v-if="searchError" class="text-[11px] text-amber-400">
|
|
231
249
|
Search failed: {{ searchError }}
|
|
232
250
|
</div>
|
|
@@ -237,38 +255,36 @@ async function doImport() {
|
|
|
237
255
|
<div
|
|
238
256
|
v-for="hit in freshHits"
|
|
239
257
|
:key="`hit:${hit.source}:${hit.externalId}`"
|
|
240
|
-
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
258
|
+
class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
|
|
241
259
|
>
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
</UButton>
|
|
271
|
-
</div>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
|
263
|
+
:disabled="!containerId"
|
|
264
|
+
:title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
|
|
265
|
+
@click="selectIssue(hit, true)"
|
|
266
|
+
>
|
|
267
|
+
<span class="block truncate text-sm font-medium text-white">
|
|
268
|
+
{{ hit.externalId }} · {{ hit.title }}
|
|
269
|
+
</span>
|
|
270
|
+
<span v-if="hit.excerpt" class="mt-0.5 line-clamp-2 block text-xs text-slate-500">
|
|
271
|
+
{{ hit.excerpt }}
|
|
272
|
+
</span>
|
|
273
|
+
</button>
|
|
274
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
275
|
+
<UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
|
|
276
|
+
{{ hit.status }}
|
|
277
|
+
</UBadge>
|
|
278
|
+
<UButton
|
|
279
|
+
color="neutral"
|
|
280
|
+
variant="ghost"
|
|
281
|
+
size="xs"
|
|
282
|
+
icon="i-lucide-external-link"
|
|
283
|
+
:to="hit.url"
|
|
284
|
+
target="_blank"
|
|
285
|
+
rel="noopener"
|
|
286
|
+
:aria-label="`View ${hit.externalId} on GitHub`"
|
|
287
|
+
/>
|
|
272
288
|
</div>
|
|
273
289
|
</div>
|
|
274
290
|
</div>
|
|
@@ -281,36 +297,36 @@ async function doImport() {
|
|
|
281
297
|
<div
|
|
282
298
|
v-for="task in sourceTasks"
|
|
283
299
|
:key="`${task.source}:${task.externalId}`"
|
|
284
|
-
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
300
|
+
class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
|
|
285
301
|
>
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
|
305
|
+
:disabled="!containerId"
|
|
306
|
+
:title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
|
|
307
|
+
@click="selectIssue(task, false)"
|
|
308
|
+
>
|
|
309
|
+
<span class="block truncate text-sm font-medium text-white">
|
|
310
|
+
{{ task.externalId }} · {{ task.title }}
|
|
311
|
+
</span>
|
|
312
|
+
<span class="mt-0.5 line-clamp-2 block text-xs text-slate-500">{{
|
|
313
|
+
task.excerpt
|
|
314
|
+
}}</span>
|
|
315
|
+
</button>
|
|
316
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
317
|
+
<UBadge color="neutral" variant="soft" size="xs">
|
|
318
|
+
{{ task.status }}
|
|
319
|
+
</UBadge>
|
|
320
|
+
<UButton
|
|
321
|
+
color="neutral"
|
|
322
|
+
variant="ghost"
|
|
323
|
+
size="xs"
|
|
324
|
+
icon="i-lucide-external-link"
|
|
325
|
+
:to="task.url"
|
|
326
|
+
target="_blank"
|
|
327
|
+
rel="noopener"
|
|
328
|
+
:aria-label="`View ${task.externalId} on GitHub`"
|
|
329
|
+
/>
|
|
314
330
|
</div>
|
|
315
331
|
</div>
|
|
316
332
|
</div>
|
|
@@ -57,10 +57,15 @@ export function tasksApi({ http, ws }: ApiContext) {
|
|
|
57
57
|
body,
|
|
58
58
|
}),
|
|
59
59
|
|
|
60
|
-
searchTaskSource: (
|
|
60
|
+
searchTaskSource: (
|
|
61
|
+
workspaceId: string,
|
|
62
|
+
source: TaskSourceKind,
|
|
63
|
+
query: string,
|
|
64
|
+
blockId?: string,
|
|
65
|
+
) =>
|
|
61
66
|
http<{ results: TaskSearchResult[] }>(`${ws(workspaceId)}/task-sources/${source}/search`, {
|
|
62
67
|
method: 'POST',
|
|
63
|
-
body: { query },
|
|
68
|
+
body: { query, ...(blockId ? { blockId } : {}) },
|
|
64
69
|
}),
|
|
65
70
|
|
|
66
71
|
linkTask: (
|
|
@@ -35,6 +35,15 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
35
35
|
const ui = useUiStore()
|
|
36
36
|
const store = useTaskExpansionStore()
|
|
37
37
|
|
|
38
|
+
// Last-known expanded height per task. A card grows downward only while it's
|
|
39
|
+
// granted (its pipeline list is rendered), so its live height collapses the
|
|
40
|
+
// moment it's denied. Testing overlap with that collapsed height is what causes
|
|
41
|
+
// the flashing: a denied card no longer overlaps its neighbour, gets re-granted,
|
|
42
|
+
// expands, overlaps again, gets denied — every frame. We cache the expanded
|
|
43
|
+
// height while a card is granted and project the footprint with it, so a denied
|
|
44
|
+
// card is still tested at its expanded extent and stays denied. Stable.
|
|
45
|
+
const expandedHeight = new Map<string, number>()
|
|
46
|
+
|
|
38
47
|
function rectOf(id: string): DOMRect | null {
|
|
39
48
|
const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
|
|
40
49
|
return el ? el.getBoundingClientRect() : null
|
|
@@ -51,26 +60,43 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
51
60
|
const cx = view.left + view.width / 2
|
|
52
61
|
const cy = view.top + view.height / 2
|
|
53
62
|
|
|
54
|
-
const candidates: { id: string; rect:
|
|
63
|
+
const candidates: { id: string; rect: Rect; dist: number }[] = []
|
|
64
|
+
const liveIds = new Set<string>()
|
|
55
65
|
for (const t of board.allTasks) {
|
|
56
66
|
// Only tasks whose run actually has steps would expand a pipeline list.
|
|
57
67
|
if (!execution.getByBlock(t.id)?.steps.length) continue
|
|
58
68
|
const rect = rectOf(t.id)
|
|
59
69
|
if (!rect) continue
|
|
60
|
-
|
|
70
|
+
liveIds.add(t.id)
|
|
71
|
+
// While a card is granted it's rendered expanded, so its live height is its
|
|
72
|
+
// expanded footprint — cache it. A denied card keeps its last cached value.
|
|
73
|
+
if (store.allowed.has(t.id)) expandedHeight.set(t.id, rect.height)
|
|
74
|
+
// Visibility: the card must intersect the board viewport (live rect).
|
|
61
75
|
if (!intersects(rect, view)) continue
|
|
76
|
+
// Project the footprint downward to the expanded extent so the overlap test
|
|
77
|
+
// is independent of the card's current (possibly collapsed) state.
|
|
78
|
+
const height = Math.max(rect.height, expandedHeight.get(t.id) ?? 0)
|
|
79
|
+
const footprint: Rect = {
|
|
80
|
+
left: rect.left,
|
|
81
|
+
right: rect.right,
|
|
82
|
+
top: rect.top,
|
|
83
|
+
bottom: rect.top + height,
|
|
84
|
+
}
|
|
62
85
|
// Stable anchor: the card's top-centre. It doesn't move as the card grows
|
|
63
86
|
// downward, so the ordering can't oscillate as cards expand / collapse.
|
|
64
87
|
const ax = rect.left + rect.width / 2
|
|
65
88
|
const ay = rect.top
|
|
66
89
|
const dist = (ax - cx) ** 2 + (ay - cy) ** 2
|
|
67
|
-
candidates.push({ id: t.id, rect, dist })
|
|
90
|
+
candidates.push({ id: t.id, rect: footprint, dist })
|
|
68
91
|
}
|
|
92
|
+
// Drop cached heights for cards that are gone, so the map can't grow unbounded.
|
|
93
|
+
for (const id of expandedHeight.keys()) if (!liveIds.has(id)) expandedHeight.delete(id)
|
|
69
94
|
candidates.sort((a, b) => a.dist - b.dist)
|
|
70
95
|
|
|
71
|
-
// Greedy by distance to centre: a candidate is granted only if its
|
|
72
|
-
// every
|
|
73
|
-
|
|
96
|
+
// Greedy by distance to centre: a candidate is granted only if its projected
|
|
97
|
+
// footprint clears every footprint already granted, so the centre-most card
|
|
98
|
+
// wins any overlap.
|
|
99
|
+
const claimed: Rect[] = []
|
|
74
100
|
const next = new Set<string>()
|
|
75
101
|
for (const c of candidates) {
|
|
76
102
|
if (claimed.some((r) => intersects(c.rect, r))) continue
|
package/app/stores/tasks.ts
CHANGED
|
@@ -170,9 +170,18 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Search a connected tracker's issues by free text (title/content). `blockId`
|
|
175
|
+
* (a service frame or a task/module under one) scopes a GitHub search to that
|
|
176
|
+
* service's linked repo — so hits stay in-repo and a pasted URL / bare issue
|
|
177
|
+
* number resolves to the exact issue. Omitted → an unscoped workspace search.
|
|
178
|
+
*/
|
|
179
|
+
async function search(
|
|
180
|
+
source: TaskSourceKind,
|
|
181
|
+
query: string,
|
|
182
|
+
blockId?: string,
|
|
183
|
+
): Promise<TaskSearchResult[]> {
|
|
184
|
+
const { results } = await api.searchTaskSource(workspace.requireId(), source, query, blockId)
|
|
176
185
|
return results
|
|
177
186
|
}
|
|
178
187
|
|
package/app/stores/ui.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
3
|
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
4
|
+
import type { PendingContext } from '~/composables/useContextLinking'
|
|
4
5
|
import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
|
|
5
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
6
7
|
import { agentKindMeta } from '~/utils/catalog'
|
|
7
8
|
|
|
9
|
+
/** Values used to seed the add-task form when it is opened from another surface. */
|
|
10
|
+
export interface AddTaskPrefill {
|
|
11
|
+
title?: string
|
|
12
|
+
description?: string
|
|
13
|
+
/** Context items staged on the new task (e.g. the source issue), linked once created. */
|
|
14
|
+
context?: PendingContext[]
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** Transient UI state: selection, panels, zoom level. */
|
|
9
18
|
export const useUiStore = defineStore('ui', () => {
|
|
10
19
|
const selectedBlockId = ref<string | null>(null)
|
|
@@ -33,12 +42,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
33
42
|
// the modal pick a connected one (there is no spawn target — issues are linked
|
|
34
43
|
// to a block for context, not expanded into structure).
|
|
35
44
|
const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
|
|
36
|
-
|
|
45
|
+
// `containerId` (a service frame) scopes the modal: it preselects that frame as
|
|
46
|
+
// the create-in target AND scopes the issue search to the frame's linked repo.
|
|
47
|
+
// Null → the unscoped "import an issue" surface (workspace-wide search).
|
|
48
|
+
const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
37
49
|
|
|
38
50
|
// Add-task modal: the container (service frame or module) a new task is being
|
|
39
51
|
// added to, or null when closed. The user types the title + description; nothing
|
|
40
52
|
// is launched until they explicitly start the created task.
|
|
41
53
|
const addTaskContainerId = ref<string | null>(null)
|
|
54
|
+
// Optional values to seed the add-task form with when it is opened from another
|
|
55
|
+
// surface (e.g. "create task from issue" prefills the title + stages the issue as
|
|
56
|
+
// linked context). The user still confirms pipeline / preset before adding.
|
|
57
|
+
const addTaskPrefill = ref<AddTaskPrefill | null>(null)
|
|
42
58
|
|
|
43
59
|
// Add-recurring-pipeline modal: the service frame a new recurring pipeline is
|
|
44
60
|
// being added to, or null when closed (mirrors the add-task flow — a button on
|
|
@@ -237,17 +253,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
237
253
|
function closeTaskConnect() {
|
|
238
254
|
taskConnect.value = null
|
|
239
255
|
}
|
|
240
|
-
function openTaskImport(source: TaskSourceKind | null = null) {
|
|
241
|
-
taskImport.value = { source }
|
|
256
|
+
function openTaskImport(source: TaskSourceKind | null = null, containerId: string | null = null) {
|
|
257
|
+
taskImport.value = { source, containerId }
|
|
242
258
|
}
|
|
243
259
|
function closeTaskImport() {
|
|
244
260
|
taskImport.value = null
|
|
245
261
|
}
|
|
246
|
-
function openAddTask(containerId: string) {
|
|
262
|
+
function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
|
|
263
|
+
addTaskPrefill.value = prefill
|
|
247
264
|
addTaskContainerId.value = containerId
|
|
248
265
|
}
|
|
249
266
|
function closeAddTask() {
|
|
250
267
|
addTaskContainerId.value = null
|
|
268
|
+
addTaskPrefill.value = null
|
|
251
269
|
}
|
|
252
270
|
function openAddRecurring(frameId: string) {
|
|
253
271
|
addRecurringFrameId.value = frameId
|
|
@@ -412,6 +430,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
412
430
|
taskConnect,
|
|
413
431
|
taskImport,
|
|
414
432
|
addTaskContainerId,
|
|
433
|
+
addTaskPrefill,
|
|
415
434
|
addRecurringFrameId,
|
|
416
435
|
bootstrapOpen,
|
|
417
436
|
addServiceOpen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|