@cat-factory/app 0.19.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/layout/IntegrationsHub.vue +36 -28
- 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/settings/OpenRouterCatalogPanel.vue +232 -71
- 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="
|
|
@@ -15,6 +15,8 @@ const tasks = useTasksStore()
|
|
|
15
15
|
const tracker = useTrackerStore()
|
|
16
16
|
const releaseHealth = useReleaseHealthStore()
|
|
17
17
|
const userSecrets = useUserSecretsStore()
|
|
18
|
+
const apiKeys = useApiKeysStore()
|
|
19
|
+
const workspace = useWorkspaceStore()
|
|
18
20
|
|
|
19
21
|
// The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
|
|
20
22
|
const trackerLabel = computed(() => {
|
|
@@ -31,6 +33,8 @@ watch(
|
|
|
31
33
|
if (isOpen) {
|
|
32
34
|
void releaseHealth.ensureLoaded().catch(() => {})
|
|
33
35
|
void userSecrets.load().catch(() => {})
|
|
36
|
+
// Drives the OpenRouter row's "Key connected" badge.
|
|
37
|
+
if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
)
|
|
@@ -66,6 +70,38 @@ function go(fn: () => void) {
|
|
|
66
70
|
const groups = computed<IntegrationGroup[]>(() => {
|
|
67
71
|
const out: IntegrationGroup[] = []
|
|
68
72
|
|
|
73
|
+
// --- Models & providers ----------------------------------------------------
|
|
74
|
+
// Top of the hub: an OpenRouter key is the fastest path to 300+ models, so it leads.
|
|
75
|
+
const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
|
|
76
|
+
out.push({
|
|
77
|
+
title: 'Models & providers',
|
|
78
|
+
items: [
|
|
79
|
+
{
|
|
80
|
+
key: 'openrouter',
|
|
81
|
+
icon: 'i-lucide-waypoints',
|
|
82
|
+
label: 'OpenRouter',
|
|
83
|
+
description: 'One gateway to 300+ models — add your key and enable models in one place.',
|
|
84
|
+
status: openRouterKeyConnected ? 'Key connected' : undefined,
|
|
85
|
+
connected: openRouterKeyConnected,
|
|
86
|
+
onClick: () => go(ui.openOpenRouter),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'vendors',
|
|
90
|
+
icon: 'i-lucide-key-round',
|
|
91
|
+
label: 'Vendors & keys',
|
|
92
|
+
description: 'LLM vendor subscriptions and provider API keys.',
|
|
93
|
+
onClick: () => go(ui.openVendorCredentials),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'local-runners',
|
|
97
|
+
icon: 'i-lucide-server',
|
|
98
|
+
label: 'My local runners',
|
|
99
|
+
description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
|
|
100
|
+
onClick: () => go(ui.openLocalModels),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
})
|
|
104
|
+
|
|
69
105
|
// --- Source control --------------------------------------------------------
|
|
70
106
|
const code: IntegrationItem[] = []
|
|
71
107
|
if (github.available) {
|
|
@@ -188,34 +224,6 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
188
224
|
})
|
|
189
225
|
}
|
|
190
226
|
|
|
191
|
-
// --- Models & providers ----------------------------------------------------
|
|
192
|
-
out.push({
|
|
193
|
-
title: 'Models & providers',
|
|
194
|
-
items: [
|
|
195
|
-
{
|
|
196
|
-
key: 'vendors',
|
|
197
|
-
icon: 'i-lucide-key-round',
|
|
198
|
-
label: 'Vendors & keys',
|
|
199
|
-
description: 'LLM vendor subscriptions and provider API keys.',
|
|
200
|
-
onClick: () => go(ui.openVendorCredentials),
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
key: 'local-runners',
|
|
204
|
-
icon: 'i-lucide-server',
|
|
205
|
-
label: 'My local runners',
|
|
206
|
-
description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
|
|
207
|
-
onClick: () => go(ui.openLocalModels),
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
key: 'openrouter',
|
|
211
|
-
icon: 'i-lucide-waypoints',
|
|
212
|
-
label: 'OpenRouter models',
|
|
213
|
-
description: 'Browse and enable models from the OpenRouter gateway.',
|
|
214
|
-
onClick: () => go(ui.openOpenRouter),
|
|
215
|
-
},
|
|
216
|
-
],
|
|
217
|
-
})
|
|
218
|
-
|
|
219
227
|
return out
|
|
220
228
|
})
|
|
221
229
|
</script>
|
|
@@ -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>
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// "OpenRouter" — the one-stop OpenRouter setup panel. OpenRouter is a single gateway to
|
|
3
|
+
// 300+ models reached via the workspace's API-key pool, so this panel owns the whole flow:
|
|
4
|
+
// 1. Connect an OpenRouter key inline (no need to detour through Vendors & keys).
|
|
5
|
+
// 2. The live catalog auto-refreshes as soon as a key exists.
|
|
6
|
+
// 3. Tick models (or one-click "Enable recommended") and Save.
|
|
7
|
+
// Enabled models — with their context window and price — appear in the model picker and
|
|
8
|
+
// meter against the spend budget. The Vendors & keys → Proxies tab remains a valid second
|
|
9
|
+
// entry point for the key; this panel just makes OpenRouter self-sufficient.
|
|
7
10
|
import { computed, ref, watch } from 'vue'
|
|
8
11
|
import type { OpenRouterModelMeta } from '~/types/openrouter'
|
|
9
12
|
|
|
10
13
|
const ui = useUiStore()
|
|
11
14
|
const workspace = useWorkspaceStore()
|
|
12
15
|
const store = useOpenRouterStore()
|
|
16
|
+
const apiKeys = useApiKeysStore()
|
|
17
|
+
const models = useModelsStore()
|
|
13
18
|
const toast = useToast()
|
|
14
19
|
|
|
15
20
|
const open = computed({
|
|
@@ -17,16 +22,39 @@ const open = computed({
|
|
|
17
22
|
set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
|
|
18
23
|
})
|
|
19
24
|
|
|
25
|
+
// Popular slugs offered by "Enable recommended" — these mirror the curated `openrouter`
|
|
26
|
+
// refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
|
|
27
|
+
// ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
|
|
28
|
+
const RECOMMENDED_SLUGS = [
|
|
29
|
+
'anthropic/claude-opus-4.8',
|
|
30
|
+
'openai/gpt-5.5',
|
|
31
|
+
'google/gemini-3-pro',
|
|
32
|
+
'deepseek/deepseek-chat',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
// Whether the workspace/user has an OpenRouter key connected at any reachable scope.
|
|
36
|
+
const keyConnected = computed(() => apiKeys.configuredProviders.has('openrouter'))
|
|
37
|
+
|
|
20
38
|
// The enabled slugs the user has ticked (seeded from the persisted catalog on open).
|
|
21
39
|
const selected = ref<Set<string>>(new Set())
|
|
22
40
|
const filter = ref('')
|
|
23
41
|
const busy = ref(false)
|
|
24
42
|
|
|
25
|
-
//
|
|
43
|
+
// Inline key-entry form state (shown until an OpenRouter key is connected).
|
|
44
|
+
const keyScope = ref<'workspace' | 'user'>('workspace')
|
|
45
|
+
const keyLabel = ref('')
|
|
46
|
+
const keyValue = ref('')
|
|
47
|
+
const connectingKey = ref(false)
|
|
48
|
+
|
|
49
|
+
// Load key state + persisted catalog whenever the panel opens; seed the tick selection,
|
|
50
|
+
// then auto-refresh the live catalog if a key is already connected (no extra click).
|
|
26
51
|
watch(open, (isOpen) => {
|
|
27
52
|
if (!isOpen || !workspace.workspaceId) return
|
|
28
|
-
|
|
53
|
+
const ws = workspace.workspaceId
|
|
54
|
+
void apiKeys.load(ws).catch(() => {})
|
|
55
|
+
void store.load(ws).then(() => {
|
|
29
56
|
selected.value = new Set(store.enabled.map((m) => m.id))
|
|
57
|
+
if (keyConnected.value && store.browse.length === 0) void refresh()
|
|
30
58
|
})
|
|
31
59
|
})
|
|
32
60
|
|
|
@@ -45,6 +73,12 @@ const visible = computed(() => {
|
|
|
45
73
|
|
|
46
74
|
const selectedCount = computed(() => selected.value.size)
|
|
47
75
|
|
|
76
|
+
// Recommended slugs that are actually available in the current browse/enabled list.
|
|
77
|
+
const recommendedAvailable = computed(() => {
|
|
78
|
+
const ids = new Set(source.value.map((m) => m.id))
|
|
79
|
+
return RECOMMENDED_SLUGS.filter((slug) => ids.has(slug))
|
|
80
|
+
})
|
|
81
|
+
|
|
48
82
|
function contextLabel(tokens: number | undefined): string {
|
|
49
83
|
if (!tokens) return ''
|
|
50
84
|
return tokens >= 1000 ? `${Math.round(tokens / 1000)}K ctx` : `${tokens} ctx`
|
|
@@ -61,13 +95,47 @@ function toggle(id: string, on: boolean) {
|
|
|
61
95
|
selected.value = next
|
|
62
96
|
}
|
|
63
97
|
|
|
98
|
+
function enableRecommended() {
|
|
99
|
+
const next = new Set(selected.value)
|
|
100
|
+
for (const slug of recommendedAvailable.value) next.add(slug)
|
|
101
|
+
selected.value = next
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function connectKey() {
|
|
105
|
+
if (!keyValue.value.trim() || !workspace.workspaceId) return
|
|
106
|
+
connectingKey.value = true
|
|
107
|
+
try {
|
|
108
|
+
const input = {
|
|
109
|
+
provider: 'openrouter' as const,
|
|
110
|
+
label: keyLabel.value.trim() || 'openrouter key',
|
|
111
|
+
key: keyValue.value.trim(),
|
|
112
|
+
}
|
|
113
|
+
if (keyScope.value === 'workspace') await apiKeys.addWorkspaceKey(input)
|
|
114
|
+
else await apiKeys.addUserKey(input)
|
|
115
|
+
keyValue.value = ''
|
|
116
|
+
keyLabel.value = ''
|
|
117
|
+
toast.add({ title: 'OpenRouter key connected', icon: 'i-lucide-check', color: 'success' })
|
|
118
|
+
// Now that a key exists, load the live catalog automatically.
|
|
119
|
+
await refresh()
|
|
120
|
+
} catch (e) {
|
|
121
|
+
toast.add({
|
|
122
|
+
title: 'Could not connect key',
|
|
123
|
+
description: e instanceof Error ? e.message : String(e),
|
|
124
|
+
icon: 'i-lucide-triangle-alert',
|
|
125
|
+
color: 'error',
|
|
126
|
+
})
|
|
127
|
+
} finally {
|
|
128
|
+
connectingKey.value = false
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
64
132
|
async function refresh() {
|
|
65
133
|
if (!workspace.workspaceId) return
|
|
66
134
|
const result = await store.refresh(workspace.workspaceId)
|
|
67
135
|
if (!result.reachable) {
|
|
68
136
|
toast.add({
|
|
69
137
|
title: 'Could not reach OpenRouter',
|
|
70
|
-
description: store.refreshError ?? 'Connect an OpenRouter key
|
|
138
|
+
description: store.refreshError ?? 'Connect an OpenRouter key first.',
|
|
71
139
|
icon: 'i-lucide-triangle-alert',
|
|
72
140
|
color: 'error',
|
|
73
141
|
})
|
|
@@ -80,10 +148,12 @@ async function save() {
|
|
|
80
148
|
try {
|
|
81
149
|
// Persist the ticked models, carrying the metadata from whichever list they came from.
|
|
82
150
|
const byId = new Map(source.value.map((m) => [m.id, m]))
|
|
83
|
-
const
|
|
151
|
+
const models2 = [...selected.value]
|
|
84
152
|
.map((id) => byId.get(id))
|
|
85
153
|
.filter((m): m is OpenRouterModelMeta => !!m)
|
|
86
|
-
await store.save(workspace.workspaceId,
|
|
154
|
+
await store.save(workspace.workspaceId, models2)
|
|
155
|
+
// Reflect newly-enabled models in the picker immediately.
|
|
156
|
+
await models.refresh(workspace.workspaceId)
|
|
87
157
|
toast.add({ title: 'OpenRouter catalog saved', icon: 'i-lucide-check', color: 'success' })
|
|
88
158
|
} catch (e) {
|
|
89
159
|
toast.add({
|
|
@@ -96,80 +166,171 @@ async function save() {
|
|
|
96
166
|
busy.value = false
|
|
97
167
|
}
|
|
98
168
|
}
|
|
169
|
+
|
|
170
|
+
function manageKeys() {
|
|
171
|
+
ui.closeOpenRouter()
|
|
172
|
+
ui.openVendorCredentials()
|
|
173
|
+
}
|
|
99
174
|
</script>
|
|
100
175
|
|
|
101
176
|
<template>
|
|
102
|
-
<UModal v-model:open="open" title="OpenRouter
|
|
177
|
+
<UModal v-model:open="open" title="OpenRouter" :ui="{ content: 'max-w-2xl' }">
|
|
103
178
|
<template #body>
|
|
104
179
|
<div class="space-y-4">
|
|
105
180
|
<p class="text-xs text-slate-400">
|
|
106
|
-
Reach <strong>300+ models</strong> through one gateway.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
models you want. Enabled models appear in the model picker with their context window and
|
|
110
|
-
price, and meter against your spend budget.
|
|
181
|
+
Reach <strong>300+ models</strong> through one gateway. Add your OpenRouter key below,
|
|
182
|
+
then enable the models you want — they appear in the model picker with their context
|
|
183
|
+
window and price, and meter against your spend budget.
|
|
111
184
|
</p>
|
|
112
185
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
<!-- Step 1: connect a key (inline) — hidden once a key is connected -->
|
|
187
|
+
<div
|
|
188
|
+
v-if="!keyConnected"
|
|
189
|
+
class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/60 p-4"
|
|
190
|
+
>
|
|
191
|
+
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
192
|
+
Connect your OpenRouter key
|
|
193
|
+
</h4>
|
|
194
|
+
<ol class="list-decimal space-y-1 pl-5 text-sm text-slate-300">
|
|
195
|
+
<li>
|
|
196
|
+
Open
|
|
197
|
+
<a
|
|
198
|
+
href="https://openrouter.ai/keys"
|
|
199
|
+
target="_blank"
|
|
200
|
+
rel="noopener noreferrer"
|
|
201
|
+
class="text-primary-400 underline"
|
|
202
|
+
>openrouter.ai → Keys ↗</a
|
|
203
|
+
>
|
|
204
|
+
and create an API key.
|
|
205
|
+
</li>
|
|
206
|
+
<li>
|
|
207
|
+
Copy the key (starts with <span class="font-mono">sk-or-…</span>) and paste it below.
|
|
208
|
+
</li>
|
|
209
|
+
</ol>
|
|
210
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
211
|
+
<UFormField label="Scope">
|
|
212
|
+
<USelect
|
|
213
|
+
v-model="keyScope"
|
|
214
|
+
:items="[
|
|
215
|
+
{ label: 'This workspace', value: 'workspace' },
|
|
216
|
+
{ label: 'My keys (only me)', value: 'user' },
|
|
217
|
+
]"
|
|
218
|
+
class="w-48"
|
|
219
|
+
/>
|
|
220
|
+
</UFormField>
|
|
221
|
+
<UFormField label="Label (optional)" class="flex-1">
|
|
222
|
+
<UInput v-model="keyLabel" placeholder="e.g. team key" />
|
|
223
|
+
</UFormField>
|
|
224
|
+
</div>
|
|
225
|
+
<UFormField label="API key">
|
|
226
|
+
<UTextarea
|
|
227
|
+
v-model="keyValue"
|
|
228
|
+
:rows="2"
|
|
229
|
+
placeholder="paste your OpenRouter key (sk-or-…)"
|
|
230
|
+
class="font-mono"
|
|
144
231
|
/>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
232
|
+
</UFormField>
|
|
233
|
+
<div class="flex justify-end">
|
|
234
|
+
<UButton
|
|
235
|
+
:loading="connectingKey"
|
|
236
|
+
:disabled="!keyValue.trim()"
|
|
237
|
+
icon="i-lucide-plus"
|
|
238
|
+
@click="connectKey()"
|
|
239
|
+
>
|
|
240
|
+
Connect & browse
|
|
241
|
+
</UButton>
|
|
242
|
+
</div>
|
|
154
243
|
</div>
|
|
155
|
-
<p v-else class="text-xs text-slate-500">
|
|
156
|
-
No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
|
|
157
|
-
OpenRouter's live list.
|
|
158
|
-
</p>
|
|
159
244
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
>
|
|
170
|
-
|
|
245
|
+
<!-- Key connected status -->
|
|
246
|
+
<div
|
|
247
|
+
v-else
|
|
248
|
+
class="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
|
|
249
|
+
>
|
|
250
|
+
<span class="flex items-center gap-2 text-slate-300">
|
|
251
|
+
<UIcon name="i-lucide-check-circle" class="h-4 w-4 text-emerald-400" />
|
|
252
|
+
OpenRouter key connected
|
|
253
|
+
</span>
|
|
254
|
+
<UButton color="neutral" variant="ghost" size="xs" @click="manageKeys()">
|
|
255
|
+
Manage in Vendors & keys
|
|
171
256
|
</UButton>
|
|
172
257
|
</div>
|
|
258
|
+
|
|
259
|
+
<!-- Step 2: browse + enable models (only once a key exists) -->
|
|
260
|
+
<template v-if="keyConnected">
|
|
261
|
+
<div class="flex items-center gap-2">
|
|
262
|
+
<UButton
|
|
263
|
+
color="neutral"
|
|
264
|
+
variant="soft"
|
|
265
|
+
size="sm"
|
|
266
|
+
icon="i-lucide-refresh-cw"
|
|
267
|
+
:loading="store.refreshing"
|
|
268
|
+
@click="refresh()"
|
|
269
|
+
>
|
|
270
|
+
Refresh catalog
|
|
271
|
+
</UButton>
|
|
272
|
+
<UButton
|
|
273
|
+
v-if="recommendedAvailable.length"
|
|
274
|
+
color="primary"
|
|
275
|
+
variant="soft"
|
|
276
|
+
size="sm"
|
|
277
|
+
icon="i-lucide-sparkles"
|
|
278
|
+
@click="enableRecommended()"
|
|
279
|
+
>
|
|
280
|
+
Enable recommended
|
|
281
|
+
</UButton>
|
|
282
|
+
<UInput
|
|
283
|
+
v-model="filter"
|
|
284
|
+
size="sm"
|
|
285
|
+
class="flex-1"
|
|
286
|
+
icon="i-lucide-search"
|
|
287
|
+
placeholder="Filter by name or slug…"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
|
|
292
|
+
|
|
293
|
+
<div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
|
|
294
|
+
<label
|
|
295
|
+
v-for="m in visible"
|
|
296
|
+
:key="m.id"
|
|
297
|
+
class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
|
|
298
|
+
>
|
|
299
|
+
<UCheckbox
|
|
300
|
+
:model-value="selected.has(m.id)"
|
|
301
|
+
@update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
|
|
302
|
+
/>
|
|
303
|
+
<span class="min-w-0 flex-1">
|
|
304
|
+
<span class="block truncate text-slate-200">{{ m.name }}</span>
|
|
305
|
+
<span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
|
|
306
|
+
</span>
|
|
307
|
+
<span class="shrink-0 text-right text-[11px] text-slate-500">
|
|
308
|
+
<span v-if="m.contextLength" class="block">{{
|
|
309
|
+
contextLabel(m.contextLength)
|
|
310
|
+
}}</span>
|
|
311
|
+
<span class="block">{{ priceLabel(m) }}</span>
|
|
312
|
+
</span>
|
|
313
|
+
</label>
|
|
314
|
+
</div>
|
|
315
|
+
<p v-else class="text-xs text-slate-500">
|
|
316
|
+
No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
|
|
317
|
+
OpenRouter's live list.
|
|
318
|
+
</p>
|
|
319
|
+
|
|
320
|
+
<div class="flex items-center justify-between">
|
|
321
|
+
<span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
|
|
322
|
+
<UButton
|
|
323
|
+
color="primary"
|
|
324
|
+
variant="soft"
|
|
325
|
+
size="sm"
|
|
326
|
+
icon="i-lucide-save"
|
|
327
|
+
:loading="busy"
|
|
328
|
+
@click="save()"
|
|
329
|
+
>
|
|
330
|
+
Save
|
|
331
|
+
</UButton>
|
|
332
|
+
</div>
|
|
333
|
+
</template>
|
|
173
334
|
</div>
|
|
174
335
|
</template>
|
|
175
336
|
</UModal>
|
|
@@ -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",
|