@cat-factory/app 1.0.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import type { Pipeline } from '~/types/domain'
3
+ import { AGENT_BY_KIND } from '~/utils/catalog'
4
+ import { setDndPayload } from '~/utils/dnd'
5
+
6
+ const pipelines = usePipelinesStore()
7
+ const ui = useUiStore()
8
+
9
+ function onDragStart(event: DragEvent, pipeline: Pipeline) {
10
+ setDndPayload(event, { kind: 'pipeline', pipelineId: pipeline.id })
11
+ ;(event.target as HTMLElement).classList.add('palette-dragging')
12
+ }
13
+
14
+ function onDragEnd(event: DragEvent) {
15
+ ;(event.target as HTMLElement).classList.remove('palette-dragging')
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div class="space-y-3">
21
+ <div class="flex items-center justify-between px-1">
22
+ <p class="text-[11px] text-slate-500">Drag a pipeline onto a block to run it.</p>
23
+ </div>
24
+
25
+ <UButton
26
+ block
27
+ color="primary"
28
+ variant="soft"
29
+ icon="i-lucide-plus"
30
+ size="sm"
31
+ @click="ui.openBuilder()"
32
+ >
33
+ Build a pipeline
34
+ </UButton>
35
+
36
+ <div class="space-y-2">
37
+ <div
38
+ v-for="p in pipelines.pipelines"
39
+ :key="p.id"
40
+ draggable="true"
41
+ class="group cursor-grab select-none rounded-lg border border-slate-700 bg-slate-800/60 p-2.5 transition hover:border-indigo-500/70 hover:bg-slate-800 active:cursor-grabbing"
42
+ @dragstart="onDragStart($event, p)"
43
+ @dragend="onDragEnd"
44
+ >
45
+ <div class="mb-2 flex items-center justify-between">
46
+ <span class="text-xs font-semibold text-slate-100">{{ p.name }}</span>
47
+ <UButton
48
+ icon="i-lucide-trash-2"
49
+ color="neutral"
50
+ variant="ghost"
51
+ size="xs"
52
+ class="opacity-0 transition group-hover:opacity-100"
53
+ @click.stop="pipelines.removePipeline(p.id)"
54
+ />
55
+ </div>
56
+ <div class="flex flex-wrap items-center gap-1">
57
+ <template v-for="(k, i) in p.agentKinds" :key="i">
58
+ <UIcon
59
+ :name="AGENT_BY_KIND[k].icon"
60
+ class="h-4 w-4"
61
+ :style="{ color: AGENT_BY_KIND[k].color }"
62
+ :title="AGENT_BY_KIND[k].label"
63
+ />
64
+ <UIcon
65
+ v-if="i < p.agentKinds.length - 1"
66
+ name="i-lucide-chevron-right"
67
+ class="h-3 w-3 text-slate-600"
68
+ />
69
+ </template>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </template>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ import { AGENT_BY_KIND } from '~/utils/catalog'
3
+
4
+ const execution = useExecutionStore()
5
+ const board = useBoardStore()
6
+ const ui = useUiStore()
7
+
8
+ const ctx = computed(() => ui.decisionContext)
9
+
10
+ const instance = computed(() => execution.getInstance(ctx.value?.instanceId))
11
+ const step = computed(() =>
12
+ instance.value?.steps.find((s) => s.decision?.id === ctx.value?.decisionId),
13
+ )
14
+ const decision = computed(() => step.value?.decision ?? null)
15
+ const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
16
+ const agent = computed(() => (step.value ? AGENT_BY_KIND[step.value.agentKind] : null))
17
+
18
+ const open = computed({
19
+ get: () => !!ctx.value && !!decision.value,
20
+ set: (v: boolean) => {
21
+ if (!v) ui.closeDecision()
22
+ },
23
+ })
24
+
25
+ function choose(option: string) {
26
+ if (!ctx.value) return
27
+ execution.resolveDecision(ctx.value.instanceId, ctx.value.decisionId, option)
28
+ ui.closeDecision()
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <UModal v-model:open="open" title="Decision required">
34
+ <template #body>
35
+ <div v-if="decision && agent" class="space-y-4">
36
+ <div class="flex items-center gap-2 text-sm text-slate-400">
37
+ <div
38
+ class="flex h-8 w-8 items-center justify-center rounded-lg"
39
+ :style="{ backgroundColor: agent.color + '22' }"
40
+ >
41
+ <UIcon :name="agent.icon" class="h-4 w-4" :style="{ color: agent.color }" />
42
+ </div>
43
+ <div>
44
+ <span class="font-medium text-slate-200">{{ agent.label }}</span>
45
+ <span v-if="block"> on </span>
46
+ <span v-if="block" class="font-medium text-slate-200">{{ block.title }}</span>
47
+ </div>
48
+ </div>
49
+
50
+ <p class="text-base font-medium text-white">{{ decision.question }}</p>
51
+
52
+ <div class="grid gap-2">
53
+ <UButton
54
+ v-for="opt in decision.options"
55
+ :key="opt"
56
+ color="primary"
57
+ variant="soft"
58
+ block
59
+ class="justify-start"
60
+ @click="choose(opt)"
61
+ >
62
+ {{ opt }}
63
+ </UButton>
64
+ </div>
65
+ <p class="text-[11px] text-slate-500">
66
+ This is a visualization — any choice simply resumes the pipeline.
67
+ </p>
68
+ </div>
69
+ </template>
70
+ </UModal>
71
+ </template>
@@ -0,0 +1,296 @@
1
+ <script setup lang="ts">
2
+ import type { Block, BlockStatus } from '~/types/domain'
3
+ import { BLOCK_TYPE_META, STATUS_META } from '~/utils/catalog'
4
+ import TaskContextDocs from '~/components/documents/TaskContextDocs.vue'
5
+ import TaskContextIssues from '~/components/tasks/TaskContextIssues.vue'
6
+ import FeatureScenarios from '~/components/scenarios/FeatureScenarios.vue'
7
+ import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
8
+ import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
9
+ import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
10
+ import TaskModelSettings from '~/components/panels/inspector/TaskModelSettings.vue'
11
+ import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
12
+ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
13
+ import AgentStopButton from '~/components/board/AgentStopButton.vue'
14
+
15
+ const board = useBoardStore()
16
+ const pipelines = usePipelinesStore()
17
+ const execution = useExecutionStore()
18
+ const ui = useUiStore()
19
+ const documents = useDocumentsStore()
20
+ const tasks = useTasksStore()
21
+ const fragments = useFragmentsStore()
22
+ const models = useModelsStore()
23
+ const agentRuns = useAgentRunsStore()
24
+ const github = useGitHubStore()
25
+ const requirements = useRequirementsStore()
26
+
27
+ onMounted(() => {
28
+ fragments.ensureLoaded()
29
+ models.ensureLoaded()
30
+ github.ensureLoaded()
31
+ })
32
+
33
+ // Pull this block's requirements review (if any) when the selection changes, so
34
+ // the inspector can show an open-question count and probe feature availability.
35
+ watch(
36
+ () => ui.selectedBlockId,
37
+ (id) => {
38
+ if (id) void requirements.load(id)
39
+ },
40
+ { immediate: true },
41
+ )
42
+
43
+ /** Open the document import/spawn flow, targeting this container's frame. */
44
+ function spawnFromDocument() {
45
+ if (!block.value) return
46
+ const frameId = isFrame.value ? block.value.id : (board.serviceOf(block.value)?.id ?? null)
47
+ ui.openDocumentImport(frameId)
48
+ }
49
+
50
+ const block = computed<Block | undefined>(() =>
51
+ ui.selectedBlockId ? board.getBlock(ui.selectedBlockId) : undefined,
52
+ )
53
+ const level = computed(() => block.value?.level ?? 'frame')
54
+ const isFrame = computed(() => level.value === 'frame')
55
+ const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
56
+ const isTask = computed(() => level.value === 'task')
57
+
58
+ const instance = computed(() => execution.getInstance(block.value?.executionId))
59
+ const typeMeta = computed(() => (block.value ? BLOCK_TYPE_META[block.value.type] : null))
60
+
61
+ // Containers show a derived activity status (never "done"); tasks use their own.
62
+ const FRAME_LABEL: Record<BlockStatus, string> = {
63
+ planned: 'No tasks',
64
+ ready: 'Live',
65
+ in_progress: 'Active',
66
+ blocked: 'Needs attention',
67
+ pr_ready: 'Active',
68
+ done: 'Live',
69
+ }
70
+ const effectiveStatus = computed<BlockStatus>(() =>
71
+ isContainer.value ? board.frameStatus(block.value!.id) : block.value!.status,
72
+ )
73
+ const statusMeta = computed(() => (block.value ? STATUS_META[effectiveStatus.value] : null))
74
+ const statusLabel = computed(() =>
75
+ isContainer.value ? FRAME_LABEL[effectiveStatus.value] : statusMeta.value!.label,
76
+ )
77
+
78
+ const runnable = computed(() => (block.value ? board.isRunnable(block.value.id) : false))
79
+
80
+ // Requirements review (questions / gaps the AI raised about this block's
81
+ // requirements). Hidden only when the feature is known to be off.
82
+ const reviewAvailable = computed(() => requirements.available !== false)
83
+ const blockReview = computed(() => (block.value ? requirements.reviewFor(block.value.id) : null))
84
+ const openReviewCount = computed(() =>
85
+ blockReview.value ? requirements.openCount(blockReview.value) : 0,
86
+ )
87
+
88
+ // The GitHub repo backing this service (a frame), if one is linked. Linkage lives
89
+ // on the github_repos projection (its `blockId`), not on the block itself.
90
+ const serviceRepo = computed(() =>
91
+ isFrame.value && block.value ? github.repoForBlock(block.value.id) : undefined,
92
+ )
93
+ const serviceRepoUrl = computed(() =>
94
+ serviceRepo.value ? github.repoUrl(serviceRepo.value.githubId) : null,
95
+ )
96
+
97
+ const runMenu = computed(() =>
98
+ pipelines.pipelines.map((p) => ({
99
+ label: p.name,
100
+ icon: 'i-lucide-play',
101
+ onSelect: () => block.value && execution.start(block.value.id, p),
102
+ })),
103
+ )
104
+
105
+ function remove() {
106
+ if (!block.value) return
107
+ execution.cancel(block.value.id)
108
+ board.removeBlock(block.value.id)
109
+ ui.select(null)
110
+ }
111
+
112
+ // ---- failed agent run (bootstrap or execution) ------------------------------
113
+ // A block whose current run failed surfaces the shared failure banner + retry,
114
+ // keyed by block id — covering a failed "bootstrap repo" frame and (for tasks) a
115
+ // failed pipeline execution alike.
116
+ const failedRun = computed(() => {
117
+ const run = block.value ? agentRuns.byBlock[block.value.id] : undefined
118
+ return run && run.status === 'failed' ? run : null
119
+ })
120
+
121
+ // A running run on a container frame (a "bootstrapping…" service). Tasks surface
122
+ // their own Stop in TaskExecution, so this covers the bootstrap case the board was
123
+ // previously unable to stop. Drives the inspector's Stop control.
124
+ const runningRun = computed(() => {
125
+ if (!isContainer.value) return null
126
+ const run = block.value ? agentRuns.byBlock[block.value.id] : undefined
127
+ return run && run.status === 'running' ? run : null
128
+ })
129
+ </script>
130
+
131
+ <template>
132
+ <div
133
+ v-if="block && statusMeta && typeMeta"
134
+ class="absolute right-4 top-4 z-20 w-80 overflow-hidden rounded-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur"
135
+ >
136
+ <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
137
+ <div class="space-y-4 p-4">
138
+ <!-- header -->
139
+ <div class="flex items-start justify-between gap-2">
140
+ <div class="flex items-center gap-2">
141
+ <div
142
+ class="flex h-9 w-9 items-center justify-center rounded-lg"
143
+ :style="{ backgroundColor: typeMeta.accent + '22' }"
144
+ >
145
+ <UIcon :name="typeMeta.icon" class="h-5 w-5" :style="{ color: typeMeta.accent }" />
146
+ </div>
147
+ <div>
148
+ <div class="text-sm font-semibold text-white">{{ block.title }}</div>
149
+ <div class="mt-0.5 flex items-center gap-1.5">
150
+ <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">
151
+ {{ statusLabel }}
152
+ </UBadge>
153
+ <span class="text-[10px] uppercase tracking-wide text-slate-500">{{ level }}</span>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ <UButton
158
+ icon="i-lucide-x"
159
+ color="neutral"
160
+ variant="ghost"
161
+ size="xs"
162
+ @click="ui.select(null)"
163
+ />
164
+ </div>
165
+
166
+ <UTextarea
167
+ v-model="block.description"
168
+ :rows="2"
169
+ autoresize
170
+ size="sm"
171
+ class="w-full"
172
+ placeholder="Describe this block…"
173
+ />
174
+
175
+ <!-- requirements review: surface the AI's questions / gaps -->
176
+ <UButton
177
+ v-if="reviewAvailable"
178
+ block
179
+ color="neutral"
180
+ variant="soft"
181
+ size="sm"
182
+ icon="i-lucide-clipboard-check"
183
+ @click="ui.openRequirementReview(block.id)"
184
+ >
185
+ Review requirements
186
+ <UBadge v-if="openReviewCount" size="xs" color="warning" variant="solid" class="ml-auto">
187
+ {{ openReviewCount }}
188
+ </UBadge>
189
+ </UButton>
190
+
191
+ <!-- failed run (bootstrap or execution): shared failure banner + retry -->
192
+ <AgentFailureCard v-if="failedRun" :run="failedRun" />
193
+
194
+ <!-- running bootstrap: let the user stop it (kills the container) -->
195
+ <div
196
+ v-else-if="runningRun"
197
+ class="flex items-center justify-between gap-2 rounded-lg border border-amber-900/60 bg-amber-950/30 px-3 py-2"
198
+ >
199
+ <span class="flex items-center gap-1.5 text-xs text-amber-300">
200
+ <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
201
+ Bootstrapping…
202
+ </span>
203
+ <AgentStopButton :run-id="runningRun.runId" :kind="runningRun.kind" size="xs" />
204
+ </div>
205
+
206
+ <!-- external links -->
207
+ <div class="flex flex-wrap gap-2">
208
+ <UButton
209
+ v-if="serviceRepoUrl"
210
+ :to="serviceRepoUrl"
211
+ target="_blank"
212
+ rel="noopener"
213
+ color="neutral"
214
+ variant="soft"
215
+ size="xs"
216
+ icon="i-lucide-github"
217
+ trailing-icon="i-lucide-external-link"
218
+ >
219
+ {{ serviceRepo!.owner }}/{{ serviceRepo!.name }}
220
+ </UButton>
221
+ <UButton
222
+ v-if="tasks.available"
223
+ color="neutral"
224
+ variant="soft"
225
+ size="xs"
226
+ icon="i-lucide-ticket"
227
+ @click="ui.openTaskImport()"
228
+ >
229
+ {{ tasks.anyConnected ? 'Import Jira issue' : 'Connect Jira' }}
230
+ </UButton>
231
+ <UButton
232
+ v-if="isContainer && documents.available && documents.anyConnected"
233
+ color="neutral"
234
+ variant="soft"
235
+ size="xs"
236
+ icon="i-lucide-wand-sparkles"
237
+ @click="spawnFromDocument"
238
+ >
239
+ Spawn from document
240
+ </UButton>
241
+ </div>
242
+
243
+ <!-- task: context documents -->
244
+ <TaskContextDocs v-if="isTask" :block="block" />
245
+
246
+ <!-- task: context issues (tracker) -->
247
+ <TaskContextIssues v-if="isTask" :block="block" />
248
+
249
+ <!-- service / module: tasks summary -->
250
+ <ContainerSummary v-if="isContainer" :block="block" />
251
+
252
+ <!-- task: dependencies, structure, scenarios, run settings, execution -->
253
+ <template v-else-if="isTask">
254
+ <TaskDependencies :block="block" />
255
+ <TaskStructure :block="block" />
256
+ <FeatureScenarios :block="block" />
257
+ <TaskModelSettings :block="block" />
258
+ <TaskExecution :block="block" />
259
+ </template>
260
+
261
+ <!-- actions -->
262
+ <div class="flex items-center gap-2">
263
+ <UDropdownMenu v-if="isTask" :items="runMenu">
264
+ <UButton
265
+ :color="runnable ? 'primary' : 'neutral'"
266
+ variant="soft"
267
+ size="sm"
268
+ :icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
269
+ trailing-icon="i-lucide-chevron-down"
270
+ :disabled="!runnable"
271
+ >
272
+ {{ instance ? 'Re-run' : 'Run' }}
273
+ </UButton>
274
+ </UDropdownMenu>
275
+ <UButton
276
+ v-if="isTask"
277
+ color="neutral"
278
+ variant="soft"
279
+ size="sm"
280
+ icon="i-lucide-maximize-2"
281
+ @click="ui.focus(block.id)"
282
+ >
283
+ Focus
284
+ </UButton>
285
+ <UButton
286
+ color="error"
287
+ variant="ghost"
288
+ size="sm"
289
+ icon="i-lucide-trash-2"
290
+ class="ml-auto"
291
+ @click="remove"
292
+ />
293
+ </div>
294
+ </div>
295
+ </div>
296
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+ import { STATUS_META } from '~/utils/catalog'
4
+
5
+ const props = defineProps<{ block: Block }>()
6
+
7
+ const board = useBoardStore()
8
+ const ui = useUiStore()
9
+
10
+ const isFrame = computed(() => (props.block.level ?? 'frame') === 'frame')
11
+ const modules = computed(() => (isFrame.value ? board.modulesOf(props.block.id) : []))
12
+ const tasks = computed(() =>
13
+ isFrame.value ? board.allTasksUnder(props.block.id) : board.tasksOf(props.block.id),
14
+ )
15
+
16
+ function addTask() {
17
+ board.addTask(props.block.id)
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <div class="space-y-4">
23
+ <!-- modules (services only) -->
24
+ <div v-if="modules.length">
25
+ <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
26
+ Modules ({{ modules.length }})
27
+ </div>
28
+ <ul class="space-y-1">
29
+ <li
30
+ v-for="m in modules"
31
+ :key="m.id"
32
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
33
+ @click="ui.select(m.id)"
34
+ >
35
+ <UIcon name="i-lucide-package" class="h-3.5 w-3.5 text-violet-400" />
36
+ <span class="truncate text-xs text-slate-200">{{ m.title }}</span>
37
+ <span class="ml-auto text-[10px] text-slate-500"
38
+ >{{ board.tasksOf(m.id).length }} task(s)</span
39
+ >
40
+ </li>
41
+ </ul>
42
+ </div>
43
+
44
+ <div>
45
+ <div class="mb-1 flex items-center justify-between">
46
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
47
+ {{ isFrame ? 'All tasks' : 'Tasks' }} ({{ tasks.length }})
48
+ </span>
49
+ <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
50
+ Add task
51
+ </UButton>
52
+ </div>
53
+ <ul v-if="tasks.length" class="space-y-1">
54
+ <li
55
+ v-for="t in tasks"
56
+ :key="t.id"
57
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
58
+ @click="ui.select(t.id)"
59
+ >
60
+ <span
61
+ class="h-2 w-2 shrink-0 rounded-full"
62
+ :style="{ backgroundColor: STATUS_META[t.status].color }"
63
+ />
64
+ <span class="truncate text-xs text-slate-200">{{ t.title }}</span>
65
+ <span class="ml-auto text-[10px] text-slate-500">{{ STATUS_META[t.status].label }}</span>
66
+ </li>
67
+ </ul>
68
+ <div v-else class="text-[11px] text-slate-500">No tasks yet — add one to start work.</div>
69
+ </div>
70
+ <p v-if="isFrame" class="text-[11px] text-slate-500">
71
+ Services are long-lived — they don't "complete". Work happens in their tasks &amp; modules.
72
+ </p>
73
+ </div>
74
+ </template>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+
4
+ const props = defineProps<{ block: Block }>()
5
+
6
+ const board = useBoardStore()
7
+ const { depLabel } = useDepLabels()
8
+
9
+ const deps = computed(() =>
10
+ (props.block.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
11
+ )
12
+ const runnable = computed(() => board.isRunnable(props.block.id))
13
+
14
+ /** Label a dependency relative to this task's container. */
15
+ const label = (dep: Block) => depLabel(dep, props.block.parentId)
16
+
17
+ // candidate tasks to depend on: any other task not already a dependency
18
+ const depMenu = computed(() => {
19
+ const current = new Set(props.block.dependsOn)
20
+ return board.allTasks
21
+ .filter((t) => t.id !== props.block.id && !current.has(t.id))
22
+ .map((t) => ({
23
+ label: label(t),
24
+ icon: 'i-lucide-plus',
25
+ onSelect: () => board.toggleDependency(props.block.id, t.id),
26
+ }))
27
+ })
28
+
29
+ function removeDep(depId: string) {
30
+ board.removeDependency(props.block.id, depId)
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div>
36
+ <div class="mb-1 flex items-center justify-between">
37
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
38
+ Depends on
39
+ </span>
40
+ <UDropdownMenu v-if="depMenu.length" :items="depMenu">
41
+ <UButton
42
+ size="xs"
43
+ variant="ghost"
44
+ color="neutral"
45
+ icon="i-lucide-plus"
46
+ trailing-icon="i-lucide-chevron-down"
47
+ />
48
+ </UDropdownMenu>
49
+ </div>
50
+ <div v-if="deps.length" class="flex flex-wrap gap-1">
51
+ <UBadge
52
+ v-for="d in deps"
53
+ :key="d.id"
54
+ :color="d.status === 'done' ? 'neutral' : 'warning'"
55
+ variant="subtle"
56
+ size="sm"
57
+ class="cursor-pointer"
58
+ :title="d.status === 'done' ? 'Merged' : 'Not merged yet'"
59
+ @click="removeDep(d.id)"
60
+ >
61
+ {{ label(d) }}
62
+ <UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
63
+ </UBadge>
64
+ </div>
65
+ <div v-else class="text-[11px] text-slate-500">No dependencies — can run any time.</div>
66
+ <div v-if="!runnable" class="mt-1 text-[10px] text-amber-400">
67
+ Blocked until dependencies merge.
68
+ </div>
69
+ </div>
70
+ </template>