@cat-factory/app 0.20.0 → 0.21.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.
- package/app/components/board/BoardCanvas.vue +21 -3
- package/app/components/board/DependencyConnectOverlay.vue +25 -0
- package/app/components/board/TaskDependencyEdges.vue +45 -0
- package/app/components/board/nodes/EpicNode.vue +51 -0
- package/app/components/board/nodes/TaskCard.vue +14 -0
- package/app/components/panels/InspectorPanel.vue +5 -0
- package/app/components/panels/inspector/EpicChildren.vue +83 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +25 -0
- package/app/components/tasks/TaskImportModal.vue +59 -0
- package/app/composables/api/board.ts +10 -0
- package/app/composables/api/tasks.ts +12 -0
- package/app/composables/useBlockQueries.ts +16 -0
- package/app/composables/useDependencyConnect.ts +61 -0
- package/app/stores/board.ts +71 -8
- package/app/stores/tasks.ts +25 -0
- package/app/types/domain.ts +14 -1
- package/package.json +1 -1
|
@@ -4,7 +4,9 @@ import { Background } from '@vue-flow/background'
|
|
|
4
4
|
import { Controls } from '@vue-flow/controls'
|
|
5
5
|
import { MiniMap } from '@vue-flow/minimap'
|
|
6
6
|
import BlockNode from './nodes/BlockNode.vue'
|
|
7
|
+
import EpicNode from './nodes/EpicNode.vue'
|
|
7
8
|
import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
9
|
+
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
8
10
|
import { STATUS_META } from '~/utils/catalog'
|
|
9
11
|
import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
|
|
10
12
|
import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
@@ -41,15 +43,24 @@ function frameExpanded(id: string) {
|
|
|
41
43
|
return ui.isFrameExpanded(id) && ui.lod !== 'far'
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
const nodes = computed(() =>
|
|
45
|
-
board.frames.map((b) => ({
|
|
46
|
+
const nodes = computed(() => [
|
|
47
|
+
...board.frames.map((b) => ({
|
|
46
48
|
id: b.id,
|
|
47
49
|
type: 'block',
|
|
48
50
|
position: b.position,
|
|
49
51
|
draggable: !frameExpanded(b.id),
|
|
50
52
|
data: {},
|
|
51
53
|
})),
|
|
52
|
-
)
|
|
54
|
+
// Epics are top-level grouping nodes (non-structural), drawn alongside frames and
|
|
55
|
+
// linked to their member tasks by the dependency-edge overlay.
|
|
56
|
+
...board.epics.map((b) => ({
|
|
57
|
+
id: b.id,
|
|
58
|
+
type: 'epic',
|
|
59
|
+
position: b.position,
|
|
60
|
+
draggable: true,
|
|
61
|
+
data: {},
|
|
62
|
+
})),
|
|
63
|
+
])
|
|
53
64
|
|
|
54
65
|
onNodeDragStop(({ node }) => {
|
|
55
66
|
board.moveBlock(node.id, { x: node.position.x, y: node.position.y })
|
|
@@ -149,6 +160,10 @@ async function onDrop(event: DragEvent) {
|
|
|
149
160
|
<template #node-block="props">
|
|
150
161
|
<BlockNode :id="props.id" />
|
|
151
162
|
</template>
|
|
163
|
+
|
|
164
|
+
<template #node-epic="props">
|
|
165
|
+
<EpicNode :id="props.id" />
|
|
166
|
+
</template>
|
|
152
167
|
</VueFlow>
|
|
153
168
|
|
|
154
169
|
<!-- An empty board reads as broken; invite the user to add a service. The
|
|
@@ -183,5 +198,8 @@ async function onDrop(event: DragEvent) {
|
|
|
183
198
|
|
|
184
199
|
<!-- task dependency arrows, overlaid in screen space -->
|
|
185
200
|
<TaskDependencyEdges />
|
|
201
|
+
|
|
202
|
+
<!-- live preview line while drag-to-connecting a dependency -->
|
|
203
|
+
<DependencyConnectOverlay />
|
|
186
204
|
</div>
|
|
187
205
|
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useDependencyConnect } from '~/composables/useDependencyConnect'
|
|
3
|
+
|
|
4
|
+
// Draws the live preview line while a dependency drag-to-connect is in progress. Mounted
|
|
5
|
+
// once on the board; reads the shared connect state. Fixed, full-viewport, click-through.
|
|
6
|
+
const { connecting } = useDependencyConnect()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<svg
|
|
11
|
+
v-if="connecting"
|
|
12
|
+
class="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
|
|
13
|
+
>
|
|
14
|
+
<line
|
|
15
|
+
:x1="connecting.x1"
|
|
16
|
+
:y1="connecting.y1"
|
|
17
|
+
:x2="connecting.x2"
|
|
18
|
+
:y2="connecting.y2"
|
|
19
|
+
stroke="#f59e0b"
|
|
20
|
+
:stroke-width="2"
|
|
21
|
+
stroke-dasharray="5 4"
|
|
22
|
+
stroke-opacity="0.9"
|
|
23
|
+
/>
|
|
24
|
+
</svg>
|
|
25
|
+
</template>
|
|
@@ -15,6 +15,9 @@ const svg = ref<SVGSVGElement | null>(null)
|
|
|
15
15
|
|
|
16
16
|
type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
|
|
17
17
|
const segments = ref<Seg[]>([])
|
|
18
|
+
// Epic→member membership links (distinct style from dependency edges).
|
|
19
|
+
type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
|
|
20
|
+
const memberSegments = ref<MemberSeg[]>([])
|
|
18
21
|
|
|
19
22
|
// task → its dependencies, both ends being tasks
|
|
20
23
|
const taskDeps = computed(() => {
|
|
@@ -29,6 +32,17 @@ const taskDeps = computed(() => {
|
|
|
29
32
|
return out
|
|
30
33
|
})
|
|
31
34
|
|
|
35
|
+
// epic → each of its member tasks (membership, drawn as a soft violet link).
|
|
36
|
+
const epicLinks = computed(() => {
|
|
37
|
+
const out: { id: string; source: string; target: string }[] = []
|
|
38
|
+
for (const e of board.epics) {
|
|
39
|
+
for (const m of board.epicMembers(e.id)) {
|
|
40
|
+
out.push({ id: `${e.id}__${m.id}`, source: e.id, target: m.id })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
})
|
|
45
|
+
|
|
32
46
|
/** Resolve a task's anchor: walk up task → module → service to the first card
|
|
33
47
|
* that's actually rendered (a container may be collapsed). */
|
|
34
48
|
function anchorEl(taskId: string): HTMLElement | null {
|
|
@@ -81,6 +95,23 @@ function recompute() {
|
|
|
81
95
|
})
|
|
82
96
|
}
|
|
83
97
|
segments.value = next
|
|
98
|
+
|
|
99
|
+
const members: MemberSeg[] = []
|
|
100
|
+
for (const link of epicLinks.value) {
|
|
101
|
+
const a = anchorEl(link.source)
|
|
102
|
+
const b = anchorEl(link.target)
|
|
103
|
+
if (!a || !b || a === b) continue
|
|
104
|
+
const ra = a.getBoundingClientRect()
|
|
105
|
+
const rb = b.getBoundingClientRect()
|
|
106
|
+
const ax = ra.left + ra.width / 2 - origin.left
|
|
107
|
+
const ay = ra.top + ra.height / 2 - origin.top
|
|
108
|
+
const bx = rb.left + rb.width / 2 - origin.left
|
|
109
|
+
const by = rb.top + rb.height / 2 - origin.top
|
|
110
|
+
const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
|
|
111
|
+
const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
|
|
112
|
+
members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
|
|
113
|
+
}
|
|
114
|
+
memberSegments.value = members
|
|
84
115
|
}
|
|
85
116
|
|
|
86
117
|
const { pause, resume } = useRafFn(recompute, { immediate: false })
|
|
@@ -115,6 +146,20 @@ onBeforeUnmount(pause)
|
|
|
115
146
|
</marker>
|
|
116
147
|
</defs>
|
|
117
148
|
|
|
149
|
+
<!-- epic → member membership links (soft violet, no arrowhead) -->
|
|
150
|
+
<line
|
|
151
|
+
v-for="s in memberSegments"
|
|
152
|
+
:key="s.id"
|
|
153
|
+
:x1="s.x1"
|
|
154
|
+
:y1="s.y1"
|
|
155
|
+
:x2="s.x2"
|
|
156
|
+
:y2="s.y2"
|
|
157
|
+
stroke="#8b5cf6"
|
|
158
|
+
:stroke-width="1.5"
|
|
159
|
+
stroke-dasharray="2 5"
|
|
160
|
+
:stroke-opacity="0.5"
|
|
161
|
+
/>
|
|
162
|
+
|
|
118
163
|
<line
|
|
119
164
|
v-for="s in segments"
|
|
120
165
|
:key="s.id"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
// Vue Flow passes the node's `id` as a prop. An epic is a NON-structural grouping node:
|
|
6
|
+
// it has no children of its own (tasks join it via their `epicId`), so it renders as a
|
|
7
|
+
// compact card showing the epic title + a roll-up of its member tasks. The dependency-edge
|
|
8
|
+
// overlay draws the links from this card to each member (anchored by `data-block-id`).
|
|
9
|
+
const props = defineProps<{ id: string }>()
|
|
10
|
+
|
|
11
|
+
const board = useBoardStore()
|
|
12
|
+
const ui = useUiStore()
|
|
13
|
+
|
|
14
|
+
const block = computed<Block | undefined>(() => board.getBlock(props.id))
|
|
15
|
+
const members = computed(() => board.epicMembers(props.id))
|
|
16
|
+
const total = computed(() => members.value.length)
|
|
17
|
+
const done = computed(() => members.value.filter((m) => m.status === 'done').length)
|
|
18
|
+
const active = computed(() =>
|
|
19
|
+
members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
|
|
20
|
+
)
|
|
21
|
+
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
v-if="block"
|
|
27
|
+
:data-block-id="block.id"
|
|
28
|
+
class="w-56 cursor-pointer rounded-lg border bg-slate-900/90 px-3 py-2 shadow-lg backdrop-blur transition-colors"
|
|
29
|
+
:class="
|
|
30
|
+
selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'
|
|
31
|
+
"
|
|
32
|
+
@click="ui.select(block.id)"
|
|
33
|
+
>
|
|
34
|
+
<div class="flex items-center gap-1.5">
|
|
35
|
+
<UIcon name="i-lucide-layers" class="h-3.5 w-3.5 shrink-0 text-violet-400" />
|
|
36
|
+
<span class="text-[10px] font-semibold uppercase tracking-wide text-violet-300">Epic</span>
|
|
37
|
+
<span class="ml-auto text-[10px] text-slate-400">{{ done }}/{{ total }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="mt-1 truncate text-sm font-medium text-slate-100" :title="block.title">
|
|
40
|
+
{{ block.title }}
|
|
41
|
+
</div>
|
|
42
|
+
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-700/60">
|
|
43
|
+
<div
|
|
44
|
+
class="h-full rounded-full bg-violet-500"
|
|
45
|
+
:style="{ width: total ? `${Math.round((done / total) * 100)}%` : '0%' }"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-if="active" class="mt-1 text-[10px] text-slate-400">{{ active }} active</div>
|
|
49
|
+
<div v-else-if="total === 0" class="mt-1 text-[10px] text-slate-500">No tasks yet</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
@@ -18,6 +18,10 @@ const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
|
|
|
18
18
|
const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
|
|
19
19
|
const selected = computed(() => ui.selectedBlockId === props.taskId)
|
|
20
20
|
|
|
21
|
+
// Drag-to-connect: dragging from this card's handle onto another task makes THAT task
|
|
22
|
+
// depend on this one (this is the prerequisite). The composable tracks the gesture.
|
|
23
|
+
const { start: startConnect } = useDependencyConnect()
|
|
24
|
+
|
|
21
25
|
// ---- dependencies (gate execution order; may point across frames) ----------
|
|
22
26
|
const deps = computed(() =>
|
|
23
27
|
(task.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
|
|
@@ -196,6 +200,16 @@ function selectTask() {
|
|
|
196
200
|
:title="schedule.enabled ? 'Recurring pipeline' : 'Recurring pipeline (paused)'"
|
|
197
201
|
/>
|
|
198
202
|
<span class="truncate text-[11px] font-semibold text-slate-100">{{ task.title }}</span>
|
|
203
|
+
<!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
class="nodrag ml-1 shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
|
|
207
|
+
title="Drag onto another task to make it depend on this one"
|
|
208
|
+
@pointerdown.stop="startConnect(task.id, $event)"
|
|
209
|
+
@click.stop
|
|
210
|
+
>
|
|
211
|
+
<UIcon name="i-lucide-spline" class="h-3 w-3" />
|
|
212
|
+
</button>
|
|
199
213
|
<span
|
|
200
214
|
class="ml-auto shrink-0 text-[9px] uppercase tracking-wide"
|
|
201
215
|
:class="
|
|
@@ -12,6 +12,7 @@ import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue
|
|
|
12
12
|
import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
|
|
13
13
|
import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
|
|
14
14
|
import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
|
|
15
|
+
import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
|
|
15
16
|
import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
|
|
16
17
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
17
18
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
@@ -51,6 +52,7 @@ const level = computed(() => block.value?.level ?? 'frame')
|
|
|
51
52
|
const isFrame = computed(() => level.value === 'frame')
|
|
52
53
|
const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
|
|
53
54
|
const isTask = computed(() => level.value === 'task')
|
|
55
|
+
const isEpic = computed(() => level.value === 'epic')
|
|
54
56
|
|
|
55
57
|
const instance = computed(() => execution.getInstance(block.value?.executionId))
|
|
56
58
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
@@ -442,6 +444,9 @@ const showOriginalDescription = ref(false)
|
|
|
442
444
|
<TaskExecution :block="block" />
|
|
443
445
|
</template>
|
|
444
446
|
|
|
447
|
+
<!-- epic: the full tree of member tasks, grouped by service → module -->
|
|
448
|
+
<EpicChildren v-else-if="isEpic" :block="block" />
|
|
449
|
+
|
|
445
450
|
<!-- actions -->
|
|
446
451
|
<div class="flex items-center gap-2">
|
|
447
452
|
<UDropdownMenu v-if="isTask" :items="runMenu">
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import { STATUS_META } from '~/utils/catalog'
|
|
5
|
+
|
|
6
|
+
// The epic inspector body: the full tree of member tasks (which may live under different
|
|
7
|
+
// services/modules), grouped service → module → task. Each task row selects it. Membership
|
|
8
|
+
// is the task's `epicId`; the epic is non-structural, so this reads across the whole board.
|
|
9
|
+
const props = defineProps<{ block: Block }>()
|
|
10
|
+
|
|
11
|
+
const board = useBoardStore()
|
|
12
|
+
const ui = useUiStore()
|
|
13
|
+
|
|
14
|
+
const members = computed(() => board.epicMembers(props.block.id))
|
|
15
|
+
const done = computed(() => members.value.filter((m) => m.status === 'done').length)
|
|
16
|
+
|
|
17
|
+
/** Member tasks grouped by their owning service, then by module (or "direct"). */
|
|
18
|
+
const groups = computed(() => {
|
|
19
|
+
const byService = new Map<
|
|
20
|
+
string,
|
|
21
|
+
{ service: Block | undefined; modules: Map<string, { module: Block | undefined; tasks: Block[] }> }
|
|
22
|
+
>()
|
|
23
|
+
for (const task of members.value) {
|
|
24
|
+
const service = board.serviceOf(task)
|
|
25
|
+
const serviceKey = service?.id ?? '—'
|
|
26
|
+
if (!byService.has(serviceKey)) byService.set(serviceKey, { service, modules: new Map() })
|
|
27
|
+
const group = byService.get(serviceKey)!
|
|
28
|
+
// The task's structural container: a module when its parent is a module, else "direct".
|
|
29
|
+
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
30
|
+
const moduleKey = parent && parent.level === 'module' ? parent.id : '—'
|
|
31
|
+
if (!group.modules.has(moduleKey)) {
|
|
32
|
+
group.modules.set(moduleKey, { module: parent?.level === 'module' ? parent : undefined, tasks: [] })
|
|
33
|
+
}
|
|
34
|
+
group.modules.get(moduleKey)!.tasks.push(task)
|
|
35
|
+
}
|
|
36
|
+
return [...byService.values()]
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div>
|
|
42
|
+
<div class="mb-1 flex items-center justify-between">
|
|
43
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
44
|
+
Epic tasks
|
|
45
|
+
</span>
|
|
46
|
+
<span class="text-[11px] text-slate-500">{{ done }}/{{ members.length }} done</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="members.length === 0" class="text-[11px] text-slate-500">
|
|
50
|
+
No tasks belong to this epic yet. Import an epic's children, or set a task's epic.
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div v-else class="space-y-2">
|
|
54
|
+
<div v-for="(group, gi) in groups" :key="gi" class="rounded-md border border-slate-700/60 p-2">
|
|
55
|
+
<div class="mb-1 flex items-center gap-1 text-[11px] font-medium text-slate-300">
|
|
56
|
+
<UIcon name="i-lucide-box" class="h-3 w-3 text-slate-500" />
|
|
57
|
+
{{ group.service?.title ?? 'Unassigned' }}
|
|
58
|
+
</div>
|
|
59
|
+
<div v-for="(mod, mi) in [...group.modules.values()]" :key="mi" class="pl-1">
|
|
60
|
+
<div v-if="mod.module" class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
61
|
+
{{ mod.module.title }}
|
|
62
|
+
</div>
|
|
63
|
+
<button
|
|
64
|
+
v-for="task in mod.tasks"
|
|
65
|
+
:key="task.id"
|
|
66
|
+
type="button"
|
|
67
|
+
class="flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-slate-200 hover:bg-slate-800"
|
|
68
|
+
@click="ui.select(task.id)"
|
|
69
|
+
>
|
|
70
|
+
<span
|
|
71
|
+
class="h-2 w-2 shrink-0 rounded-full"
|
|
72
|
+
:style="{ backgroundColor: STATUS_META[task.status].color }"
|
|
73
|
+
/>
|
|
74
|
+
<span class="truncate">{{ task.title }}</span>
|
|
75
|
+
<span class="ml-auto shrink-0 text-[10px] text-slate-500">
|
|
76
|
+
{{ STATUS_META[task.status].label }}
|
|
77
|
+
</span>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
@@ -45,6 +45,13 @@ function setResponsible(userId: string) {
|
|
|
45
45
|
board.updateBlock(props.block.id, { responsibleProductUserId: userId })
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// ---- auto-start dependents -------------------------------------------------
|
|
49
|
+
// Preceding-task toggle: when this task merges, the engine auto-starts the tasks that
|
|
50
|
+
// depend on it (skipping any on an individual-usage model, which can't unlock unattended).
|
|
51
|
+
function setAutoStartDependents(value: boolean) {
|
|
52
|
+
board.updateBlock(props.block.id, { autoStartDependents: value })
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
// ---- merge policy preset ---------------------------------------------------
|
|
49
56
|
// Which merge threshold preset governs this task's auto-merge decision + CI-fixer
|
|
50
57
|
// budget. None selected → the workspace default preset. (The old confidence-based
|
|
@@ -365,5 +372,23 @@ const resolveOnMergeLabel = computed(() =>
|
|
|
365
372
|
Unassigned — set a product owner to notify them when requirement review flags this task.
|
|
366
373
|
</div>
|
|
367
374
|
</div>
|
|
375
|
+
|
|
376
|
+
<!-- auto-start dependents: when this task merges, start the tasks that depend on it -->
|
|
377
|
+
<div>
|
|
378
|
+
<div class="flex items-center justify-between gap-2">
|
|
379
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
380
|
+
Auto-start dependents
|
|
381
|
+
</span>
|
|
382
|
+
<USwitch
|
|
383
|
+
size="sm"
|
|
384
|
+
:model-value="block.autoStartDependents ?? false"
|
|
385
|
+
@update:model-value="setAutoStartDependents"
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="mt-1 text-[11px] text-slate-500">
|
|
389
|
+
When this task merges, automatically start the tasks that depend on it (once their
|
|
390
|
+
other dependencies are also done).
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
368
393
|
</div>
|
|
369
394
|
</template>
|
|
@@ -162,6 +162,35 @@ async function doImport() {
|
|
|
162
162
|
importing.value = false
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
// Spawn the referenced issue as an EPIC: an epic node + a task per child issue (into the
|
|
167
|
+
// chosen container), with dependency edges seeded from the issues' blocked-by/depends-on
|
|
168
|
+
// links. Needs a container for the child tasks.
|
|
169
|
+
async function doSpawnEpic() {
|
|
170
|
+
const value = ref_.value.trim()
|
|
171
|
+
if (!value || !source.value || !containerId.value) return
|
|
172
|
+
importing.value = true
|
|
173
|
+
try {
|
|
174
|
+
const { epic, tasks: spawned } = await tasks.spawnEpic(source.value, value, containerId.value)
|
|
175
|
+
ref_.value = ''
|
|
176
|
+
ui.closeTaskImport()
|
|
177
|
+
ui.select(epic.id)
|
|
178
|
+
toast.add({
|
|
179
|
+
title: `Spawned epic "${epic.title}"`,
|
|
180
|
+
description: `${spawned.length} child task(s) created`,
|
|
181
|
+
icon: 'i-lucide-layers',
|
|
182
|
+
})
|
|
183
|
+
} catch (e) {
|
|
184
|
+
toast.add({
|
|
185
|
+
title: 'Could not spawn epic',
|
|
186
|
+
description: e instanceof Error ? e.message : String(e),
|
|
187
|
+
icon: 'i-lucide-triangle-alert',
|
|
188
|
+
color: 'error',
|
|
189
|
+
})
|
|
190
|
+
} finally {
|
|
191
|
+
importing.value = false
|
|
192
|
+
}
|
|
193
|
+
}
|
|
165
194
|
</script>
|
|
166
195
|
|
|
167
196
|
<template>
|
|
@@ -209,8 +238,38 @@ async function doImport() {
|
|
|
209
238
|
>
|
|
210
239
|
Import
|
|
211
240
|
</UButton>
|
|
241
|
+
<UButton
|
|
242
|
+
color="primary"
|
|
243
|
+
variant="soft"
|
|
244
|
+
icon="i-lucide-layers"
|
|
245
|
+
:loading="importing"
|
|
246
|
+
:disabled="!ref_.trim() || !containerId"
|
|
247
|
+
:title="
|
|
248
|
+
containerId
|
|
249
|
+
? 'Spawn this epic + its children as a linked task group'
|
|
250
|
+
: 'Pick a container for the child tasks first'
|
|
251
|
+
"
|
|
252
|
+
@click="doSpawnEpic"
|
|
253
|
+
>
|
|
254
|
+
As epic
|
|
255
|
+
</UButton>
|
|
212
256
|
</div>
|
|
213
257
|
|
|
258
|
+
<!-- Container for epic children when spawning from a pasted ref (the shared
|
|
259
|
+
"Create tasks in" selector below covers the search-results case). -->
|
|
260
|
+
<UFormField
|
|
261
|
+
v-if="containerItems.length && !freshHits.length && !sourceTasks.length"
|
|
262
|
+
label="Epic children container"
|
|
263
|
+
class="w-72"
|
|
264
|
+
>
|
|
265
|
+
<USelect
|
|
266
|
+
v-model="containerId"
|
|
267
|
+
:items="containerItems"
|
|
268
|
+
placeholder="Pick a frame or module"
|
|
269
|
+
class="w-full"
|
|
270
|
+
/>
|
|
271
|
+
</UFormField>
|
|
272
|
+
|
|
214
273
|
<!-- Browse: search the tracker by title so an issue can be turned into a
|
|
215
274
|
task without knowing its key. -->
|
|
216
275
|
<UFormField v-if="searchable" label="Search issues">
|
|
@@ -51,6 +51,16 @@ export function boardApi({ http, ws }: ApiContext) {
|
|
|
51
51
|
body: { name: string; position?: Position },
|
|
52
52
|
) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/modules`, { method: 'POST', body }),
|
|
53
53
|
|
|
54
|
+
// Create an epic grouping node (optionally placed under a service/module).
|
|
55
|
+
addEpic: (
|
|
56
|
+
workspaceId: string,
|
|
57
|
+
body: { title: string; description?: string; position: Position; parentId?: string },
|
|
58
|
+
) => http<Block>(`${ws(workspaceId)}/epics`, { method: 'POST', body }),
|
|
59
|
+
|
|
60
|
+
// Assign a task to an epic, or detach it (epicId: null).
|
|
61
|
+
assignToEpic: (workspaceId: string, blockId: string, body: { epicId: string | null }) =>
|
|
62
|
+
http<Block>(`${ws(workspaceId)}/blocks/${blockId}/epic`, { method: 'POST', body }),
|
|
63
|
+
|
|
54
64
|
updateBlock: (workspaceId: string, blockId: string, body: Partial<Block>) =>
|
|
55
65
|
http<Block>(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'PATCH', body }),
|
|
56
66
|
|
|
@@ -82,6 +82,18 @@ export function tasksApi({ http, ws }: ApiContext) {
|
|
|
82
82
|
body,
|
|
83
83
|
}),
|
|
84
84
|
|
|
85
|
+
// Spawn an epic + its children as an epic node + child tasks, with dependency edges
|
|
86
|
+
// seeded from the issues' blocked-by/depends-on links.
|
|
87
|
+
spawnEpic: (
|
|
88
|
+
workspaceId: string,
|
|
89
|
+
source: TaskSourceKind,
|
|
90
|
+
body: { ref: string; containerId: string; position?: { x: number; y: number } },
|
|
91
|
+
) =>
|
|
92
|
+
http<{ epic: Block; tasks: Block[] }>(
|
|
93
|
+
`${ws(workspaceId)}/task-sources/${source}/epics/spawn`,
|
|
94
|
+
{ method: 'POST', body },
|
|
95
|
+
),
|
|
96
|
+
|
|
85
97
|
// ---- issue-tracker selection (workspace-level) ------------------------
|
|
86
98
|
getTrackerSettings: (workspaceId: string) =>
|
|
87
99
|
http<TrackerSettings>(`${ws(workspaceId)}/tracker-settings`),
|
|
@@ -56,6 +56,19 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
56
56
|
/** All tasks across every service (used for the dependency picker). */
|
|
57
57
|
const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
|
|
58
58
|
|
|
59
|
+
/** Epic grouping nodes (non-structural; group tasks via their `epicId`). */
|
|
60
|
+
const epics = computed(() => blocks.value.filter((b) => b.level === 'epic'))
|
|
61
|
+
|
|
62
|
+
/** The tasks that belong to an epic (anywhere on the board) via their `epicId`. */
|
|
63
|
+
function epicMembers(epicId: string): Block[] {
|
|
64
|
+
return blocks.value.filter((b) => b.epicId === epicId)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The epic a task belongs to, if any. */
|
|
68
|
+
function epicOf(task: Block): Block | undefined {
|
|
69
|
+
return task.epicId ? getBlock(task.epicId) : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** A task's dependencies that are not yet merged (i.e. block it from running). */
|
|
60
73
|
function unmetDeps(taskId: string) {
|
|
61
74
|
const t = getBlock(taskId)
|
|
@@ -139,6 +152,9 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
139
152
|
getBlock,
|
|
140
153
|
frames,
|
|
141
154
|
allTasks,
|
|
155
|
+
epics,
|
|
156
|
+
epicMembers,
|
|
157
|
+
epicOf,
|
|
142
158
|
childrenOf,
|
|
143
159
|
tasksOf,
|
|
144
160
|
modulesOf,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drag-to-connect for board dependencies. A small connector handle on a task card calls
|
|
5
|
+
* {@link start} on pointerdown; we then track the pointer (a live preview line drawn by
|
|
6
|
+
* `DependencyConnectOverlay`) and, on release over another task card, create the edge
|
|
7
|
+
* "dropped-on dependsOn dragged-from" — i.e. you drag from the prerequisite onto the task
|
|
8
|
+
* that should wait for it. Module-level state so the handle and the overlay share one drag.
|
|
9
|
+
*
|
|
10
|
+
* Coordinates are client (screen) space, so the overlay is a fixed full-viewport SVG and
|
|
11
|
+
* the gesture follows pan/zoom for free (the cards move under the cursor, the line tracks
|
|
12
|
+
* the cursor). Target resolution uses `elementFromPoint` → nearest `[data-block-id]`.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConnectState {
|
|
15
|
+
sourceId: string
|
|
16
|
+
x1: number
|
|
17
|
+
y1: number
|
|
18
|
+
x2: number
|
|
19
|
+
y2: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const connecting = ref<ConnectState | null>(null)
|
|
23
|
+
|
|
24
|
+
export function useDependencyConnect() {
|
|
25
|
+
const board = useBoardStore()
|
|
26
|
+
|
|
27
|
+
function onMove(ev: PointerEvent) {
|
|
28
|
+
if (!connecting.value) return
|
|
29
|
+
connecting.value.x2 = ev.clientX
|
|
30
|
+
connecting.value.y2 = ev.clientY
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function onUp(ev: PointerEvent) {
|
|
34
|
+
const state = connecting.value
|
|
35
|
+
window.removeEventListener('pointermove', onMove)
|
|
36
|
+
window.removeEventListener('pointerup', onUp)
|
|
37
|
+
connecting.value = null
|
|
38
|
+
if (!state) return
|
|
39
|
+
// Resolve the task under the cursor on release.
|
|
40
|
+
const el = document.elementFromPoint(ev.clientX, ev.clientY)
|
|
41
|
+
const card = el?.closest('[data-block-id]') as HTMLElement | null
|
|
42
|
+
const targetId = card?.getAttribute('data-block-id') ?? null
|
|
43
|
+
if (!targetId || targetId === state.sourceId) return
|
|
44
|
+
const target = board.getBlock(targetId)
|
|
45
|
+
const source = board.getBlock(state.sourceId)
|
|
46
|
+
if (!target || target.level !== 'task' || !source || source.level !== 'task') return
|
|
47
|
+
// Dropped-on task depends on the dragged-from (prerequisite) task.
|
|
48
|
+
await board.toggleDependency(targetId, state.sourceId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Begin a connect drag from `sourceId`'s handle. */
|
|
52
|
+
function start(sourceId: string, ev: PointerEvent) {
|
|
53
|
+
ev.preventDefault()
|
|
54
|
+
ev.stopPropagation()
|
|
55
|
+
connecting.value = { sourceId, x1: ev.clientX, y1: ev.clientY, x2: ev.clientX, y2: ev.clientY }
|
|
56
|
+
window.addEventListener('pointermove', onMove)
|
|
57
|
+
window.addEventListener('pointerup', onUp)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { connecting, start }
|
|
61
|
+
}
|
package/app/stores/board.ts
CHANGED
|
@@ -15,8 +15,8 @@ import { useBlockQueries } from '~/composables/useBlockQueries'
|
|
|
15
15
|
interface RemovalSnapshot {
|
|
16
16
|
/** The removed block + all its descendants, in their original order. */
|
|
17
17
|
removed: Block[]
|
|
18
|
-
/** Survivors whose `dependsOn` lost an edge to a removed block (originals to restore). */
|
|
19
|
-
edges: { id: string; dependsOn: string[] }[]
|
|
18
|
+
/** Survivors whose `dependsOn`/`epicId` lost an edge to a removed block (originals to restore). */
|
|
19
|
+
edges: { id: string; dependsOn: string[]; epicId: string | null }[]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export const useBoardStore = defineStore('board', () => {
|
|
@@ -97,6 +97,44 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
97
97
|
return block
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Add an epic grouping node. Epics are non-structural: they group tasks via the tasks'
|
|
102
|
+
* `epicId`, so this just drops a new `epic`-level block on the board.
|
|
103
|
+
*/
|
|
104
|
+
async function addEpic(
|
|
105
|
+
title: string,
|
|
106
|
+
position: { x: number; y: number },
|
|
107
|
+
options?: { description?: string; parentId?: string },
|
|
108
|
+
): Promise<Block> {
|
|
109
|
+
const block = await api.addEpic(useWorkspaceStore().requireId(), {
|
|
110
|
+
title,
|
|
111
|
+
position,
|
|
112
|
+
...(options?.description ? { description: options.description } : {}),
|
|
113
|
+
...(options?.parentId ? { parentId: options.parentId } : {}),
|
|
114
|
+
})
|
|
115
|
+
upsert(block)
|
|
116
|
+
return block
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Assign a task to an epic, or detach it (epicId: null). */
|
|
120
|
+
async function assignToEpic(taskId: string, epicId: string | null) {
|
|
121
|
+
const t = getBlock(taskId)
|
|
122
|
+
if (!t) return
|
|
123
|
+
const prev = t.epicId ?? null
|
|
124
|
+
t.epicId = epicId // optimistic
|
|
125
|
+
try {
|
|
126
|
+
upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
|
|
127
|
+
} catch (e) {
|
|
128
|
+
t.epicId = prev
|
|
129
|
+
toast.add({
|
|
130
|
+
title: 'Could not change epic',
|
|
131
|
+
description: e instanceof Error ? e.message : String(e),
|
|
132
|
+
icon: 'i-lucide-triangle-alert',
|
|
133
|
+
color: 'error',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
100
138
|
/** Add a module (sub-frame) inside a service. */
|
|
101
139
|
async function addModule(
|
|
102
140
|
serviceId: string,
|
|
@@ -171,15 +209,22 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
const removed = blocks.value.filter((b) => doomed.has(b.id))
|
|
174
|
-
// Survivors that pointed at a doomed block
|
|
212
|
+
// Survivors that pointed at a doomed block (dependency edge or epic membership) lose
|
|
213
|
+
// that link — snapshot the originals so a failed delete restores them faithfully.
|
|
175
214
|
const edges = blocks.value
|
|
176
|
-
.filter(
|
|
177
|
-
|
|
215
|
+
.filter(
|
|
216
|
+
(b) =>
|
|
217
|
+
!doomed.has(b.id) &&
|
|
218
|
+
(b.dependsOn.some((d) => doomed.has(d)) || (b.epicId != null && doomed.has(b.epicId))),
|
|
219
|
+
)
|
|
220
|
+
.map((b) => ({ id: b.id, dependsOn: [...b.dependsOn], epicId: b.epicId ?? null }))
|
|
178
221
|
blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
|
|
179
222
|
for (const b of blocks.value) {
|
|
180
223
|
if (b.dependsOn.some((d) => doomed.has(d))) {
|
|
181
224
|
b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
|
|
182
225
|
}
|
|
226
|
+
// A member of a deleted epic loses its membership (the task itself survives).
|
|
227
|
+
if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
|
|
183
228
|
}
|
|
184
229
|
return { removed, edges }
|
|
185
230
|
}
|
|
@@ -189,7 +234,10 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
189
234
|
for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
|
|
190
235
|
for (const e of snap.edges) {
|
|
191
236
|
const b = getBlock(e.id)
|
|
192
|
-
if (b)
|
|
237
|
+
if (b) {
|
|
238
|
+
b.dependsOn = e.dependsOn
|
|
239
|
+
b.epicId = e.epicId
|
|
240
|
+
}
|
|
193
241
|
}
|
|
194
242
|
}
|
|
195
243
|
|
|
@@ -252,10 +300,23 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
252
300
|
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
253
301
|
}
|
|
254
302
|
|
|
255
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Toggle a dependency edge target -> source (target dependsOn source). The backend
|
|
305
|
+
* rejects an edge that would close a cycle (422) — surface that as a toast rather than
|
|
306
|
+
* letting it throw unhandled out of a board gesture.
|
|
307
|
+
*/
|
|
256
308
|
async function toggleDependency(targetId: string, sourceId: string) {
|
|
257
309
|
if (targetId === sourceId || !getBlock(targetId)) return
|
|
258
|
-
|
|
310
|
+
try {
|
|
311
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
312
|
+
} catch (e) {
|
|
313
|
+
toast.add({
|
|
314
|
+
title: 'Could not link tasks',
|
|
315
|
+
description: e instanceof Error ? e.message : String(e),
|
|
316
|
+
icon: 'i-lucide-triangle-alert',
|
|
317
|
+
color: 'error',
|
|
318
|
+
})
|
|
319
|
+
}
|
|
259
320
|
}
|
|
260
321
|
|
|
261
322
|
/** Remove a dependency edge target -> source if it exists. */
|
|
@@ -275,6 +336,8 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
275
336
|
addServiceFromRepo,
|
|
276
337
|
addTask,
|
|
277
338
|
addModule,
|
|
339
|
+
addEpic,
|
|
340
|
+
assignToEpic,
|
|
278
341
|
reparentBlock,
|
|
279
342
|
detach,
|
|
280
343
|
reattach,
|
package/app/stores/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TaskSourceState,
|
|
10
10
|
} from '~/types/domain'
|
|
11
11
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
12
|
+
import { useBoardStore } from '~/stores/board'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Task-source integration state: the trackers the backend offers (and their
|
|
@@ -210,6 +211,29 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
210
211
|
return result
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Spawn an epic and its children onto the board: an epic node + a task per child issue
|
|
216
|
+
* (joined to the epic), with dependency edges seeded from the issues' links. Upserts the
|
|
217
|
+
* created blocks so the board reflects them immediately (the stream also re-broadcasts).
|
|
218
|
+
*/
|
|
219
|
+
async function spawnEpic(
|
|
220
|
+
source: TaskSourceKind,
|
|
221
|
+
ref: string,
|
|
222
|
+
containerId: string,
|
|
223
|
+
position?: { x: number; y: number },
|
|
224
|
+
) {
|
|
225
|
+
const board = useBoardStore()
|
|
226
|
+
const result = await api.spawnEpic(workspace.requireId(), source, {
|
|
227
|
+
ref,
|
|
228
|
+
containerId,
|
|
229
|
+
...(position ? { position } : {}),
|
|
230
|
+
})
|
|
231
|
+
board.upsert(result.epic)
|
|
232
|
+
for (const t of result.tasks) board.upsert(t)
|
|
233
|
+
await loadTasks().catch(() => {})
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
|
|
213
237
|
return {
|
|
214
238
|
available,
|
|
215
239
|
probeError,
|
|
@@ -237,5 +261,6 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
237
261
|
search,
|
|
238
262
|
linkToBlock,
|
|
239
263
|
createTaskFromIssue,
|
|
264
|
+
spawnEpic,
|
|
240
265
|
}
|
|
241
266
|
})
|
package/app/types/domain.ts
CHANGED
|
@@ -59,8 +59,11 @@ export type BlockType =
|
|
|
59
59
|
* - `module` a sub-frame inside a service; created when a task assigned to a
|
|
60
60
|
* not-yet-existing module is implemented (tasks can also be dragged in)
|
|
61
61
|
* - `task` a draggable unit of work living inside a service or a module
|
|
62
|
+
* - `epic` a NON-structural grouping node: it groups tasks (which keep their own
|
|
63
|
+
* service/module parent) via their `epicId`, and is drawn linked to them.
|
|
64
|
+
* Never a container — nothing is reparented into an epic.
|
|
62
65
|
*/
|
|
63
|
-
export type BlockLevel = 'frame' | 'module' | 'task'
|
|
66
|
+
export type BlockLevel = 'frame' | 'module' | 'task' | 'epic'
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* The kind of work a task represents, chosen at creation. Drives the card's badge/icon
|
|
@@ -109,6 +112,16 @@ export interface Block {
|
|
|
109
112
|
level: BlockLevel
|
|
110
113
|
/** parent container: service or module for a task, service for a module. */
|
|
111
114
|
parentId: string | null
|
|
115
|
+
/**
|
|
116
|
+
* task-only: membership link to an `epic`-level block, independent of `parentId`.
|
|
117
|
+
* Set when the task belongs to an epic (drawn linked to it); absent/null otherwise.
|
|
118
|
+
*/
|
|
119
|
+
epicId?: string | null
|
|
120
|
+
/**
|
|
121
|
+
* task-only: when this task merges, automatically start the tasks that depend on it
|
|
122
|
+
* (and whose other dependencies are also done). Off/absent ⇒ dependents wait.
|
|
123
|
+
*/
|
|
124
|
+
autoStartDependents?: boolean
|
|
112
125
|
/** task-only: 0..1 confidence produced when the pipeline finishes. */
|
|
113
126
|
confidence?: number
|
|
114
127
|
/** task-only: the task-estimator's triage (complexity/risk/impact); absent until it runs. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|