@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.
@@ -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
+ }
@@ -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 lose that edge snapshot the originals.
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((b) => !doomed.has(b.id) && b.dependsOn.some((d) => doomed.has(d)))
177
- .map((b) => ({ id: b.id, dependsOn: [...b.dependsOn] }))
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) b.dependsOn = e.dependsOn
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
- /** Toggle a dependency edge target -> source (target dependsOn source). */
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
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
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,
@@ -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
  })
@@ -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.20.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",